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, embed as
  git submodule
- 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)
- add (untested) workaround for #1070
  • Loading branch information
chadoh committed Oct 2, 2024
1 parent eecc678 commit d8682d9
Show file tree
Hide file tree
Showing 19 changed files with 1,397 additions and 1,357 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"
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "test/e2e/test-contracts"]
path = test/e2e/test-contracts
url = [email protected]:AhaLabs/soroban-test-examples.git
90 changes: 56 additions & 34 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 @@ -836,28 +846,40 @@ export class AssembledTransaction<T> {

// eslint-disable-next-line no-restricted-syntax
for (const [i, entry] of authEntries.entries()) {
// workaround for https://github.com/stellar/js-stellar-sdk/issues/1070
const credentials = xdr.SorobanCredentials.fromXDR(entry.credentials().toXDR())
if (
entry.credentials().switch() !==
credentials.switch() !==
xdr.SorobanCredentialsType.sorobanCredentialsAddress()
) {
// if the invoker/source account, then the entry doesn't need explicit
// signature, since the tx envelope is already signed by the source
// 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(
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
2 changes: 1 addition & 1 deletion test/e2e/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ yarn.lock
contract-*.txt
.soroban
wasms/specs/*.json
test-contracts
.last_build_hash
53 changes: 25 additions & 28 deletions test/e2e/initialize.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,57 +14,57 @@ 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"
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
get_test_contracts_git_hash() {
git ls-files -s "$contracts_dir" | cut -d ' ' -f 2
}

# Get the current git hash
current_hash=$(get_remote_git_hash)
current_hash=$(get_test_contracts_git_hash)

# Check if a stored hash exists
hash_file="$contracts_dir/.last_build_hash"
hash_file="$dirname/.last_build_hash"
if [ -f "$hash_file" ]; then
stored_hash=$(cat "$hash_file")
else
Expand All @@ -75,20 +75,17 @@ 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

# If any WASM file is missing or the git hash has changed, initialize and build the contracts
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

# 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
45 changes: 16 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,33 @@ 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(),
);
// TODO: use newer interface instead, `stellar contract info interface` (doesn't yet support xdr-base64-array output)
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 +79,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 d8682d9

Please sign in to comment.