Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## [Unreleased]
- Add Protocol 27 (CAP-71) Soroban authorization support

## [3.1.0] - 10.Jun.2026.
- Add OpenZeppelin smart account support
- Package the SDK as a Flutter plugin with native iOS code, distributed for both CocoaPods and Swift Package Manager; minimum iOS deployment target 15.0 (passkey features require iOS 16 at runtime)
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ XDRS = xdr/Stellar-SCP.x xdr/Stellar-ledger-entries.x xdr/Stellar-ledger.x \
xdr/Stellar-internal.x xdr/Stellar-contract-config-setting.x \
xdr/Stellar-exporter.x

XDR_COMMIT = 0a56f5be107098efe67edf766c41955c2b277663
XDR_COMMIT = 55a00d96afcf4d85340b071b8d44a4e645f25bc4
RUBY_IMAGE = ruby:3.4

# Use CURDIR (always set by GNU Make) instead of PWD for portability
Expand Down
4 changes: 4 additions & 0 deletions documentation/sep/sep-45.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,10 @@ final webAuthTestnet = await WebAuthForContracts.fromDomain('testnet.anchor.com'
final webAuthPubnet = await WebAuthForContracts.fromDomain('anchor.com', Network.PUBLIC);
```

## Protocol 27 credentials

The SDK signs whichever credential arm the server returns. `jwtToken()` accepts `ADDRESS`, `ADDRESS_V2`, and `ADDRESS_WITH_DELEGATES` entries (protocol 27, CAP-71) and preserves the arm on write-back, so no flow change is needed when an anchor adopts the V2 or delegated arms. For `ADDRESS_WITH_DELEGATES` entries, every signer registered in the contract's `__check_auth` rules must be supplied in the signers list, including delegate signers.

## Reference contracts

Your contract account must implement `__check_auth` to define authorization rules. The Stellar Anchor Platform provides a reference implementation:
Expand Down
85 changes: 85 additions & 0 deletions documentation/soroban.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,91 @@ await tx.signAuthEntries(
GetTransactionResponse response = await tx.signAndSend();
```

### Protocol 27 Credentials (CAP-71)

Protocol 27 adds two address-credential arms to `SorobanCredentials`:

- `ADDRESS_V2` carries the same `SorobanAddressCredentials` body as the legacy `ADDRESS` arm, but the signature payload additionally binds the credential address.
- `ADDRESS_WITH_DELEGATES` extends V2 with a tree of delegate signatures, letting additional addresses co-sign one authorization entry.

The legacy `ADDRESS` arm remains the default everywhere and stays fully valid. The new arms are opt-in: emitting them on a network below protocol 27 invalidates the transaction.

All signing APIs (`signAuthEntries`, `SorobanAuthorizationEntry.sign`, SEP-45) support all three arms and preserve the arm on write-back. `needsNonInvokerSigningBy` reports the address of every node whose signature is void, including each unsigned delegate node of a `WITH_DELEGATES` entry. Use `credentials.innerAddressCredentials` to read the inner credentials of any address arm (it returns `null` only for source-account credentials).

#### Delegated Authorization

A `WITH_DELEGATES` entry lets delegate addresses co-sign a single authorization entry. Simulation never returns `WITH_DELEGATES` entries; clients assemble the tree from an `ADDRESS` or `ADDRESS_V2` entry using `SorobanAuthorizationEntry.withDelegates`.

Rules enforced by the host and handled by the SDK builder:

- Every delegate array must be sorted ascending by the XDR-encoded bytes of the delegate address, with no duplicates within one array. The builder sorts automatically and throws on duplicates — always construct trees through `withDelegates` rather than assembling the XDR by hand.
- Every signer in the tree — top-level and delegates at any depth — signs the same payload, which is bound to the top-level credential address. Delegates carry no nonce and no expiration; only the top-level credentials do.
- A void top-level signature is legitimate when the delegates sign (the delegates-only pattern); such an entry passes the send precheck once every delegate is signed.

```dart
import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart';

final SorobanServer server =
SorobanServer('https://soroban-testnet.stellar.org:443');

// Top-level credential account and a delegate signer's account
final KeyPair topLevelKeyPair = KeyPair.fromSecretSeed('STOPLEVEL...');
final KeyPair delegateKeyPair = KeyPair.fromSecretSeed('SDELEGATE...');

// An ADDRESS or ADDRESS_V2 entry bound to the top-level account. In practice
// this comes from simulation (tx.simulationResponse!.sorobanAuth!.first);
// it is built explicitly here so the snippet is self-contained.
final SorobanAuthorizationEntry sourceEntry = SorobanAuthorizationEntry(
SorobanCredentials.forAddressV2(SorobanAddressCredentials(
Address.forAccountId(topLevelKeyPair.accountId),
BigInt.from(1234), // nonce
0, // signatureExpirationLedger (set by withDelegates below)
XdrSCVal.forVoid(),
)),
SorobanAuthorizedInvocation(SorobanAuthorizedFunction.forContractFunction(
Address.forContractId(
'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'),
'swap',
[],
)),
);

// Latest ledger sequence, used to set the signature expiration
final GetLatestLedgerResponse latestLedger = await server.getLatestLedger();
final int expirationLedger = latestLedger.sequence! + 100;

// Build the WITH_DELEGATES entry. The builder sorts the delegate array,
// rejects duplicates, and resets the top-level signature to void.
SorobanAuthorizationEntry delegated = SorobanAuthorizationEntry.withDelegates(
sourceEntry,
[SorobanDelegateDescriptor(delegateKeyPair.accountId)],
expirationLedger,
);

// Optional top-level signature (skip this for the delegates-only pattern).
// When one node needs multiple classical (G-address) signatures, add them in
// ascending public-key order — the host requires that order and the SDK
// appends signatures in the order you call sign.
delegated.sign(topLevelKeyPair, Network.TESTNET);

// Delegate signer: forAddress routes the signature into the matching node
// (top-level or any delegate, depth-first) and throws when no node matches.
delegated.sign(delegateKeyPair, Network.TESTNET,
forAddress: delegateKeyPair.accountId);
```

`SorobanDelegateDescriptor` supports nesting via `nestedDelegates` and accepts a pre-built `signature` (default void) for nodes signed externally, such as contract addresses.

`SorobanCredentials.forAddressV2` and the delegated arms are built client-side: simulation and the high-level `SorobanClient` / `AssembledTransaction` only ever return legacy `ADDRESS` entries, so the V2 and `WITH_DELEGATES` arms are assembled and submitted at the `SorobanServer` level.

After attaching the signed entries with `transaction.setSorobanAuth(...)`, re-simulate in enforcing mode before submitting. The first (recording) simulation does not run the authorizing account's `__check_auth`, so it understates the resource fee and — for a custom (contract) account whose `__check_auth` reads storage or calls into delegates — omits the footprint entries that authorization touches. Re-simulate with the signed entry attached and `authMode` set to `enforce` (`SimulateTransactionRequest(transaction, authMode: 'enforce')`), then apply the returned data before signing: assign `response.transactionData` to `transaction.sorobanTransactionData` and add `response.minResourceFee` via `transaction.addResourceFee(...)`. The already-signed auth is preserved.

When converting a simulated `ADDRESS` entry to `ADDRESS_V2` in place, reuse its nonce — `SorobanCredentials.forAddressV2(credentials.innerAddressCredentials!)` carries the nonce over; a fresh nonce will not match the recorded footprint and then relies on the enforcing re-simulation above.

#### Source Compatibility

`SorobanCredentials`, `XdrSorobanCredentialsType`, `XdrEnvelopeType`, and `XdrHashIDPreimage` gain new union cases for the V2 and delegated arms. Code that switches exhaustively over these without a `default` arm will no longer compile after upgrading. Add a `default` case to such switches.

## Type Conversions

Convert between Dart native types and Soroban XDR values.
Expand Down
83 changes: 42 additions & 41 deletions lib/src/sep/0045/webauth_for_contracts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -666,15 +666,17 @@ class WebAuthForContracts {
}
}

// Check which entry this is (server, client, or client domain)
// Check which entry this is (server, client, or client domain).
// All three address credential arms (ADDRESS, ADDRESS_V2,
// ADDRESS_WITH_DELEGATES) are accepted here.
final credentials = entry.credentials;
if (credentials.addressCredentials != null) {
final credentialsAddress = credentials.addressCredentials!.address;
final credentialsAddressStr = _addressToString(credentialsAddress);
final innerCreds = credentials.innerAddressCredentials;
if (innerCreds != null) {
final credentialsAddressStr = _addressToString(innerCreds.address);

if (credentialsAddressStr == _serverSigningKey) {
serverEntryFound = true;
// Verify server signature
// Verify server signature; accepts all three address arms.
if (!_verifyServerSignature(entry)) {
throw ContractChallengeValidationErrorInvalidServerSignature(
'Server authorization entry has invalid signature',
Expand Down Expand Up @@ -737,16 +739,19 @@ class WebAuthForContracts {

for (final entry in authEntries) {
final credentials = entry.credentials;
if (credentials.addressCredentials != null) {
final credentialsAddress = credentials.addressCredentials!.address;
final credentialsAddressStr = _addressToString(credentialsAddress);
// Use innerAddressCredentials so all three address arms
// (ADDRESS, ADDRESS_V2, ADDRESS_WITH_DELEGATES) are handled.
// Source-account entries are passed through unsigned.
final innerCreds = credentials.innerAddressCredentials;
if (innerCreds != null) {
final credentialsAddressStr = _addressToString(innerCreds.address);

// Sign client entry
if (credentialsAddressStr == clientAccountId) {
// Set signature expiration ledger if provided
// Stamp expiration before signing; innerAddressCredentials mutates
// in-place for all three address arms.
if (signatureExpirationLedger != null) {
credentials.addressCredentials!.signatureExpirationLedger =
signatureExpirationLedger;
innerCreds.signatureExpirationLedger = signatureExpirationLedger;
}

// Sign with all provided signers
Expand All @@ -761,8 +766,7 @@ class WebAuthForContracts {
if (clientDomainKeyPair != null &&
credentialsAddressStr == clientDomainKeyPair.accountId) {
if (signatureExpirationLedger != null) {
credentials.addressCredentials!.signatureExpirationLedger =
signatureExpirationLedger;
innerCreds.signatureExpirationLedger = signatureExpirationLedger;
}
entry.sign(clientDomainKeyPair, _network);
signedEntries.add(entry);
Expand All @@ -773,10 +777,9 @@ class WebAuthForContracts {
if (clientDomainSigningCallback != null &&
clientDomainAccountId != null &&
credentialsAddressStr == clientDomainAccountId) {
// Set signature expiration ledger before sending to callback
// Stamp expiration before handing off to the callback.
if (signatureExpirationLedger != null) {
credentials.addressCredentials!.signatureExpirationLedger =
signatureExpirationLedger;
innerCreds.signatureExpirationLedger = signatureExpirationLedger;
}
final signedEntry = await clientDomainSigningCallback(entry);
signedEntries.add(signedEntry);
Expand Down Expand Up @@ -938,44 +941,42 @@ class WebAuthForContracts {
}
}

/// Verifies server signature on authorization entry.
/// Verifies server signature on an authorization entry.
///
/// Accepts all three address credential arms (ADDRESS, ADDRESS_V2,
/// ADDRESS_WITH_DELEGATES). The preimage is built via
/// [SorobanAuthorizationEntry.buildPreimage], which selects the correct
/// envelope type for each arm: legacy ADDRESS entries use
/// ENVELOPE_TYPE_SOROBAN_AUTHORIZATION; ADDRESS_V2 and WITH_DELEGATES
/// entries use ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS.
///
/// Source-account credentials do not carry a signature and return false.
bool _verifyServerSignature(SorobanAuthorizationEntry entry) {
try {
final xdrCredentials = entry.credentials.toXdr();
if (entry.credentials.addressCredentials == null ||
xdrCredentials.type !=
XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS ||
xdrCredentials.address == null) {
final innerCreds = entry.credentials.innerAddressCredentials;
if (innerCreds == null) {
// Source-account credentials carry no signature.
return false;
}

// Build authorization preimage
final networkId = Util.hash(Uint8List.fromList(
_network.networkPassphrase.codeUnits));
final authPreimageXdr = XdrHashIDPreimageSorobanAuthorization(
XdrHash(networkId),
xdrCredentials.address!.nonce,
xdrCredentials.address!.signatureExpirationLedger,
entry.rootInvocation.toXdr(),
);
final rootInvocationPreimage = XdrHashIDPreimage(
XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION,
);
rootInvocationPreimage.sorobanAuthorization = authPreimageXdr;
// Build the arm-correct preimage via the shared preimage builder.
// The preimage must be built from the credentials as-is; the
// signatureExpirationLedger is already set by the server.
final preimage = entry.buildPreimage(_network);

final xdrOutputStream = XdrDataOutputStream();
XdrHashIDPreimage.encode(xdrOutputStream, rootInvocationPreimage);
XdrHashIDPreimage.encode(xdrOutputStream, preimage);
final payload = Util.hash(Uint8List.fromList(xdrOutputStream.bytes));

// Get signature from credentials
final signatureVal = entry.credentials.addressCredentials!.signature;
// Get signature from inner credentials (same field for all address arms)
final signatureVal = innerCreds.signature;
if (signatureVal.discriminant != XdrSCValType.SCV_VEC ||
signatureVal.vec == null ||
signatureVal.vec!.isEmpty) {
return false;
}

// Extract public key and signature from first signature entry
// Extract public key and signature from the first signature entry
final firstSig = signatureVal.vec![0];
if (firstSig.discriminant != XdrSCValType.SCV_MAP ||
firstSig.map == null) {
Expand All @@ -1002,14 +1003,14 @@ class WebAuthForContracts {
return false;
}

// Verify that extracted public key matches expected server signing key
// Verify that the extracted public key matches the expected server key
final expectedPublicKey =
KeyPair.fromAccountId(_serverSigningKey).publicKey;
if (!_bytesEqual(publicKey, expectedPublicKey)) {
return false;
}

// Verify signature
// Verify signature against the arm-correct payload
final serverKeyPair = KeyPair.fromAccountId(_serverSigningKey);
return serverKeyPair.verify(payload, signature);
} catch (e) {
Expand Down
Loading