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 3, 2024
1 parent eecc678 commit 02261d9
Show file tree
Hide file tree
Showing 19 changed files with 1,417 additions and 1,374 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"
2 changes: 2 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:
--health-retries 50
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/cache@v4
with:
path: |
Expand Down
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]:stellar/soroban-test-examples.git
45 changes: 31 additions & 14 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,48 @@ A breaking change will get clearly marked in this log.

## Unreleased

### Breaking Changes

- `contract.AssembledTransaction#signAuthEntries` now takes an `address` instead of a `publicKey`. This brings the API more inline with its actual functionality. It can be used to sign all the auth entries for a particular _address_, whether that is the address of a user (a public key) or a contract. (#1044)

### Added

- `contract.AssembledTransaction#signAuthEntries` now allows you to override `authorizeEntry`. This can be used to streamline novel workflows using cross-contract auth. (#1044)

- `rpc.Server` now has a `getSACBalance` helper which lets you fetch the balance of a built-in Stellar Asset Contract token held by a contract ([#1046](https://github.com/stellar/js-stellar-sdk/pull/1046)):

```typescript
export interface BalanceResponse {
latestLedger: number;
/** present only on success, otherwise request malformed or no balance */
balanceEntry?: {
/** a 64-bit integer */
amount: string;
authorized: boolean;
clawback: boolean;
```typescript
export interface BalanceResponse {
latestLedger: number;
/** present only on success, otherwise request malformed or no balance */
balanceEntry?: {
/** a 64-bit integer */
amount: string;
authorized: boolean;
clawback: boolean;

lastModifiedLedgerSeq?: number;
liveUntilLedgerSeq?: number;
};
}
```

lastModifiedLedgerSeq?: number;
liveUntilLedgerSeq?: number;
};
}
```
- You can now build the browser bundle without the `axios` dependency. Set `USE_AXIOS=false` `stellar-sdk-no-axios.js` and `stellar-sdk-no-axios.min.js` in the `dist/` directory, or just run `yarn build:browser:no-axios` to generate these files.

- Similarly, you can import Node packages without the `axios` dependency via `@stellar/stellar-sdk/no-axios`. For Node environments that don't support modern imports, use `@stellar/stellar-sdk/lib/no-axios/index`.

- There is also a new build target for creating a browser bundle without EventSource dependency. Set USE_EVENTSOURCE=false environment variable to build stellar-sdk-no-eventsource.js and stellar-sdk-no-eventsource.min.js in the dist/ directory. Use yarn build:browser:no-eventsource to generate these files.

- A new import path for the node package without the EventSource dependency can be used with `@stellar/stellar-sdk/no-eventsource`. For Node.js environments that don't support the package.json `exports` configuration, use `@stellar/stellar-sdk/lib/no-eventsource/index`.

- To use a minimal build without Axios and EventSource, use `stellar-sdk-minimal.js` for the browser build and import from `@stellar/stellar-sdk/minimal` for the node package.

- The Node.js code will now Babelify to Node 18 instead of Node 16, but we stopped supporting Node 16 long ago so this shouldn't be a breaking change.

### Fixed

- `contract.AssembledTransaction#nonInvokerSigningBy` now correctly returns contract addresses, in instances of cross-contract auth, rather than throwing an error. `sign` will ignore these contract addresses, since auth happens via cross-contract call. (#1044)


## [v12.3.0](https://github.com/stellar/js-stellar-sdk/compare/v12.2.0...v12.3.0)

Expand Down
87 changes: 53 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,37 @@ 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: typeof signAuthEntry = signAuthEntry ?? Promise.resolve;

// 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="$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
Loading

0 comments on commit 02261d9

Please sign in to comment.