Skip to content

Commit

Permalink
feat!: enable cross-contract auth
Browse files Browse the repository at this point in the history
Fixes: #1030

Soroban allows contract authors to call `require_auth()` not only on a
G-address (for users), but also on a C-address (for contracts). This
will result in a cross-contract call to the `__check_auth` function in
the signing contract. This enables all sorts of powerful and novel
use-cases. See https://developers.stellar.org/docs/build/guides/conventions/check-auth-tutorials#customaccountinterface-implementation

Just as if one account submits a transaction (meaning that account signs
the transaction envelope) but the contract calls `require_auth` on a
different one, the app author will need to use
`needsNonInvokerSigningBy` and `signAuthEntries`, so app authors benefit
from these functions when `require_auth` is called on a contract.

This fixes various assumptions that these functions made to also work
with this sort of cross-contract auth.

- `needsNonInvokerSigningBy` now returns contract addresses
- `sign` ignores contracts in the `needsNonInvokerSigningBy` list, since
  the actual authorization will happen via cross-contract call
- `signAuthEntry` now takes an `address` instead of a `publicKey`, since
  this address may be a user's public key (a G-address) or a contract
  address. Furthermore, it allows setting a custom `authorizeEntry`, so
  that app and library authors implementing custom cross-contract auth
  can more easily assemble complex transactions.

Additional changes:

- switch to new test cases from AhaLabs/soroban-test-contracts
- switch to `stellar` instead of `soroban`
- use latest cli version (this version fixes a problem that some of the
  tests were working around, so they were removed)

feat!: signAuthEntries with custom authorizeEntry

If `needsNonInvokerSigningBy` returns a contract address, app
authors may still want to call `signAuthEntries` with custom logic so
they can easily build up their `AssembledTransaction`.

This allows them to do so, passing a custom `authorizeEntry` function,
which will cause `signAuthEntry` to be ignored.

This also introduces a breaking change, renaming the `publicKey`
parameter to `address`, since it may be a G-address (for a user) or a
C-address (for a contract).
  • Loading branch information
chadoh committed Sep 26, 2024
1 parent afb7814 commit 2d805c3
Show file tree
Hide file tree
Showing 15 changed files with 222 additions and 185 deletions.
2 changes: 1 addition & 1 deletion .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# paths = ["/path/to/override"] # path dependency overrides

[alias] # command aliases
install_soroban = "install --version 21.4.1 --root ./target soroban-cli --debug"
install_stellar = "install --version 21.5.0 --root ./target stellar-cli --debug --force"
# b = "build --target wasm32-unknown-unknown --release"
# c = "check"
# t = "test"
Expand Down
6 changes: 3 additions & 3 deletions .env
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
SOROBAN_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
SOROBAN_RPC_URL="http://localhost:8000/soroban/rpc"
SOROBAN_ACCOUNT="me"
STELLAR_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
STELLAR_RPC_URL="http://localhost:8000/soroban/rpc"
STELLAR_ACCOUNT="me"
FRIENDBOT_URL="http://localhost:8000/friendbot"
86 changes: 53 additions & 33 deletions src/contract/assembled_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
/* eslint max-classes-per-file: 0 */
import {
Account,
Address,
BASE_FEE,
Contract,
Operation,
SorobanDataBuilder,
StrKey,
TransactionBuilder,
authorizeEntry,
authorizeEntry as stellarBaseAuthorizeEntry,
xdr,
} from "@stellar/stellar-base";
import type {
Expand Down Expand Up @@ -636,9 +636,11 @@ export class AssembledTransaction<T> {
);
}

if (this.needsNonInvokerSigningBy().length) {
// filter out contracts, as these are dealt with via cross contract calls
const sigsNeeded = this.needsNonInvokerSigningBy().filter(id => !id.startsWith('C'));
if (sigsNeeded.length) {
throw new AssembledTransaction.Errors.NeedsMoreSignatures(
"Transaction requires more signatures. " +
`Transaction requires signatures from ${sigsNeeded}. ` +
"See `needsNonInvokerSigningBy` for details.",
);
}
Expand Down Expand Up @@ -760,9 +762,9 @@ export class AssembledTransaction<T> {
"scvVoid"),
)
.map((entry) =>
StrKey.encodeEd25519PublicKey(
entry.credentials().address().address().accountId().ed25519(),
),
Address.fromScAddress(
entry.credentials().address().address(),
).toString(),
),
),
];
Expand All @@ -788,7 +790,8 @@ export class AssembledTransaction<T> {
expiration = (async () =>
(await this.server.getLatestLedger()).sequence + 100)(),
signAuthEntry = this.options.signAuthEntry,
publicKey = this.options.publicKey,
address = this.options.publicKey,
authorizeEntry = stellarBaseAuthorizeEntry,
}: {
/**
* When to set each auth entry to expire. Could be any number of blocks in
Expand All @@ -800,33 +803,40 @@ export class AssembledTransaction<T> {
* Sign all auth entries for this account. Default: the account that
* constructed the transaction
*/
publicKey?: string;
address?: string;
/**
* You must provide this here if you did not provide one before. Default:
* the `signAuthEntry` function from the `Client` options. Must
* sign things as the given `publicKey`.
* You must provide this here if you did not provide one before and you are not passing `authorizeEntry`. Default: the `signAuthEntry` function from the `Client` options. Must sign things as the given `publicKey`.
*/
signAuthEntry?: ClientOptions["signAuthEntry"];

/**
* If you have a pro use-case and need to override the default `authorizeEntry` function, rather than using the one in @stellar/stellar-base, you can do that! Your function needs to take at least the first argument, `entry: xdr.SorobanAuthorizationEntry`, and return a `Promise<xdr.SorobanAuthorizationEntry>`.
*
* Note that you if you pass this, then `signAuthEntry` will be ignored.
*/
authorizeEntry?: typeof stellarBaseAuthorizeEntry;
} = {}): Promise<void> => {
if (!this.built)
throw new Error("Transaction has not yet been assembled or simulated");
const needsNonInvokerSigningBy = this.needsNonInvokerSigningBy();

if (!needsNonInvokerSigningBy) {
throw new AssembledTransaction.Errors.NoUnsignedNonInvokerAuthEntries(
"No unsigned non-invoker auth entries; maybe you already signed?",
);
}
if (needsNonInvokerSigningBy.indexOf(publicKey ?? "") === -1) {
throw new AssembledTransaction.Errors.NoSignatureNeeded(
`No auth entries for public key "${publicKey}"`,
);
}
if (!signAuthEntry) {
throw new AssembledTransaction.Errors.NoSigner(
"You must provide `signAuthEntry` when calling `signAuthEntries`, " +
"or when constructing the `Client` or `AssembledTransaction`",
);
// Likely if we're using a custom authorizeEntry then we know better than the `needsNonInvokerSigningBy` logic.
if (authorizeEntry === stellarBaseAuthorizeEntry) {
const needsNonInvokerSigningBy = this.needsNonInvokerSigningBy();
if (needsNonInvokerSigningBy.length === 0) {
throw new AssembledTransaction.Errors.NoUnsignedNonInvokerAuthEntries(
"No unsigned non-invoker auth entries; maybe you already signed?",
);
}
if (needsNonInvokerSigningBy.indexOf(address ?? "") === -1) {
throw new AssembledTransaction.Errors.NoSignatureNeeded(
`No auth entries for public key "${address}"`,
);
}
if (!signAuthEntry) {
throw new AssembledTransaction.Errors.NoSigner(
"You must provide `signAuthEntry` or a custom `authorizeEntry`"
);
}
}

const rawInvokeHostFunctionOp = this.built
Expand All @@ -845,19 +855,29 @@ export class AssembledTransaction<T> {
// account, so only check for sorobanCredentialsAddress
continue; // eslint-disable-line no-continue
}
const pk = StrKey.encodeEd25519PublicKey(
entry.credentials().address().address().accountId().ed25519(),
);
const authEntryAddress = Address.fromScAddress(
entry.credentials().address().address(),
).toString();

// this auth entry needs to be signed by a different account
// (or maybe already was!)
if (pk !== publicKey) continue; // eslint-disable-line no-continue
if (authEntryAddress !== address) continue; // eslint-disable-line no-continue

const sign = signAuthEntry ??
function fakeSignAuthEntry(x) {
return Promise.resolve(x);
}

// eslint-disable-next-line no-await-in-loop
authEntries[i] = await authorizeEntry(
entry,
async (preimage) =>
Buffer.from(await signAuthEntry(preimage.toXDR("base64")), "base64"),
Buffer.from(
await sign(preimage.toXDR("base64"), {
accountToSign: address,
}),
"base64",
),
await expiration, // eslint-disable-line no-await-in-loop
this.options.networkPassphrase,
);
Expand Down
49 changes: 25 additions & 24 deletions test/e2e/initialize.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,50 +14,51 @@ dirname="$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

echo "###################### Initializing e2e tests ########################"

soroban="$dirname/../../target/bin/soroban"
if [[ -f "$soroban" ]]; then
current=$($soroban --version | head -n 1 | cut -d ' ' -f 2)
stellar=$(realpath "$dirname/../../target/bin/stellar")
if [[ -f "$stellar" ]]; then
current=$($stellar --version | head -n 1 | cut -d ' ' -f 2)
desired=$(cat .cargo/config.toml | grep -oE -- "--version\s+\S+" | awk '{print $2}')
if [[ "$current" != "$desired" ]]; then
echo "Current pinned soroban binary: $current. Desired: $desired. Building soroban binary."
(cd "$dirname/../.." && cargo install_soroban)
echo "Current pinned stellar binary: $current. Desired: $desired. Building stellar binary."
(cd "$dirname/../.." && cargo install_stellar)
else
echo "Using soroban binary from ./target/bin"
echo "Using stellar binary from ./target/bin"
fi
else
echo "Building pinned soroban binary"
(cd "$dirname/../.." && cargo install_soroban)
echo "Building pinned stellar binary"
(cd "$dirname/../.." && cargo install_stellar)
fi

NETWORK_STATUS=$(curl -s -X POST "$SOROBAN_RPC_URL" -H "Content-Type: application/json" -d '{ "jsonrpc": "2.0", "id": 8675309, "method": "getHealth" }' | sed -n 's/.*"status":\s*"\([^"]*\)".*/\1/p')
NETWORK_STATUS=$(curl -s -X POST "$STELLAR_RPC_URL" -H "Content-Type: application/json" -d '{ "jsonrpc": "2.0", "id": 8675309, "method": "getHealth" }' | sed -n 's/.*"status":\s*"\([^"]*\)".*/\1/p')

echo Network
echo " RPC: $SOROBAN_RPC_URL"
echo " Passphrase: \"$SOROBAN_NETWORK_PASSPHRASE\""
echo " RPC: $STELLAR_RPC_URL"
echo " Passphrase: \"$STELLAR_NETWORK_PASSPHRASE\""
echo " Status: $NETWORK_STATUS"

if [[ "$NETWORK_STATUS" != "healthy" ]]; then
echo "Network is not healthy (not running?), exiting"
exit 1
fi

$soroban keys generate $SOROBAN_ACCOUNT
$stellar keys generate $STELLAR_ACCOUNT

# retrieve the contracts using soroban contract init then build them if they dont already exist
# retrieve the contracts using stellar contract init then build them if they dont already exist
# Define directory and WASM file paths
target_dir="$dirname/test-contracts/target/wasm32-unknown-unknown/release"
contracts_dir="$dirname/test-contracts"
repo_url="https://github.com/stellar/soroban-examples.git"
test_examples_repo="https://github.com/AhaLabs/soroban-test-examples.git"
wasm_files=(
"soroban_other_custom_types_contract.wasm"
"soroban_atomic_swap_contract.wasm"
"soroban_token_contract.wasm"
"soroban_increment_contract.wasm"
"hello_world.wasm"
"custom_types.wasm"
"atomic_swap.wasm"
"token.wasm"
"increment.wasm"
"needs_a_signature.wasm"
"this_one_signs.wasm"
)

get_remote_git_hash() {
git ls-remote "$repo_url" HEAD | cut -f1
git ls-remote "$test_examples_repo" HEAD | cut -f1
}

# Get the current git hash
Expand All @@ -75,7 +76,7 @@ fi
all_exist=true
for wasm_file in "${wasm_files[@]}"; do
if [ ! -f "$target_dir/$wasm_file" ]; then
all_exist=false
all_exist=false
break
fi
done
Expand All @@ -84,11 +85,11 @@ done
if [ "$all_exist" = false ] || [ "$current_hash" != "$stored_hash" ]; then
echo "WASM files are missing or contracts have been updated. Initializing and building contracts..."
# Initialize contracts
$soroban contract init "$dirname/test-contracts" --with-example increment other_custom_types atomic_swap token
npx degit --force AhaLabs/soroban-test-examples "$dirname/test-contracts"

# Change directory to test-contracts and build the contracts
cd "$dirname/test-contracts" || { echo "Failed to change directory!"; exit 1; }
$soroban contract build
$stellar contract build
# Save git hash to file
echo "$current_hash" > "$hash_file"
else
Expand Down
44 changes: 15 additions & 29 deletions test/e2e/src/test-contract-client-constructor.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
networkPassphrase,
rpcUrl,
generateFundedKeypair,
run,
} = require("./util");
const { Address, contract } = require("../../../lib");

Expand All @@ -15,47 +16,32 @@ async function clientFromConstructor(
if (!contracts[name]) {
throw new Error(
`Contract ${name} not found. ` +
`Pick one of: ${Object.keys(contracts).join(", ")}`,
`Pick one of: ${Object.keys(contracts).join()}`,
);
}
keypair = await keypair; // eslint-disable-line no-param-reassign
const wallet = contract.basicNodeSigner(keypair, networkPassphrase);

const { path } = contracts[name];
const xdr = JSON.parse(
spawnSync(
"./target/bin/soroban",
["contract", "inspect", "--wasm", path, "--output", "xdr-base64-array"],
{ shell: true, encoding: "utf8" },
).stdout.trim(),
);
const inspected = run(
`./target/bin/stellar contract inspect --wasm ${path} --output xdr-base64-array`,
).stdout;
const xdr = JSON.parse(inspected);

const spec = new contract.Spec(xdr);
let wasmHash = contracts[name].hash;
if (!wasmHash) {
wasmHash = spawnSync(
"./target/bin/soroban",
["contract", "install", "--wasm", path],
{ shell: true, encoding: "utf8" },
).stdout.trim();
wasmHash = run(
`./target/bin/stellar contract install --wasm ${path}`,
).stdout;
}

// TODO: do this with js-stellar-sdk, instead of shelling out to the CLI
contractId =
contractId ??
spawnSync(
"./target/bin/soroban",
[
// eslint-disable-line no-param-reassign
"contract",
"deploy",
"--source",
keypair.secret(),
"--wasm-hash",
wasmHash,
],
{ shell: true, encoding: "utf8" },
).stdout.trim();
run(
`./target/bin/stellar contract deploy --source ${keypair.secret()} --wasm-hash ${wasmHash}`,
).stdout;

const client = new contract.Client(spec, {
networkPassphrase,
Expand Down Expand Up @@ -92,15 +78,15 @@ async function clientForFromTest(contractId, publicKey, keypair) {
describe("Client", function () {
before(async function () {
const { client, keypair, contractId } =
await clientFromConstructor("helloWorld");
await clientFromConstructor("customTypes");
const publicKey = keypair.publicKey();
const addr = Address.fromString(publicKey);
this.context = { client, publicKey, addr, contractId, keypair };
});

it("can be constructed with `new Client`", async function () {
const { result } = await this.context.client.hello({ to: "tests" });
expect(result).to.deep.equal(["Hello", "tests"]);
const { result } = await this.context.client.hello({ hello: "tests" });
expect(result).to.equal("tests");
});

it("can be constructed with `from`", async function () {
Expand Down
6 changes: 3 additions & 3 deletions test/e2e/src/test-methods-as-args.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ function callMethod(method, args) {

describe("methods-as-args", function () {
it("should pass methods as arguments and have them still work", async function () {
const { client } = await clientFor("helloWorld");
const { result } = await callMethod(client.hello, { to: "tests" });
expect(result).to.deep.equal(["Hello", "tests"]);
const { client } = await clientFor("customTypes");
const { result } = await callMethod(client.hello, { hello: "tests" });
expect(result).to.equal("tests");
});
});
Loading

0 comments on commit 2d805c3

Please sign in to comment.