diff --git a/CHANGELOG.md b/CHANGELOG.md index 35429515..f5972547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Makefile b/Makefile index 5334b465..cb581d14 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/documentation/sep/sep-45.md b/documentation/sep/sep-45.md index a613d125..89fa80e2 100644 --- a/documentation/sep/sep-45.md +++ b/documentation/sep/sep-45.md @@ -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: diff --git a/documentation/soroban.md b/documentation/soroban.md index c12f24f9..9bbe4b86 100644 --- a/documentation/soroban.md +++ b/documentation/soroban.md @@ -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. diff --git a/lib/src/sep/0045/webauth_for_contracts.dart b/lib/src/sep/0045/webauth_for_contracts.dart index 9f4c7d0d..894d396f 100644 --- a/lib/src/sep/0045/webauth_for_contracts.dart +++ b/lib/src/sep/0045/webauth_for_contracts.dart @@ -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', @@ -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 @@ -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); @@ -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); @@ -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) { @@ -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) { diff --git a/lib/src/smartaccount/oz/oz_multi_signer_manager.dart b/lib/src/smartaccount/oz/oz_multi_signer_manager.dart index 8118148b..0f564778 100644 --- a/lib/src/smartaccount/oz/oz_multi_signer_manager.dart +++ b/lib/src/smartaccount/oz/oz_multi_signer_manager.dart @@ -5,13 +5,13 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:crypto/crypto.dart' as crypto; import 'package:meta/meta.dart'; import '../../account.dart'; import '../../invoke_host_function_operation.dart'; import '../../key_pair.dart'; import '../../memo.dart'; +import '../../network.dart'; import '../../soroban/soroban_auth.dart'; import '../../soroban/soroban_server.dart'; import '../../transaction.dart'; @@ -404,14 +404,38 @@ class OZMultiSignerManager implements OZMultiSignerManagerInterface { for (var entryIndex = 0; entryIndex < authEntries.length; entryIndex++) { final entry = authEntries[entryIndex]; - final addressCreds = entry.credentials.address; - if (entry.credentials.discriminant != - XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS || - addressCreds == null) { + // Pass SOURCE_ACCOUNT entries through unchanged. + if (entry.credentials.discriminant == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { signedAuthEntries.add(entry); continue; } + // ADDRESS_WITH_DELEGATES entries carry a delegate tree that requires + // caller-policy routing. The OZ auto-sign flow cannot determine which + // delegate node each selected signer should sign. Use + // SorobanAuthorizationEntry.sign(signer, network, forAddress: address) + // to route signatures to specific nodes before calling this method. + if (entry.credentials.discriminant == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + throw SmartAccountTransactionException.signingFailed( + 'ADDRESS_WITH_DELEGATES auth entries cannot be auto-signed by the ' + 'multi-signer pipeline. Use SorobanAuthorizationEntry.sign with the ' + 'forAddress parameter to sign each delegate node, then pass the ' + 'pre-signed entries via the auth parameter.', + ); + } + + // For ADDRESS and ADDRESS_V2: extract the inner credentials. + final addressCreds = _innerAddressCredentialsXdr(entry.credentials); + if (addressCreds == null) { + // Unknown arm — fail fast rather than passing through unsigned. + throw SmartAccountTransactionException.signingFailed( + 'Unrecognised credential arm ' + '${entry.credentials.discriminant} in auth entry; cannot sign.', + ); + } + final entryAddress = OZAddressStrKey.fromXdr(addressCreds.address); if (entryAddress != connected.contractId) { OZSelectedSignerWallet? matching; @@ -833,7 +857,7 @@ class OZMultiSignerManager implements OZMultiSignerManagerInterface { }) async { final signedEntry = _cloneEntryWithExpiration(entry, expirationLedger); - final credentials = signedEntry.credentials.address; + final credentials = _innerAddressCredentialsXdr(signedEntry.credentials); if (credentials == null) { throw SmartAccountTransactionException.signingFailed( 'Expected Address credentials on wallet auth entry for ' @@ -841,17 +865,21 @@ class OZMultiSignerManager implements OZMultiSignerManagerInterface { ); } - final networkId = _sha256(utf8.encode(_kit.config.networkPassphrase)); - - final authPreimage = XdrHashIDPreimageSorobanAuthorization( - XdrHash(networkId), - credentials.nonce, - credentials.signatureExpirationLedger, - signedEntry.rootInvocation, - ); - final preimage = XdrHashIDPreimage( - XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION, - )..sorobanAuthorization = authPreimage; + // Build the preimage via the canonical preimage builder so the preimage + // type matches the credential arm: ADDRESS -> legacy preimage, + // ADDRESS_V2 -> address-bound preimage. + final highLevelEntry = SorobanAuthorizationEntry.fromXdr(signedEntry); + XdrHashIDPreimage preimage; + try { + preimage = highLevelEntry.buildPreimage( + Network(_kit.config.networkPassphrase), + ); + } catch (e) { + throw SmartAccountTransactionException.signingFailed( + 'Failed to build preimage for wallet auth entry', + cause: e, + ); + } final stream = XdrDataOutputStream(); XdrHashIDPreimage.encode(stream, preimage); @@ -889,8 +917,9 @@ class OZMultiSignerManager implements OZMultiSignerManagerInterface { signatureScVal, ); + // Preserve the credential arm on write-back. return XdrSorobanAuthorizationEntry( - XdrSorobanCredentials.forAddressCredentials(updatedCredentials), + _rebuildCredentialsXdr(signedEntry.credentials, updatedCredentials), signedEntry.rootInvocation, ); } @@ -905,7 +934,7 @@ class OZMultiSignerManager implements OZMultiSignerManagerInterface { XdrDataInputStream(Uint8List.fromList(stream.bytes)), ); - final credentials = cloned.credentials.address; + final credentials = _innerAddressCredentialsXdr(cloned.credentials); if (credentials == null) return cloned; final updated = XdrSorobanAddressCredentials( @@ -915,8 +944,9 @@ class OZMultiSignerManager implements OZMultiSignerManagerInterface { credentials.signature, ); + // Preserve the credential arm: an ADDRESS_V2 entry stays ADDRESS_V2. return XdrSorobanAuthorizationEntry( - XdrSorobanCredentials.forAddressCredentials(updated), + _rebuildCredentialsXdr(cloned.credentials, updated), cloned.rootInvocation, ); } @@ -929,17 +959,35 @@ class OZMultiSignerManager implements OZMultiSignerManagerInterface { }) async { final nonce = _generateNonce(); - final networkId = _sha256(utf8.encode(_kit.config.networkPassphrase)); - - final authPreimage = XdrHashIDPreimageSorobanAuthorization( - XdrHash(networkId), + // Build a fresh legacy ADDRESS entry so the canonical preimage + // builder can be used. This entry is always ADDRESS (not V2) because it + // is a brand-new entry synthesised for the delegated `__check_auth` + // invocation, not a simulation-returned entry. + final addressCredentials = XdrSorobanAddressCredentials( + Address.forAccountId(publicKey).toXdr(), nonce, XdrUint32(validUntilLedgerSeq), + XdrSCVal.forVoid(), + ); + final freshEntry = XdrSorobanAuthorizationEntry( + XdrSorobanCredentials.forAddressCredentials(addressCredentials), invocation, ); - final preimage = XdrHashIDPreimage( - XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION, - )..sorobanAuthorization = authPreimage; + + // Build the preimage via the canonical builder so preimage type always + // follows the credential arm. + final highLevelEntry = SorobanAuthorizationEntry.fromXdr(freshEntry); + XdrHashIDPreimage preimage; + try { + preimage = highLevelEntry.buildPreimage( + Network(_kit.config.networkPassphrase), + ); + } catch (e) { + throw SmartAccountTransactionException.signingFailed( + 'Failed to build preimage for __check_auth invocation', + cause: e, + ); + } final result = await signer(preimage); @@ -972,6 +1020,45 @@ class OZMultiSignerManager implements OZMultiSignerManagerInterface { ); } + /// Returns the inner [XdrSorobanAddressCredentials] for ADDRESS or + /// ADDRESS_V2 arms, or null for SOURCE_ACCOUNT and unknown arms. + /// + /// ADDRESS_WITH_DELEGATES is intentionally excluded: the callers that use + /// this helper enforce the WITH_DELEGATES restriction before calling. + static XdrSorobanAddressCredentials? _innerAddressCredentialsXdr( + XdrSorobanCredentials creds, + ) { + switch (creds.discriminant) { + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: + return creds.address; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + return creds.addressV2; + default: + return null; + } + } + + /// Rebuilds a [XdrSorobanCredentials] wrapper preserving the original + /// credential arm, replacing the inner credentials with [updated]. + /// + /// Only ADDRESS and ADDRESS_V2 arms are supported; other arms throw + /// [SmartAccountTransactionSigningFailed]. + static XdrSorobanCredentials _rebuildCredentialsXdr( + XdrSorobanCredentials original, + XdrSorobanAddressCredentials updated, + ) { + switch (original.discriminant) { + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: + return XdrSorobanCredentials.forAddressCredentials(updated); + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + return XdrSorobanCredentials.forAddressV2Credentials(updated); + default: + throw SmartAccountTransactionException.signingFailed( + 'Cannot rebuild credentials for arm: ${original.discriminant}', + ); + } + } + /// Generates an 8-byte cryptographically-random Soroban /// address-credentials nonce. /// @@ -985,9 +1072,6 @@ class OZMultiSignerManager implements OZMultiSignerManagerInterface { XdrInt64 _generateNonce() => OZSecureNonce.generate(); - Uint8List _sha256(List data) => - Uint8List.fromList(crypto.sha256.convert(data).bytes); - Future _fetchAccount(String accountId) async { final account = await _kit.sorobanServer.getAccount(accountId); if (account == null) { diff --git a/lib/src/smartaccount/oz/oz_smart_account_auth.dart b/lib/src/smartaccount/oz/oz_smart_account_auth.dart index 059df778..de9d1836 100644 --- a/lib/src/smartaccount/oz/oz_smart_account_auth.dart +++ b/lib/src/smartaccount/oz/oz_smart_account_auth.dart @@ -2,11 +2,12 @@ // Use of this source code is governed by a license that can be // found in the LICENSE file. -import 'dart:convert'; import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; +import '../../network.dart'; +import '../../soroban/soroban_auth.dart'; import '../../xdr/xdr.dart'; import '../core/smart_account_errors.dart'; import 'oz_smart_account_auth_payload.dart'; @@ -33,6 +34,16 @@ import 'oz_smart_account_signatures.dart'; /// call concurrently from any isolate. The [signAuthEntry] helper never /// mutates its input entry — it clones via XDR round-trip and returns a new /// entry. +/// +/// Credential arm handling: +/// - ADDRESS: legacy preimage (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION). +/// - ADDRESS_V2: address-bound preimage +/// (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS). Handled identically +/// to ADDRESS in the signing flow; the arm is preserved on write-back. +/// - ADDRESS_WITH_DELEGATES: cannot be auto-signed by these flows. Use +/// [SorobanAuthorizationEntry.sign] with the [forAddress] parameter instead. +/// - SOURCE_ACCOUNT: not signable; throws on all entry points that require +/// address credentials. abstract class OZSmartAccountAuth { OZSmartAccountAuth._(); @@ -82,46 +93,47 @@ abstract class OZSmartAccountAuth { /// /// Computes the hash that must be signed to authorise a Soroban /// operation. This hash is used as the WebAuthn challenge when - /// collecting biometric signatures. The entry must have address - /// credentials. + /// collecting biometric signatures. /// - /// The payload preimage is constructed as - /// `HashIDPreimage::SorobanAuthorization { networkId, - /// nonce: credentials.nonce, signatureExpirationLedger: expirationLedger, - /// invocation: entry.rootInvocation }` and the returned value is - /// `SHA-256(XDR_encode(preimage))`. + /// Preimage selection follows the credential arm: + /// - ADDRESS: ENVELOPE_TYPE_SOROBAN_AUTHORIZATION (legacy). + /// - ADDRESS_V2: ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS. + /// - ADDRESS_WITH_DELEGATES: not supported by this flow; throws a + /// [SmartAccountTransactionSigningFailed] directing the caller to use + /// [SorobanAuthorizationEntry.sign] with the [forAddress] parameter. /// - /// Throws [SmartAccountTransactionSigningFailed] when [entry] does not have address - /// credentials, or when XDR encoding fails. + /// [expirationLedger] is stamped onto the credentials before the preimage + /// is built. The returned hash is `SHA-256(XDR_encode(preimage))`. + /// + /// Throws [SmartAccountTransactionSigningFailed] when the entry does not carry + /// address credentials or when XDR encoding fails. static Future buildAuthPayloadHash( XdrSorobanAuthorizationEntry entry, int expirationLedger, String networkPassphrase, ) async { - final credentials = entry.credentials.address; - if (entry.credentials.discriminant != - XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS || - credentials == null) { - throw SmartAccountTransactionException.signingFailed( - 'Credentials must be of type address to build auth payload hash', - ); - } - - return _hashAuthPreimage( - nonce: credentials.nonce, - expirationLedger: expirationLedger, - invocation: entry.rootInvocation, - networkPassphrase: networkPassphrase, + _requireAddressOrV2Credentials(entry.credentials); + + final inner = _innerAddressCredentials(entry.credentials)!; + // Stamp the expiration before building the preimage so the hash covers + // the submitted expiration value. + final stampedInner = XdrSorobanAddressCredentials( + inner.address, + inner.nonce, + XdrUint32(expirationLedger), + inner.signature, ); + final stampedEntry = _rebuildEntry(entry, stampedInner); + + return _hashPreimage(stampedEntry, networkPassphrase); } /// Builds the authorisation payload hash for source-account credentials. /// /// Used when converting source-account credentials to address /// credentials, typically for relayer fee sponsoring. The preimage is - /// constructed identically to [buildAuthPayloadHash] but uses the - /// supplied [nonce] and [expirationLedger] instead of reading them from - /// existing credentials. + /// always ENVELOPE_TYPE_SOROBAN_AUTHORIZATION because this flow creates + /// new legacy ADDRESS credentials for a temporary keypair. /// /// Throws [SmartAccountTransactionSigningFailed] when XDR encoding fails. static Future buildSourceAccountAuthPayloadHash( @@ -153,10 +165,17 @@ abstract class OZSmartAccountAuth { /// the payload back, and returns a new authorisation entry with the updated /// credentials. The input entry is never mutated. /// + /// The credential arm is preserved on write-back: an ADDRESS_V2 entry stays + /// ADDRESS_V2 after signing. + /// /// When [contextRuleIds] is non-empty it overrides any existing /// context-rule IDs in the payload; otherwise the existing value is /// preserved. /// + /// ADDRESS_WITH_DELEGATES entries are not supported here; the caller must + /// use [SorobanAuthorizationEntry.sign] with the [forAddress] parameter to + /// route signatures to the appropriate delegate nodes. + /// /// Throws [SmartAccountTransactionSigningFailed] when credentials are not address /// type, when the entry cannot be cloned via XDR, when [signer] cannot /// be encoded as an ScVal, or when [OZSmartAccountSignature.toAuthPayloadBytes] @@ -195,14 +214,9 @@ abstract class OZSmartAccountAuth { } // Step 2: extract the address credentials from the cloned entry. - final credentialsCopy = entryCopy.credentials.address; - if (entryCopy.credentials.discriminant != - XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS || - credentialsCopy == null) { - throw SmartAccountTransactionException.signingFailed( - 'Credentials must be of type address to sign auth entry', - ); - } + // ADDRESS_WITH_DELEGATES is not supported in this auto-sign flow. + _requireAddressOrV2Credentials(entryCopy.credentials); + final credentialsCopy = _innerAddressCredentials(entryCopy.credentials)!; // Step 3: produce the bytes for the on-wire signers Map. The exact // content is verifier-dependent: WebAuthn/Policy XDR-encode their @@ -246,8 +260,10 @@ abstract class OZSmartAccountAuth { payloadScVal, ); - final updatedCredsWrapper = - XdrSorobanCredentials.forAddressCredentials(updatedCredentials); + final updatedCredsWrapper = _rebuildCredentials( + entryCopy.credentials, + updatedCredentials, + ); return XdrSorobanAuthorizationEntry( updatedCredsWrapper, entryCopy.rootInvocation, @@ -266,6 +282,11 @@ abstract class OZSmartAccountAuth { /// stored directly; otherwise the value is XDR-encoded and the resulting /// bytes are stored. /// + /// The credential arm is preserved on write-back. + /// + /// ADDRESS_WITH_DELEGATES entries are not supported here; use + /// [SorobanAuthorizationEntry.sign] with the [forAddress] parameter instead. + /// /// Throws [SmartAccountTransactionSigningFailed] when [entry] does not have address /// credentials, or when XDR encoding of the signature value fails. static XdrSorobanAuthorizationEntry addRawSignatureMapEntry({ @@ -274,14 +295,9 @@ abstract class OZSmartAccountAuth { required XdrSCVal signatureValue, List contextRuleIds = const [], }) { - final credentials = entry.credentials.address; - if (entry.credentials.discriminant != - XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS || - credentials == null) { - throw SmartAccountTransactionException.signingFailed( - 'Credentials must be of type address to add signature map entry', - ); - } + // ADDRESS_WITH_DELEGATES is not supported in this auto-sign flow. + _requireAddressOrV2Credentials(entry.credentials); + final credentials = _innerAddressCredentials(entry.credentials)!; final existingPayload = OZSmartAccountAuthPayloadCodec.read(credentials.signature); @@ -322,8 +338,10 @@ abstract class OZSmartAccountAuth { payloadScVal, ); - final updatedCredsWrapper = - XdrSorobanCredentials.forAddressCredentials(updatedCredentials); + final updatedCredsWrapper = _rebuildCredentials( + entry.credentials, + updatedCredentials, + ); return XdrSorobanAuthorizationEntry( updatedCredsWrapper, entry.rootInvocation, @@ -332,33 +350,94 @@ abstract class OZSmartAccountAuth { // Helper functions - /// Hashes a Soroban authorisation preimage. + /// Returns the inner [XdrSorobanAddressCredentials] for any address arm + /// (ADDRESS, ADDRESS_V2, or ADDRESS_WITH_DELEGATES), or null for + /// SOURCE_ACCOUNT. + static XdrSorobanAddressCredentials? _innerAddressCredentials( + XdrSorobanCredentials creds, + ) { + switch (creds.discriminant) { + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: + return creds.address; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + return creds.addressV2; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + return creds.addressWithDelegates?.addressCredentials; + default: + return null; + } + } + + /// Rebuilds a [XdrSorobanCredentials] wrapper from the same discriminant arm, + /// replacing the inner [XdrSorobanAddressCredentials] with [updated]. /// - /// Constructs `HashIDPreimage::SorobanAuthorization` from the given - /// parameters, XDR-encodes it, and returns `SHA-256(encoded bytes)`. - /// Used by both [buildAuthPayloadHash] and - /// [buildSourceAccountAuthPayloadHash]. - static Future _hashAuthPreimage({ - required XdrInt64 nonce, - required int expirationLedger, - required XdrSorobanAuthorizedInvocation invocation, - required String networkPassphrase, - }) async { - final networkId = Uint8List.fromList( - crypto.sha256.convert(utf8.encode(networkPassphrase)).bytes, - ); + /// Preserves the credential arm: an ADDRESS_V2 entry stays ADDRESS_V2. + /// Throws [SmartAccountTransactionSigningFailed] for SOURCE_ACCOUNT or unknown arms. + static XdrSorobanCredentials _rebuildCredentials( + XdrSorobanCredentials original, + XdrSorobanAddressCredentials updated, + ) { + switch (original.discriminant) { + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: + return XdrSorobanCredentials.forAddressCredentials(updated); + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + return XdrSorobanCredentials.forAddressV2Credentials(updated); + default: + throw SmartAccountTransactionException.signingFailed( + 'Cannot rebuild credentials for arm: ${original.discriminant}', + ); + } + } - final authPreimage = XdrHashIDPreimageSorobanAuthorization( - XdrHash(networkId), - nonce, - XdrUint32(expirationLedger), - invocation, - ); + /// Validates that [creds] carries ADDRESS or ADDRESS_V2 credentials. + /// + /// Throws [SmartAccountTransactionSigningFailed] for SOURCE_ACCOUNT (no address + /// credentials available) and for ADDRESS_WITH_DELEGATES (delegate routing + /// is caller policy; use [SorobanAuthorizationEntry.sign] with the + /// [forAddress] parameter to route signatures to specific delegate nodes). + static void _requireAddressOrV2Credentials(XdrSorobanCredentials creds) { + switch (creds.discriminant) { + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + return; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + throw SmartAccountTransactionException.signingFailed( + 'ADDRESS_WITH_DELEGATES entries cannot be auto-signed by the OZ auth flow. ' + 'Use SorobanAuthorizationEntry.sign(signer, network, forAddress: address) ' + 'to route signatures to specific delegate nodes before calling these helpers.', + ); + default: + throw SmartAccountTransactionException.signingFailed( + 'Credentials must be of type ADDRESS or ADDRESS_V2 to sign auth entry', + ); + } + } - final preimage = XdrHashIDPreimage( - XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION, - ); - preimage.sorobanAuthorization = authPreimage; + /// Hashes the authorisation preimage for the given entry. + /// + /// Promotes the XDR entry to the higher-level [SorobanAuthorizationEntry] + /// and delegates preimage construction to + /// [SorobanAuthorizationEntry.buildPreimage]. This ensures a single + /// canonical preimage-construction path is used for all credential arms: + /// - ADDRESS -> ENVELOPE_TYPE_SOROBAN_AUTHORIZATION + /// - ADDRESS_V2 -> ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS + /// + /// Returns `SHA-256(XDR_encode(preimage))`. + static Future _hashPreimage( + XdrSorobanAuthorizationEntry xdrEntry, + String networkPassphrase, + ) async { + final entry = SorobanAuthorizationEntry.fromXdr(xdrEntry); + final network = Network(networkPassphrase); + XdrHashIDPreimage preimage; + try { + preimage = entry.buildPreimage(network); + } catch (e) { + throw SmartAccountTransactionException.signingFailed( + 'Failed to build auth payload preimage', + cause: e, + ); + } Uint8List encodedPreimage; try { @@ -374,4 +453,50 @@ abstract class OZSmartAccountAuth { return Uint8List.fromList(crypto.sha256.convert(encodedPreimage).bytes); } + + /// Hashes a legacy Soroban authorisation preimage. + /// + /// Constructs a synthetic legacy ADDRESS entry from the given parameters, + /// then delegates to [_hashPreimage] (which uses the canonical + /// builder). The credential address field is a placeholder because the + /// legacy preimage (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION) does not include + /// the address, so its value is irrelevant to the hash. + /// + /// Used exclusively by [buildSourceAccountAuthPayloadHash] for the + /// source-account -> legacy ADDRESS conversion path. + static Future _hashAuthPreimage({ + required XdrInt64 nonce, + required int expirationLedger, + required XdrSorobanAuthorizedInvocation invocation, + required String networkPassphrase, + }) async { + // Build a synthetic legacy ADDRESS entry. The address value is a + // placeholder (all-zero contract ID) because the legacy preimage type + // (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION) does not include the address. + const _kPlaceholderContractId = + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; + final addressCreds = XdrSorobanAddressCredentials( + XdrSCAddress.forContractId(_kPlaceholderContractId), + nonce, + XdrUint32(expirationLedger), + XdrSCVal.forVoid(), + ); + final syntheticEntry = XdrSorobanAuthorizationEntry( + XdrSorobanCredentials.forAddressCredentials(addressCreds), + invocation, + ); + return _hashPreimage(syntheticEntry, networkPassphrase); + } + + /// Rebuilds an [XdrSorobanAuthorizationEntry] with [stampedInner] as the + /// credentials body, preserving the credential arm. + static XdrSorobanAuthorizationEntry _rebuildEntry( + XdrSorobanAuthorizationEntry original, + XdrSorobanAddressCredentials stampedInner, + ) { + return XdrSorobanAuthorizationEntry( + _rebuildCredentials(original.credentials, stampedInner), + original.rootInvocation, + ); + } } diff --git a/lib/src/smartaccount/oz/oz_transaction_operations.dart b/lib/src/smartaccount/oz/oz_transaction_operations.dart index 310e7cd8..7d222bcc 100644 --- a/lib/src/smartaccount/oz/oz_transaction_operations.dart +++ b/lib/src/smartaccount/oz/oz_transaction_operations.dart @@ -576,15 +576,36 @@ class OZTransactionOperations { _checkCancellation(cancelToken); final entry = simulatedAuthEntries[entryIndex]; - final addressCreds = entry.credentials.address; - if (entry.credentials.discriminant != - XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS || - addressCreds == null) { - // Source-account or unknown credentials — pass through unchanged. + // Pass SOURCE_ACCOUNT entries through unchanged. + if (entry.credentials.discriminant == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { signed.add(entry); continue; } + // ADDRESS_WITH_DELEGATES entries cannot be auto-signed by this flow. + // Use SorobanAuthorizationEntry.sign with the forAddress parameter to + // route signatures to the appropriate delegate nodes. + if (entry.credentials.discriminant == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + throw SmartAccountTransactionException.signingFailed( + 'ADDRESS_WITH_DELEGATES auth entries cannot be auto-signed by the ' + 'single-signer pipeline. Use SorobanAuthorizationEntry.sign with the ' + 'forAddress parameter to sign each delegate node, then pass the ' + 'pre-signed entries via the auth parameter.', + ); + } + + // For ADDRESS and ADDRESS_V2: extract the inner credentials. + final addressCreds = _innerAddressCredentialsXdr(entry.credentials); + if (addressCreds == null) { + // Unknown credential arm — fail fast. + throw SmartAccountTransactionException.signingFailed( + 'Unrecognised credential arm ' + '${entry.credentials.discriminant} in auth entry; cannot sign.', + ); + } + final entryAddress = OZAddressStrKey.fromXdr(addressCreds.address); if (entryAddress != contractId) { // Auth entry does not point at our smart account — pass through. @@ -1040,11 +1061,20 @@ class OZTransactionOperations { _cloneInvocation(entry.rootInvocation), )); } else if (entry.credentials.discriminant == - XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS && - entry.credentials.address != null) { + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS || + entry.credentials.discriminant == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2) { final entryCopy = _cloneAuthEntry(entry); - final credentials = entryCopy.credentials.address!; + final credentials = _innerAddressCredentialsXdr(entryCopy.credentials); + if (credentials == null) { + throw SmartAccountTransactionException.signingFailed( + 'Address credentials unexpectedly null after clone for arm ' + '${entryCopy.credentials.discriminant}', + ); + } + // buildAuthPayloadHash handles both ADDRESS and ADDRESS_V2: it stamps + // the expiration and selects the preimage type based on the arm. final payloadHash = await OZSmartAccountAuth.buildAuthPayloadHash( entryCopy, expirationLedger, @@ -1070,13 +1100,26 @@ class OZTransactionOperations { XdrUint32(expirationLedger), signatureVec, ); + // Preserve the credential arm on write-back. result.add(XdrSorobanAuthorizationEntry( - XdrSorobanCredentials.forAddressCredentials(updatedCredentials), + _rebuildCredentialsXdr(entryCopy.credentials, updatedCredentials), entryCopy.rootInvocation, )); + } else if (entry.credentials.discriminant == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + // ADDRESS_WITH_DELEGATES entries require caller-policy routing. + // The fundWallet flow cannot determine which delegate node to sign. + throw SmartAccountTransactionException.signingFailed( + 'ADDRESS_WITH_DELEGATES auth entries cannot be signed by the ' + 'fundWallet flow. Use SorobanAuthorizationEntry.sign with the ' + 'forAddress parameter to sign delegate nodes before calling fundWallet.', + ); } else { - // Unknown credential type — pass through unchanged. - result.add(entry); + // Unknown credential arm — fail fast rather than passing through unsigned. + throw SmartAccountTransactionException.signingFailed( + 'Unrecognised credential arm ' + '${entry.credentials.discriminant} in auth entry; cannot sign.', + ); } } return result; @@ -1467,6 +1510,45 @@ class OZTransactionOperations { return XdrSorobanAuthorizedInvocation.decode(XdrDataInputStream(bytes)); } + /// Returns the inner [XdrSorobanAddressCredentials] for ADDRESS or + /// ADDRESS_V2 arms, or null for SOURCE_ACCOUNT and unknown arms. + /// + /// ADDRESS_WITH_DELEGATES is intentionally excluded: the callers that use + /// this helper enforce the WITH_DELEGATES restriction before calling. + static XdrSorobanAddressCredentials? _innerAddressCredentialsXdr( + XdrSorobanCredentials creds, + ) { + switch (creds.discriminant) { + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: + return creds.address; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + return creds.addressV2; + default: + return null; + } + } + + /// Rebuilds a [XdrSorobanCredentials] wrapper preserving the original + /// credential arm, replacing the inner credentials with [updated]. + /// + /// Only ADDRESS and ADDRESS_V2 arms are supported; other arms throw + /// [SmartAccountTransactionSigningFailed]. + static XdrSorobanCredentials _rebuildCredentialsXdr( + XdrSorobanCredentials original, + XdrSorobanAddressCredentials updated, + ) { + switch (original.discriminant) { + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: + return XdrSorobanCredentials.forAddressCredentials(updated); + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + return XdrSorobanCredentials.forAddressV2Credentials(updated); + default: + throw SmartAccountTransactionException.signingFailed( + 'Cannot rebuild credentials for arm: ${original.discriminant}', + ); + } + } + // Static amount helpers /// Maximum number of decimal places accepted by [amountToBaseUnits]. diff --git a/lib/src/soroban/soroban_auth.dart b/lib/src/soroban/soroban_auth.dart index 780a8a6f..ced91900 100644 --- a/lib/src/soroban/soroban_auth.dart +++ b/lib/src/soroban/soroban_auth.dart @@ -280,54 +280,142 @@ class SorobanAddressCredentials { } } +/// A single delegate node in a WITH_DELEGATES authorization tree (protocol 27+). +/// +/// Each node holds: +/// - [address] The SCAddress of the delegate (account or contract; never muxed). +/// - [signature] The delegate's signature as an XdrSCVal (void until signed). +/// - [nestedDelegates] Child delegates authorized by this delegate. +/// +/// Sorting invariant: within any [nestedDelegates] list, delegates are ordered +/// ascending by the lexicographic comparison of their complete XDR-encoded +/// [XdrSCAddress] bytes. Build trees with [SorobanDelegateDescriptor] and +/// [SorobanAuthorizationEntry.withDelegates]; that API enforces the sort order. +/// +/// Signing semantics: all delegates at any depth sign the same preimage hash +/// as the top-level credentials; delegates carry no nonce or expiration. +/// Signing the same node twice with the same key appends a duplicate that the +/// host rejects. +class SorobanDelegateSignature { + /// The SCAddress of this delegate. + XdrSCAddress address; + + /// The delegate's signature (void until explicitly signed). + XdrSCVal signature; + + /// Child delegates authorized by this delegate. Must be sorted ascending by + /// the XDR-encoded bytes of each child's [address]; no duplicates within this list. + List nestedDelegates; + + SorobanDelegateSignature(this.address, this.signature, this.nestedDelegates); + + /// Creates a [SorobanDelegateSignature] from its XDR representation. + static SorobanDelegateSignature fromXdr(XdrSorobanDelegateSignature xdr) { + final nested = xdr.nestedDelegates + .map((d) => SorobanDelegateSignature.fromXdr(d)) + .toList(); + return SorobanDelegateSignature(xdr.address, xdr.signature, nested); + } + + /// Converts this delegate to its XDR representation. + XdrSorobanDelegateSignature toXdr() { + return XdrSorobanDelegateSignature( + address, + signature, + nestedDelegates.map((d) => d.toXdr()).toList(), + ); + } +} + +/// Wraps the inner credentials and top-level delegate list for the +/// SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES arm (protocol 27+). +/// +/// The [delegates] list must be sorted ascending by the XDR-encoded bytes of +/// each delegate's address and must not contain duplicate addresses at the +/// same level. Use [SorobanAuthorizationEntry.withDelegates] to construct +/// entries with correct sort order. +class SorobanAddressCredentialsWithDelegates { + /// The core address credentials (address, nonce, expiration, top-level signature). + SorobanAddressCredentials addressCredentials; + + /// Top-level delegates. Each element may itself carry nested delegates. + /// Sorted ascending by XDR-encoded address bytes; no duplicates within this list. + List delegates; + + SorobanAddressCredentialsWithDelegates(this.addressCredentials, this.delegates); + + /// Creates from its XDR representation. + static SorobanAddressCredentialsWithDelegates fromXdr( + XdrSorobanAddressCredentialsWithDelegates xdr) { + return SorobanAddressCredentialsWithDelegates( + SorobanAddressCredentials.fromXdr(xdr.addressCredentials), + xdr.delegates.map((d) => SorobanDelegateSignature.fromXdr(d)).toList(), + ); + } + + /// Converts to XDR representation. + XdrSorobanAddressCredentialsWithDelegates toXdr() { + return XdrSorobanAddressCredentialsWithDelegates( + addressCredentials.toXdr(), + delegates.map((d) => d.toXdr()).toList(), + ); + } +} + /// Authorization credentials for Soroban contract invocations. /// -/// Soroban supports two authorization modes: +/// Soroban supports four credential arms: /// /// 1. Source Account Authorization (implicit): -/// - Used when the transaction source account authorizes the invocation -/// - No explicit signature required in the auth entry -/// - Created via [SorobanCredentials.forSourceAccount] -/// - The transaction signature itself provides authorization -/// -/// 2. Address Credentials Authorization (explicit): -/// - Used for multi-party transactions where accounts other than the source -/// must authorize specific invocations -/// - Requires explicit signature in [addressCredentials] -/// - Created via [SorobanCredentials.forAddress] or [SorobanCredentials.forAddressCredentials] -/// - Common in token swaps, escrow releases, and multi-sig scenarios -/// -/// Example - Source account authorization: -/// ```dart -/// // Transaction source account implicitly authorizes -/// final credentials = SorobanCredentials.forSourceAccount(); -/// ``` +/// - Used when the transaction source account authorizes the invocation. +/// - No explicit signature required. +/// - Created via [SorobanCredentials.forSourceAccount]. /// -/// Example - Multi-party authorization: -/// ```dart -/// // Another party must sign this auth entry -/// final authEntry = simulationResponse.auth![0]; -/// authEntry.sign(otherPartyKeyPair, network); -/// ``` +/// 2. Address Credentials (legacy, all protocol versions): +/// - Explicit per-address signature; preimage type ENVELOPE_TYPE_SOROBAN_AUTHORIZATION. +/// - Created via [SorobanCredentials.forAddress] or [SorobanCredentials.forAddressCredentials]. +/// - This is the default and remains valid on all protocol versions. +/// +/// 3. Address V2 Credentials (protocol 27+): +/// - Same body as Address but preimage type changes to +/// ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS (address-bound). +/// - Emitting this arm below protocol 27 invalidates the transaction. +/// - Created via [SorobanCredentials.forAddressV2]. +/// +/// 4. Address With Delegates Credentials (protocol 27+): +/// - Extends V2 with a recursive delegate tree. +/// - Emitting this arm below protocol 27 invalidates the transaction. +/// - Created via [SorobanCredentials.forAddressWithDelegates]. +/// - Use [SorobanAuthorizationEntry.withDelegates] to build correctly sorted trees. /// /// See also: /// - [SorobanAuthorizationEntry] for complete authorization entry structure /// - [AssembledTransaction.signAuthEntries] for signing workflows class SorobanCredentials { + // Exactly one of the four fields below is non-null, matching the active arm. SorobanAddressCredentials? addressCredentials; + SorobanAddressCredentials? addressV2Credentials; + SorobanAddressCredentialsWithDelegates? addressWithDelegatesCredentials; + + // null means SOURCE_ACCOUNT; set by the private _arm tracker + XdrSorobanCredentialsType _arm; + + SorobanCredentials._(this._arm, { + this.addressCredentials, + this.addressV2Credentials, + this.addressWithDelegatesCredentials, + }); /// Creates SorobanCredentials for contract invocation authorization. /// - /// This constructor creates credentials that determine how an invocation is authorized. - /// Two modes are supported based on whether addressCredentials is provided. - /// - /// Parameters: - /// - [addressCredentials] Optional address credentials for explicit authorization + /// This constructor creates source-account credentials when + /// [addressCredentials] is null, or legacy address credentials otherwise. /// - /// Authorization modes: - /// - If null: Source account authorization (transaction signer authorizes) - /// - If provided: Address-based authorization (specified account must sign) - SorobanCredentials({SorobanAddressCredentials? addressCredentials}) { + /// For the new P27 arms use [forAddressV2] or [forAddressWithDelegates]. + SorobanCredentials({SorobanAddressCredentials? addressCredentials}) : + _arm = addressCredentials != null + ? XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS + : XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT { if (addressCredentials != null) { this.addressCredentials = addressCredentials; } @@ -335,16 +423,15 @@ class SorobanCredentials { /// Creates credentials for source account authorization. /// - /// Use when the transaction source account authorizes the invocation. - /// No explicit signature is required; transaction signature provides authorization. + /// The transaction signature provides authorization; no explicit signature is required. static SorobanCredentials forSourceAccount() { return SorobanCredentials(); } - /// Creates credentials for address-based authorization. + /// Creates legacy address-based credentials. /// - /// Use when an account other than the source must authorize the invocation. - /// The specified address must sign the authorization entry. + /// Preimage type: ENVELOPE_TYPE_SOROBAN_AUTHORIZATION. + /// Valid on all protocol versions. static SorobanCredentials forAddress(Address address, BigInt nonce, int signatureExpirationLedger, XdrSCVal signature) { SorobanAddressCredentials addressCredentials = SorobanAddressCredentials( @@ -357,26 +444,116 @@ class SorobanCredentials { return SorobanCredentials(addressCredentials: addressCredentials); } + /// Creates ADDRESS_V2 credentials (protocol 27+). + /// + /// Preimage type: ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS. + /// The preimage binds the top-level credential address. + /// Emitting this arm on a network below protocol 27 invalidates the transaction. + static SorobanCredentials forAddressV2(SorobanAddressCredentials credentials) { + return SorobanCredentials._( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + addressV2Credentials: credentials, + ); + } + + /// Creates ADDRESS_WITH_DELEGATES credentials (protocol 27+). + /// + /// Preimage type: ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS. + /// Emitting this arm on a network below protocol 27 invalidates the transaction. + /// Prefer [SorobanAuthorizationEntry.withDelegates] which enforces delegate sort order. + static SorobanCredentials forAddressWithDelegates( + SorobanAddressCredentialsWithDelegates credentials) { + return SorobanCredentials._( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + addressWithDelegatesCredentials: credentials, + ); + } + + /// The active credential arm discriminant. + XdrSorobanCredentialsType get arm => _arm; + + /// Returns the inner [SorobanAddressCredentials] for any address arm, + /// or null for SOURCE_ACCOUNT. + SorobanAddressCredentials? get innerAddressCredentials { + switch (_arm) { + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: + return addressCredentials; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + return addressV2Credentials; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + return addressWithDelegatesCredentials?.addressCredentials; + default: + return null; + } + } + /// Creates Soroban credentials from their XDR representation. + /// + /// Throws if the discriminant is unrecognized. static SorobanCredentials fromXdr(XdrSorobanCredentials xdr) { - if (xdr.type == XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS && - xdr.address != null) { - return SorobanCredentials.forAddressCredentials( - SorobanAddressCredentials.fromXdr(xdr.address!)); + switch (xdr.type) { + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: + if (xdr.address == null) { + throw Exception('ADDRESS credentials missing address field'); + } + return SorobanCredentials._( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS, + addressCredentials: SorobanAddressCredentials.fromXdr(xdr.address!), + ); + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + if (xdr.addressV2 == null) { + throw Exception('ADDRESS_V2 credentials missing addressV2 field'); + } + return SorobanCredentials._( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + addressV2Credentials: SorobanAddressCredentials.fromXdr(xdr.addressV2!), + ); + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + if (xdr.addressWithDelegates == null) { + throw Exception('ADDRESS_WITH_DELEGATES credentials missing addressWithDelegates field'); + } + return SorobanCredentials._( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + addressWithDelegatesCredentials: + SorobanAddressCredentialsWithDelegates.fromXdr(xdr.addressWithDelegates!), + ); + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT: + return SorobanCredentials._( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT, + ); + default: + throw Exception( + 'Unknown SorobanCredentials discriminant: ${xdr.type}', + ); } - return SorobanCredentials(); } /// Converts these Soroban credentials to their XDR representation. XdrSorobanCredentials toXdr() { - if (addressCredentials != null) { - XdrSorobanCredentials cred = XdrSorobanCredentials( - XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS); - cred.address = addressCredentials!.toXdr(); - return cred; + switch (_arm) { + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: + if (addressCredentials == null) { + throw Exception('ADDRESS credentials missing addressCredentials'); + } + return XdrSorobanCredentials.forAddressCredentials( + addressCredentials!.toXdr()); + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + if (addressV2Credentials == null) { + throw Exception('ADDRESS_V2 credentials missing addressV2Credentials'); + } + return XdrSorobanCredentials.forAddressV2Credentials( + addressV2Credentials!.toXdr()); + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + if (addressWithDelegatesCredentials == null) { + throw Exception( + 'ADDRESS_WITH_DELEGATES credentials missing addressWithDelegatesCredentials'); + } + return XdrSorobanCredentials.forAddressWithDelegatesCredentials( + addressWithDelegatesCredentials!.toXdr()); + default: + return XdrSorobanCredentials( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT); } - return XdrSorobanCredentials( - XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT); } } @@ -644,6 +821,31 @@ class SorobanAuthorizedInvocation { } } +/// Descriptor for a single delegate node when building a WITH_DELEGATES entry. +/// +/// Used with [SorobanAuthorizationEntry.withDelegates] to specify the delegate +/// tree. The [signature] field defaults to void (the normal pre-signing state). +/// [nestedDelegates] lists child delegates authorized by this node. +/// +/// Muxed addresses (M...) are not valid Soroban auth delegate addresses and +/// will be rejected by [SorobanAuthorizationEntry.withDelegates]. +class SorobanDelegateDescriptor { + /// The strkey of this delegate (G... account or C... contract). + final String addressStrKey; + + /// Pre-filled signature for this node. Defaults to void. + final XdrSCVal? signature; + + /// Child delegates authorized by this node (optional; defaults to none). + final List nestedDelegates; + + const SorobanDelegateDescriptor( + this.addressStrKey, { + this.signature, + this.nestedDelegates = const [], + }); +} + /// Authorization entry for Soroban contract invocations. /// /// SorobanAuthorizationEntry represents authorization for a contract function call @@ -663,9 +865,7 @@ class SorobanAuthorizedInvocation { /// /// // Sign auth entries for each required signer /// for (var entry in authEntries) { -/// if (entry.credentials.addressCredentials != null) { -/// final signerAddress = entry.credentials.addressCredentials!.address; -/// // Get appropriate keypair and sign +/// if (entry.credentials.innerAddressCredentials != null) { /// entry.sign(signerKeyPair, network); /// } /// } @@ -682,6 +882,10 @@ class SorobanAuthorizationEntry { SorobanCredentials credentials; SorobanAuthorizedInvocation rootInvocation; + /// Maximum depth for delegate tree traversal. Guards against pathological + /// trees in forAddress routing. + static const int _maxDelegateTraversalDepth = 128; + /// Creates a SorobanAuthorizationEntry with credentials and invocation tree. /// /// This constructor combines authorization credentials with the function invocation @@ -720,39 +924,406 @@ class SorobanAuthorizationEntry { return base64Encode(xdrOutputStream.bytes); } - /// Signs the authorization entry. - /// The signature will be set to the soroban credentials - void sign(KeyPair signer, Network network) { - XdrSorobanCredentials xdrCredentials = credentials.toXdr(); - if (credentials.addressCredentials == null || - xdrCredentials.type != - XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS || - xdrCredentials.address == null) { - throw Exception("no soroban address credentials found"); - } - - XdrHashIDPreimageSorobanAuthorization authPreimageXdr = - XdrHashIDPreimageSorobanAuthorization( - XdrHash(network.networkId!), - xdrCredentials.address!.nonce, - xdrCredentials.address!.signatureExpirationLedger, - rootInvocation.toXdr()); - XdrHashIDPreimage rootInvocationPreimage = - XdrHashIDPreimage(XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION); - rootInvocationPreimage.sorobanAuthorization = authPreimageXdr; - XdrDataOutputStream xdrOutputStream = XdrDataOutputStream(); - XdrHashIDPreimage.encode(xdrOutputStream, rootInvocationPreimage); - Uint8List payload = Util.hash(Uint8List.fromList(xdrOutputStream.bytes)); - Uint8List signatureBytes = signer.sign(payload); - AccountEd25519Signature signature = - AccountEd25519Signature(signer.xdrPublicKey, signatureBytes); - List signatures = List.empty(growable: true); - if (credentials.addressCredentials!.signature.vec != null) { - signatures.addAll(credentials.addressCredentials!.signature.vec!); - } - signatures.add(signature.toXdrSCVal()); - credentials.addressCredentials!.signature = XdrSCVal.forVec(signatures); + /// Builds the XdrHashIDPreimage for this entry based on its credential arm. + /// + /// Arm selection: + /// - ADDRESS -> ENVELOPE_TYPE_SOROBAN_AUTHORIZATION (address-independent preimage) + /// - ADDRESS_V2 and ADDRESS_WITH_DELEGATES -> ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS + /// The address in the preimage is always the top-level credential address. + /// + /// [signatureExpirationLedger] must be set on the credentials BEFORE calling this + /// method; the network reconstructs the preimage from the submitted credentials. + /// + /// Throws if the credentials are source-account or have an unknown arm. + XdrHashIDPreimage buildPreimage(Network network) { + final arm = credentials.arm; + final inner = credentials.innerAddressCredentials; + + if (inner == null) { + throw Exception( + 'Cannot build preimage for source-account credentials'); + } + + if (arm == XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) { + // Legacy preimage: no address binding + final authPreimage = XdrHashIDPreimageSorobanAuthorization( + XdrHash(network.networkId!), + XdrInt64(inner.nonce), + XdrUint32(inner.signatureExpirationLedger), + rootInvocation.toXdr(), + ); + final preimage = XdrHashIDPreimage( + XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION); + preimage.sorobanAuthorization = authPreimage; + return preimage; + } else if (arm == XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2 || + arm == XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + // Address-bound preimage; address is always the top-level credential address + final authPreimage = XdrHashIDPreimageSorobanAuthorizationWithAddress( + XdrHash(network.networkId!), + XdrInt64(inner.nonce), + XdrUint32(inner.signatureExpirationLedger), + inner.address.toXdr(), + rootInvocation.toXdr(), + ); + final preimage = XdrHashIDPreimage( + XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS); + preimage.sorobanAuthorizationWithAddress = authPreimage; + return preimage; + } else { + throw Exception( + 'Cannot build preimage for unknown credential arm: $arm'); + } + } + + /// Encodes [preimage] and returns its SHA-256 hash (the payload to sign). + static Uint8List _hashPreimage(XdrHashIDPreimage preimage) { + final out = XdrDataOutputStream(); + XdrHashIDPreimage.encode(out, preimage); + return Util.hash(Uint8List.fromList(out.bytes)); + } + + /// Appends [newSig] to the signature vector of [inner]. + /// + /// If the current signature is void, it is replaced with a one-element vector. + /// Otherwise the new element is appended to the existing vector. + /// Non-void signatures are never silently overwritten. + /// + /// Callers are responsible for signing in ascending public-key order when + /// G-address verification is required; the SDK appends in call order and does + /// not sort. Signing the same node twice with the same key appends a duplicate + /// that the host rejects. + static void _appendSignature( + SorobanAddressCredentials inner, AccountEd25519Signature newSig) { + final existing = inner.signature; + final List sigs = List.empty(growable: true); + if (existing.vec != null) { + sigs.addAll(existing.vec!); + } + sigs.add(newSig.toXdrSCVal()); + inner.signature = XdrSCVal.forVec(sigs); + } + + /// Signs this authorization entry. + /// + /// [signatureExpirationLedger] must be set on the credentials before calling; + /// the preimage is built from the current expiration value. + /// + /// All three address arms are supported: + /// - ADDRESS: legacy preimage (no address binding) + /// - ADDRESS_V2 and ADDRESS_WITH_DELEGATES: address-bound preimage + /// + /// The top-level signer and every delegate at any depth sign the same payload + /// hash for a given entry. + /// + /// [forAddress]: if null, signs the top-level credentials. If non-null + /// (strkey of a G... account or C... contract), the signature is routed + /// into every node (top-level or delegate, depth-first) whose address matches. + /// Throws if no node's address matches [forAddress]. + /// Muxed addresses (M...) are rejected as Soroban auth delegate targets. + /// + /// Append semantics: appends to existing signatures; void becomes one-element + /// vector. The arm is preserved on write-back. + /// + /// Signing source-account credentials throws an exception. + void sign(KeyPair signer, Network network, {String? forAddress}) { + final arm = credentials.arm; + + if (arm == XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { + throw Exception('Cannot sign source-account credentials'); + } + + final inner = credentials.innerAddressCredentials; + if (inner == null) { + throw Exception('No address credentials found for signing'); + } + + if (forAddress != null && forAddress.startsWith('M')) { + throw Exception( + 'Muxed addresses (M...) are not valid Soroban auth targets; ' + 'use the underlying G... account address instead'); + } + + // Build the payload once; all nodes (top-level and delegates) sign this same hash. + final preimage = buildPreimage(network); + final payload = _hashPreimage(preimage); + final signatureBytes = signer.sign(payload); + final sig = AccountEd25519Signature(signer.xdrPublicKey, signatureBytes); + + if (forAddress == null) { + // Sign top-level credentials + _appendSignature(inner, sig); + _writeBackInnerCredentials(inner); + } else { + // Route to matching nodes in the delegate tree + final matched = _routeSignatureToAddress( + forAddress, inner, sig, 0); + if (!matched) { + throw Exception( + 'No node with address $forAddress found in this authorization entry'); + } + } + } + + /// Writes [inner] back into the credentials preserving the credential arm. + void _writeBackInnerCredentials(SorobanAddressCredentials inner) { + switch (credentials.arm) { + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: + credentials.addressCredentials = inner; + break; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + credentials.addressV2Credentials = inner; + break; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + // The inner credentials live inside the WithDelegates wrapper; they are + // mutated in place so no explicit write-back is needed here. + break; + default: + break; + } + } + + /// Encodes [addr] to XDR bytes for address comparison. + static Uint8List _encodeAddress(XdrSCAddress addr) { + final out = XdrDataOutputStream(); + XdrSCAddress.encode(out, addr); + return Uint8List.fromList(out.bytes); + } + + /// Returns the strkey representation of an [XdrSCAddress] for address matching. + /// + /// Only account (G...) and contract (C...) addresses are valid delegate targets. + static String _xdrAddressToStrKey(XdrSCAddress addr) { + if (addr.discriminant == XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT && + addr.accountId != null) { + return KeyPair.fromXdrPublicKey(addr.accountId!.accountID).accountId; + } else if (addr.discriminant == XdrSCAddressType.SC_ADDRESS_TYPE_CONTRACT && + addr.contractId != null) { + return StrKey.encodeContractId(addr.contractId!.hash); + } + // Other address types (muxed, claimable balance, pool) return empty string + // so they never match a forAddress target. + return ''; + } + + /// Depth-first walk over the credential address and delegate tree. + /// + /// Appends [sig] to every node whose address strkey equals [targetStrKey]. + /// Returns true if at least one node was signed. + /// + /// Throws if [depth] exceeds [_maxDelegateTraversalDepth] to prevent + /// unbounded recursion on pathological inputs. + bool _routeSignatureToAddress( + String targetStrKey, + SorobanAddressCredentials topLevel, + AccountEd25519Signature sig, + int depth) { + if (depth > _maxDelegateTraversalDepth) { + throw Exception( + 'Delegate tree traversal depth limit ($_maxDelegateTraversalDepth) exceeded'); + } + + bool matched = false; + + // Check the top-level credential address (only at depth 0) + if (depth == 0) { + final topStrKey = _xdrAddressToStrKey(topLevel.address.toXdr()); + if (topStrKey == targetStrKey) { + _appendSignature(topLevel, sig); + _writeBackInnerCredentials(topLevel); + matched = true; + } + + // Walk delegates if present + if (credentials.arm == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + final withDelegates = credentials.addressWithDelegatesCredentials!; + for (final delegate in withDelegates.delegates) { + if (_walkDelegate(targetStrKey, delegate, sig, depth + 1)) { + matched = true; + } + } + } + } + + return matched; + } + + /// Recursive walk of a delegate node and its nested delegates. + bool _walkDelegate( + String targetStrKey, + SorobanDelegateSignature node, + AccountEd25519Signature sig, + int depth) { + if (depth > _maxDelegateTraversalDepth) { + throw Exception( + 'Delegate tree traversal depth limit ($_maxDelegateTraversalDepth) exceeded'); + } + + bool matched = false; + final nodeStrKey = _xdrAddressToStrKey(node.address); + if (nodeStrKey == targetStrKey) { + _appendSignatureToDelegate(node, sig); + matched = true; + } + + for (final child in node.nestedDelegates) { + if (_walkDelegate(targetStrKey, child, sig, depth + 1)) { + matched = true; + } + } + + return matched; + } + + /// Appends [sig] to the signature of delegate [node]. + static void _appendSignatureToDelegate( + SorobanDelegateSignature node, AccountEd25519Signature sig) { + final existing = node.signature; + final List sigs = List.empty(growable: true); + if (existing.vec != null) { + sigs.addAll(existing.vec!); + } + sigs.add(sig.toXdrSCVal()); + node.signature = XdrSCVal.forVec(sigs); + } + + /// Constructs a WITH_DELEGATES authorization entry from an existing ADDRESS or + /// ADDRESS_V2 entry plus a list of delegate descriptors. + /// + /// Rules enforced: + /// - [source] must be ADDRESS or ADDRESS_V2; throws if it is already WITH_DELEGATES + /// or SOURCE_ACCOUNT. + /// - The top-level signature is defaulted to void (delegates-only pattern). + /// - Each delegate's [address] must be a G... or C... strkey; M... is rejected. + /// - Within each delegate array (top-level and each nestedDelegates), entries are + /// sorted ascending by the lexicographic comparison of the complete XDR-encoded + /// XdrSCAddress bytes. Strkey order is NOT used for sorting. + /// - Duplicate addresses within one array (same encoded bytes) throw. + /// The same address appearing at different levels is legal. + /// - [signatureExpirationLedger] must be provided; it is stamped onto the inner + /// credentials so the preimage is buildable immediately after this call. + /// + /// Returns: A new [SorobanAuthorizationEntry] with the ADDRESS_WITH_DELEGATES arm. + static SorobanAuthorizationEntry withDelegates( + SorobanAuthorizationEntry source, + List delegates, + int signatureExpirationLedger, + ) { + final arm = source.credentials.arm; + if (arm == XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + throw ArgumentError( + 'source entry is already ADDRESS_WITH_DELEGATES; ' + 'cannot nest delegate trees'); + } + if (arm == XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { + throw ArgumentError( + 'source entry has source-account credentials; ' + 'delegate trees require address credentials'); + } + + final sourceInner = source.credentials.innerAddressCredentials!; + final innerCreds = SorobanAddressCredentials( + sourceInner.address, + sourceInner.nonce, + signatureExpirationLedger, + XdrSCVal.forVoid(), // top-level signature is void (delegates-only) + ); + + final sortedDelegates = _buildAndSortDelegates(delegates, depth: 0); + final withDelegates = SorobanAddressCredentialsWithDelegates( + innerCreds, sortedDelegates); + + return SorobanAuthorizationEntry( + SorobanCredentials.forAddressWithDelegates(withDelegates), + source.rootInvocation, + ); + } + + /// Recursively builds [SorobanDelegateSignature] objects from descriptors, + /// sorting each array by XDR-encoded address bytes and checking for duplicates. + static List _buildAndSortDelegates( + List descriptors, { + required int depth, + }) { + if (depth > _maxDelegateTraversalDepth) { + throw ArgumentError( + 'Delegate tree depth limit ($_maxDelegateTraversalDepth) exceeded ' + 'during construction'); + } + + final List<_DelegateSortable> items = []; + for (final desc in descriptors) { + if (desc.addressStrKey.startsWith('M')) { + throw ArgumentError( + 'Muxed addresses (M...) are not valid Soroban delegate addresses: ' + '${desc.addressStrKey}'); + } + + final xdrAddr = _strKeyToXdrAddress(desc.addressStrKey); + final encodedAddr = _encodeAddress(xdrAddr); + final nested = _buildAndSortDelegates(desc.nestedDelegates, + depth: depth + 1); + + items.add(_DelegateSortable( + encodedAddr: encodedAddr, + delegate: SorobanDelegateSignature( + xdrAddr, + desc.signature ?? XdrSCVal.forVoid(), + nested, + ), + )); + } + + // Sort ascending by XDR-encoded address bytes (lexicographic) + items.sort((a, b) { + for (int i = 0; i < a.encodedAddr.length && i < b.encodedAddr.length; i++) { + final cmp = a.encodedAddr[i].compareTo(b.encodedAddr[i]); + if (cmp != 0) return cmp; + } + return a.encodedAddr.length.compareTo(b.encodedAddr.length); + }); + + // Check for duplicates within this array + for (int i = 1; i < items.length; i++) { + if (_bytesEqual(items[i - 1].encodedAddr, items[i].encodedAddr)) { + throw ArgumentError( + 'Duplicate delegate address within one array: ' + '${descriptors[i].addressStrKey}'); + } + } + + return items.map((e) => e.delegate).toList(); + } + + /// Converts a strkey (G... or C...) to an [XdrSCAddress]. + static XdrSCAddress _strKeyToXdrAddress(String strKey) { + if (strKey.startsWith('G')) { + return XdrSCAddress.forAccountId(strKey); + } else if (strKey.startsWith('C')) { + return XdrSCAddress.forContractId(strKey); + } else { + throw ArgumentError( + 'Invalid delegate address strkey (must start with G or C): $strKey'); + } } + + /// Returns true if [a] and [b] have identical contents. + static bool _bytesEqual(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +/// Internal helper for sorting delegates by their XDR-encoded address bytes. +class _DelegateSortable { + final Uint8List encodedAddr; + final SorobanDelegateSignature delegate; + + _DelegateSortable({required this.encodedAddr, required this.delegate}); } /// Ed25519 signature for Soroban authorization entries. @@ -783,7 +1354,7 @@ class SorobanAuthorizationEntry { /// authEntry.sign(signerKeyPair, network); /// /// // The signature is embedded in the auth entry credentials -/// final credentials = authEntry.credentials.addressCredentials; +/// final credentials = authEntry.credentials.innerAddressCredentials; /// print('Signature embedded in auth entry'); /// ``` /// diff --git a/lib/src/soroban/soroban_client.dart b/lib/src/soroban/soroban_client.dart index 2d4592cd..cf3ae170 100644 --- a/lib/src/soroban/soroban_client.dart +++ b/lib/src/soroban/soroban_client.dart @@ -682,8 +682,8 @@ class AssembledTransaction { var shouldRestore = restore ?? options.methodOptions.restore; _simulationResult = null; - simulationResponse = - await server.simulateTransaction(SimulateTransactionRequest(tx!)); + simulationResponse = await server.simulateTransaction( + SimulateTransactionRequest(tx!)); if (shouldRestore && simulationResponse!.restorePreamble != null) { if (options.clientOptions.sourceAccountKeyPair.privateKey == null) { throw new Exception( @@ -800,13 +800,12 @@ class AssembledTransaction { 'Source account keypair has no private key, but needed for signing.'); } - final allNeededSigners = needsNonInvokerSigningBy(); - final neededAccountSigners = List.empty(growable: true); - for (String signer in allNeededSigners) { - if (!signer.startsWith("C")) { - neededAccountSigners.add(signer); - } - } + // Use the send precheck: a WITH_DELEGATES entry whose top-level is void but + // every delegate is signed does not block submission (delegates-only + // pattern). needsNonInvokerSigningBy() reports the void top-level for + // informational purposes, but _neededSignersForSend() omits it when all + // delegates are present and signed. + final neededAccountSigners = _neededSignersForSend(); if (neededAccountSigners.isNotEmpty) { throw new Exception( "Transaction requires signatures from multiple signers. " + @@ -825,20 +824,30 @@ class AssembledTransaction { } } - /// Get a list of accounts, other than the invoker of the simulation, that - /// need to sign auth entries in this transaction. - /// - /// Soroban allows multiple people to sign a transaction. Someone needs to - /// sign the final transaction envelope; this person/account is called the - /// _invoker_, or _source_. Other accounts might need to sign individual auth - /// entries in the transaction, if they're not also the invoker. - /// - /// This function returns a list of accounts that need to sign auth entries, - /// assuming that the same invoker/source account will sign the final - /// transaction envelope as signed the initial simulation. - /// - /// With [includeAlreadySigned] you can define if the returned list should - /// include the needed signers that already signed their auth entries. + /// Get a list of addresses, other than the invoker of the simulation, that + /// still need to provide signatures for auth entries in this transaction. + /// + /// Soroban allows multiple parties to authorize different parts of a + /// transaction. Someone needs to sign the final transaction envelope; this + /// person/account is called the _invoker_, or _source_. Other accounts or + /// delegates might need to sign individual auth entries. + /// + /// This function returns strkeys for every node whose signature is currently + /// void, assuming that the same invoker/source account will sign the final + /// transaction envelope. For ADDRESS and ADDRESS_V2 entries the top-level + /// credential address is reported when its signature is void. For + /// ADDRESS_WITH_DELEGATES entries BOTH the top-level address (when its + /// signature is void) AND every unsigned delegate node at any depth are + /// reported — a top-level signature alongside delegates is a legal pattern. + /// + /// Note: a WITH_DELEGATES entry where the top-level signature is void but + /// every delegate node is signed is the legitimate delegates-only pattern; + /// this function still lists the void top-level, but the send precheck + /// treats such an entry as fully satisfied. Use [sign] (not this function) + /// to determine whether the transaction is ready to submit. + /// + /// With [includeAlreadySigned] set to true, addresses that already have + /// signatures are included in the returned list. List needsNonInvokerSigningBy({bool includeAlreadySigned = false}) { if (tx == null) { throw Exception("Transaction has not yet been simulated"); @@ -852,25 +861,186 @@ class AssembledTransaction { if (invokeHostFuncOp is InvokeHostFunctionOperation) { final authEntries = invokeHostFuncOp.auth; for (SorobanAuthorizationEntry entry in authEntries) { - final addressCredentials = entry.credentials.addressCredentials; - if (addressCredentials != null) { - if (includeAlreadySigned || - addressCredentials.signature.discriminant == - XdrSCValType.SCV_VOID) { - final signer = addressCredentials.address.accountId ?? - addressCredentials.address.contractId; - if (signer != null) { - needed.add(signer); - } + final arm = entry.credentials.arm; + + if (arm == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { + // Source-account entries: no explicit signing needed. + continue; + } + + if (arm != XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS && + arm != XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2 && + arm != XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + throw Exception( + 'Unknown credential arm in auth entry: $arm'); + } + + final inner = entry.credentials.innerAddressCredentials; + if (inner == null) { + // Should not happen for any of the three address arms. + throw Exception( + 'Address credentials missing for arm $arm'); + } + + // Report the top-level address when its signature is void (or when + // includeAlreadySigned is true). + final topSigIsVoid = + inner.signature.discriminant == XdrSCValType.SCV_VOID; + if (includeAlreadySigned || topSigIsVoid) { + final signer = + inner.address.accountId ?? inner.address.contractId; + if (signer != null) { + needed.add(signer); } } + + // For WITH_DELEGATES, also walk every delegate node at any depth. + if (arm == XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + final withDelegates = + entry.credentials.addressWithDelegatesCredentials!; + _collectUnsignedDelegates( + withDelegates.delegates, needed, includeAlreadySigned, 0); + } } - } else { + } + return needed; + } + + /// Depth-first walk of a delegate list, appending unsigned addresses to [out]. + /// + /// [depth] guards against pathological nesting; uses the same limit as + /// [SorobanAuthorizationEntry._maxDelegateTraversalDepth]. + static void _collectUnsignedDelegates( + List delegates, + List out, + bool includeAlreadySigned, + int depth) { + if (depth > 128) { + throw Exception( + 'Delegate tree traversal depth limit (128) exceeded'); + } + for (final node in delegates) { + final isSigned = + node.signature.discriminant != XdrSCValType.SCV_VOID; + if (includeAlreadySigned || !isSigned) { + final nodeAddr = _xdrAddressToStrKey(node.address); + if (nodeAddr.isNotEmpty) { + out.add(nodeAddr); + } + } + _collectUnsignedDelegates( + node.nestedDelegates, out, includeAlreadySigned, depth + 1); + } + } + + /// Returns the strkey for an [XdrSCAddress]. + /// + /// Returns an empty string for address types that are not valid Soroban + /// auth targets (muxed, claimable balance, liquidity pool). + static String _xdrAddressToStrKey(XdrSCAddress addr) { + if (addr.discriminant == XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT && + addr.accountId != null) { + return KeyPair.fromXdrPublicKey(addr.accountId!.accountID).accountId; + } else if (addr.discriminant == XdrSCAddressType.SC_ADDRESS_TYPE_CONTRACT && + addr.contractId != null) { + return StrKey.encodeContractId(addr.contractId!.hash); + } + return ''; + } + + /// Returns true when the auth entries in [tx] require no additional signing + /// before the transaction can be submitted. + /// + /// The logic differs from [needsNonInvokerSigningBy]: + /// - A WITH_DELEGATES entry whose top-level signature is void but whose + /// every delegate node (at any depth) carries a signature is treated as + /// satisfied — that is the legitimate delegates-only pattern. + /// - Any other entry with a void signature on its top-level address (ADDRESS + /// or ADDRESS_V2) is unsatisfied. + /// + /// Returns a list of unsatisfied non-invoker G-address signers. An empty + /// list means the transaction is ready to submit. + List _neededSignersForSend() { + if (tx == null) { + return []; + } + final ops = tx!.operations; + if (ops.isEmpty) { + return []; + } + final needed = []; + final invokeHostFuncOp = ops.first; + if (invokeHostFuncOp is! InvokeHostFunctionOperation) { return needed; } + for (final entry in invokeHostFuncOp.auth) { + final arm = entry.credentials.arm; + if (arm == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { + continue; + } + + final inner = entry.credentials.innerAddressCredentials; + if (inner == null) continue; + if (arm == XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + // Delegates-only pattern: top-level may be void; entry is satisfied + // when every delegate node at any depth carries a non-void signature. + final withDelegates = + entry.credentials.addressWithDelegatesCredentials!; + if (_allDelegatesSigned(withDelegates.delegates, 0)) { + // Fully satisfied; skip. + continue; + } + // Some delegate nodes are unsigned — collect them (and top-level if + // also void) for reporting. + final topSigIsVoid = + inner.signature.discriminant == XdrSCValType.SCV_VOID; + if (topSigIsVoid) { + final signer = + inner.address.accountId ?? inner.address.contractId; + if (signer != null && !signer.startsWith('C')) { + needed.add(signer); + } + } + _collectUnsignedDelegates(withDelegates.delegates, needed, false, 0); + } else { + // ADDRESS or ADDRESS_V2: top-level must be signed. + final topSigIsVoid = + inner.signature.discriminant == XdrSCValType.SCV_VOID; + if (topSigIsVoid) { + final signer = + inner.address.accountId ?? inner.address.contractId; + if (signer != null && !signer.startsWith('C')) { + needed.add(signer); + } + } + } + } return needed; } + /// Returns true when every delegate node in [delegates] (depth-first) has a + /// non-void signature. + static bool _allDelegatesSigned( + List delegates, int depth) { + if (depth > 128) { + throw Exception('Delegate tree traversal depth limit (128) exceeded'); + } + for (final node in delegates) { + if (node.signature.discriminant == XdrSCValType.SCV_VOID) { + return false; + } + if (!_allDelegatesSigned(node.nestedDelegates, depth + 1)) { + return false; + } + } + return true; + } + /// Whether this transaction is a read call. This is determined by the /// simulation result and the transaction data. If the transaction is a read /// call, it will not need to be signed and sent to the network. If this @@ -892,25 +1062,40 @@ class AssembledTransaction { /// Signs authorization entries for multi-party transactions. /// - /// Soroban allows multiple parties to authorize different parts of a transaction. - /// When a transaction requires authorization from accounts other than the source/invoker, - /// those parties must sign their authorization entries before the transaction is submitted. + /// Soroban allows multiple parties to authorize different parts of a + /// transaction. When a transaction requires authorization from accounts or + /// contracts other than the source/invoker, those parties must sign their + /// authorization entries before the transaction is submitted. /// - /// This method: - /// - Finds auth entries that need signing by the specified signer - /// - Signs them using the provided keypair or custom delegate function - /// - Updates the transaction with signed auth entries - /// - Sets expiration for signatures + /// All three address credential arms are handled: + /// - ADDRESS and ADDRESS_V2: matches on the top-level credential address. + /// - ADDRESS_WITH_DELEGATES: matches on the top-level address AND on every + /// delegate node at any depth (depth-first). When the signer address + /// matches a delegate node, the expiration is stamped on the top-level + /// credentials and the signature is routed into that delegate node via + /// [SorobanAuthorizationEntry.sign] with `forAddress`. A delegate signer + /// never silently matches no entry — if [signerKeyPair.accountId] appears + /// in [needsNonInvokerSigningBy] but no entry matches during the signing + /// walk, an exception is thrown. /// - /// Use needsNonInvokerSigningBy() to determine which accounts need to sign. + /// SOURCE_ACCOUNT entries are skipped. Unknown/unrecognized arms fail fast. + /// + /// The credential arm is preserved after signing; V2 entries stay V2. + /// + /// Use [needsNonInvokerSigningBy] to determine which addresses need to sign. /// /// Parameters: - /// - [signerKeyPair] KeyPair of the signer (must include private key unless using delegate) - /// - [authorizeEntryDelegate] Optional custom signing function for remote/HSM signing - /// - [validUntilLedgerSeq] Signature expiration ledger (default: current + 100 ledgers) + /// - [signerKeyPair] KeyPair of the signer (must include private key unless + /// [authorizeEntryDelegate] is provided) + /// - [authorizeEntryDelegate] Optional custom signing function (e.g. for + /// remote or HSM signing). When provided the validation checks are skipped + /// and the returned entry replaces the original. + /// - [validUntilLedgerSeq] Signature expiration ledger. Defaults to the + /// latest ledger sequence plus [NetworkConstants.DEFAULT_LEDGER_EXPIRATION_OFFSET]. /// /// Throws: - /// - Exception If signer is not needed, keypair lacks private key, or signing fails + /// - Exception If no entries need signing, keypair lacks a private key, or + /// the signer address is not found in any entry. /// /// Example - Local signing: /// ```dart @@ -929,26 +1114,19 @@ class AssembledTransaction { /// /// Example - Remote signing: /// ```dart - /// // When signer's private key is on another server /// await tx.signAuthEntries( /// signerKeyPair: KeyPair.fromAccountId(bobAccountId), /// authorizeEntryDelegate: (entry, network) async { - /// // Serialize for remote signing /// final base64Entry = entry.toBase64EncodedXdrString(); - /// - /// // Send to signing server /// final signedBase64 = await sendToSigningServer(base64Entry); - /// - /// // Deserialize and return /// return SorobanAuthorizationEntry.fromBase64EncodedXdr(signedBase64); /// }, /// ); /// ``` /// /// See also: - /// - [needsNonInvokerSigningBy] to check which accounts need to sign + /// - [needsNonInvokerSigningBy] to check which addresses need to sign /// - [SorobanAuthorizationEntry] for authorization entry details - /// - Multi-auth example in [AssembledTransaction] class documentation Future signAuthEntries( {required KeyPair signerKeyPair, Future Function( @@ -957,7 +1135,7 @@ class AssembledTransaction { int? validUntilLedgerSeq}) async { final signerAddress = signerKeyPair.accountId; if (authorizeEntryDelegate == null) { - final neededSigning = await needsNonInvokerSigningBy(); + final neededSigning = needsNonInvokerSigningBy(); if (neededSigning.isEmpty) { throw Exception( "No unsigned non-invoker auth entries; maybe you already signed?"); @@ -979,7 +1157,8 @@ class AssembledTransaction { if (getLatestLedgerResponse.sequence == null) { throw Exception("Could not fetch latest ledger sequence from server"); } - expirationLedger = getLatestLedgerResponse.sequence! + NetworkConstants.DEFAULT_LEDGER_EXPIRATION_OFFSET; + expirationLedger = getLatestLedgerResponse.sequence! + + NetworkConstants.DEFAULT_LEDGER_EXPIRATION_OFFSET; } final ops = tx!.operations; @@ -987,33 +1166,104 @@ class AssembledTransaction { throw Exception("Unexpected Transaction type; no operations found."); } final invokeHostFuncOp = ops.first; - if (invokeHostFuncOp is InvokeHostFunctionOperation) { - var authEntries = invokeHostFuncOp.auth; - for (var i = 0; i < authEntries.length; i++) { - final entry = authEntries[i]; - final addressCredentials = entry.credentials.addressCredentials; - if (addressCredentials == null || - addressCredentials.address.accountId == null || - addressCredentials.address.accountId != signerAddress) { - continue; - } - entry.credentials.addressCredentials!.signatureExpirationLedger = - expirationLedger; - SorobanAuthorizationEntry? authorized; - if (authorizeEntryDelegate != null) { - authorized = await authorizeEntryDelegate( - entry, options.clientOptions.network); - } else { - entry.sign(signerKeyPair, options.clientOptions.network); - authorized = entry; - } + if (invokeHostFuncOp is! InvokeHostFunctionOperation) { + throw Exception( + "Unexpected Transaction type; no invoke host function operations found."); + } + + var authEntries = invokeHostFuncOp.auth; + for (var i = 0; i < authEntries.length; i++) { + final entry = authEntries[i]; + final arm = entry.credentials.arm; + + if (arm == XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { + // Source-account entries need no explicit signing. + continue; + } + + if (arm != XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS && + arm != XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2 && + arm != XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + throw Exception( + 'Unknown credential arm in auth entry: $arm'); + } + + final inner = entry.credentials.innerAddressCredentials; + if (inner == null) { + // Should not happen for any of the three address arms. + throw Exception('Address credentials missing for arm $arm'); + } + + final topLevelStrKey = + inner.address.accountId ?? inner.address.contractId ?? ''; + + // Determine whether the signer matches the top-level address or any + // delegate node (for WITH_DELEGATES entries). Only entries the signer is + // responsible for are touched; entries for other parties are left intact, + // so a signature already applied by another signer is never disturbed. + final bool matchesTopLevel = topLevelStrKey == signerAddress; + + bool matchesDelegate = false; + if (arm == XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + final withDelegates = + entry.credentials.addressWithDelegatesCredentials!; + matchesDelegate = + _delegateListContains(withDelegates.delegates, signerAddress, 0); + } + + if (!matchesTopLevel && !matchesDelegate) { + // This entry does not involve the signer; skip. + continue; + } + + // Stamp expiration before signing — the preimage is built from the + // current expiration value. + inner.signatureExpirationLedger = expirationLedger; + + if (authorizeEntryDelegate != null) { + // Hand the matching entry off to the external signer. + final authorized = + await authorizeEntryDelegate(entry, options.clientOptions.network); authEntries[i] = authorized; + continue; + } + + if (matchesTopLevel && !matchesDelegate) { + // Sign the top-level credentials. + entry.sign(signerKeyPair, options.clientOptions.network); + } else { + // Signer matches a delegate node (and possibly also the top-level + // address). forAddress routing signs the top-level credential when the + // address matches and every matching delegate node in one pass, so a + // preceding plain sign() would write a duplicate top-level signature the + // host rejects. + entry.sign(signerKeyPair, options.clientOptions.network, + forAddress: signerAddress); + } + authEntries[i] = entry; + } + tx!.setSorobanAuth(authEntries); + } + + /// Returns true when [targetStrKey] appears as the address of any delegate + /// node in [delegates] or their nested delegates (depth-first). + static bool _delegateListContains( + List delegates, + String targetStrKey, + int depth) { + if (depth > 128) { + throw Exception('Delegate tree traversal depth limit (128) exceeded'); + } + for (final node in delegates) { + final nodeStr = _xdrAddressToStrKey(node.address); + if (nodeStr == targetStrKey) return true; + if (_delegateListContains(node.nestedDelegates, targetStrKey, depth + 1)) { + return true; } - tx!.setSorobanAuth(authEntries); - } else { - throw new Exception( - "Unexpected Transaction type; no invoke host function operations found."); } + return false; } /// Restores the footprint (resource ledger entries that can be read or written) @@ -1615,7 +1865,7 @@ class DeployRequest { /// print('Transaction requires ${simulationData.auth!.length} authorization(s)'); /// /// for (var entry in simulationData.auth!) { -/// final address = entry.credentials.addressCredentials?.address; +/// final address = entry.credentials.innerAddressCredentials?.address; /// print('Needs signature from: ${address?.accountId ?? address?.contractId}'); /// } /// } diff --git a/lib/src/soroban/soroban_server.dart b/lib/src/soroban/soroban_server.dart index 3c297be8..0e0eb337 100644 --- a/lib/src/soroban/soroban_server.dart +++ b/lib/src/soroban/soroban_server.dart @@ -1966,7 +1966,8 @@ class SimulateTransactionRequest { /// Creates a SimulateTransactionRequest for transaction simulation. /// /// Contains transaction to simulate with optional resource config and auth mode. - SimulateTransactionRequest(this.transaction, {this.resourceConfig, this.authMode}); + SimulateTransactionRequest(this.transaction, + {this.resourceConfig, this.authMode}); Map getRequestArgs() { var map = {}; diff --git a/lib/src/xdr/xdr.dart b/lib/src/xdr/xdr.dart index df051e9a..c75a430d 100644 --- a/lib/src/xdr/xdr.dart +++ b/lib/src/xdr/xdr.dart @@ -154,6 +154,7 @@ export 'xdr_hash_id_preimage_contract_id.dart'; export 'xdr_hash_id_preimage_operation_id.dart'; export 'xdr_hash_id_preimage_revoke_id.dart'; export 'xdr_hash_id_preimage_soroban_authorization.dart'; +export 'xdr_hash_id_preimage_soroban_authorization_with_address.dart'; export 'xdr_hello.dart'; export 'xdr_hmac_sha256_key.dart'; export 'xdr_hmac_sha256_mac.dart'; @@ -381,6 +382,7 @@ export 'xdr_signer_key.dart'; export 'xdr_signer_key_type.dart'; export 'xdr_simple_payment_result.dart'; export 'xdr_soroban_address_credentials.dart'; +export 'xdr_soroban_address_credentials_with_delegates.dart'; export 'xdr_soroban_authorization_entries.dart'; export 'xdr_soroban_authorization_entry.dart'; export 'xdr_soroban_authorized_function.dart'; @@ -390,6 +392,7 @@ export 'xdr_soroban_authorized_invocation.dart'; export 'xdr_soroban_credentials.dart'; export 'xdr_soroban_credentials_base.dart'; export 'xdr_soroban_credentials_type.dart'; +export 'xdr_soroban_delegate_signature.dart'; export 'xdr_soroban_resources.dart'; export 'xdr_soroban_resources_ext_v0.dart'; export 'xdr_soroban_transaction_data.dart'; diff --git a/lib/src/xdr/xdr_data_io.dart b/lib/src/xdr/xdr_data_io.dart index 836af62b..6f1c2094 100644 --- a/lib/src/xdr/xdr_data_io.dart +++ b/lib/src/xdr/xdr_data_io.dart @@ -290,8 +290,35 @@ class DataOutput { } class XdrDataInputStream extends DataInput { + /// Maximum recursive XDR decode depth. Prevents stack exhaustion from + /// deeply nested structures (e.g. SorobanDelegateSignature nestedDelegates). + static const int maxRecursiveDecodeDepth = 128; + + int _recursiveDecodeDepth = 0; + XdrDataInputStream(Uint8List data) : super.fromUint8List(data); + /// Increments the recursive decode depth counter and throws if the limit + /// is exceeded. Call before entering a recursive decode invocation. + void enterRecursiveDecode() { + _recursiveDecodeDepth++; + if (_recursiveDecodeDepth > maxRecursiveDecodeDepth) { + throw Exception( + 'XDR decode depth limit exceeded ($maxRecursiveDecodeDepth). ' + 'The encoded data contains a recursion depth that exceeds the ' + 'safety cap; decoding is aborted to prevent stack exhaustion.', + ); + } + } + + /// Decrements the recursive decode depth counter. Call after a recursive + /// decode invocation completes (in a finally block). + void exitRecursiveDecode() { + if (_recursiveDecodeDepth > 0) { + _recursiveDecodeDepth--; + } + } + int read() { return readByte(); } diff --git a/lib/src/xdr/xdr_envelope_type.dart b/lib/src/xdr/xdr_envelope_type.dart index 2dd03091..0a74a205 100644 --- a/lib/src/xdr/xdr_envelope_type.dart +++ b/lib/src/xdr/xdr_envelope_type.dart @@ -36,6 +36,8 @@ class XdrEnvelopeType { static const ENVELOPE_TYPE_CONTRACT_ID = const XdrEnvelopeType._internal(8); static const ENVELOPE_TYPE_SOROBAN_AUTHORIZATION = const XdrEnvelopeType._internal(9); + static const ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS = + const XdrEnvelopeType._internal(10); static XdrEnvelopeType decode(XdrDataInputStream stream) { int value = stream.readInt(); @@ -60,6 +62,8 @@ class XdrEnvelopeType { return ENVELOPE_TYPE_CONTRACT_ID; case 9: return ENVELOPE_TYPE_SOROBAN_AUTHORIZATION; + case 10: + return ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS; default: throw Exception("Unknown enum value: $value"); } @@ -106,6 +110,8 @@ class XdrEnvelopeType { return 'ENVELOPE_TYPE_CONTRACT_ID'; case 9: return 'ENVELOPE_TYPE_SOROBAN_AUTHORIZATION'; + case 10: + return 'ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS'; default: return 'XdrEnvelopeType#$_value'; } @@ -139,6 +145,8 @@ class XdrEnvelopeType { return ENVELOPE_TYPE_CONTRACT_ID; case 'ENVELOPE_TYPE_SOROBAN_AUTHORIZATION': return ENVELOPE_TYPE_SOROBAN_AUTHORIZATION; + case 'ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS': + return ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS; default: if (name.startsWith('XdrEnvelopeType#')) { int? val = int.tryParse(name.substring('XdrEnvelopeType#'.length)); diff --git a/lib/src/xdr/xdr_hash_id_preimage.dart b/lib/src/xdr/xdr_hash_id_preimage.dart index c9baea5f..728507c3 100644 --- a/lib/src/xdr/xdr_hash_id_preimage.dart +++ b/lib/src/xdr/xdr_hash_id_preimage.dart @@ -12,6 +12,7 @@ import 'xdr_hash_id_preimage_contract_id.dart'; import 'xdr_hash_id_preimage_operation_id.dart'; import 'xdr_hash_id_preimage_revoke_id.dart'; import 'xdr_hash_id_preimage_soroban_authorization.dart'; +import 'xdr_hash_id_preimage_soroban_authorization_with_address.dart'; class XdrHashIDPreimage { XdrEnvelopeType _type; @@ -41,6 +42,12 @@ class XdrHashIDPreimage { XdrHashIDPreimageSorobanAuthorization? get sorobanAuthorization => this._sorobanAuthorization; + XdrHashIDPreimageSorobanAuthorizationWithAddress? + _sorobanAuthorizationWithAddress; + + XdrHashIDPreimageSorobanAuthorizationWithAddress? + get sorobanAuthorizationWithAddress => this._sorobanAuthorizationWithAddress; + XdrHashIDPreimage(this._type); set operationID(XdrHashIDPreimageOperationID? value) => @@ -54,6 +61,10 @@ class XdrHashIDPreimage { set sorobanAuthorization(XdrHashIDPreimageSorobanAuthorization? value) => this._sorobanAuthorization = value; + set sorobanAuthorizationWithAddress( + XdrHashIDPreimageSorobanAuthorizationWithAddress? value, + ) => this._sorobanAuthorizationWithAddress = value; + static void encode( XdrDataOutputStream stream, XdrHashIDPreimage encodedHashIDPreimage, @@ -84,6 +95,12 @@ class XdrHashIDPreimage { encodedHashIDPreimage._sorobanAuthorization!, ); break; + case XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS: + XdrHashIDPreimageSorobanAuthorizationWithAddress.encode( + stream, + encodedHashIDPreimage._sorobanAuthorizationWithAddress!, + ); + break; default: break; } @@ -112,6 +129,10 @@ class XdrHashIDPreimage { decodedHashIDPreimage._sorobanAuthorization = XdrHashIDPreimageSorobanAuthorization.decode(stream); break; + case XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS: + decodedHashIDPreimage._sorobanAuthorizationWithAddress = + XdrHashIDPreimageSorobanAuthorizationWithAddress.decode(stream); + break; default: break; } diff --git a/lib/src/xdr/xdr_hash_id_preimage_soroban_authorization_with_address.dart b/lib/src/xdr/xdr_hash_id_preimage_soroban_authorization_with_address.dart new file mode 100644 index 00000000..6fab136c --- /dev/null +++ b/lib/src/xdr/xdr_hash_id_preimage_soroban_authorization_with_address.dart @@ -0,0 +1,109 @@ +// Copyright 2026 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. +// This file is automatically generated by xdrgen. Do not edit manually. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'xdr_data_io.dart'; +import 'xdr_hash.dart'; +import 'xdr_int64.dart'; +import 'xdr_sc_address.dart'; +import 'xdr_soroban_authorized_invocation.dart'; +import 'xdr_uint32.dart'; + +class XdrHashIDPreimageSorobanAuthorizationWithAddress { + XdrHash _networkID; + XdrHash get networkID => this._networkID; + set networkID(XdrHash value) => this._networkID = value; + + XdrInt64 _nonce; + XdrInt64 get nonce => this._nonce; + set nonce(XdrInt64 value) => this._nonce = value; + + XdrUint32 _signatureExpirationLedger; + XdrUint32 get signatureExpirationLedger => this._signatureExpirationLedger; + set signatureExpirationLedger(XdrUint32 value) => + this._signatureExpirationLedger = value; + + XdrSCAddress _address; + XdrSCAddress get address => this._address; + set address(XdrSCAddress value) => this._address = value; + + XdrSorobanAuthorizedInvocation _invocation; + XdrSorobanAuthorizedInvocation get invocation => this._invocation; + set invocation(XdrSorobanAuthorizedInvocation value) => + this._invocation = value; + + XdrHashIDPreimageSorobanAuthorizationWithAddress( + this._networkID, + this._nonce, + this._signatureExpirationLedger, + this._address, + this._invocation, + ); + + static void encode( + XdrDataOutputStream stream, + XdrHashIDPreimageSorobanAuthorizationWithAddress + encodedHashIDPreimageSorobanAuthorizationWithAddress, + ) { + XdrHash.encode( + stream, + encodedHashIDPreimageSorobanAuthorizationWithAddress.networkID, + ); + XdrInt64.encode( + stream, + encodedHashIDPreimageSorobanAuthorizationWithAddress.nonce, + ); + XdrUint32.encode( + stream, + encodedHashIDPreimageSorobanAuthorizationWithAddress + .signatureExpirationLedger, + ); + XdrSCAddress.encode( + stream, + encodedHashIDPreimageSorobanAuthorizationWithAddress.address, + ); + XdrSorobanAuthorizedInvocation.encode( + stream, + encodedHashIDPreimageSorobanAuthorizationWithAddress.invocation, + ); + } + + static XdrHashIDPreimageSorobanAuthorizationWithAddress decode( + XdrDataInputStream stream, + ) { + XdrHash networkID = XdrHash.decode(stream); + XdrInt64 nonce = XdrInt64.decode(stream); + XdrUint32 signatureExpirationLedger = XdrUint32.decode(stream); + XdrSCAddress address = XdrSCAddress.decode(stream); + XdrSorobanAuthorizedInvocation invocation = + XdrSorobanAuthorizedInvocation.decode(stream); + return XdrHashIDPreimageSorobanAuthorizationWithAddress( + networkID, + nonce, + signatureExpirationLedger, + address, + invocation, + ); + } + + String toBase64EncodedXdrString() { + XdrDataOutputStream xdrOutputStream = XdrDataOutputStream(); + XdrHashIDPreimageSorobanAuthorizationWithAddress.encode( + xdrOutputStream, + this, + ); + return base64Encode(xdrOutputStream.bytes); + } + + static XdrHashIDPreimageSorobanAuthorizationWithAddress + fromBase64EncodedXdrString(String base64Encoded) { + Uint8List bytes = base64Decode(base64Encoded); + return XdrHashIDPreimageSorobanAuthorizationWithAddress.decode( + XdrDataInputStream(bytes), + ); + } +} diff --git a/lib/src/xdr/xdr_soroban_address_credentials_with_delegates.dart b/lib/src/xdr/xdr_soroban_address_credentials_with_delegates.dart new file mode 100644 index 00000000..4d36c01b --- /dev/null +++ b/lib/src/xdr/xdr_soroban_address_credentials_with_delegates.dart @@ -0,0 +1,114 @@ +// Copyright 2026 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. +// This file is automatically generated by xdrgen. Do not edit manually. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'txrep_helper.dart'; +import 'xdr_data_io.dart'; +import 'xdr_soroban_address_credentials.dart'; +import 'xdr_soroban_delegate_signature.dart'; + +class XdrSorobanAddressCredentialsWithDelegates { + XdrSorobanAddressCredentials _addressCredentials; + XdrSorobanAddressCredentials get addressCredentials => + this._addressCredentials; + set addressCredentials(XdrSorobanAddressCredentials value) => + this._addressCredentials = value; + + List _delegates; + List get delegates => this._delegates; + set delegates(List value) => + this._delegates = value; + + XdrSorobanAddressCredentialsWithDelegates( + this._addressCredentials, + this._delegates, + ); + + static void encode( + XdrDataOutputStream stream, + XdrSorobanAddressCredentialsWithDelegates + encodedSorobanAddressCredentialsWithDelegates, + ) { + XdrSorobanAddressCredentials.encode( + stream, + encodedSorobanAddressCredentialsWithDelegates.addressCredentials, + ); + int delegatessize = + encodedSorobanAddressCredentialsWithDelegates.delegates.length; + stream.writeInt(delegatessize); + for (int i = 0; i < delegatessize; i++) { + XdrSorobanDelegateSignature.encode( + stream, + encodedSorobanAddressCredentialsWithDelegates.delegates[i], + ); + } + } + + static XdrSorobanAddressCredentialsWithDelegates decode( + XdrDataInputStream stream, + ) { + XdrSorobanAddressCredentials addressCredentials = + XdrSorobanAddressCredentials.decode(stream); + int delegatessize = stream.readInt(); + List delegates = + List.empty(growable: true); + for (int i = 0; i < delegatessize; i++) { + delegates.add(XdrSorobanDelegateSignature.decode(stream)); + } + return XdrSorobanAddressCredentialsWithDelegates( + addressCredentials, + delegates, + ); + } + + String toBase64EncodedXdrString() { + XdrDataOutputStream xdrOutputStream = XdrDataOutputStream(); + XdrSorobanAddressCredentialsWithDelegates.encode(xdrOutputStream, this); + return base64Encode(xdrOutputStream.bytes); + } + + static XdrSorobanAddressCredentialsWithDelegates fromBase64EncodedXdrString( + String base64Encoded, + ) { + Uint8List bytes = base64Decode(base64Encoded); + return XdrSorobanAddressCredentialsWithDelegates.decode( + XdrDataInputStream(bytes), + ); + } + + void toTxRep(String prefix, List lines) { + _addressCredentials.toTxRep('$prefix.addressCredentials', lines); + lines.add('$prefix.delegates.len: ${_delegates.length}'); + for (int i = 0; i < _delegates.length; i++) { + _delegates[i].toTxRep('$prefix.delegates[$i]', lines); + } + } + + static XdrSorobanAddressCredentialsWithDelegates fromTxRep( + Map map, + String prefix, + ) { + XdrSorobanAddressCredentials addressCredentials = + XdrSorobanAddressCredentials.fromTxRep( + map, + '$prefix.addressCredentials', + ); + int delegatesLen = TxRepHelper.parseInt( + TxRepHelper.getValue(map, '$prefix.delegates.len') ?? '0', + ); + List delegates = []; + for (int i = 0; i < delegatesLen; i++) { + delegates.add( + XdrSorobanDelegateSignature.fromTxRep(map, '$prefix.delegates[$i]'), + ); + } + return XdrSorobanAddressCredentialsWithDelegates( + addressCredentials, + delegates, + ); + } +} diff --git a/lib/src/xdr/xdr_soroban_credentials.dart b/lib/src/xdr/xdr_soroban_credentials.dart index 2e2c06b2..d5aeabc6 100644 --- a/lib/src/xdr/xdr_soroban_credentials.dart +++ b/lib/src/xdr/xdr_soroban_credentials.dart @@ -4,6 +4,7 @@ import 'xdr_data_io.dart'; import 'xdr_soroban_address_credentials.dart'; +import 'xdr_soroban_address_credentials_with_delegates.dart'; import 'xdr_soroban_credentials_base.dart'; import 'xdr_soroban_credentials_type.dart'; @@ -28,6 +29,8 @@ class XdrSorobanCredentials extends XdrSorobanCredentialsBase { var b = XdrSorobanCredentialsBase.fromTxRep(map, prefix); var result = XdrSorobanCredentials(b.discriminant); result.address = b.address; + result.addressV2 = b.addressV2; + result.addressWithDelegates = b.addressWithDelegates; return result; } @@ -46,4 +49,32 @@ class XdrSorobanCredentials extends XdrSorobanCredentialsBase { result.address = addressCredentials; return result; } + + /// Creates credentials for the ADDRESS_V2 arm (protocol 27+). + /// + /// ADDRESS_V2 uses the same body as ADDRESS but a different preimage type + /// (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS). Emitting this arm on + /// a network below protocol 27 invalidates the transaction. + static XdrSorobanCredentials forAddressV2Credentials( + XdrSorobanAddressCredentials addressCredentials, + ) { + var result = XdrSorobanCredentials( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + result.addressV2 = addressCredentials; + return result; + } + + /// Creates credentials for the ADDRESS_WITH_DELEGATES arm (protocol 27+). + /// + /// Emitting this arm on a network below protocol 27 invalidates the transaction. + static XdrSorobanCredentials forAddressWithDelegatesCredentials( + XdrSorobanAddressCredentialsWithDelegates addressWithDelegates, + ) { + var result = XdrSorobanCredentials( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + ); + result.addressWithDelegates = addressWithDelegates; + return result; + } } diff --git a/lib/src/xdr/xdr_soroban_credentials_base.dart b/lib/src/xdr/xdr_soroban_credentials_base.dart index b024eef4..89ee9013 100644 --- a/lib/src/xdr/xdr_soroban_credentials_base.dart +++ b/lib/src/xdr/xdr_soroban_credentials_base.dart @@ -9,6 +9,7 @@ import 'dart:typed_data'; import 'txrep_helper.dart'; import 'xdr_data_io.dart'; import 'xdr_soroban_address_credentials.dart'; +import 'xdr_soroban_address_credentials_with_delegates.dart'; import 'xdr_soroban_credentials_type.dart'; class XdrSorobanCredentialsBase { @@ -26,10 +27,24 @@ class XdrSorobanCredentialsBase { XdrSorobanAddressCredentials? get address => this._address; + XdrSorobanAddressCredentials? _addressV2; + + XdrSorobanAddressCredentials? get addressV2 => this._addressV2; + + XdrSorobanAddressCredentialsWithDelegates? _addressWithDelegates; + + XdrSorobanAddressCredentialsWithDelegates? get addressWithDelegates => + this._addressWithDelegates; + XdrSorobanCredentialsBase(this._type); set address(XdrSorobanAddressCredentials? value) => this._address = value; + set addressV2(XdrSorobanAddressCredentials? value) => this._addressV2 = value; + + set addressWithDelegates(XdrSorobanAddressCredentialsWithDelegates? value) => + this._addressWithDelegates = value; + static void encode( XdrDataOutputStream stream, XdrSorobanCredentialsBase encodedSorobanCredentials, @@ -44,6 +59,18 @@ class XdrSorobanCredentialsBase { encodedSorobanCredentials._address!, ); break; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + XdrSorobanAddressCredentials.encode( + stream, + encodedSorobanCredentials._addressV2!, + ); + break; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + XdrSorobanAddressCredentialsWithDelegates.encode( + stream, + encodedSorobanCredentials._addressWithDelegates!, + ); + break; default: break; } @@ -64,6 +91,13 @@ class XdrSorobanCredentialsBase { case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: decoded._address = XdrSorobanAddressCredentials.decode(stream); break; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + decoded._addressV2 = XdrSorobanAddressCredentials.decode(stream); + break; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + decoded._addressWithDelegates = + XdrSorobanAddressCredentialsWithDelegates.decode(stream); + break; default: break; } @@ -91,6 +125,12 @@ class XdrSorobanCredentialsBase { case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: _address!.toTxRep('$prefix.address', lines); break; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + _addressV2!.toTxRep('$prefix.addressV2', lines); + break; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + _addressWithDelegates!.toTxRep('$prefix.addressWithDelegates', lines); + break; default: break; } @@ -113,6 +153,19 @@ class XdrSorobanCredentialsBase { '$prefix.address', ); break; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + result._addressV2 = XdrSorobanAddressCredentials.fromTxRep( + map, + '$prefix.addressV2', + ); + break; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + result._addressWithDelegates = + XdrSorobanAddressCredentialsWithDelegates.fromTxRep( + map, + '$prefix.addressWithDelegates', + ); + break; default: break; } diff --git a/lib/src/xdr/xdr_soroban_credentials_type.dart b/lib/src/xdr/xdr_soroban_credentials_type.dart index 3d11c5e5..085395e7 100644 --- a/lib/src/xdr/xdr_soroban_credentials_type.dart +++ b/lib/src/xdr/xdr_soroban_credentials_type.dart @@ -28,6 +28,10 @@ class XdrSorobanCredentialsType { const XdrSorobanCredentialsType._internal(0); static const SOROBAN_CREDENTIALS_ADDRESS = const XdrSorobanCredentialsType._internal(1); + static const SOROBAN_CREDENTIALS_ADDRESS_V2 = + const XdrSorobanCredentialsType._internal(2); + static const SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES = + const XdrSorobanCredentialsType._internal(3); static XdrSorobanCredentialsType decode(XdrDataInputStream stream) { int value = stream.readInt(); @@ -36,6 +40,10 @@ class XdrSorobanCredentialsType { return SOROBAN_CREDENTIALS_SOURCE_ACCOUNT; case 1: return SOROBAN_CREDENTIALS_ADDRESS; + case 2: + return SOROBAN_CREDENTIALS_ADDRESS_V2; + case 3: + return SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES; default: throw Exception("Unknown enum value: $value"); } @@ -71,6 +79,10 @@ class XdrSorobanCredentialsType { return 'SOROBAN_CREDENTIALS_SOURCE_ACCOUNT'; case 1: return 'SOROBAN_CREDENTIALS_ADDRESS'; + case 2: + return 'SOROBAN_CREDENTIALS_ADDRESS_V2'; + case 3: + return 'SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES'; default: return 'XdrSorobanCredentialsType#$_value'; } @@ -91,6 +103,10 @@ class XdrSorobanCredentialsType { return SOROBAN_CREDENTIALS_SOURCE_ACCOUNT; case 'SOROBAN_CREDENTIALS_ADDRESS': return SOROBAN_CREDENTIALS_ADDRESS; + case 'SOROBAN_CREDENTIALS_ADDRESS_V2': + return SOROBAN_CREDENTIALS_ADDRESS_V2; + case 'SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES': + return SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES; default: if (name.startsWith('XdrSorobanCredentialsType#')) { int? val = int.tryParse( diff --git a/lib/src/xdr/xdr_soroban_delegate_signature.dart b/lib/src/xdr/xdr_soroban_delegate_signature.dart new file mode 100644 index 00000000..a2e960b8 --- /dev/null +++ b/lib/src/xdr/xdr_soroban_delegate_signature.dart @@ -0,0 +1,111 @@ +// Copyright 2026 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. +// This file is automatically generated by xdrgen. Do not edit manually. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'txrep_helper.dart'; +import 'xdr_data_io.dart'; +import 'xdr_sc_address.dart'; +import 'xdr_sc_val.dart'; + +class XdrSorobanDelegateSignature { + XdrSCAddress _address; + XdrSCAddress get address => this._address; + set address(XdrSCAddress value) => this._address = value; + + XdrSCVal _signature; + XdrSCVal get signature => this._signature; + set signature(XdrSCVal value) => this._signature = value; + + List _nestedDelegates; + List get nestedDelegates => + this._nestedDelegates; + set nestedDelegates(List value) => + this._nestedDelegates = value; + + XdrSorobanDelegateSignature( + this._address, + this._signature, + this._nestedDelegates, + ); + + static void encode( + XdrDataOutputStream stream, + XdrSorobanDelegateSignature encodedSorobanDelegateSignature, + ) { + XdrSCAddress.encode(stream, encodedSorobanDelegateSignature.address); + XdrSCVal.encode(stream, encodedSorobanDelegateSignature.signature); + int nestedDelegatessize = + encodedSorobanDelegateSignature.nestedDelegates.length; + stream.writeInt(nestedDelegatessize); + for (int i = 0; i < nestedDelegatessize; i++) { + XdrSorobanDelegateSignature.encode( + stream, + encodedSorobanDelegateSignature.nestedDelegates[i], + ); + } + } + + static XdrSorobanDelegateSignature decode(XdrDataInputStream stream) { + stream.enterRecursiveDecode(); + try { + XdrSCAddress address = XdrSCAddress.decode(stream); + XdrSCVal signature = XdrSCVal.decode(stream); + int nestedDelegatessize = stream.readInt(); + List nestedDelegates = + List.empty(growable: true); + for (int i = 0; i < nestedDelegatessize; i++) { + nestedDelegates.add(XdrSorobanDelegateSignature.decode(stream)); + } + return XdrSorobanDelegateSignature(address, signature, nestedDelegates); + } finally { + stream.exitRecursiveDecode(); + } + } + + String toBase64EncodedXdrString() { + XdrDataOutputStream xdrOutputStream = XdrDataOutputStream(); + XdrSorobanDelegateSignature.encode(xdrOutputStream, this); + return base64Encode(xdrOutputStream.bytes); + } + + static XdrSorobanDelegateSignature fromBase64EncodedXdrString( + String base64Encoded, + ) { + Uint8List bytes = base64Decode(base64Encoded); + return XdrSorobanDelegateSignature.decode(XdrDataInputStream(bytes)); + } + + void toTxRep(String prefix, List lines) { + _address.toTxRep('$prefix.address', lines); + _signature.toTxRep('$prefix.signature', lines); + lines.add('$prefix.nestedDelegates.len: ${_nestedDelegates.length}'); + for (int i = 0; i < _nestedDelegates.length; i++) { + _nestedDelegates[i].toTxRep('$prefix.nestedDelegates[$i]', lines); + } + } + + static XdrSorobanDelegateSignature fromTxRep( + Map map, + String prefix, + ) { + XdrSCAddress address = XdrSCAddress.fromTxRep(map, '$prefix.address'); + XdrSCVal signature = XdrSCVal.fromTxRep(map, '$prefix.signature'); + int nestedDelegatesLen = TxRepHelper.parseInt( + TxRepHelper.getValue(map, '$prefix.nestedDelegates.len') ?? '0', + ); + List nestedDelegates = []; + for (int i = 0; i < nestedDelegatesLen; i++) { + nestedDelegates.add( + XdrSorobanDelegateSignature.fromTxRep( + map, + '$prefix.nestedDelegates[$i]', + ), + ); + } + return XdrSorobanDelegateSignature(address, signature, nestedDelegates); + } +} diff --git a/skills/stellar-flutter-sdk.zip b/skills/stellar-flutter-sdk.zip index 5f4e7fb8..32fc0b6f 100644 Binary files a/skills/stellar-flutter-sdk.zip and b/skills/stellar-flutter-sdk.zip differ diff --git a/skills/stellar-flutter-sdk/SKILL.md b/skills/stellar-flutter-sdk/SKILL.md index 302dd5d2..d98d7c34 100644 --- a/skills/stellar-flutter-sdk/SKILL.md +++ b/skills/stellar-flutter-sdk/SKILL.md @@ -337,7 +337,7 @@ XdrSCVal result = await client.invokeMethod( ); ``` -For contract authorization, multi-auth workflows, and low-level deploy/invoke: +For contract authorization, multi-auth workflows, protocol 27 credentials (ADDRESS_V2 / WITH_DELEGATES), and low-level deploy/invoke: [Smart Contracts Guide](./references/soroban_contracts.md) ## 7. Smart Accounts (OpenZeppelin) diff --git a/skills/stellar-flutter-sdk/references/api_reference.md b/skills/stellar-flutter-sdk/references/api_reference.md index 807e7fc6..a45f458c 100644 --- a/skills/stellar-flutter-sdk/references/api_reference.md +++ b/skills/stellar-flutter-sdk/references/api_reference.md @@ -3,7 +3,7 @@ Compact method signature reference for `stellar_flutter_sdk`. Generated by `generate_api_reference.py`. Do not edit manually. -**Stats:** 662 classes, 4348 members +**Stats:** 665 classes, 4371 members --- ## Core Classes @@ -3558,6 +3558,12 @@ XdrSCVal signature SorobanAddressCredentials(this.address, this.nonce, this.signatureExpirationLedger, this.signature) static SorobanAddressCredentials fromXdr(XdrSorobanAddressCredentials xdr) XdrSorobanAddressCredentials toXdr() +## SorobanAddressCredentialsWithDelegates +SorobanAddressCredentials addressCredentials +List delegates +SorobanAddressCredentialsWithDelegates(this.addressCredentials, this.delegates) +static SorobanAddressCredentialsWithDelegates fromXdr(XdrSorobanAddressCredentialsWithDelegates xdr) +XdrSorobanAddressCredentialsWithDelegates toXdr() ## SorobanAuthorizationEntry SorobanCredentials credentials SorobanAuthorizedInvocation rootInvocation @@ -3566,7 +3572,9 @@ static SorobanAuthorizationEntry fromXdr(XdrSorobanAuthorizationEntry xdr) XdrSorobanAuthorizationEntry toXdr() static SorobanAuthorizationEntry fromBase64EncodedXdr(String xdr) String toBase64EncodedXdrString() -void sign(KeyPair signer, Network network) +XdrHashIDPreimage buildPreimage(Network network) +void sign(KeyPair signer, Network network, {String? forAddress}) +static SorobanAuthorizationEntry withDelegates(SorobanAuthorizationEntry source, List delegates, int signatureExpirationLedger,) ## SorobanAuthorizedFunction XdrInvokeContractArgs? contractFn XdrCreateContractArgs? createContractHostFn @@ -3615,12 +3623,30 @@ static SorobanContractInfo parseContractByteCode(Uint8List byteCode) SorobanContractParserFailed(this._message) ## SorobanCredentials SorobanAddressCredentials? addressCredentials +SorobanAddressCredentials? addressV2Credentials +SorobanAddressCredentialsWithDelegates? addressWithDelegatesCredentials SorobanCredentials({SorobanAddressCredentials? addressCredentials}) +XdrSorobanCredentialsType get arm +SorobanAddressCredentials? get innerAddressCredentials static SorobanCredentials forSourceAccount() static SorobanCredentials forAddress(Address address, BigInt nonce, int signatureExpirationLedger, XdrSCVal signature) static SorobanCredentials forAddressCredentials(SorobanAddressCredentials addressCredentials) +static SorobanCredentials forAddressV2(SorobanAddressCredentials credentials) +static SorobanCredentials forAddressWithDelegates(SorobanAddressCredentialsWithDelegates credentials) static SorobanCredentials fromXdr(XdrSorobanCredentials xdr) XdrSorobanCredentials toXdr() +## SorobanDelegateDescriptor +final String addressStrKey +final XdrSCVal? signature +final List nestedDelegates +const SorobanDelegateDescriptor(this.addressStrKey, { this.signature, this.nestedDelegates = const [], }) +## SorobanDelegateSignature +XdrSCAddress address +XdrSCVal signature +List nestedDelegates +SorobanDelegateSignature(this.address, this.signature, this.nestedDelegates) +static SorobanDelegateSignature fromXdr(XdrSorobanDelegateSignature xdr) +XdrSorobanDelegateSignature toXdr() ## SorobanRpcErrorResponse Map jsonResponse String? code diff --git a/skills/stellar-flutter-sdk/references/sep-45.md b/skills/stellar-flutter-sdk/references/sep-45.md index 73667241..92f635e3 100644 --- a/skills/stellar-flutter-sdk/references/sep-45.md +++ b/skills/stellar-flutter-sdk/references/sep-45.md @@ -184,6 +184,8 @@ final jwtToken = await webAuth.jwtToken( **Signature expiration:** When signers are provided and `signatureExpirationLedger` is `null`, the SDK calls `SorobanServer.getLatestLedger()` and sets expiration to `sequence + 10` (~50–60 seconds). If the signers array is empty, this Soroban RPC call is skipped entirely. +**Protocol 27 credentials (CAP-71):** signing accepts `ADDRESS`, `ADDRESS_V2`, and `ADDRESS_WITH_DELEGATES` entries transparently and preserves the arm — no flow change. For `ADDRESS_WITH_DELEGATES` entries, supply every signer the contract's `__check_auth` requires, including delegate signers, in the `signers` list. + --- ## Contracts Without Signature Requirements diff --git a/skills/stellar-flutter-sdk/references/soroban_contracts.md b/skills/stellar-flutter-sdk/references/soroban_contracts.md index 58448823..02792629 100644 --- a/skills/stellar-flutter-sdk/references/soroban_contracts.md +++ b/skills/stellar-flutter-sdk/references/soroban_contracts.md @@ -176,6 +176,28 @@ GetTransactionResponse response = await swapTx.signAndSend(sourceAccountKeyPair: aliceKeyPair); ``` +### Protocol 27 Credentials (CAP-71) + +`SorobanCredentials` arms: source-account, legacy `ADDRESS` (default, valid all protocols), `ADDRESS_V2`, `ADDRESS_WITH_DELEGATES` (V2 and delegates are protocol 27+; emitting below p27 invalidates the tx). `credentials.innerAddressCredentials` returns the inner creds for any address arm (null for source-account); `credentials.arm` is the discriminant. + +`signAuthEntries` / `needsNonInvokerSigningBy` handle all arms; `needsNonInvokerSigningBy` lists every void node including delegates. + +```dart +// Build a WITH_DELEGATES entry from an ADDRESS/ADDRESS_V2 entry; the builder +// sorts delegate arrays by XDR-encoded address bytes and rejects duplicates. +int exp = (await server.getLatestLedger()).sequence! + 100; +SorobanAuthorizationEntry delegated = SorobanAuthorizationEntry.withDelegates( + sourceEntry, // ADDRESS from simulation, or ADDRESS_V2 assembled client-side + [SorobanDelegateDescriptor(delegateKeyPair.accountId)], // nestedDelegates / signature optional + exp, +); +delegated.sign(topLevelKeyPair, Network.TESTNET); // optional; skip for delegates-only +delegated.sign(delegateKeyPair, Network.TESTNET, + forAddress: delegateKeyPair.accountId); // routes into matching node depth-first +``` + +Delegates share one payload bound to the top-level address; they carry no nonce/expiration. Multiple G-address signatures on one node must be added in ascending public-key order (the SDK appends in call order, no sorting). After `tx.setSorobanAuth([delegated])`, re-simulate in enforcing mode (`SimulateTransactionRequest(tx, authMode: 'enforce')`) and apply the returned resources (`sorobanTransactionData`, `addResourceFee`) before submitting: the recording simulation does not run `__check_auth`, so for a custom (contract) account it omits the footprint its authorization reads (and understates the delegate fee). New union cases on `SorobanCredentials`/`XdrSorobanCredentialsType`/`XdrEnvelopeType`/`XdrHashIDPreimage` need a `default` arm in exhaustive switches. + --- ## Low-Level: SorobanServer diff --git a/skills/stellar-flutter-sdk/references/troubleshooting.md b/skills/stellar-flutter-sdk/references/troubleshooting.md index 6cb8d44d..625097e8 100644 --- a/skills/stellar-flutter-sdk/references/troubleshooting.md +++ b/skills/stellar-flutter-sdk/references/troubleshooting.md @@ -293,6 +293,12 @@ int bufferedFee = (simResponse.minResourceFee! * 1.15).ceil(); transaction.addResourceFee(bufferedFee); ``` +**Delegated auth (WITH_DELEGATES) submission fails:** after `setSorobanAuth`, the initial simulation excluded the delegate authorization, so its resources are understated — re-simulate with the delegated entry attached and apply the returned `sorobanTransactionData` / `addResourceFee` before submitting. Multiple G-address signatures on one node must be added in ascending public-key order (the SDK appends in call order, no sorting). + +**V2 entries on an old network:** emitting `ADDRESS_V2` / `WITH_DELEGATES` below protocol 27 invalidates the tx — assemble the V2 arms only when targeting protocol 27+. Simulation returns legacy `ADDRESS` entries; assemble the `ADDRESS_V2` arm client-side and inspect `credentials.arm` to confirm which arm an entry uses. + +**Switch no longer compiles after upgrade:** the new union cases on `SorobanCredentials` / `XdrSorobanCredentialsType` / `XdrEnvelopeType` / `XdrHashIDPreimage` require a `default` arm in exhaustive switches. + --- ## Debugging Techniques diff --git a/test/integration/soroban_auth_v2_test.dart b/test/integration/soroban_auth_v2_test.dart new file mode 100644 index 00000000..62bdde56 --- /dev/null +++ b/test/integration/soroban_auth_v2_test.dart @@ -0,0 +1,290 @@ +// Copyright 2026 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. + +// Integration tests for Protocol 27 (CAP-71) ADDRESS_V2 credential arm. +// +// These tests require a testnet running Protocol 27 and a deployed Soroban auth +// contract. +// +// Simulation returns legacy ADDRESS entries; the tests assemble the ADDRESS_V2 +// credential arm client-side to exercise the address-bound signing path. +// +// Each test deploys its own contract instance for independence. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; +import '../tests_util.dart'; + +void main() { + const testOn = 'testnet'; + final network = testOn == 'testnet' ? Network.TESTNET : Network.FUTURENET; + final rpcUrl = testOn == 'testnet' + ? 'https://soroban-testnet.stellar.org' + : 'https://rpc-futurenet.stellar.org'; + final sdk = + testOn == 'testnet' ? StellarSDK.TESTNET : StellarSDK.FUTURENET; + + const authContractPath = 'test/wasm/soroban_auth_contract.wasm'; + + // --------------------------------------------------------------------------- + // Shared helpers + // --------------------------------------------------------------------------- + + Future fundAccount(String accountId) async { + try { + await sdk.accounts.account(accountId); + } catch (_) { + if (testOn == 'testnet') { + await FriendBot.fundTestAccount(accountId); + } else { + await FuturenetFriendBot.fundTestAccount(accountId); + } + } + } + + /// Installs and deploys the auth contract via the high-level client; returns contractId. + Future deployAuthContract(KeyPair submitterKp) async { + final Uint8List wasmBytes = await loadContractCode(authContractPath); + final wasmHash = await SorobanClient.install( + installRequest: InstallRequest( + wasmBytes: wasmBytes, + sourceAccountKeyPair: submitterKp, + network: network, + rpcUrl: rpcUrl, + ), + ); + final client = await SorobanClient.deploy( + deployRequest: DeployRequest( + sourceAccountKeyPair: submitterKp, + network: network, + rpcUrl: rpcUrl, + wasmHash: wasmHash, + ), + ); + return client.getContractId(); + } + + /// Rewrites every ADDRESS auth entry whose credential address matches + /// [signerAccountId] to ADDRESS_V2, assembling the V2 credential arm + /// client-side. Simulation returns legacy ADDRESS entries, so this is how a + /// client opts into the address-bound V2 signing path. + void rewriteAddressToV2( + AssembledTransaction assembled, String signerAccountId) { + final tx = assembled.tx; + if (tx == null) return; + final ops = tx.operations; + if (ops.isEmpty || ops.first is! InvokeHostFunctionOperation) return; + final authEntries = (ops.first as InvokeHostFunctionOperation).auth; + for (final entry in authEntries) { + final creds = entry.credentials; + if (creds.arm == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS && + creds.addressCredentials?.address.accountId == signerAccountId) { + entry.credentials = + SorobanCredentials.forAddressV2(creds.addressCredentials!); + } + } + tx.setSorobanAuth(authEntries); + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + // Test 1: ADDRESS_V2 arm round-trip. + // + // The required signer (the invoker) differs from the transaction source, so + // simulation returns an ADDRESS auth entry for the invoker. That entry is + // rewritten to the ADDRESS_V2 arm client-side, signed, and submitted. + test( + 'ADDRESS_V2 arm round-trip: build, rewrite, sign, submit', + () async { + final sourceKp = KeyPair.random(); + final invokerKp = KeyPair.random(); + await fundAccount(sourceKp.accountId); + await fundAccount(invokerKp.accountId); + + final contractId = await deployAuthContract(sourceKp); + + final clientOptions = ClientOptions( + sourceAccountKeyPair: sourceKp, + contractId: contractId, + network: network, + rpcUrl: rpcUrl, + enableServerLogging: true, + ); + final assembled = await AssembledTransaction.build( + options: AssembledTransactionOptions( + clientOptions: clientOptions, + methodOptions: MethodOptions(), + method: 'increment', + arguments: [ + Address.forAccountId(invokerKp.accountId).toXdrSCVal(), + XdrSCVal.forU32(42), + ], + ), + ); + + // The invoker differs from the source, so simulation returned an ADDRESS + // entry for the invoker. Assemble the ADDRESS_V2 arm client-side. + rewriteAddressToV2(assembled, invokerKp.accountId); + + final invokeOp = + assembled.tx!.operations.first as InvokeHostFunctionOperation; + final hasV2 = invokeOp.auth.any((e) => + e.credentials.arm == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2); + assert(hasV2, + 'Expected an ADDRESS_V2 auth entry after the client-side rewrite'); + + final needsSigning = assembled.needsNonInvokerSigningBy(); + assert(needsSigning.isNotEmpty, + 'The invoker must be required to sign the ADDRESS_V2 auth entry'); + await assembled.signAuthEntries(signerKeyPair: invokerKp); + + assembled.sign(); + final response = await assembled.send(); + assert(response.status == GetTransactionResponse.STATUS_SUCCESS, + 'Expected SUCCESS, got ${response.status}'); + }, + timeout: const Timeout(Duration(minutes: 5)), + ); + + // Test 2: ADDRESS_V2 entry uses the address-bound preimage envelope type. + // + // After assembling the ADDRESS_V2 arm client-side, verifies that an ADDRESS_V2 + // entry uses ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS (CAP-71) and any + // remaining ADDRESS entry uses the legacy ENVELOPE_TYPE_SOROBAN_AUTHORIZATION. + test( + 'ADDRESS_V2 entry uses address-bound preimage (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS)', + () async { + final sourceKp = KeyPair.random(); + final invokerKp = KeyPair.random(); + await fundAccount(sourceKp.accountId); + await fundAccount(invokerKp.accountId); + + final contractId = await deployAuthContract(sourceKp); + + final assembled = await AssembledTransaction.build( + options: AssembledTransactionOptions( + clientOptions: ClientOptions( + sourceAccountKeyPair: sourceKp, + contractId: contractId, + network: network, + rpcUrl: rpcUrl, + ), + methodOptions: MethodOptions(), + method: 'increment', + arguments: [ + Address.forAccountId(invokerKp.accountId).toXdrSCVal(), + XdrSCVal.forU32(1), + ], + ), + ); + + // Assemble the ADDRESS_V2 arm client-side before inspecting the preimage. + rewriteAddressToV2(assembled, invokerKp.accountId); + + final invokeOp = + assembled.tx!.operations.first as InvokeHostFunctionOperation; + var checkedV2 = false; + for (final entry in invokeOp.auth) { + final arm = entry.credentials.arm; + if (arm == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2) { + checkedV2 = true; + final preimage = entry.buildPreimage(network); + assert( + preimage.discriminant == + XdrEnvelopeType + .ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS, + 'ADDRESS_V2 must use the address-bound preimage envelope type', + ); + } else if (arm == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) { + final preimage = entry.buildPreimage(network); + assert( + preimage.discriminant == + XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION, + 'ADDRESS must use the legacy preimage envelope type', + ); + } + } + assert(checkedV2, + 'Expected an ADDRESS_V2 auth entry to verify the address-bound preimage'); + }, + timeout: const Timeout(Duration(minutes: 5)), + ); + + // Test 3: Signed ADDRESS_V2 entry survives an XDR round-trip intact. + // + // Signs the client-assembled ADDRESS_V2 entry and verifies that the arm and the + // non-void signature survive a toBase64EncodedXdrString / fromBase64EncodedXdr + // cycle. + test( + 'ADDRESS_V2 signed entry survives XDR round-trip', + () async { + final sourceKp = KeyPair.random(); + final invokerKp = KeyPair.random(); + await fundAccount(sourceKp.accountId); + await fundAccount(invokerKp.accountId); + + final contractId = await deployAuthContract(sourceKp); + + final assembled = await AssembledTransaction.build( + options: AssembledTransactionOptions( + clientOptions: ClientOptions( + sourceAccountKeyPair: sourceKp, + contractId: contractId, + network: network, + rpcUrl: rpcUrl, + ), + methodOptions: MethodOptions(), + method: 'increment', + arguments: [ + Address.forAccountId(invokerKp.accountId).toXdrSCVal(), + XdrSCVal.forU32(7), + ], + ), + ); + + // Assemble the ADDRESS_V2 arm client-side, then sign it. + rewriteAddressToV2(assembled, invokerKp.accountId); + await assembled.signAuthEntries(signerKeyPair: invokerKp); + + final invokeOp = + assembled.tx!.operations.first as InvokeHostFunctionOperation; + var roundTripped = false; + for (final entry in invokeOp.auth) { + if (entry.credentials.arm != + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2) { + continue; + } + roundTripped = true; + final b64 = entry.toBase64EncodedXdrString(); + final restored = SorobanAuthorizationEntry.fromBase64EncodedXdr(b64); + + assert( + restored.credentials.arm == + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + 'Arm must be preserved through the XDR round-trip', + ); + assert( + restored.credentials.addressV2Credentials != null, + 'addressV2Credentials must be non-null after the round-trip', + ); + assert( + restored.credentials.addressV2Credentials!.signature.discriminant != + XdrSCValType.SCV_VOID, + 'Signature must be non-void after signing and the XDR round-trip', + ); + } + assert(roundTripped, + 'Expected a signed ADDRESS_V2 entry to round-trip'); + }, + timeout: const Timeout(Duration(minutes: 5)), + ); +} diff --git a/test/integration/soroban_auth_with_delegates_test.dart b/test/integration/soroban_auth_with_delegates_test.dart new file mode 100644 index 00000000..64a09b6a --- /dev/null +++ b/test/integration/soroban_auth_with_delegates_test.dart @@ -0,0 +1,236 @@ +// Copyright 2026 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. + +// Integration test for the Protocol 27 (CAP-71) ADDRESS_WITH_DELEGATES credential arm. +// +// Deploys a modular custom account whose __check_auth carries no signature of its +// own and forwards authorization to its registered delegate signers, plus the +// standard auth (increment) contract. The increment call is invoked with the +// modular account as the authorizing address, so the host calls the modular +// account's __check_auth, which delegates to a registered G-account signer. +// +// Simulation returns a legacy ADDRESS entry for the modular account; it is +// converted to ADDRESS_WITH_DELEGATES client-side (preserving the simulated +// nonce), the delegate node is signed, and the transaction is re-simulated in +// enforcing mode so the modular account's __check_auth runs and its footprint is +// captured before submission. Both contracts are deployed with the high-level +// SorobanClient; the delegated-auth invocation is driven at the SorobanServer +// level. +// +// Requires a testnet running Protocol 27. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; +import '../tests_util.dart'; + +void main() { + const testOn = 'testnet'; + final network = testOn == 'testnet' ? Network.TESTNET : Network.FUTURENET; + final rpcUrl = testOn == 'testnet' + ? 'https://soroban-testnet.stellar.org' + : 'https://rpc-futurenet.stellar.org'; + final sdk = testOn == 'testnet' ? StellarSDK.TESTNET : StellarSDK.FUTURENET; + + const modularAccountPath = 'test/wasm/soroban_modular_account_contract.wasm'; + const authContractPath = 'test/wasm/soroban_auth_contract.wasm'; + + Future fundAccount(String accountId) async { + try { + await sdk.accounts.account(accountId); + } catch (_) { + if (testOn == 'testnet') { + await FriendBot.fundTestAccount(accountId); + } else { + await FuturenetFriendBot.fundTestAccount(accountId); + } + } + } + + Future pollUntilDone( + SorobanServer server, String transactionId) async { + var status = GetTransactionResponse.STATUS_NOT_FOUND; + GetTransactionResponse? response; + while (status == GetTransactionResponse.STATUS_NOT_FOUND) { + await Future.delayed(const Duration(seconds: 3)); + response = await server.getTransaction(transactionId); + assert(response.error == null, + 'getTransaction returned an error: ${response.error}'); + status = response.status!; + if (status == GetTransactionResponse.STATUS_FAILED) { + fail('Transaction $transactionId failed: ${response.resultXdr}'); + } + } + return response!; + } + + // ADDRESS_WITH_DELEGATES round-trip. + // + // The authorizing address is the modular custom account (a C-address). Its + // __check_auth verifies no signature of its own and instead delegates to the + // registered G-account, whose real signature the host verifies. + test( + 'ADDRESS_WITH_DELEGATES round-trip: deploy modular account, delegate-sign, enforce re-simulate, submit', + () async { + final server = SorobanServer(rpcUrl); + server.enableLogging = true; + + final submitterKp = KeyPair.random(); + final delegateKp = KeyPair.random(); + await fundAccount(submitterKp.accountId); + await fundAccount(delegateKp.accountId); + + // Deploy the modular custom account (registering the delegate as an allowed + // signer) and the auth (increment) business contract, both via the + // high-level client. + final Uint8List modularWasm = await loadContractCode(modularAccountPath); + final modularWasmHash = await SorobanClient.install( + installRequest: InstallRequest( + wasmBytes: modularWasm, + sourceAccountKeyPair: submitterKp, + network: network, + rpcUrl: rpcUrl, + ), + ); + final signersArg = XdrSCVal.forVec( + [Address.forAccountId(delegateKp.accountId).toXdrSCVal()]); + final modularClient = await SorobanClient.deploy( + deployRequest: DeployRequest( + sourceAccountKeyPair: submitterKp, + network: network, + rpcUrl: rpcUrl, + wasmHash: modularWasmHash, + constructorArgs: [signersArg], + ), + ); + final modularAccountId = modularClient.getContractId(); + + final Uint8List authWasm = await loadContractCode(authContractPath); + final authWasmHash = await SorobanClient.install( + installRequest: InstallRequest( + wasmBytes: authWasm, + sourceAccountKeyPair: submitterKp, + network: network, + rpcUrl: rpcUrl, + ), + ); + final authClient = await SorobanClient.deploy( + deployRequest: DeployRequest( + sourceAccountKeyPair: submitterKp, + network: network, + rpcUrl: rpcUrl, + wasmHash: authWasmHash, + ), + ); + final authContractId = authClient.getContractId(); + + // increment(user = modular account, value = 1) requires the modular + // account's authorization, so the host invokes its __check_auth, which + // delegates to the registered G-account. + Account? account = await server.getAccount(submitterKp.accountId); + assert(account != null); + final invokeFn = InvokeContractHostFunction( + authContractId, + 'increment', + arguments: [ + Address.forContractId(modularAccountId).toXdrSCVal(), + XdrSCVal.forU32(1), + ], + ); + final invokeOp = InvokeHostFuncOpBuilder(invokeFn).build(); + final tx = TransactionBuilder(account!).addOperation(invokeOp).build(); + + // Recording-mode simulation: returns the legacy ADDRESS authorization entry + // for the modular account (with the RPC-assigned nonce). __check_auth is not + // executed in this pass. + final sim = await server.simulateTransaction(SimulateTransactionRequest(tx)); + assert(!sim.isErrorResponse, 'simulation error: ${sim.error}'); + final auth = sim.sorobanAuth; + assert(auth != null && auth.isNotEmpty, + 'simulation should return authorization entries'); + assert(auth!.length == 1, + 'increment should require exactly one authorization (the modular account)'); + + final latest = await server.getLatestLedger(); + final expirationLedger = latest.sequence! + 100; + + // Convert each address-based entry to the ADDRESS_WITH_DELEGATES arm + // (preserving the simulated nonce), attach the delegate, and sign only the + // delegate node. The top-level signature stays void: the modular account + // verifies no signature of its own and authorizes through its delegate. + final signedAuth = []; + for (final entry in auth!) { + final arm = entry.credentials.arm; + if (arm == XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS || + arm == XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2) { + final withDelegates = SorobanAuthorizationEntry.withDelegates( + entry, + [SorobanDelegateDescriptor(delegateKp.accountId)], + expirationLedger, + ); + withDelegates.sign(delegateKp, network, + forAddress: delegateKp.accountId); + signedAuth.add(withDelegates); + } else { + signedAuth.add(entry); + } + } + + // A WITH_DELEGATES entry must be present with a void top-level signature and + // a signed delegate node for the G-account. + final wdEntries = signedAuth + .where((e) => + e.credentials.arm == + XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) + .toList(); + assert(wdEntries.length == 1, + 'Expected exactly one ADDRESS_WITH_DELEGATES auth entry'); + final wrapper = wdEntries.first.credentials.addressWithDelegatesCredentials!; + assert( + wrapper.addressCredentials.signature.discriminant == + XdrSCValType.SCV_VOID, + 'The top-level signature must remain void (the modular account signs nothing itself)', + ); + assert(wrapper.delegates.length == 1, + 'Exactly one delegate node should be attached'); + assert( + wrapper.delegates.first.signature.discriminant != XdrSCValType.SCV_VOID, + 'The delegate node must carry a signature after signing', + ); + + // Attach the signed auth and re-simulate in enforcing mode so the modular + // account's __check_auth runs and its footprint reads (plus the delegate's + // account entry) are captured. The recording-mode simulation above could not + // have captured them. + tx.setSorobanAuth(signedAuth); + final reSim = await server + .simulateTransaction(SimulateTransactionRequest(tx, authMode: 'enforce')); + assert(!reSim.isErrorResponse, 're-simulation error: ${reSim.error}'); + assert(reSim.transactionData != null); + + // Apply the enforcing simulation's footprint and resource fee; the + // already-signed auth is kept. + tx.sorobanTransactionData = reSim.transactionData; + tx.addResourceFee(reSim.minResourceFee!); + tx.sign(submitterKp, network); + + final send = await server.sendTransaction(tx); + assert(send.hash != null); + final result = await pollUntilDone(server, send.hash!); + assert(result.status == GetTransactionResponse.STATUS_SUCCESS, + 'Expected SUCCESS, got ${result.status}'); + + // increment returns the modular account's accumulated counter; a fresh + // account starts at 0, so a single increment by 1 returns 1, proving the + // delegated authorization succeeded. + final resultValue = result.getResultValue(); + assert(resultValue?.u32 != null, 'increment should return a u32'); + assert(resultValue!.u32!.uint32 == 1, + 'increment should return the accumulated counter value (1)'); + }, + timeout: const Timeout(Duration(minutes: 5)), + ); +} diff --git a/test/unit/sep/webauth_contracts_p27_test.dart b/test/unit/sep/webauth_contracts_p27_test.dart new file mode 100644 index 00000000..5859d564 --- /dev/null +++ b/test/unit/sep/webauth_contracts_p27_test.dart @@ -0,0 +1,868 @@ +// Copyright 2025 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. + +// Tests for Protocol 27 credential-arm support in WebAuthForContracts. +// +// Covers: +// - _verifyServerSignature accepts ADDRESS, ADDRESS_V2, and ADDRESS_WITH_DELEGATES +// entries, using the arm-correct preimage for each. +// - An ADDRESS_V2 entry signed over the wrong (legacy) preimage fails verification. +// - Legacy ADDRESS preimage produces a byte-identical hash to the cross-SDK golden +// constant captured from the iOS implementation. +// - signAuthorizationEntries preserves the credential arm when stamping expiration. +// - Source-account credentials are passed through (not matched as a signing target) +// and never cause an error in the sign flow. +// - validateChallenge recognises ADDRESS_V2 and ADDRESS_WITH_DELEGATES entries. + +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; + +// ============================================================================ +// Cross-SDK golden constants (section 8.1 of p27-plan.md) +// Fixed inputs: TESTNET, signer seed SDJHRQF4GCMIIKAAAQ6IHY42X73FQFLHUULAPSKKD4DFDM7UXWWCRHBE, +// contract CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE, +// fn hello(u64 1234), nonce 123456789101112, signatureExpirationLedger 4242. +// ============================================================================ + +const _goldenSignerSeed = + 'SDJHRQF4GCMIIKAAAQ6IHY42X73FQFLHUULAPSKKD4DFDM7UXWWCRHBE'; +const _goldenSignerAccount = + 'GCZHXL5HXQX5ABDM26LHYRCQZ5OJFHLOPLZX47WEBP3V2PF5AVFK2A5D'; +const _goldenContract = + 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; +const _goldenNonce = 123456789101112; +const _goldenExpiration = 4242; + +// SHA-256 of the legacy ADDRESS (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION) preimage. +const _goldenLegacyPayloadHex = + '120c429d4333e12e0ca2c5ac10630e728fdd33240bf7066f4c62f6a2d6fa3cbe'; + +// SHA-256 of the ADDRESS_V2 (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS) preimage. +const _goldenV2PayloadHex = + '252a0d6117840dff37b765839810fb6ecc446198e73062e01bc961e49355b7b9'; + +// Ed25519 signature produced by the fixed seed over the legacy payload. +const _goldenLegacySigHex = + '3c69ceefc532f97e1d0e0eb9f204c9aa85cb2b68cf293bce832590b01455e060' + 'e89900ea3ba2c45257908769a1a71f25b6d3befbadffd220f896dc0058699008'; + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Converts a hex string to Uint8List. +Uint8List _hexDecode(String hex) { + final result = Uint8List(hex.length ~/ 2); + for (var i = 0; i < result.length; i++) { + result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return result; +} + +/// Builds a web_auth_verify args map SCVal for use in test auth entries. +XdrSCVal _buildArgsMap({ + required String account, + required String homeDomain, + required String webAuthDomain, + required String webAuthDomainAccount, + required String nonce, +}) { + XdrSCMapEntry makeEntry(String key, String value) => XdrSCMapEntry( + XdrSCVal.forSymbol(key), + XdrSCVal.forString(value), + ); + + return XdrSCVal.forMap([ + makeEntry('account', account), + makeEntry('home_domain', homeDomain), + makeEntry('nonce', nonce), + makeEntry('web_auth_domain', webAuthDomain), + makeEntry('web_auth_domain_account', webAuthDomainAccount), + ]); +} + +/// Builds a minimal SorobanAuthorizationEntry with web_auth_verify invocation +/// and no sub-invocations, using the provided credentials. +SorobanAuthorizationEntry _buildWebAuthEntry({ + required SorobanCredentials credentials, + required String webAuthContractId, + required XdrSCVal argsMap, +}) { + final contractAddr = Address.forContractId(webAuthContractId); + final function = SorobanAuthorizedFunction.forContractFunction( + contractAddr, + 'web_auth_verify', + [argsMap], + ); + final invocation = SorobanAuthorizedInvocation(function); + return SorobanAuthorizationEntry(credentials, invocation); +} + +/// Returns the SHA-256 payload for [entry] encoded as a hex string. +String _computePayloadHex(SorobanAuthorizationEntry entry, Network network) { + final preimage = entry.buildPreimage(network); + final out = XdrDataOutputStream(); + XdrHashIDPreimage.encode(out, preimage); + final hash = Util.hash(Uint8List.fromList(out.bytes)); + return hash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +} + +/// Builds a fully-signed server entry using [serverKeyPair], for the given +/// credential arm and contract/args. Stamps expiration before signing. +SorobanAuthorizationEntry _buildSignedServerEntry({ + required KeyPair serverKeyPair, + required SorobanCredentials credentials, + required String webAuthContractId, + required XdrSCVal argsMap, + required Network network, + required int signatureExpirationLedger, +}) { + // Stamp expiration on the inner credentials before building the preimage. + final inner = credentials.innerAddressCredentials!; + inner.signatureExpirationLedger = signatureExpirationLedger; + + final entry = _buildWebAuthEntry( + credentials: credentials, + webAuthContractId: webAuthContractId, + argsMap: argsMap, + ); + entry.sign(serverKeyPair, network); + return entry; +} + +// ============================================================================ +// Test constants shared across groups +// ============================================================================ + +const _webAuthContractId = + 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; +const _authEndpoint = 'https://testanchor.stellar.org/auth'; +const _homeDomain = 'testanchor.stellar.org'; +const _webAuthDomain = 'testanchor.stellar.org'; +// Contract address used as the "client" account in tests. +// Stored as strkey; passed through the XDR round-trip where needed so that +// the Address object holds a hex contractId (as it would in a real decode). +const _clientAccountId = + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'; + +/// Returns an [Address] for a contract strkey whose [contractId] field stores +/// the hex representation (matching the XDR-decode path used in production). +Address _contractAddress(String strkey) => + Address.fromXdr(XdrSCAddress.forContractId(strkey)); +const _nonce = '42'; + +// ============================================================================ + +void main() { + // --------------------------------------------------------------------------- + // Golden-vector tests + // --------------------------------------------------------------------------- + group('WebAuthForContracts - golden payload vectors', () { + test('legacy ADDRESS preimage produces golden SHA-256 payload', () { + final signer = KeyPair.fromSecretSeed(_goldenSignerSeed); + final address = Address.forAccountId(_goldenSignerAccount); + final innerCreds = SorobanAddressCredentials( + address, + BigInt.from(_goldenNonce), + _goldenExpiration, + XdrSCVal.forVoid(), + ); + final credentials = SorobanCredentials(addressCredentials: innerCreds); + + final contractAddr = Address.forContractId(_goldenContract); + final invocation = SorobanAuthorizedInvocation( + SorobanAuthorizedFunction.forContractFunction( + contractAddr, + 'hello', + [XdrSCVal.forU64(BigInt.from(1234))], + ), + ); + final entry = + SorobanAuthorizationEntry(credentials, invocation); + + final payloadHex = _computePayloadHex(entry, Network.TESTNET); + expect(payloadHex, equals(_goldenLegacyPayloadHex)); + + // Also verify the ed25519 signature matches the golden constant. + final payloadBytes = _hexDecode(payloadHex); + final sigBytes = signer.sign(payloadBytes); + expect( + sigBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), + equals(_goldenLegacySigHex), + ); + }); + + test('ADDRESS_V2 preimage produces golden SHA-256 payload', () { + final address = Address.forAccountId(_goldenSignerAccount); + final innerCreds = SorobanAddressCredentials( + address, + BigInt.from(_goldenNonce), + _goldenExpiration, + XdrSCVal.forVoid(), + ); + final credentials = SorobanCredentials.forAddressV2(innerCreds); + + final contractAddr = Address.forContractId(_goldenContract); + final invocation = SorobanAuthorizedInvocation( + SorobanAuthorizedFunction.forContractFunction( + contractAddr, + 'hello', + [XdrSCVal.forU64(BigInt.from(1234))], + ), + ); + final entry = SorobanAuthorizationEntry(credentials, invocation); + + final payloadHex = _computePayloadHex(entry, Network.TESTNET); + expect(payloadHex, equals(_goldenV2PayloadHex)); + }); + + test('ADDRESS and ADDRESS_V2 preimages differ for otherwise identical fields', () { + final address = Address.forAccountId(_goldenSignerAccount); + final innerCreds1 = SorobanAddressCredentials( + address, + BigInt.from(_goldenNonce), + _goldenExpiration, + XdrSCVal.forVoid(), + ); + final innerCreds2 = SorobanAddressCredentials( + address, + BigInt.from(_goldenNonce), + _goldenExpiration, + XdrSCVal.forVoid(), + ); + + final contractAddr = Address.forContractId(_goldenContract); + final function = SorobanAuthorizedFunction.forContractFunction( + contractAddr, + 'hello', + [XdrSCVal.forU64(BigInt.from(1234))], + ); + final invocation1 = SorobanAuthorizedInvocation(function); + final invocation2 = SorobanAuthorizedInvocation(function); + + final legacyEntry = SorobanAuthorizationEntry( + SorobanCredentials(addressCredentials: innerCreds1), + invocation1, + ); + final v2Entry = SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(innerCreds2), + invocation2, + ); + + expect( + _computePayloadHex(legacyEntry, Network.TESTNET), + isNot(equals(_computePayloadHex(v2Entry, Network.TESTNET))), + ); + }); + }); + + // --------------------------------------------------------------------------- + // _verifyServerSignature path through validateChallenge + // --------------------------------------------------------------------------- + group('WebAuthForContracts - _verifyServerSignature arm coverage', () { + late KeyPair serverKeyPair; + late String serverAccountId; + late XdrSCVal argsMap; + late WebAuthForContracts webAuth; + + setUp(() { + serverKeyPair = KeyPair.random(); + serverAccountId = serverKeyPair.accountId; + + argsMap = _buildArgsMap( + account: _clientAccountId, + homeDomain: _homeDomain, + webAuthDomain: _webAuthDomain, + webAuthDomainAccount: serverAccountId, + nonce: _nonce, + ); + + webAuth = WebAuthForContracts( + _authEndpoint, + _webAuthContractId, + serverAccountId, + _homeDomain, + Network.TESTNET, + ); + }); + + test('legacy ADDRESS server entry verifies successfully', () { + final innerCreds = SorobanAddressCredentials( + Address.forAccountId(serverAccountId), + BigInt.from(1), + 9999, + XdrSCVal.forVoid(), + ); + final credentials = + SorobanCredentials(addressCredentials: innerCreds); + + final serverEntry = _buildSignedServerEntry( + serverKeyPair: serverKeyPair, + credentials: credentials, + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + network: Network.TESTNET, + signatureExpirationLedger: 9999, + ); + + final clientInnerCreds = SorobanAddressCredentials( + _contractAddress(_clientAccountId), + BigInt.from(2), + 9999, + XdrSCVal.forVoid(), + ); + final clientEntry = _buildWebAuthEntry( + credentials: SorobanCredentials(addressCredentials: clientInnerCreds), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + + // validateChallenge must not throw — the server entry has a valid + // legacy-preimage signature. + expect( + () => webAuth.validateChallenge( + [serverEntry, clientEntry], + _clientAccountId, + ), + returnsNormally, + ); + }); + + test('ADDRESS_V2 server entry verifies successfully with WITH_ADDRESS preimage', () { + final innerCreds = SorobanAddressCredentials( + Address.forAccountId(serverAccountId), + BigInt.from(1), + 9999, + XdrSCVal.forVoid(), + ); + final credentials = SorobanCredentials.forAddressV2(innerCreds); + + final serverEntry = _buildSignedServerEntry( + serverKeyPair: serverKeyPair, + credentials: credentials, + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + network: Network.TESTNET, + signatureExpirationLedger: 9999, + ); + + // Arm must be preserved after signing. + expect( + serverEntry.credentials.arm, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2), + ); + + final clientInnerCreds = SorobanAddressCredentials( + _contractAddress(_clientAccountId), + BigInt.from(2), + 9999, + XdrSCVal.forVoid(), + ); + final clientEntry = _buildWebAuthEntry( + credentials: SorobanCredentials(addressCredentials: clientInnerCreds), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + + // validateChallenge must not throw. + expect( + () => webAuth.validateChallenge( + [serverEntry, clientEntry], + _clientAccountId, + ), + returnsNormally, + ); + }); + + test( + 'ADDRESS_V2 server entry signed over the wrong (legacy) preimage fails verification', + () { + // Build V2 credentials but sign manually over the LEGACY preimage. + final innerCreds = SorobanAddressCredentials( + Address.forAccountId(serverAccountId), + BigInt.from(1), + 9999, + XdrSCVal.forVoid(), + ); + innerCreds.signatureExpirationLedger = 9999; + + final v2Credentials = SorobanCredentials.forAddressV2(innerCreds); + final serverEntry = _buildWebAuthEntry( + credentials: v2Credentials, + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + + // Construct the WRONG (legacy) preimage manually and sign over it. + final legacyCreds = SorobanAddressCredentials( + Address.forAccountId(serverAccountId), + BigInt.from(1), + 9999, + XdrSCVal.forVoid(), + ); + final legacyEntry = _buildWebAuthEntry( + credentials: SorobanCredentials(addressCredentials: legacyCreds), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + // Compute the legacy payload hash. + final legacyPreimage = legacyEntry.buildPreimage(Network.TESTNET); + final legacyOut = XdrDataOutputStream(); + XdrHashIDPreimage.encode(legacyOut, legacyPreimage); + final legacyPayload = + Util.hash(Uint8List.fromList(legacyOut.bytes)); + + // Sign the V2 entry's credential using the legacy payload hash. + final sigBytes = serverKeyPair.sign(legacyPayload); + final sig = AccountEd25519Signature(serverKeyPair.xdrPublicKey, sigBytes); + final sigVec = XdrSCVal.forVec([sig.toXdrSCVal()]); + serverEntry.credentials.innerAddressCredentials!.signature = sigVec; + + final clientInnerCreds = SorobanAddressCredentials( + _contractAddress(_clientAccountId), + BigInt.from(2), + 9999, + XdrSCVal.forVoid(), + ); + final clientEntry = _buildWebAuthEntry( + credentials: SorobanCredentials(addressCredentials: clientInnerCreds), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + + // validateChallenge must throw because the signature is over the wrong preimage. + expect( + () => webAuth.validateChallenge( + [serverEntry, clientEntry], + _clientAccountId, + ), + throwsA( + isA(), + ), + ); + }); + + test('ADDRESS_WITH_DELEGATES server entry verifies successfully', () { + // Build an ADDRESS entry and convert to WITH_DELEGATES. + final innerCreds = SorobanAddressCredentials( + Address.forAccountId(serverAccountId), + BigInt.from(1), + 9999, + XdrSCVal.forVoid(), + ); + final baseEntry = _buildWebAuthEntry( + credentials: SorobanCredentials(addressCredentials: innerCreds), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + + // Delegate to a different G-address (distinct from server address). + final delegateKeyPair = KeyPair.random(); + final delegateAddress = delegateKeyPair.accountId; + + final withDelegatesEntry = SorobanAuthorizationEntry.withDelegates( + baseEntry, + [SorobanDelegateDescriptor(delegateAddress)], + 9999, + ); + + // Sign at the top level (server address) since it is a G-address. + withDelegatesEntry.sign(serverKeyPair, Network.TESTNET); + + expect( + withDelegatesEntry.credentials.arm, + equals( + XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES), + ); + + final clientInnerCreds = SorobanAddressCredentials( + _contractAddress(_clientAccountId), + BigInt.from(2), + 9999, + XdrSCVal.forVoid(), + ); + final clientEntry = _buildWebAuthEntry( + credentials: SorobanCredentials(addressCredentials: clientInnerCreds), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + + // validateChallenge must not throw. + expect( + () => webAuth.validateChallenge( + [withDelegatesEntry, clientEntry], + _clientAccountId, + ), + returnsNormally, + ); + }); + + test('source-account server entry is not matched as server entry', () { + // A source-account entry cannot be the server entry because + // innerAddressCredentials is null and the address check is skipped. + // validateChallenge must throw ContractChallengeValidationErrorMissingServerEntry. + final sourceAccountEntry = _buildWebAuthEntry( + credentials: SorobanCredentials.forSourceAccount(), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + + final clientInnerCreds = SorobanAddressCredentials( + _contractAddress(_clientAccountId), + BigInt.from(2), + 9999, + XdrSCVal.forVoid(), + ); + final clientEntry = _buildWebAuthEntry( + credentials: SorobanCredentials(addressCredentials: clientInnerCreds), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + + expect( + () => webAuth.validateChallenge( + [sourceAccountEntry, clientEntry], + _clientAccountId, + ), + throwsA( + isA(), + ), + ); + }); + }); + + // --------------------------------------------------------------------------- + // signAuthorizationEntries — arm preservation through expiration stamping + // --------------------------------------------------------------------------- + group('WebAuthForContracts - signAuthorizationEntries arm preservation', () { + late KeyPair serverKeyPair; + late String serverAccountId; + late KeyPair clientKeyPair; + late String clientAccountId; + late XdrSCVal argsMap; + + setUp(() { + serverKeyPair = KeyPair.random(); + serverAccountId = serverKeyPair.accountId; + clientKeyPair = KeyPair.random(); + clientAccountId = clientKeyPair.accountId; + + argsMap = _buildArgsMap( + account: clientAccountId, + homeDomain: _homeDomain, + webAuthDomain: _webAuthDomain, + webAuthDomainAccount: serverAccountId, + nonce: _nonce, + ); + }); + + test('ADDRESS arm preserved after expiration stamping', () async { + final innerCreds = SorobanAddressCredentials( + Address.forAccountId(clientAccountId), + BigInt.from(1), + 0, + XdrSCVal.forVoid(), + ); + final entry = _buildWebAuthEntry( + credentials: SorobanCredentials(addressCredentials: innerCreds), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + + final webAuth = WebAuthForContracts( + _authEndpoint, + _webAuthContractId, + serverAccountId, + _homeDomain, + Network.TESTNET, + ); + + final signed = await webAuth.signAuthorizationEntries( + [entry], + clientAccountId, + [clientKeyPair], + 5000, + null, + null, + null, + ); + + expect(signed.length, equals(1)); + expect( + signed[0].credentials.arm, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS), + ); + expect( + signed[0].credentials.innerAddressCredentials!.signatureExpirationLedger, + equals(5000), + ); + }); + + test('ADDRESS_V2 arm preserved after expiration stamping and signing', () async { + final innerCreds = SorobanAddressCredentials( + Address.forAccountId(clientAccountId), + BigInt.from(1), + 0, + XdrSCVal.forVoid(), + ); + final entry = _buildWebAuthEntry( + credentials: SorobanCredentials.forAddressV2(innerCreds), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + + final webAuth = WebAuthForContracts( + _authEndpoint, + _webAuthContractId, + serverAccountId, + _homeDomain, + Network.TESTNET, + ); + + final signed = await webAuth.signAuthorizationEntries( + [entry], + clientAccountId, + [clientKeyPair], + 6000, + null, + null, + null, + ); + + expect(signed.length, equals(1)); + expect( + signed[0].credentials.arm, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2), + ); + expect( + signed[0].credentials.innerAddressCredentials!.signatureExpirationLedger, + equals(6000), + ); + // The signature vector must be non-empty after signing. + final sigVec = signed[0].credentials.innerAddressCredentials!.signature; + expect(sigVec.discriminant, equals(XdrSCValType.SCV_VEC)); + expect(sigVec.vec, isNotEmpty); + }); + + test('ADDRESS_WITH_DELEGATES arm preserved after expiration stamping', () async { + final innerCreds = SorobanAddressCredentials( + Address.forAccountId(clientAccountId), + BigInt.from(1), + 0, + XdrSCVal.forVoid(), + ); + final baseEntry = _buildWebAuthEntry( + credentials: SorobanCredentials(addressCredentials: innerCreds), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + + final delegateKey = KeyPair.random(); + final withDelegates = SorobanAuthorizationEntry.withDelegates( + baseEntry, + [SorobanDelegateDescriptor(delegateKey.accountId)], + 0, + ); + + final webAuth = WebAuthForContracts( + _authEndpoint, + _webAuthContractId, + serverAccountId, + _homeDomain, + Network.TESTNET, + ); + + final signed = await webAuth.signAuthorizationEntries( + [withDelegates], + clientAccountId, + [clientKeyPair], + 7000, + null, + null, + null, + ); + + expect(signed.length, equals(1)); + expect( + signed[0].credentials.arm, + equals( + XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES), + ); + expect( + signed[0].credentials.innerAddressCredentials!.signatureExpirationLedger, + equals(7000), + ); + }); + + test('source-account entry passes through unsigned', () async { + final sourceEntry = _buildWebAuthEntry( + credentials: SorobanCredentials.forSourceAccount(), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + + final webAuth = WebAuthForContracts( + _authEndpoint, + _webAuthContractId, + serverAccountId, + _homeDomain, + Network.TESTNET, + ); + + // Must not throw even though source-account credentials carry no address. + final result = await webAuth.signAuthorizationEntries( + [sourceEntry], + clientAccountId, + [clientKeyPair], + 5000, + null, + null, + null, + ); + + expect(result.length, equals(1)); + expect( + result[0].credentials.arm, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT), + ); + }); + }); + + // --------------------------------------------------------------------------- + // validateChallenge — ADDRESS_V2 and WITH_DELEGATES client entries accepted + // --------------------------------------------------------------------------- + group('WebAuthForContracts - validateChallenge arm detection', () { + test('ADDRESS_V2 client entry is recognised and does not block validation', () { + final serverKeyPair = KeyPair.random(); + final serverAccountId = serverKeyPair.accountId; + + final argsMap = _buildArgsMap( + account: _clientAccountId, + homeDomain: _homeDomain, + webAuthDomain: _webAuthDomain, + webAuthDomainAccount: serverAccountId, + nonce: _nonce, + ); + + final webAuth = WebAuthForContracts( + _authEndpoint, + _webAuthContractId, + serverAccountId, + _homeDomain, + Network.TESTNET, + ); + + // Build a signed server entry (legacy arm is fine for the server). + final serverEntry = _buildSignedServerEntry( + serverKeyPair: serverKeyPair, + credentials: SorobanCredentials( + addressCredentials: SorobanAddressCredentials( + Address.forAccountId(serverAccountId), + BigInt.from(1), + 9999, + XdrSCVal.forVoid(), + ), + ), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + network: Network.TESTNET, + signatureExpirationLedger: 9999, + ); + + // Client entry uses ADDRESS_V2 arm. + final clientInnerCreds = SorobanAddressCredentials( + _contractAddress(_clientAccountId), + BigInt.from(2), + 9999, + XdrSCVal.forVoid(), + ); + final clientEntry = _buildWebAuthEntry( + credentials: SorobanCredentials.forAddressV2(clientInnerCreds), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + + // validateChallenge must locate the client entry via innerAddressCredentials + // and not throw ContractChallengeValidationErrorMissingClientEntry. + expect( + () => webAuth.validateChallenge( + [serverEntry, clientEntry], + _clientAccountId, + ), + returnsNormally, + ); + }); + + test('ADDRESS_WITH_DELEGATES client entry is recognised', () { + final serverKeyPair = KeyPair.random(); + final serverAccountId = serverKeyPair.accountId; + + final clientContractId = _clientAccountId; + final argsMap = _buildArgsMap( + account: clientContractId, + homeDomain: _homeDomain, + webAuthDomain: _webAuthDomain, + webAuthDomainAccount: serverAccountId, + nonce: _nonce, + ); + + final webAuth = WebAuthForContracts( + _authEndpoint, + _webAuthContractId, + serverAccountId, + _homeDomain, + Network.TESTNET, + ); + + final serverEntry = _buildSignedServerEntry( + serverKeyPair: serverKeyPair, + credentials: SorobanCredentials( + addressCredentials: SorobanAddressCredentials( + Address.forAccountId(serverAccountId), + BigInt.from(1), + 9999, + XdrSCVal.forVoid(), + ), + ), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + network: Network.TESTNET, + signatureExpirationLedger: 9999, + ); + + // Build a WITH_DELEGATES client entry; top-level address is the + // contract clientContractId, delegate is an arbitrary G-address. + final clientInnerCreds = SorobanAddressCredentials( + _contractAddress(clientContractId), + BigInt.from(2), + 0, + XdrSCVal.forVoid(), + ); + final clientBaseEntry = _buildWebAuthEntry( + credentials: SorobanCredentials(addressCredentials: clientInnerCreds), + webAuthContractId: _webAuthContractId, + argsMap: argsMap, + ); + final delegateKey = KeyPair.random(); + final clientWithDelegates = SorobanAuthorizationEntry.withDelegates( + clientBaseEntry, + [SorobanDelegateDescriptor(delegateKey.accountId)], + 9999, + ); + + // validateChallenge must locate the client entry by its top-level address. + expect( + () => webAuth.validateChallenge( + [serverEntry, clientWithDelegates], + clientContractId, + ), + returnsNormally, + ); + }); + }); +} diff --git a/test/unit/smartaccount/oz/oz_d1_p27_auth_test.dart b/test/unit/smartaccount/oz/oz_d1_p27_auth_test.dart new file mode 100644 index 00000000..0a9ce38b --- /dev/null +++ b/test/unit/smartaccount/oz/oz_d1_p27_auth_test.dart @@ -0,0 +1,765 @@ +// Copyright 2026 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. + +// Protocol 27 credential-arm unit tests for the OZ +// smart-account auth layer. +// +// Fixed cross-SDK vectors (from section 8.1 of p27-plan.md): +// Network: TESTNET "Test SDF Network ; September 2015" +// Signer: SDJHRQF4GCMIIKAAAQ6IHY42X73FQFLHUULAPSKKD4DFDM7UXWWCRHBE +// Contract: CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE +// Function: hello(u64 1234) +// Nonce: 123456789101112 +// Expiration: 4242 +// +// Legacy ADDRESS payload sha256: +// 120c429d4333e12e0ca2c5ac10630e728fdd33240bf7066f4c62f6a2d6fa3cbe +// ADDRESS_V2 payload sha256: +// 252a0d6117840dff37b765839810fb6ecc446198e73062e01bc961e49355b7b9 + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart' as crypto; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; + +// --------------------------------------------------------------------------- +// Golden-vector constants (section 8.1 of p27-plan.md) +// --------------------------------------------------------------------------- + +const String _kNetworkPassphrase = 'Test SDF Network ; September 2015'; +const String _kContractId = + 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; +final BigInt _kNonce = BigInt.parse('123456789101112'); +const int _kExpiration = 4242; + +// The ADDRESS_V2 golden vector uses the signer account as the credential +// address (section 8.1: "credential address = the signer account"). +const String _kV2SignerAccount = + 'GCZHXL5HXQX5ABDM26LHYRCQZ5OJFHLOPLZX47WEBP3V2PF5AVFK2A5D'; + +/// Payload sha256 for the fixed inputs with the legacy ADDRESS arm +/// (credential address = contract). +const String _kLegacyPayloadSha256 = + '120c429d4333e12e0ca2c5ac10630e728fdd33240bf7066f4c62f6a2d6fa3cbe'; + +/// Payload sha256 for the fixed inputs with the ADDRESS_V2 arm +/// (credential address = signer account, per section 8.1 of p27-plan.md). +const String _kV2PayloadSha256 = + '252a0d6117840dff37b765839810fb6ecc446198e73062e01bc961e49355b7b9'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +Uint8List _hexToBytes(String hex) { + final result = Uint8List(hex.length ~/ 2); + for (var i = 0; i < result.length; i++) { + result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return result; +} + +String _bytesToHex(Uint8List bytes) { + final sb = StringBuffer(); + for (final b in bytes) { + sb.write(b.toRadixString(16).padLeft(2, '0')); + } + return sb.toString(); +} + +/// Builds a canonical golden XDR auth entry matching the legacy ADDRESS +/// golden vector from section 8.1 of p27-plan.md. +/// +/// The invocation is `contractFn hello(u64 1234)` on [_kContractId]. +/// The credential address is also [_kContractId] (matching the plan vector). +XdrSorobanAuthorizationEntry _buildLegacyGoldenEntry() { + final contractAddr = XdrSCAddress.forContractId(_kContractId); + + final fn = XdrSorobanAuthorizedFunction( + XdrSorobanAuthorizedFunctionType + .SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN, + ); + fn.contractFn = XdrInvokeContractArgs( + contractAddr, + 'hello', + [XdrSCVal.forU64(BigInt.from(1234))], + ); + final invocation = XdrSorobanAuthorizedInvocation( + fn, + [], + ); + + final addressCreds = XdrSorobanAddressCredentials( + contractAddr, + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + + return XdrSorobanAuthorizationEntry( + XdrSorobanCredentials.forAddressCredentials(addressCreds), + invocation, + ); +} + +/// Builds a canonical golden XDR auth entry matching the ADDRESS_V2 +/// golden vector from section 8.1 of p27-plan.md. +/// +/// The invocation is `contractFn hello(u64 1234)` on [_kContractId]. +/// The credential address is [_kV2SignerAccount] (the signer account, per +/// section 8.1: "credential address = the signer account"). +XdrSorobanAuthorizationEntry _buildV2GoldenEntry() { + final contractAddr = XdrSCAddress.forContractId(_kContractId); + final signerAddr = XdrSCAddress.forAccountId(_kV2SignerAccount); + + final fn = XdrSorobanAuthorizedFunction( + XdrSorobanAuthorizedFunctionType + .SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN, + ); + fn.contractFn = XdrInvokeContractArgs( + contractAddr, + 'hello', + [XdrSCVal.forU64(BigInt.from(1234))], + ); + final invocation = XdrSorobanAuthorizedInvocation( + fn, + [], + ); + + final addressCreds = XdrSorobanAddressCredentials( + signerAddr, + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + + return XdrSorobanAuthorizationEntry( + XdrSorobanCredentials.forAddressV2Credentials(addressCreds), + invocation, + ); +} + +/// Builds an entry with the given arm using the contract as both the invoking +/// contract and the credential address. Used for arm-pair comparison tests +/// (same body, different arm). +XdrSorobanAuthorizationEntry _buildGoldenEntry({ + required XdrSorobanCredentialsType arm, +}) { + final contractAddr = XdrSCAddress.forContractId(_kContractId); + + final fn = XdrSorobanAuthorizedFunction( + XdrSorobanAuthorizedFunctionType + .SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN, + ); + fn.contractFn = XdrInvokeContractArgs( + contractAddr, + 'hello', + [XdrSCVal.forU64(BigInt.from(1234))], + ); + final invocation = XdrSorobanAuthorizedInvocation( + fn, + [], + ); + + final addressCreds = XdrSorobanAddressCredentials( + contractAddr, + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + + final XdrSorobanCredentials creds; + switch (arm) { + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS: + creds = XdrSorobanCredentials.forAddressCredentials(addressCreds); + break; + case XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2: + creds = XdrSorobanCredentials.forAddressV2Credentials(addressCreds); + break; + default: + throw ArgumentError('Unsupported arm: $arm'); + } + + return XdrSorobanAuthorizationEntry(creds, invocation); +} + +/// Builds a WITH_DELEGATES entry (for error-path tests). +XdrSorobanAuthorizationEntry _buildWithDelegatesEntry() { + final contractAddr = XdrSCAddress.forContractId(_kContractId); + final fn = XdrSorobanAuthorizedFunction( + XdrSorobanAuthorizedFunctionType + .SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN, + ); + fn.contractFn = XdrInvokeContractArgs( + contractAddr, + 'hello', + [XdrSCVal.forU64(BigInt.from(1234))], + ); + final invocation = XdrSorobanAuthorizedInvocation( + fn, + [], + ); + + final addressCreds = XdrSorobanAddressCredentials( + contractAddr, + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + final withDelegates = XdrSorobanAddressCredentialsWithDelegates( + addressCreds, + [], + ); + + return XdrSorobanAuthorizationEntry( + XdrSorobanCredentials.forAddressWithDelegatesCredentials(withDelegates), + invocation, + ); +} + +/// Computes the OZ auth payload hash directly using the shared preimage builder for +/// cross-checking. +Uint8List _buildPayloadHashDirect( + XdrSorobanAuthorizationEntry xdrEntry, + String networkPassphrase, +) { + final entry = SorobanAuthorizationEntry.fromXdr(xdrEntry); + final preimage = entry.buildPreimage(Network(networkPassphrase)); + final out = XdrDataOutputStream(); + XdrHashIDPreimage.encode(out, preimage); + return Uint8List.fromList( + crypto.sha256.convert(out.bytes).bytes, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // ------------------------------------------------------------------------- + // Golden-vector tests + // ------------------------------------------------------------------------- + + group('golden-vector: legacy ADDRESS payload hash', () { + test( + 'buildAuthPayloadHash_legacyEntry_matchesGoldenSha256', + () async { + final entry = _buildLegacyGoldenEntry(); + + final hash = await OZSmartAccountAuth.buildAuthPayloadHash( + entry, + _kExpiration, + _kNetworkPassphrase, + ); + + final actualHex = _bytesToHex(hash); + expect(actualHex, _kLegacyPayloadSha256, + reason: 'Legacy ADDRESS payload hash must match the golden sha256 ' + 'derived from the cross-SDK preimage vector. Actual: $actualHex'); + }); + + test( + 'buildAuthPayloadHash_legacyEntry_matchesSharedPreimageBuilder', + () async { + final entry = _buildLegacyGoldenEntry(); + + final ozHash = await OZSmartAccountAuth.buildAuthPayloadHash( + entry, + _kExpiration, + _kNetworkPassphrase, + ); + final batchBHash = _buildPayloadHashDirect(entry, _kNetworkPassphrase); + + expect(ozHash, batchBHash, + reason: 'OZ hash must equal the shared preimage builder output'); + }); + }); + + group('golden-vector: ADDRESS_V2 payload hash', () { + // The V2 golden vector uses the signer's account as the credential + // address (section 8.1: "credential address = the signer account"). + test( + 'buildAuthPayloadHash_v2Entry_matchesGoldenSha256', + () async { + final entry = _buildV2GoldenEntry(); + + final hash = await OZSmartAccountAuth.buildAuthPayloadHash( + entry, + _kExpiration, + _kNetworkPassphrase, + ); + + final actualHex = _bytesToHex(hash); + expect(actualHex, _kV2PayloadSha256, + reason: 'ADDRESS_V2 payload hash must match the golden sha256 ' + 'derived from the cross-SDK preimage vector. Actual: $actualHex'); + }); + + test( + 'v2PayloadHash_differsFromLegacyForIdenticalBodyFields', + () async { + // Both entries use the same body fields (same nonce, expiration, + // invocation, credential address). The only difference is the arm + // discriminant. The hashes must differ because the preimage type + // changes (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION vs + // ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS). + final legacyEntry = _buildGoldenEntry( + arm: XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS, + ); + final v2Entry = _buildGoldenEntry( + arm: XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + + final legacyHash = await OZSmartAccountAuth.buildAuthPayloadHash( + legacyEntry, + _kExpiration, + _kNetworkPassphrase, + ); + final v2Hash = await OZSmartAccountAuth.buildAuthPayloadHash( + v2Entry, + _kExpiration, + _kNetworkPassphrase, + ); + + expect(legacyHash, isNot(v2Hash), + reason: 'Legacy and V2 preimage hashes must differ even when all ' + 'credential body fields are identical, because the preimage ' + 'discriminant changes.'); + }); + }); + + // ------------------------------------------------------------------------- + // ADDRESS_V2 signing path and arm preservation + // ------------------------------------------------------------------------- + + group('ADDRESS_V2: signing path and arm preservation', () { + test( + 'signAuthEntry_v2Entry_armPreservedAfterSigning', + () async { + final entry = _buildGoldenEntry( + arm: XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + final delegatedSigner = OZDelegatedSigner( + 'GDUKMGUGDZQK6YHYA5Z6AY2G4XDSZPSZ3SW5UN3ARVMO6QSRDWP5YLEX', + ); + final sig = OZWebAuthnSignature( + authenticatorData: Uint8List(16), + clientData: Uint8List(20), + signature: Uint8List(64), + ); + + final signed = await OZSmartAccountAuth.signAuthEntry( + entry: entry, + signer: delegatedSigner, + signature: sig, + expirationLedger: _kExpiration, + ); + + expect( + signed.credentials.discriminant, + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + reason: 'signAuthEntry must preserve the ADDRESS_V2 arm on write-back', + ); + }); + + test( + 'signAuthEntry_v2Entry_signaturePayloadDiffersFromLegacy', + () async { + // The signed auth-payload hash is derived from the preimage hash, so + // two entries with the same fields but different arms produce different + // payloads. Verify that signing a V2 entry does not silently use a + // legacy preimage hash. + final legacyEntry = _buildGoldenEntry( + arm: XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS, + ); + final v2Entry = _buildGoldenEntry( + arm: XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + + final legacyHash = await OZSmartAccountAuth.buildAuthPayloadHash( + legacyEntry, + _kExpiration, + _kNetworkPassphrase, + ); + final v2Hash = await OZSmartAccountAuth.buildAuthPayloadHash( + v2Entry, + _kExpiration, + _kNetworkPassphrase, + ); + + expect(legacyHash, isNot(v2Hash)); + }); + + test( + 'addRawSignatureMapEntry_v2Entry_armPreservedAfterWriteBack', + () { + final entry = _buildGoldenEntry( + arm: XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + final signer = OZDelegatedSigner( + 'GDUKMGUGDZQK6YHYA5Z6AY2G4XDSZPSZ3SW5UN3ARVMO6QSRDWP5YLEX', + ); + + final result = OZSmartAccountAuth.addRawSignatureMapEntry( + entry: entry, + signerKey: signer.toScVal(), + signatureValue: XdrSCVal.forBytes(Uint8List(0)), + ); + + expect( + result.credentials.discriminant, + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + reason: 'addRawSignatureMapEntry must preserve the ADDRESS_V2 arm', + ); + }); + }); + + // ------------------------------------------------------------------------- + // Expiration stamping preserves the arm + // ------------------------------------------------------------------------- + + group('expiration stamping preserves arm', () { + test( + 'buildAuthPayloadHash_v2Entry_stampingPreservesV2Arm', + () async { + // buildAuthPayloadHash stamps expiration before building the preimage. + // The hash must equal the V2 golden vector (not the legacy golden), + // proving the stamping step did not silently convert the arm. + final entry = _buildV2GoldenEntry(); + + final hash = await OZSmartAccountAuth.buildAuthPayloadHash( + entry, + _kExpiration, + _kNetworkPassphrase, + ); + + expect(_bytesToHex(hash), _kV2PayloadSha256); + }); + + test( + 'buildAuthPayloadHash_legacyEntry_stampingPreservesLegacyArm', + () async { + final entry = _buildLegacyGoldenEntry(); + + final hash = await OZSmartAccountAuth.buildAuthPayloadHash( + entry, + _kExpiration, + _kNetworkPassphrase, + ); + + expect(_bytesToHex(hash), _kLegacyPayloadSha256); + }); + + test( + 'signAuthEntry_v2Entry_expirationSetOnV2Credentials', + () async { + final entry = _buildGoldenEntry( + arm: XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + const newExpiration = 9999; + + final signed = await OZSmartAccountAuth.signAuthEntry( + entry: entry, + signer: OZDelegatedSigner( + 'GDUKMGUGDZQK6YHYA5Z6AY2G4XDSZPSZ3SW5UN3ARVMO6QSRDWP5YLEX', + ), + signature: OZWebAuthnSignature( + authenticatorData: Uint8List(16), + clientData: Uint8List(20), + signature: Uint8List(64), + ), + expirationLedger: newExpiration, + ); + + // The expiration must be stamped on the V2 credentials. + expect(signed.credentials.addressV2?.signatureExpirationLedger.uint32, + newExpiration); + expect(signed.credentials.discriminant, + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2); + }); + }); + + // ------------------------------------------------------------------------- + // WITH_DELEGATES produces a descriptive error in auto-sign flows + // ------------------------------------------------------------------------- + + group('ADDRESS_WITH_DELEGATES: auto-sign flow throws descriptive error', () { + test( + 'buildAuthPayloadHash_withDelegatesEntry_throwsDescriptiveError', + () async { + final entry = _buildWithDelegatesEntry(); + + await expectLater( + OZSmartAccountAuth.buildAuthPayloadHash( + entry, + _kExpiration, + _kNetworkPassphrase, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('ADDRESS_WITH_DELEGATES'), + )), + ); + }); + + test( + 'signAuthEntry_withDelegatesEntry_throwsDescriptiveError', + () async { + final entry = _buildWithDelegatesEntry(); + + await expectLater( + OZSmartAccountAuth.signAuthEntry( + entry: entry, + signer: OZDelegatedSigner( + 'GDUKMGUGDZQK6YHYA5Z6AY2G4XDSZPSZ3SW5UN3ARVMO6QSRDWP5YLEX', + ), + signature: OZWebAuthnSignature( + authenticatorData: Uint8List(16), + clientData: Uint8List(20), + signature: Uint8List(64), + ), + expirationLedger: _kExpiration, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + allOf( + contains('ADDRESS_WITH_DELEGATES'), + contains('SorobanAuthorizationEntry.sign'), + ), + )), + ); + }); + + test( + 'addRawSignatureMapEntry_withDelegatesEntry_throwsDescriptiveError', + () { + final entry = _buildWithDelegatesEntry(); + final signer = OZDelegatedSigner( + 'GDUKMGUGDZQK6YHYA5Z6AY2G4XDSZPSZ3SW5UN3ARVMO6QSRDWP5YLEX', + ); + + expect( + () => OZSmartAccountAuth.addRawSignatureMapEntry( + entry: entry, + signerKey: signer.toScVal(), + signatureValue: XdrSCVal.forBytes(Uint8List(0)), + ), + throwsA(isA().having( + (e) => e.message, + 'message', + allOf( + contains('ADDRESS_WITH_DELEGATES'), + contains('SorobanAuthorizationEntry.sign'), + ), + )), + ); + }); + }); + + // ------------------------------------------------------------------------- + // Source-account errors still work (regression guard) + // ------------------------------------------------------------------------- + + group('source-account: still throws on address-requiring operations', () { + XdrSorobanAuthorizationEntry _buildSourceEntry() { + final contractAddr = XdrSCAddress.forContractId(_kContractId); + final fn = XdrSorobanAuthorizedFunction( + XdrSorobanAuthorizedFunctionType + .SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN, + ); + fn.contractFn = XdrInvokeContractArgs( + contractAddr, + 'hello', + [], + ); + final invocation = XdrSorobanAuthorizedInvocation( + fn, + [], + ); + return XdrSorobanAuthorizationEntry( + XdrSorobanCredentials.forSourceAccount(), + invocation, + ); + } + + test('buildAuthPayloadHash_sourceAccountEntry_throws', () async { + final entry = _buildSourceEntry(); + await expectLater( + OZSmartAccountAuth.buildAuthPayloadHash( + entry, + _kExpiration, + _kNetworkPassphrase, + ), + throwsA(isA()), + ); + }); + + test('signAuthEntry_sourceAccountEntry_throws', () async { + final entry = _buildSourceEntry(); + await expectLater( + OZSmartAccountAuth.signAuthEntry( + entry: entry, + signer: OZDelegatedSigner( + 'GDUKMGUGDZQK6YHYA5Z6AY2G4XDSZPSZ3SW5UN3ARVMO6QSRDWP5YLEX', + ), + signature: OZWebAuthnSignature( + authenticatorData: Uint8List(16), + clientData: Uint8List(20), + signature: Uint8List(64), + ), + expirationLedger: _kExpiration, + ), + throwsA(isA()), + ); + }); + + test('addRawSignatureMapEntry_sourceAccountEntry_throws', () { + final entry = _buildSourceEntry(); + expect( + () => OZSmartAccountAuth.addRawSignatureMapEntry( + entry: entry, + signerKey: + OZDelegatedSigner('GDUKMGUGDZQK6YHYA5Z6AY2G4XDSZPSZ3SW5UN3ARVMO6QSRDWP5YLEX') + .toScVal(), + signatureValue: XdrSCVal.forBytes(Uint8List(0)), + ), + throwsA(isA()), + ); + }); + }); + + // ------------------------------------------------------------------------- + // Preimage byte-identity: refactored path == shared preimage builder + // ------------------------------------------------------------------------- + + group('preimage byte-identity: refactored path matches shared preimage builder', () { + test( + 'legacyEntry_ozHashMatchesSharedBuilderOutput', + () async { + final entry = _buildLegacyGoldenEntry(); + + final ozHash = await OZSmartAccountAuth.buildAuthPayloadHash( + entry, + _kExpiration, + _kNetworkPassphrase, + ); + final batchBHash = _buildPayloadHashDirect(entry, _kNetworkPassphrase); + + expect(ozHash, batchBHash); + }); + + test( + 'v2Entry_ozHashMatchesSharedBuilderOutput', + () async { + final entry = _buildV2GoldenEntry(); + + final ozHash = await OZSmartAccountAuth.buildAuthPayloadHash( + entry, + _kExpiration, + _kNetworkPassphrase, + ); + final batchBHash = _buildPayloadHashDirect(entry, _kNetworkPassphrase); + + expect(ozHash, batchBHash); + }); + }); + + // ------------------------------------------------------------------------- + // buildSourceAccountAuthPayloadHash is always legacy (regression guard) + // ------------------------------------------------------------------------- + + group('buildSourceAccountAuthPayloadHash produces legacy preimage', () { + test( + 'buildSourceAccountAuthPayloadHash_matchesManualLegacyPreimage', + () async { + final contractAddr = XdrSCAddress.forContractId(_kContractId); + final fn = XdrSorobanAuthorizedFunction( + XdrSorobanAuthorizedFunctionType + .SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN, + ); + fn.contractFn = XdrInvokeContractArgs( + contractAddr, + 'hello', + [XdrSCVal.forU64(BigInt.from(1234))], + ); + final invocation = XdrSorobanAuthorizedInvocation( + fn, + [], + ); + final entry = XdrSorobanAuthorizationEntry( + XdrSorobanCredentials.forSourceAccount(), + invocation, + ); + final nonce = XdrInt64(_kNonce); + + final ozHash = await OZSmartAccountAuth.buildSourceAccountAuthPayloadHash( + entry, + nonce, + _kExpiration, + _kNetworkPassphrase, + ); + + // Manual construction of the legacy preimage. + final networkId = Uint8List.fromList( + crypto.sha256.convert(utf8.encode(_kNetworkPassphrase)).bytes, + ); + final authPreimage = XdrHashIDPreimageSorobanAuthorization( + XdrHash(networkId), + nonce, + XdrUint32(_kExpiration), + invocation, + ); + final preimage = XdrHashIDPreimage( + XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION, + ); + preimage.sorobanAuthorization = authPreimage; + final stream = XdrDataOutputStream(); + XdrHashIDPreimage.encode(stream, preimage); + final expectedHash = Uint8List.fromList( + crypto.sha256.convert(stream.bytes).bytes, + ); + + expect(ozHash, expectedHash, + reason: 'buildSourceAccountAuthPayloadHash must produce a ' + 'byte-identical result to the manual legacy preimage construction'); + }); + }); + + // ------------------------------------------------------------------------- + // signAuthEntry: legacy ADDRESS arm preserved on write-back (regression) + // ------------------------------------------------------------------------- + + group('signAuthEntry: legacy ADDRESS arm preserved', () { + test('signAuthEntry_legacyEntry_armPreservedAfterSigning', () async { + final entry = _buildGoldenEntry( + arm: XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS, + ); + final delegatedSigner = OZDelegatedSigner( + 'GDUKMGUGDZQK6YHYA5Z6AY2G4XDSZPSZ3SW5UN3ARVMO6QSRDWP5YLEX', + ); + + final signed = await OZSmartAccountAuth.signAuthEntry( + entry: entry, + signer: delegatedSigner, + signature: OZWebAuthnSignature( + authenticatorData: Uint8List(16), + clientData: Uint8List(20), + signature: Uint8List(64), + ), + expirationLedger: _kExpiration, + ); + + expect( + signed.credentials.discriminant, + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS, + reason: 'signAuthEntry must preserve the legacy ADDRESS arm', + ); + }); + }); +} diff --git a/test/unit/smartaccount/oz/oz_p27_rejected_coverage_test.dart b/test/unit/smartaccount/oz/oz_p27_rejected_coverage_test.dart new file mode 100644 index 00000000..e698c7a2 --- /dev/null +++ b/test/unit/smartaccount/oz/oz_p27_rejected_coverage_test.dart @@ -0,0 +1,414 @@ +// Copyright 2026 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. + +// Coverage tests for the OZ and AssembledTransaction credential-arm paths +// reachable-but-untested. No production code changes; all tests use the +// existing mock infrastructure. +// +// Lines covered: +// oz_transaction_operations.dart:591 — WITH_DELEGATES throw in _signSimulationAuthEntries +// oz_multi_signer_manager.dart:421 — WITH_DELEGATES throw in submitWithMultipleSigners +// oz_multi_signer_manager.dart:1034 — ADDRESS_V2 arm in _innerAddressCredentialsXdr +// oz_multi_signer_manager.dart:1035 — return creds.addressV2 +// oz_multi_signer_manager.dart:1053 — ADDRESS_V2 arm in _rebuildCredentialsXdr +// oz_multi_signer_manager.dart:1054 — forAddressV2Credentials(updated) + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; + +import 'oz_pipeline_fixtures.dart'; + +// --------------------------------------------------------------------------- +// Shared constants (same contracts used by the existing pipeline tests so that +// the harness connected-state matches the auth-entry addresses). +// --------------------------------------------------------------------------- + +const String _contractA = + 'CDCYWK73YTYFJZZSJ5V7EDFNHYBG4QN3VUNG2IGD27KJDDPNCZKBCBXK'; +const String _contractB = + 'CADQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQP5KR'; +const String _credentialIdB64 = 'aGVsbG8tc21hcnQtYWNjb3VudA'; + +Uint8List _bytes(int length, [int seed = 0]) { + final out = Uint8List(length); + for (var i = 0; i < length; i++) { + out[i] = (seed + i) & 0xFF; + } + return out; +} + +// --------------------------------------------------------------------------- +// XDR auth-entry helpers +// --------------------------------------------------------------------------- + +/// Builds an [XdrSorobanAuthorizationEntry] with +/// [SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES] discriminant. +/// +/// This type cannot be constructed via the public high-level API without +/// pre-signing, so we build it directly from XDR primitives to exercise the +/// throw-guard in the single-signer and multi-signer pipelines. +XdrSorobanAuthorizationEntry _makeWithDelegatesXdrEntry(String contractAddress) { + final invokeArgs = XdrInvokeContractArgs( + Address.forContractId(contractAddress).toXdr(), + 'noop', + const [], + ); + final invocation = XdrSorobanAuthorizedInvocation( + XdrSorobanAuthorizedFunction.forInvokeContractArgs(invokeArgs), + [], + ); + final addressCreds = XdrSorobanAddressCredentials( + Address.forContractId(contractAddress).toXdr(), + XdrInt64(BigInt.zero), + XdrUint32(0), + XdrSCVal(XdrSCValType.SCV_VOID), + ); + final delegateKp = KeyPair.random(); + final delegateNode = XdrSorobanDelegateSignature( + XdrSCAddress.forAccountId(delegateKp.accountId), + XdrSCVal(XdrSCValType.SCV_VOID), + [], + ); + final withDels = XdrSorobanAddressCredentialsWithDelegates( + addressCreds, + [delegateNode], + ); + final creds = XdrSorobanCredentials( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + ); + creds.addressWithDelegates = withDels; + return XdrSorobanAuthorizationEntry(creds, invocation); +} + +/// Builds an [XdrSorobanAuthorizationEntry] with +/// [SOROBAN_CREDENTIALS_ADDRESS_V2] discriminant pointing at [contractAddress]. +XdrSorobanAuthorizationEntry _makeAddressV2XdrEntry(String contractAddress) { + final invokeArgs = XdrInvokeContractArgs( + Address.forContractId(contractAddress).toXdr(), + 'noop', + const [], + ); + final invocation = XdrSorobanAuthorizedInvocation( + XdrSorobanAuthorizedFunction.forInvokeContractArgs(invokeArgs), + [], + ); + final addressCreds = XdrSorobanAddressCredentials( + Address.forContractId(contractAddress).toXdr(), + XdrInt64(BigInt.zero), + XdrUint32(0), + XdrSCVal(XdrSCValType.SCV_VOID), + ); + final creds = XdrSorobanCredentials( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + creds.addressV2 = addressCreds; + return XdrSorobanAuthorizationEntry(creds, invocation); +} + +// --------------------------------------------------------------------------- +// Soroban response helpers (mirrors oz_pipeline_group_c_test.dart) +// --------------------------------------------------------------------------- + +SimulateTransactionResponse _simResponseWithAuthEntry({ + required XdrSorobanAuthorizationEntry entry, + int? minResourceFee, +}) { + final entryXdr = entry.toBase64EncodedXdrString(); + final result = SimulateTransactionResult('', [entryXdr]); + final r = SimulateTransactionResponse({}); + r.results = [result]; + r.minResourceFee = minResourceFee; + return r; +} + +SimulateTransactionResponse _simResponseEmpty({int? minResourceFee}) { + final r = SimulateTransactionResponse({}); + r.results = []; + r.minResourceFee = minResourceFee; + return r; +} + +GetLatestLedgerResponse _latestLedger(int sequence) { + final r = GetLatestLedgerResponse({}); + r.sequence = sequence; + return r; +} + +SendTransactionResponse _sendPending({required String hash}) { + final r = SendTransactionResponse({}); + r.hash = hash; + r.status = SendTransactionResponse.STATUS_PENDING; + return r; +} + +GetTransactionResponse _txSuccess({int ledger = 12345}) { + final r = GetTransactionResponse({}); + r.status = GetTransactionResponse.STATUS_SUCCESS; + r.ledger = ledger; + return r; +} + +Account _deployerAccount(KeyPair deployer, {int seq = 1}) { + return Account(deployer.accountId, BigInt.from(seq)); +} + +WebAuthnAuthenticationResult _fakeAuthResult() { + final credIdBytes = base64Url.decode(base64Url.normalize(_credentialIdB64)); + final sig = Uint8List.fromList([ + 0x30, 0x44, + 0x02, 0x20, ..._bytes(32, 1), + 0x02, 0x20, ..._bytes(32, 2), + ]); + return WebAuthnAuthenticationResult( + credentialId: credIdBytes, + authenticatorData: _bytes(37, 3), + clientDataJSON: utf8.encode( + '{"type":"webauthn.get","challenge":"abc","origin":"https://test"}', + ), + signature: sig, + ); +} + +// --------------------------------------------------------------------------- +// Shared harness (mirrors oz_multi_signer_pipeline_test.dart) +// --------------------------------------------------------------------------- + +Future< + ({ + FakePipelineKit kit, + MockSorobanServer soroban, + RecordingWebAuthnProvider provider, + KeyPair deployer, + OZStoredCredential stored, + })> _harness() async { + final soroban = MockSorobanServer(); + final provider = RecordingWebAuthnProvider(); + final deployer = KeyPair.random(); + final config = OZSmartAccountConfig( + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Network.TESTNET.networkPassphrase, + accountWasmHash: '0' * 64, + webauthnVerifierAddress: _contractA, + webauthnProvider: provider, + ); + final credentials = StubCredentialManager(); + final stored = OZStoredCredential( + credentialId: _credentialIdB64, + publicKey: _bytes(65, 4), + contractId: _contractA, + ); + credentials.inject(stored); + final storage = OZInMemoryStorageAdapter(); + await storage.save(stored); + final kit = FakePipelineKit( + config: config, + sorobanServer: soroban, + deployer: deployer, + credentialManager: credentials, + storage: storage, + )..setConnected(credentialId: _credentialIdB64, contractId: _contractA); + return ( + kit: kit, + soroban: soroban, + provider: provider, + deployer: deployer, + stored: stored, + ); +} + +/// Passkey key-data blob (65-byte uncompressed public key + credential id). +Uint8List _passkeyKeyData() { + final pk = Uint8List(65); + pk[0] = 0x04; + for (var i = 1; i < 65; i++) pk[i] = (4 + i) & 0xFF; + final credIdBytes = base64Url.decode(base64Url.normalize(_credentialIdB64)); + return Uint8List(pk.length + credIdBytes.length) + ..setRange(0, pk.length, pk) + ..setRange(pk.length, pk.length + credIdBytes.length, credIdBytes); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // ------------------------------------------------------------------------- + // oz_transaction_operations.dart:591 + // WITH_DELEGATES throw in _signSimulationAuthEntries. + // + // submit() calls _signSimulationAuthEntries when the simulate response + // contains auth entries. When the entry has the ADDRESS_WITH_DELEGATES + // discriminant, line 591 throws SmartAccountTransactionSigningFailed with + // a message that mentions 'ADDRESS_WITH_DELEGATES'. + // ------------------------------------------------------------------------- + group('OZTransactionOperations submit — WITH_DELEGATES entry throws (line 591)', () { + test( + 'submit_withDelegatesAuthEntry_throwsSigningFailed_mentionsAddressWithDelegates', + () async { + final h = await _harness(); + h.soroban.getAccountResponses.add(_deployerAccount(h.deployer)); + // Simulate returns a WITH_DELEGATES entry. The pipeline must throw + // before attempting to sign it. + h.soroban.simulateResponses.add(_simResponseWithAuthEntry( + entry: _makeWithDelegatesXdrEntry(_contractA), + minResourceFee: 100, + )); + h.soroban.latestLedgerResponses.add(_latestLedger(1000)); + + final ops = OZTransactionOperations(h.kit); + await expectLater( + () => ops.submit( + hostFunction: XdrHostFunction.forInvokingContractWithArgs( + XdrInvokeContractArgs( + Address.forContractId(_contractB).toXdr(), + 'noop', + const [], + ), + ), + auth: const [], + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('ADDRESS_WITH_DELEGATES'), + ), + ), + ); + }); + }); + + // ------------------------------------------------------------------------- + // oz_multi_signer_manager.dart:421 + // WITH_DELEGATES throw in the signing loop of submitWithMultipleSigners. + // + // The multi-signer pipeline performs the same guard as the single-signer + // pipeline: when it encounters an ADDRESS_WITH_DELEGATES entry in the + // auth-entry list returned by simulate, it throws at line 421. + // ------------------------------------------------------------------------- + group('OZMultiSignerManager submitWithMultipleSigners — WITH_DELEGATES throws (line 421)', () { + test( + 'submitWithMultipleSigners_withDelegatesEntry_throwsSigningFailed', + () async { + final h = await _harness(); + h.soroban.getAccountResponses.add(_deployerAccount(h.deployer)); + h.soroban.simulateResponses.add(_simResponseWithAuthEntry( + entry: _makeWithDelegatesXdrEntry(_contractA), + minResourceFee: 100, + )); + h.soroban.latestLedgerResponses.add(_latestLedger(1000)); + + final keyData = _passkeyKeyData(); + final manager = OZMultiSignerManager(h.kit); + await expectLater( + () => manager.submitWithMultipleSigners( + hostFunction: XdrHostFunction.forInvokingContractWithArgs( + XdrInvokeContractArgs( + Address.forContractId(_contractB).toXdr(), + 'noop', + const [], + ), + ), + selectedSigners: [ + OZSelectedSignerPasskey(keyData: keyData), + ], + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('ADDRESS_WITH_DELEGATES'), + ), + ), + ); + }); + }); + + // ------------------------------------------------------------------------- + // oz_multi_signer_manager.dart:1034-1035 (_innerAddressCredentialsXdr ADDRESS_V2) + // oz_multi_signer_manager.dart:1053-1054 (_rebuildCredentialsXdr ADDRESS_V2) + // + // When the simulate response returns an ADDRESS_V2 entry whose credential + // address matches the connected contract (_contractA), the signing loop: + // 1. calls _innerAddressCredentialsXdr → hits the ADDRESS_V2 arm (1034-1035) + // 2. calls _rebuildCredentialsXdr → hits the ADDRESS_V2 arm (1053-1054) + // inside _cloneEntryWithExpiration (via the rebuild step in signing) + // + // The full pipeline succeeds: after signing the ADDRESS_V2 entry with the + // passkey, the re-simulate + submit + poll are fed from the mock queue. + // ------------------------------------------------------------------------- + group('OZMultiSignerManager submitWithMultipleSigners — ADDRESS_V2 entry (lines 1034-1035 + 1053-1054)', () { + test( + 'submitWithMultipleSigners_addressV2Entry_matchingContract_executesV2Arms', + () async { + final h = await _harness(); + + h.soroban.getAccountResponses.add(_deployerAccount(h.deployer)); + // Simulation returns an ADDRESS_V2 entry for _contractA (the connected contract). + h.soroban.simulateResponses.add(_simResponseWithAuthEntry( + entry: _makeAddressV2XdrEntry(_contractA), + minResourceFee: 100, + )); + h.soroban.latestLedgerResponses.add(_latestLedger(1000)); + // WebAuthn provider returns a valid signature for the passkey signer. + h.provider.authenticateResponses.add(_fakeAuthResult()); + // Re-simulate after signing. + h.soroban.getAccountResponses.add(_deployerAccount(h.deployer, seq: 2)); + h.soroban.simulateResponses.add(_simResponseEmpty(minResourceFee: 200)); + // Submit + poll. + h.soroban.sendResponses.add(_sendPending(hash: 'v2-multi-hash')); + h.soroban.pollResponses.add(_txSuccess(ledger: 9999)); + + final keyData = _passkeyKeyData(); + final credentialIdBytes = base64Url.decode( + base64Url.normalize(_credentialIdB64), + ); + final manager = OZMultiSignerManager(h.kit); + final result = await manager.submitWithMultipleSigners( + hostFunction: XdrHostFunction.forInvokingContractWithArgs( + XdrInvokeContractArgs( + Address.forContractId(_contractB).toXdr(), + 'vote', + const [], + ), + ), + selectedSigners: [ + OZSelectedSignerPasskey( + keyData: keyData, + credentialIdBytes: credentialIdBytes, + transports: const ['internal'], + ), + ], + // Custom resolver returns rule ID 0 to bypass on-chain context-rule + // lookup (which would require additional mock responses). + resolveContextRuleIds: (entry, idx) async => [0], + ); + + // Pipeline completed successfully — both V2 arms were traversed. + expect(result.success, isTrue, + reason: 'ADDRESS_V2 entry must be signed and submitted successfully'); + expect(result.hash, equals('v2-multi-hash')); + // WebAuthn was invoked exactly once (one auth entry). + expect(h.provider.authenticateCalls, hasLength(1)); + + // The sent transaction must carry an auth entry whose credentials arm + // is ADDRESS_V2 (the arm was preserved by _rebuildCredentialsXdr). + expect(h.soroban.sendCalls.length, equals(1)); + final sentTx = h.soroban.sendCalls.single; + final envelope = sentTx.toEnvelopeXdr(); + // ignore: invalid_use_of_internal_member + final opAuth = + envelope.v1!.tx.operations.first.body.invokeHostFunctionOp!.auth; + expect(opAuth, isNotEmpty); + expect( + opAuth.first.credentials.discriminant, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2), + reason: '_rebuildCredentialsXdr must preserve the ADDRESS_V2 arm on write-back', + ); + }); + }); +} diff --git a/test/unit/soroban/soroban_auth_p27_test.dart b/test/unit/soroban/soroban_auth_p27_test.dart new file mode 100644 index 00000000..c1a441be --- /dev/null +++ b/test/unit/soroban/soroban_auth_p27_test.dart @@ -0,0 +1,1170 @@ +// Copyright 2026 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; + +// --------------------------------------------------------------------------- +// Shared test fixtures +// --------------------------------------------------------------------------- + +/// Seed from the golden vector specification. +const _kSeed = 'SDJHRQF4GCMIIKAAAQ6IHY42X73FQFLHUULAPSKKD4DFDM7UXWWCRHBE'; + +/// Known contract for golden vectors. +const _kContractId = 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; + +/// Nonce from the golden vector specification. +final BigInt _kNonce = BigInt.parse('123456789101112'); + +/// Expiration ledger from the golden vector specification. +const int _kExpiration = 4242; + +/// Expected legacy (ADDRESS arm) preimage encoded as base64. +const _kLegacyPreimageB64 = + 'AAAACc7gMC1ZhE0yvcqRXIID3USzP7t+3BkFHqN6vt8o7NRyAABwSIYPOjgAABCSAAAAAAAAAAE2Pqo4Z' + '4QfutD07YjHeeT+ZuVqJHDcmMDsnAc9BcexAwAAAAVoZWxsbwAAAAAAAAEAAAAFAAAAAAAABNIAAAAA'; + +/// Expected legacy payload hash (hex). +const _kLegacyPayloadHex = + '120c429d4333e12e0ca2c5ac10630e728fdd33240bf7066f4c62f6a2d6fa3cbe'; + +/// Expected legacy Ed25519 signature (hex, 64 bytes). +const _kLegacySigHex = + '3c69ceefc532f97e1d0e0eb9f204c9aa85cb2b68cf293bce832590b01455e06' + '0e89900ea3ba2c45257908769a1a71f25b6d3befbadffd220f896dc005869900' + '8'; + +/// Expected ADDRESS_V2 preimage encoded as base64. +const _kV2PreimageB64 = + 'AAAACs7gMC1ZhE0yvcqRXIID3USzP7t+3BkFHqN6vt8o7NRyAABwSIYPOjgAABCSAAAAAAAAAACye6+n' + 'vC/QBGzXlnxEUM9ckp1uevN+fsQL9108vQVKrQAAAAAAAAABNj6qOGeEH7rQ9O2Ix3nk/mblaiRw3Jj' + 'A7JwHPQXHsQMAAAAFaGVsbG8AAAAAAAABAAAABQAAAAAAAATSAAAAAA=='; + +/// Expected V2 payload hash (hex). +const _kV2PayloadHex = + '252a0d6117840dff37b765839810fb6ecc446198e73062e01bc961e49355b7b9'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +SorobanAuthorizedInvocation _buildHelloInvocation() { + final contractAddr = Address.forContractId(_kContractId); + final fn = SorobanAuthorizedFunction.forContractFunction( + contractAddr, + 'hello', + [XdrSCVal.forU64(BigInt.from(1234))], + ); + return SorobanAuthorizedInvocation(fn); +} + +String _bytesToHex(Uint8List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + +Uint8List _hexToBytes(String hex) { + final result = Uint8List(hex.length ~/ 2); + for (int i = 0; i < result.length; i++) { + result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return result; +} + +Uint8List _encodePreimage(XdrHashIDPreimage preimage) { + final out = XdrDataOutputStream(); + XdrHashIDPreimage.encode(out, preimage); + return Uint8List.fromList(out.bytes); +} + +// Encode XdrSCAddress to bytes for comparison. +Uint8List _encodeXdrSCAddress(XdrSCAddress addr) { + final out = XdrDataOutputStream(); + XdrSCAddress.encode(out, addr); + return Uint8List.fromList(out.bytes); +} + +// Build a 130-deep nested XdrSorobanDelegateSignature for the depth guard test. +XdrSorobanDelegateSignature _build130DeepXdrTree() { + final addr = XdrSCAddress.forAccountId( + KeyPair.fromSecretSeed(_kSeed).accountId); + final sig = XdrSCVal.forVoid(); + XdrSorobanDelegateSignature current = + XdrSorobanDelegateSignature(addr, sig, []); + // Build 130 levels of nesting + for (int i = 0; i < 130; i++) { + current = XdrSorobanDelegateSignature(addr, sig, [current]); + } + return current; +} + +void main() { + // ------------------------------------------------------------------------- + // GROUP: XdrSorobanCredentials wrapper round-trip fidelity + // ------------------------------------------------------------------------- + group('XdrSorobanCredentials wrapper round-trip fidelity', () { + final signer = KeyPair.fromSecretSeed(_kSeed); + final accountStrKey = signer.accountId; + final accountXdrAddr = XdrSCAddress.forAccountId(accountStrKey); + final contractXdrAddr = XdrSCAddress.forContractId(_kContractId); + + // Helper: encode XdrSorobanCredentials to base64 and decode back. + // XdrSorobanCredentials inherits toBase64EncodedXdrString() from the base + // class, but the static fromBase64EncodedXdrString is only on the base. + // We decode via XdrSorobanCredentials.decode to keep the subtype. + XdrSorobanCredentials decodeFromB64(XdrSorobanCredentials src) { + final bytes = base64Decode(src.toBase64EncodedXdrString()); + return XdrSorobanCredentials.decode(XdrDataInputStream(bytes)); + } + + test('ADDRESS arm: XDR encode/decode round-trip preserves arm and payload', + () { + final addrCreds = XdrSorobanAddressCredentials( + accountXdrAddr, + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + final xdrCreds = + XdrSorobanCredentials.forAddressCredentials(addrCreds); + + final decoded = decodeFromB64(xdrCreds); + + expect(decoded.discriminant, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS)); + expect(decoded.address, isNotNull); + expect(decoded.addressV2, isNull); + expect(decoded.addressWithDelegates, isNull); + expect(decoded.address!.signatureExpirationLedger.uint32, + equals(_kExpiration)); + }); + + test('ADDRESS_V2 arm: XDR encode/decode round-trip preserves arm', + () { + final addrCreds = XdrSorobanAddressCredentials( + accountXdrAddr, + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + final xdrCreds = + XdrSorobanCredentials.forAddressV2Credentials(addrCreds); + + final decoded = decodeFromB64(xdrCreds); + + expect(decoded.discriminant, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2)); + expect(decoded.addressV2, isNotNull); + expect(decoded.address, isNull); + expect(decoded.addressWithDelegates, isNull); + expect(decoded.addressV2!.signatureExpirationLedger.uint32, + equals(_kExpiration)); + }); + + test('ADDRESS_WITH_DELEGATES arm: XDR encode/decode round-trip', () { + final innerCreds = XdrSorobanAddressCredentials( + accountXdrAddr, + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + final delegateSig = XdrSorobanDelegateSignature( + contractXdrAddr, XdrSCVal.forVoid(), []); + final withDels = XdrSorobanAddressCredentialsWithDelegates( + innerCreds, [delegateSig]); + final xdrCreds = XdrSorobanCredentials.forAddressWithDelegatesCredentials( + withDels); + + final decoded = decodeFromB64(xdrCreds); + + expect( + decoded.discriminant, + equals(XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES)); + expect(decoded.addressWithDelegates, isNotNull); + expect(decoded.address, isNull); + expect(decoded.addressV2, isNull); + expect(decoded.addressWithDelegates!.delegates.length, equals(1)); + }); + + test('SOURCE_ACCOUNT arm: XDR encode/decode round-trip', () { + final xdrCreds = XdrSorobanCredentials.forSourceAccount(); + + final decoded = decodeFromB64(xdrCreds); + + expect( + decoded.discriminant, + equals( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT)); + }); + + // fromTxRep must preserve V2/WithDelegates fields through the round-trip + test( + 'ADDRESS_V2 TxRep round-trip: fromTxRep preserves addressV2 data (regression)', + () { + final addrCreds = XdrSorobanAddressCredentials( + accountXdrAddr, + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + final xdrCreds = + XdrSorobanCredentials.forAddressV2Credentials(addrCreds); + + final lines = []; + xdrCreds.toTxRep('creds', lines); + final map = Map.fromEntries( + lines.map((l) => l.split(': ')).map((p) => MapEntry(p[0], p[1]))); + + final restored = XdrSorobanCredentials.fromTxRep(map, 'creds'); + + expect(restored.discriminant, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2)); + // Critically: addressV2 must not be null after fromTxRep + expect(restored.addressV2, isNotNull, + reason: 'addressV2 must be populated by fromTxRep'); + expect(restored.address, isNull); + expect(restored.addressWithDelegates, isNull); + }); + + test( + 'ADDRESS_WITH_DELEGATES TxRep round-trip: fromTxRep preserves data (regression)', + () { + final innerCreds = XdrSorobanAddressCredentials( + accountXdrAddr, + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + final delegateSig = XdrSorobanDelegateSignature( + contractXdrAddr, XdrSCVal.forVoid(), []); + final withDels = XdrSorobanAddressCredentialsWithDelegates( + innerCreds, [delegateSig]); + final xdrCreds = XdrSorobanCredentials.forAddressWithDelegatesCredentials( + withDels); + + final lines = []; + xdrCreds.toTxRep('creds', lines); + final map = {}; + for (final l in lines) { + final idx = l.indexOf(': '); + if (idx >= 0) { + map[l.substring(0, idx)] = l.substring(idx + 2); + } + } + + final restored = XdrSorobanCredentials.fromTxRep(map, 'creds'); + + expect( + restored.discriminant, + equals(XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES)); + expect(restored.addressWithDelegates, isNotNull, + reason: 'addressWithDelegates must be populated by fromTxRep'); + expect(restored.address, isNull); + expect(restored.addressV2, isNull); + expect(restored.addressWithDelegates!.delegates.length, equals(1)); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: SorobanCredentials (high-level wrapper) round-trip + // ------------------------------------------------------------------------- + group('SorobanCredentials high-level round-trip', () { + final signer = KeyPair.fromSecretSeed(_kSeed); + final accountAddr = Address.forAccountId(signer.accountId); + + test('fromXdr -> toXdr preserves ADDRESS arm', () { + final creds = SorobanCredentials.forAddress( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final roundTripped = + SorobanCredentials.fromXdr(creds.toXdr()); + + expect(roundTripped.arm, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS)); + expect(roundTripped.addressCredentials, isNotNull); + expect(roundTripped.addressCredentials!.signatureExpirationLedger, + equals(_kExpiration)); + }); + + test('fromXdr -> toXdr preserves ADDRESS_V2 arm', () { + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final creds = SorobanCredentials.forAddressV2(inner); + final roundTripped = + SorobanCredentials.fromXdr(creds.toXdr()); + + expect(roundTripped.arm, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2)); + expect(roundTripped.addressV2Credentials, isNotNull, + reason: 'V2 data must survive fromXdr -> toXdr round-trip'); + expect(roundTripped.addressCredentials, isNull); + expect(roundTripped.addressWithDelegatesCredentials, isNull); + }); + + test('fromXdr -> toXdr preserves ADDRESS_WITH_DELEGATES arm', () { + final contractAddr = Address.forContractId(_kContractId); + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final delegateDesc = SorobanDelegateDescriptor(contractAddr.contractId!); + final entry = SorobanAuthorizationEntry.withDelegates( + SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + _buildHelloInvocation(), + ), + [delegateDesc], + _kExpiration, + ); + final roundTripped = + SorobanCredentials.fromXdr(entry.credentials.toXdr()); + + expect( + roundTripped.arm, + equals(XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES)); + expect(roundTripped.addressWithDelegatesCredentials, isNotNull, + reason: + 'WITH_DELEGATES data must survive fromXdr -> toXdr round-trip'); + }); + + test('fromXdr -> toXdr preserves SOURCE_ACCOUNT arm', () { + final creds = SorobanCredentials.forSourceAccount(); + final roundTripped = + SorobanCredentials.fromXdr(creds.toXdr()); + + expect( + roundTripped.arm, + equals( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT)); + }); + + test('innerAddressCredentials returns null for SOURCE_ACCOUNT', () { + final creds = SorobanCredentials.forSourceAccount(); + expect(creds.innerAddressCredentials, isNull); + }); + + test('innerAddressCredentials returns payload for ADDRESS arm', () { + final creds = SorobanCredentials.forAddress( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final inner = creds.innerAddressCredentials; + expect(inner, isNotNull); + expect(inner!.nonce, equals(_kNonce)); + }); + + test('innerAddressCredentials returns payload for ADDRESS_V2 arm', () { + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final creds = SorobanCredentials.forAddressV2(inner); + final got = creds.innerAddressCredentials; + expect(got, isNotNull); + expect(got!.nonce, equals(_kNonce)); + }); + + test( + 'innerAddressCredentials returns addressCredentials for ADDRESS_WITH_DELEGATES arm', + () { + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final withDels = SorobanAddressCredentialsWithDelegates(inner, []); + final creds = SorobanCredentials.forAddressWithDelegates(withDels); + final got = creds.innerAddressCredentials; + expect(got, isNotNull); + expect(got!.nonce, equals(_kNonce)); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: Golden byte-identity vectors + // ------------------------------------------------------------------------- + group('Golden byte-identity vectors', () { + late KeyPair signer; + late Address accountAddr; + late SorobanAuthorizedInvocation rootInvocation; + + setUp(() { + signer = KeyPair.fromSecretSeed(_kSeed); + accountAddr = Address.forAccountId(signer.accountId); + rootInvocation = _buildHelloInvocation(); + }); + + test('signer account ID matches golden vector', () { + expect(signer.accountId, + equals('GCZHXL5HXQX5ABDM26LHYRCQZ5OJFHLOPLZX47WEBP3V2PF5AVFK2A5D')); + }); + + test('legacy ADDRESS preimage matches golden base64', () { + final creds = SorobanCredentials.forAddress( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry(creds, rootInvocation); + + final preimage = entry.buildPreimage(Network.TESTNET); + final encoded = base64Encode(_encodePreimage(preimage)); + + expect(encoded, equals(_kLegacyPreimageB64)); + }); + + test('legacy ADDRESS payload SHA-256 matches golden hex', () { + final creds = SorobanCredentials.forAddress( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry(creds, rootInvocation); + + final preimage = entry.buildPreimage(Network.TESTNET); + final preimageBytes = _encodePreimage(preimage); + final payloadHash = Util.hash(preimageBytes); + + expect(_bytesToHex(payloadHash), equals(_kLegacyPayloadHex)); + }); + + test('legacy ADDRESS Ed25519 signature matches golden hex', () { + final creds = SorobanCredentials.forAddress( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry(creds, rootInvocation); + + final preimage = entry.buildPreimage(Network.TESTNET); + final preimageBytes = _encodePreimage(preimage); + final payloadHash = Util.hash(preimageBytes); + final sigBytes = signer.sign(payloadHash); + + // The golden is a 64-byte hex string (128 hex chars); normalize spacing + final goldenNorm = _kLegacySigHex.replaceAll(' ', ''); + expect(_bytesToHex(sigBytes), equals(goldenNorm)); + }); + + test('ADDRESS_V2 preimage matches golden base64', () { + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final creds = SorobanCredentials.forAddressV2(inner); + final entry = SorobanAuthorizationEntry(creds, rootInvocation); + + final preimage = entry.buildPreimage(Network.TESTNET); + final encoded = base64Encode(_encodePreimage(preimage)); + + expect(encoded, equals(_kV2PreimageB64)); + }); + + test('ADDRESS_V2 payload SHA-256 matches golden hex', () { + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final creds = SorobanCredentials.forAddressV2(inner); + final entry = SorobanAuthorizationEntry(creds, rootInvocation); + + final preimage = entry.buildPreimage(Network.TESTNET); + final preimageBytes = _encodePreimage(preimage); + final payloadHash = Util.hash(preimageBytes); + + expect(_bytesToHex(payloadHash), equals(_kV2PayloadHex)); + }); + + test('ADDRESS and ADDRESS_V2 preimages differ for identical fields', () { + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + + final legacyEntry = SorobanAuthorizationEntry( + SorobanCredentials.forAddressCredentials(inner), + rootInvocation, + ); + final v2Entry = SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + rootInvocation, + ); + + final legacyEncoded = + base64Encode(_encodePreimage(legacyEntry.buildPreimage(Network.TESTNET))); + final v2Encoded = + base64Encode(_encodePreimage(v2Entry.buildPreimage(Network.TESTNET))); + + expect(legacyEncoded, isNot(equals(v2Encoded)), + reason: + 'ADDRESS and ADDRESS_V2 must produce different preimages for the same credentials'); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: Preimage discriminant per arm + // ------------------------------------------------------------------------- + group('Preimage discriminant per arm', () { + late Address accountAddr; + late SorobanAuthorizedInvocation rootInvocation; + + setUp(() { + final signer = KeyPair.fromSecretSeed(_kSeed); + accountAddr = Address.forAccountId(signer.accountId); + rootInvocation = _buildHelloInvocation(); + }); + + test('ADDRESS arm -> ENVELOPE_TYPE_SOROBAN_AUTHORIZATION', () { + final entry = SorobanAuthorizationEntry( + SorobanCredentials.forAddress( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()), + rootInvocation, + ); + final preimage = entry.buildPreimage(Network.TESTNET); + expect(preimage.discriminant, + equals(XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION)); + }); + + test('ADDRESS_V2 arm -> ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS', + () { + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + rootInvocation, + ); + final preimage = entry.buildPreimage(Network.TESTNET); + expect(preimage.discriminant, + equals(XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS)); + }); + + test( + 'ADDRESS_WITH_DELEGATES arm -> ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS', + () { + final contractAddr = Address.forContractId(_kContractId); + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry.withDelegates( + SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + rootInvocation, + ), + [SorobanDelegateDescriptor(contractAddr.contractId!)], + _kExpiration, + ); + final preimage = entry.buildPreimage(Network.TESTNET); + expect(preimage.discriminant, + equals(XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS)); + }); + + test('buildPreimage throws for SOURCE_ACCOUNT credentials', () { + final entry = SorobanAuthorizationEntry( + SorobanCredentials.forSourceAccount(), + rootInvocation, + ); + expect(() => entry.buildPreimage(Network.TESTNET), throwsException); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: Preimage address binding for WITH_DELEGATES + // ------------------------------------------------------------------------- + group('Preimage address == top-level credential address for WITH_DELEGATES', + () { + test( + 'WITH_DELEGATES preimage address is the top-level credential address, not a delegate', + () { + final signer = KeyPair.fromSecretSeed(_kSeed); + final accountAddr = Address.forAccountId(signer.accountId); + final contractAddr = Address.forContractId(_kContractId); + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry.withDelegates( + SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + _buildHelloInvocation(), + ), + [SorobanDelegateDescriptor(contractAddr.contractId!)], + _kExpiration, + ); + + final preimage = entry.buildPreimage(Network.TESTNET); + final withAddr = preimage.sorobanAuthorizationWithAddress; + expect(withAddr, isNotNull); + + // The address in the preimage must be the account (top-level), not the contract delegate + expect(withAddr!.address.discriminant, + equals(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)); + final preimageAddrKey = + KeyPair.fromXdrPublicKey(withAddr.address.accountId!.accountID) + .accountId; + expect(preimageAddrKey, equals(signer.accountId)); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: Expiration-before-hash + // ------------------------------------------------------------------------- + group('Expiration is stamped before hashing', () { + test( + 'sign() uses the signatureExpirationLedger set on credentials at call time', + () { + final signer = KeyPair.fromSecretSeed(_kSeed); + final accountAddr = Address.forAccountId(signer.accountId); + + // Build entry with stale expiration + final creds = SorobanCredentials.forAddress( + accountAddr, _kNonce, 100, XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry(creds, _buildHelloInvocation()); + + // Update expiration before signing (simulates "set expiration then sign") + entry.credentials.addressCredentials!.signatureExpirationLedger = + _kExpiration; + entry.sign(signer, Network.TESTNET); + + // The signed hash should match the golden vector for expiration=4242 + final inner = entry.credentials.addressCredentials!; + expect(inner.signatureExpirationLedger, equals(_kExpiration)); + + // Verify the signature produced matches the golden vector + final sig = inner.signature; + expect(sig.vec, isNotNull); + expect(sig.vec!.length, equals(1)); + + final sigMap = sig.vec!.first.map!; + final sigEntry = sigMap.firstWhere((e) => + e.key.sym != null && e.key.sym == 'signature'); + final actualSig = sigEntry.val.bytes!.sCBytes; + final goldenNorm = _kLegacySigHex.replaceAll(' ', ''); + expect(_bytesToHex(actualSig), equals(goldenNorm)); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: Delegate sort order (XDR bytes, not strkey) + // ------------------------------------------------------------------------- + group('Delegate sort order is by XDR-encoded address bytes', () { + test('G-address sorts before C-address (XDR byte order, opposite strkey)', + () { + final signer = KeyPair.fromSecretSeed(_kSeed); + // strkey "C" < "G" alphabetically, but XDR account-type (0) < contract-type (1) + // so the G-address should appear first in XDR sort order. + final accountStrKey = signer.accountId; // G... + final contractStrKey = _kContractId; // C... + + final accountXdrAddr = XdrSCAddress.forAccountId(accountStrKey); + final contractXdrAddr = XdrSCAddress.forContractId(contractStrKey); + + final accountBytes = _encodeXdrSCAddress(accountXdrAddr); + final contractBytes = _encodeXdrSCAddress(contractXdrAddr); + + // Verify that in XDR bytes, account < contract (first differing byte) + int cmp = 0; + for (int i = 0; i < accountBytes.length && i < contractBytes.length; i++) { + cmp = accountBytes[i].compareTo(contractBytes[i]); + if (cmp != 0) break; + } + expect(cmp, lessThan(0), + reason: + 'Account address (type=0) must XDR-sort before contract address (type=1)'); + + // Build a with-delegates entry putting the contract address FIRST in input + final inner = SorobanAddressCredentials( + Address.forAccountId(accountStrKey), _kNonce, _kExpiration, XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry.withDelegates( + SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + _buildHelloInvocation(), + ), + [ + SorobanDelegateDescriptor(contractStrKey), // C... first in input + SorobanDelegateDescriptor(accountStrKey), // G... second in input + ], + _kExpiration, + ); + + final delegates = + entry.credentials.addressWithDelegatesCredentials!.delegates; + expect(delegates.length, equals(2)); + // After sort: G-address (account) must be at index 0 + expect(delegates[0].address.discriminant, + equals(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT), + reason: + 'G-address must sort to index 0 because XDR type-0 < type-1'); + expect(delegates[1].address.discriminant, + equals(XdrSCAddressType.SC_ADDRESS_TYPE_CONTRACT)); + }); + + test('duplicate address within one delegate array throws', () { + final signer = KeyPair.fromSecretSeed(_kSeed); + final accountAddr = Address.forAccountId(signer.accountId); + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + + expect( + () => SorobanAuthorizationEntry.withDelegates( + SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + _buildHelloInvocation(), + ), + [ + SorobanDelegateDescriptor(_kContractId), + SorobanDelegateDescriptor(_kContractId), // duplicate + ], + _kExpiration, + ), + throwsArgumentError, + reason: 'Duplicate address within one array must throw', + ); + }); + + test('same address at two different levels is legal', () { + final signer = KeyPair.fromSecretSeed(_kSeed); + final accountAddr = Address.forAccountId(signer.accountId); + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + + // Contract address both as top-level delegate and nested delegate + expect( + () => SorobanAuthorizationEntry.withDelegates( + SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + _buildHelloInvocation(), + ), + [ + SorobanDelegateDescriptor(_kContractId, nestedDelegates: [ + SorobanDelegateDescriptor(_kContractId), + ]), + ], + _kExpiration, + ), + returnsNormally, + reason: + 'Same address at two different nesting levels must be accepted', + ); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: withDelegates factory constraints + // ------------------------------------------------------------------------- + group('SorobanAuthorizationEntry.withDelegates constraints', () { + late SorobanAuthorizedInvocation rootInvocation; + late SorobanAddressCredentials inner; + + setUp(() { + final signer = KeyPair.fromSecretSeed(_kSeed); + rootInvocation = _buildHelloInvocation(); + inner = SorobanAddressCredentials( + Address.forAccountId(signer.accountId), + _kNonce, + _kExpiration, + XdrSCVal.forVoid()); + }); + + test('source entry already WITH_DELEGATES throws', () { + final v2Entry = SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), rootInvocation); + final withDelEntry = SorobanAuthorizationEntry.withDelegates( + v2Entry, [SorobanDelegateDescriptor(_kContractId)], _kExpiration); + + expect( + () => SorobanAuthorizationEntry.withDelegates( + withDelEntry, [SorobanDelegateDescriptor(_kContractId)], + _kExpiration), + throwsArgumentError, + reason: 'Nesting WITH_DELEGATES inside WITH_DELEGATES must throw', + ); + }); + + test('source entry with SOURCE_ACCOUNT credentials throws', () { + final sourceEntry = SorobanAuthorizationEntry( + SorobanCredentials.forSourceAccount(), rootInvocation); + + expect( + () => SorobanAuthorizationEntry.withDelegates( + sourceEntry, [SorobanDelegateDescriptor(_kContractId)], + _kExpiration), + throwsArgumentError, + reason: + 'SOURCE_ACCOUNT entry used as source for withDelegates must throw', + ); + }); + + test('muxed address (M...) as delegate throws', () { + final muxedAddr = + 'MAAAAAAAAAAAJURAAB2X52XFQP6FBXLGT6LWOOWMEXWHEWBDVRZ7V5WH34Y22MPFBHUHY'; + final entry = SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), rootInvocation); + + expect( + () => SorobanAuthorizationEntry.withDelegates( + entry, [SorobanDelegateDescriptor(muxedAddr)], _kExpiration), + throwsArgumentError, + reason: 'Muxed addresses must be rejected as delegate addresses', + ); + }); + + test( + 'withDelegates from ADDRESS arm source produces WITH_DELEGATES result', + () { + final addressEntry = SorobanAuthorizationEntry( + SorobanCredentials.forAddressCredentials(inner), rootInvocation); + + final result = SorobanAuthorizationEntry.withDelegates( + addressEntry, [SorobanDelegateDescriptor(_kContractId)], _kExpiration); + + expect(result.credentials.arm, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES)); + }); + + test('top-level signature is void after withDelegates', () { + final v2Entry = SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), rootInvocation); + final result = SorobanAuthorizationEntry.withDelegates( + v2Entry, [SorobanDelegateDescriptor(_kContractId)], _kExpiration); + + final topSig = result.credentials.addressWithDelegatesCredentials! + .addressCredentials.signature; + expect(topSig.discriminant, equals(XdrSCValType.SCV_VOID), + reason: 'Top-level signature must default to void'); + }); + + test('signatureExpirationLedger is stamped from parameter', () { + final v2Entry = SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), rootInvocation); + final newExpiration = 9999; + final result = SorobanAuthorizationEntry.withDelegates( + v2Entry, [SorobanDelegateDescriptor(_kContractId)], newExpiration); + + expect( + result.credentials.addressWithDelegatesCredentials!.addressCredentials + .signatureExpirationLedger, + equals(newExpiration)); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: sign() forAddress routing + // ------------------------------------------------------------------------- + group('sign() with forAddress routing', () { + late KeyPair signer; + late SorobanAuthorizedInvocation rootInvocation; + + setUp(() { + signer = KeyPair.fromSecretSeed(_kSeed); + rootInvocation = _buildHelloInvocation(); + }); + + test('forAddress null signs top-level ADDRESS arm', () { + final creds = SorobanCredentials.forAddress( + Address.forAccountId(signer.accountId), + _kNonce, + _kExpiration, + XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry(creds, rootInvocation); + + entry.sign(signer, Network.TESTNET); // forAddress == null + + final inner = entry.credentials.addressCredentials!; + expect(inner.signature.vec, isNotNull); + expect(inner.signature.vec!.length, equals(1)); + }); + + test('forAddress matching top-level address routes to top-level', () { + final inner = SorobanAddressCredentials( + Address.forAccountId(signer.accountId), + _kNonce, + _kExpiration, + XdrSCVal.forVoid()); + final creds = SorobanCredentials.forAddressV2(inner); + final entry = SorobanAuthorizationEntry(creds, rootInvocation); + + entry.sign(signer, Network.TESTNET, forAddress: signer.accountId); + + expect(entry.credentials.addressV2Credentials!.signature.vec, isNotNull); + expect( + entry.credentials.addressV2Credentials!.signature.vec!.length, + equals(1)); + }); + + test( + 'forAddress with distinct top-level (A) and delegate (B): signs both nodes independently', + () { + // Build a delegate keypair distinct from the top-level signer + final delegateKp = + KeyPair.fromSecretSeed('SCZANGBA5YHTNYVVV4C3U252E2B6P6F5T3U6MM63WBSBZATAQI3EBTQ4'); + + final topAddr = Address.forAccountId(signer.accountId); + final delegateAddr = delegateKp.accountId; // C or G + + final inner = SorobanAddressCredentials( + topAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + + final entry = SorobanAuthorizationEntry.withDelegates( + SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + rootInvocation, + ), + [SorobanDelegateDescriptor(delegateAddr)], + _kExpiration, + ); + + // Sign top-level with signer A + entry.sign(signer, Network.TESTNET, forAddress: signer.accountId); + // Sign delegate with signer B + entry.sign(delegateKp, Network.TESTNET, forAddress: delegateAddr); + + final topSig = + entry.credentials.addressWithDelegatesCredentials!.addressCredentials.signature; + expect(topSig.vec, isNotNull); + expect(topSig.vec!.length, equals(1), + reason: 'Top-level must have exactly 1 signature'); + + final delegates = + entry.credentials.addressWithDelegatesCredentials!.delegates; + expect(delegates[0].signature.vec, isNotNull); + expect(delegates[0].signature.vec!.length, equals(1), + reason: 'Delegate must have exactly 1 signature'); + }); + + test('forAddress no match throws', () { + final creds = SorobanCredentials.forAddress( + Address.forAccountId(signer.accountId), + _kNonce, + _kExpiration, + XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry(creds, rootInvocation); + + expect( + () => entry.sign(signer, Network.TESTNET, + forAddress: _kContractId), + throwsException, + reason: 'forAddress with no matching node must throw', + ); + }); + + test('forAddress with muxed address (M...) throws', () { + final creds = SorobanCredentials.forAddress( + Address.forAccountId(signer.accountId), + _kNonce, + _kExpiration, + XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry(creds, rootInvocation); + const muxed = + 'MAAAAAAAAAAAJURAAB2X52XFQP6FBXLGT6LWOOWMEXWHEWBDVRZ7V5WH34Y22MPFBHUHY'; + + expect( + () => entry.sign(signer, Network.TESTNET, forAddress: muxed), + throwsException, + reason: 'Muxed address target must throw', + ); + }); + + test('signing source-account credentials throws', () { + final entry = SorobanAuthorizationEntry( + SorobanCredentials.forSourceAccount(), rootInvocation); + + expect( + () => entry.sign(signer, Network.TESTNET), + throwsException, + reason: 'Signing source-account entry must throw', + ); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: Append semantics + // ------------------------------------------------------------------------- + group('Append semantics for signature vectors', () { + late KeyPair signer; + late KeyPair signer2; + late SorobanAuthorizedInvocation rootInvocation; + + setUp(() { + signer = KeyPair.fromSecretSeed(_kSeed); + signer2 = KeyPair.fromSecretSeed( + 'SCZANGBA5YHTNYVVV4C3U252E2B6P6F5T3U6MM63WBSBZATAQI3EBTQ4'); + rootInvocation = _buildHelloInvocation(); + }); + + test('void top-level becomes one-element vec after first sign()', () { + final creds = SorobanCredentials.forAddress( + Address.forAccountId(signer.accountId), + _kNonce, + _kExpiration, + XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry(creds, rootInvocation); + + expect(entry.credentials.addressCredentials!.signature.discriminant, + equals(XdrSCValType.SCV_VOID)); + + entry.sign(signer, Network.TESTNET); + + expect(entry.credentials.addressCredentials!.signature.vec, isNotNull); + expect(entry.credentials.addressCredentials!.signature.vec!.length, + equals(1)); + }); + + test('second sign() appends without removing existing signature', () { + final creds = SorobanCredentials.forAddress( + Address.forAccountId(signer.accountId), + _kNonce, + _kExpiration, + XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry(creds, rootInvocation); + + entry.sign(signer, Network.TESTNET); + entry.sign(signer2, Network.TESTNET); + + expect(entry.credentials.addressCredentials!.signature.vec!.length, + equals(2)); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: Decode depth guard + // ------------------------------------------------------------------------- + group('Decode depth guard for XdrSorobanDelegateSignature', () { + test('decoding a 130-deep nested tree throws before stack exhaustion', () { + final deepTree = _build130DeepXdrTree(); + final out = XdrDataOutputStream(); + XdrSorobanDelegateSignature.encode(out, deepTree); + final encoded = Uint8List.fromList(out.bytes); + + expect( + () => XdrSorobanDelegateSignature.decode(XdrDataInputStream(encoded)), + throwsException, + reason: + 'A 130-deep tree must be rejected by the depth guard (limit=128)', + ); + }); + + test('decoding a shallow tree succeeds', () { + final signer = KeyPair.fromSecretSeed(_kSeed); + final addr = XdrSCAddress.forAccountId(signer.accountId); + // 2 levels deep: root -> child (depth 1 during decode) + final child = XdrSorobanDelegateSignature(addr, XdrSCVal.forVoid(), []); + final root = XdrSorobanDelegateSignature(addr, XdrSCVal.forVoid(), [child]); + + final out = XdrDataOutputStream(); + XdrSorobanDelegateSignature.encode(out, root); + final encoded = Uint8List.fromList(out.bytes); + + final decoded = + XdrSorobanDelegateSignature.decode(XdrDataInputStream(encoded)); + expect(decoded.nestedDelegates.length, equals(1)); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: SorobanAuthorizationEntry TxRep round-trip for P27 arms + // ------------------------------------------------------------------------- + group('SorobanAuthorizationEntry TxRep round-trip (P27 arms)', () { + late KeyPair signer; + late Address accountAddr; + late SorobanAuthorizedInvocation rootInvocation; + + setUp(() { + signer = KeyPair.fromSecretSeed(_kSeed); + accountAddr = Address.forAccountId(signer.accountId); + rootInvocation = _buildHelloInvocation(); + }); + + test('ADDRESS_V2 entry TxRep round-trip', () { + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), rootInvocation); + + // Encode to XDR base64 and decode back + final b64 = entry.toBase64EncodedXdrString(); + final restored = SorobanAuthorizationEntry.fromBase64EncodedXdr(b64); + + expect(restored.credentials.arm, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2)); + expect(restored.credentials.addressV2Credentials, isNotNull); + expect( + restored.credentials.addressV2Credentials!.signatureExpirationLedger, + equals(_kExpiration)); + }); + + test( + 'ADDRESS_WITH_DELEGATES entry XDR round-trip with non-empty delegates', + () { + final contractAddr = Address.forContractId(_kContractId); + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry.withDelegates( + SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + rootInvocation, + ), + [ + SorobanDelegateDescriptor(contractAddr.contractId!, + nestedDelegates: [ + SorobanDelegateDescriptor(signer.accountId), + ]), + ], + _kExpiration, + ); + + final b64 = entry.toBase64EncodedXdrString(); + final restored = SorobanAuthorizationEntry.fromBase64EncodedXdr(b64); + + expect( + restored.credentials.arm, + equals(XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES)); + expect(restored.credentials.addressWithDelegatesCredentials, isNotNull); + + final delegates = + restored.credentials.addressWithDelegatesCredentials!.delegates; + expect(delegates.length, equals(1)); + // The delegate is the contract (C...) — it sorted after the account in XDR + // Wait: entry has only one top-level delegate (contract), with one nested (account). + // After XDR round-trip, the nested delegate under the contract should be the account. + final nested = delegates[0].nestedDelegates; + expect(nested.length, equals(1), + reason: 'Nested delegate must survive XDR round-trip'); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: Missing private key / wrong address error paths + // ------------------------------------------------------------------------- + group('Error paths', () { + late KeyPair signer; + late SorobanAuthorizedInvocation rootInvocation; + + setUp(() { + signer = KeyPair.fromSecretSeed(_kSeed); + rootInvocation = _buildHelloInvocation(); + }); + + test( + 'ADDRESS_V2 arm: signing with non-matching forAddress on ADDRESS-only entry throws', + () { + final inner = SorobanAddressCredentials( + Address.forAccountId(signer.accountId), + _kNonce, + _kExpiration, + XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), rootInvocation); + + // Request signature for a contract address that is not a delegate or top-level + expect( + () => entry.sign(signer, Network.TESTNET, forAddress: _kContractId), + throwsException, + reason: 'No node matches forAddress in V2 entry, must throw', + ); + }); + + test( + 'ADDRESS_WITH_DELEGATES arm: signing with non-matching forAddress throws', + () { + final nonExistentAddr = KeyPair.random().accountId; + final inner = SorobanAddressCredentials( + Address.forAccountId(signer.accountId), + _kNonce, + _kExpiration, + XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry.withDelegates( + SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + rootInvocation, + ), + [SorobanDelegateDescriptor(_kContractId)], + _kExpiration, + ); + + expect( + () => entry.sign(signer, Network.TESTNET, forAddress: nonExistentAddr), + throwsException, + reason: 'Non-matching forAddress in WITH_DELEGATES entry must throw', + ); + }); + }); +} diff --git a/test/unit/soroban/soroban_p27_client_server_test.dart b/test/unit/soroban/soroban_p27_client_server_test.dart new file mode 100644 index 00000000..777a6207 --- /dev/null +++ b/test/unit/soroban/soroban_p27_client_server_test.dart @@ -0,0 +1,957 @@ +// Copyright 2026 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. + +// Protocol 27 simulation and AssembledTransaction unit tests: +// SimulateTransactionRequest / MethodOptions serialization, needsNonInvokerSigningBy, +// signAuthEntries, and the send precheck for WITH_DELEGATES entries. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; + +// --------------------------------------------------------------------------- +// Shared test fixtures (golden-vector addresses from section 8.1) +// --------------------------------------------------------------------------- + +const _kSeed = 'SDJHRQF4GCMIIKAAAQ6IHY42X73FQFLHUULAPSKKD4DFDM7UXWWCRHBE'; +const _kContractId = 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; + +const int _kExpiration = 4242; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Builds a minimal valid SorobanAuthorizationEntry with the given credentials. +SorobanAuthorizationEntry _makeEntry(SorobanCredentials creds) { + final contractAddr = Address.forContractId(_kContractId); + final fn = SorobanAuthorizedFunction.forContractFunction( + contractAddr, + 'hello', + [XdrSCVal.forU64(BigInt.from(1234))], + ); + final invocation = SorobanAuthorizedInvocation(fn); + return SorobanAuthorizationEntry(creds, invocation); +} + +/// Builds a legacy ADDRESS entry for [accountId] with a void signature. +SorobanAuthorizationEntry _makeLegacyEntry(String accountId) { + final address = Address.forAccountId(accountId); + final inner = SorobanAddressCredentials( + address, BigInt.parse('123456789101112'), _kExpiration, XdrSCVal.forVoid()); + return _makeEntry(SorobanCredentials.forAddressCredentials(inner)); +} + +/// Builds an ADDRESS_V2 entry for [accountId] with a void signature. +SorobanAuthorizationEntry _makeV2Entry(String accountId) { + final address = Address.forAccountId(accountId); + final inner = SorobanAddressCredentials( + address, BigInt.parse('123456789101112'), _kExpiration, XdrSCVal.forVoid()); + return _makeEntry(SorobanCredentials.forAddressV2(inner)); +} + +/// Builds a WITH_DELEGATES entry whose top-level is [topAccountId] and with a +/// single delegate at [delegateAccountId]. Both signatures start void. +SorobanAuthorizationEntry _makeWithDelegatesEntry( + String topAccountId, String delegateAccountId) { + // Build the base V2 entry first, then promote to WITH_DELEGATES. + final base = _makeV2Entry(topAccountId); + final desc = SorobanDelegateDescriptor(delegateAccountId); + return SorobanAuthorizationEntry.withDelegates(base, [desc], _kExpiration); +} + +/// Encodes a [SorobanAuthorizationEntry] to base64 XDR (for mock responses). +String _entryToBase64(SorobanAuthorizationEntry entry) => + entry.toBase64EncodedXdrString(); + +/// Encodes a minimal [XdrAccountEntry] for [accountId] with [seqNum] to base64 +/// so the mock getLedgerEntries response can satisfy [SorobanServer.getAccount]. +String _buildAccountLedgerEntryXdr(String accountId, BigInt seqNum) { + final xdrAccountId = + XdrAccountID(KeyPair.fromAccountId(accountId).xdrPublicKey); + final accountEntry = XdrAccountEntry( + xdrAccountId, + XdrInt64(BigInt.from(100000000)), + XdrSequenceNumber(seqNum), + XdrUint32(0), + null, + XdrUint32(0), + XdrString32(''), + XdrThresholds(Uint8List.fromList([1, 0, 0, 0])), + [], + XdrAccountEntryExt(0), + ); + final ledgerEntryData = XdrLedgerEntryData(XdrLedgerEntryType.ACCOUNT); + ledgerEntryData.account = accountEntry; + return ledgerEntryData.toBase64EncodedXdrString(); +} + +/// Minimal XdrSorobanTransactionData for mock simulate responses. +String _buildSorobanTransactionDataXdr() { + final sorobanData = XdrSorobanTransactionData( + XdrSorobanTransactionDataExt(0), + XdrSorobanResources( + XdrLedgerFootprint([], []), + XdrUint32(0), + XdrUint32(0), + XdrUint32(0), + ), + XdrInt64(BigInt.zero), + ); + return sorobanData.toBase64EncodedXdrString(); +} + +/// Starts a local HTTP server that: +/// - Returns [accountXdr] for any `getLedgerEntries` call. +/// - Returns a simulate response containing [authEntries] as base64 XDR. +/// - Records the last `simulateTransaction` request body to [capturedRequest]. +/// Returns a Future<[httpServer, capturedRequestHolder]> tuple. +Future<(HttpServer, Map)> _startMockRpcServer({ + required String accountId, + required BigInt seqNum, + List? authEntries, +}) async { + final accountXdr = _buildAccountLedgerEntryXdr(accountId, seqNum); + final sorobanDataXdr = _buildSorobanTransactionDataXdr(); + final capturedRequest = {}; + + final server = await HttpServer.bind('127.0.0.1', 0); + server.listen((req) async { + final body = await utf8.decoder.bind(req).join(); + final parsed = json.decode(body) as Map; + final method = parsed['method'] as String?; + + String responseJson; + if (method == 'getLedgerEntries') { + responseJson = json.encode({ + 'jsonrpc': '2.0', + 'id': parsed['id'], + 'result': { + 'entries': [ + { + 'key': 'AAAAAAAAAA==', + 'xdr': accountXdr, + 'lastModifiedLedgerSeq': 100, + } + ], + 'latestLedger': 1000, + } + }); + } else if (method == 'simulateTransaction') { + // Capture request params for assertion. + capturedRequest.addAll( + (parsed['params'] as Map).cast()); + final authBase64 = (authEntries ?? []) + .map((e) => _entryToBase64(e)) + .toList(); + responseJson = json.encode({ + 'jsonrpc': '2.0', + 'id': parsed['id'], + 'result': { + 'results': [ + { + 'xdr': 'AAAAAA==', + 'auth': authBase64, + } + ], + 'transactionData': sorobanDataXdr, + 'minResourceFee': '100', + 'latestLedger': 1000, + } + }); + } else if (method == 'getLatestLedger') { + responseJson = json.encode({ + 'jsonrpc': '2.0', + 'id': parsed['id'], + 'result': {'sequence': 1000}, + }); + } else { + responseJson = json.encode({ + 'jsonrpc': '2.0', + 'id': parsed['id'], + 'error': {'code': -32601, 'message': 'Method not found: $method'}, + }); + } + + req.response + ..statusCode = 200 + ..headers.contentType = ContentType.json + ..write(responseJson); + await req.response.close(); + }); + + return (server, capturedRequest); +} + +/// Builds an [AssembledTransaction] wired to the mock server at [rpcUrl]. +/// [simulate] controls whether auto-simulation runs during build. +Future _buildAssembledTx( + String rpcUrl, + String accountId, + KeyPair kp, { + bool simulate = false, +}) async { + final clientOptions = ClientOptions( + sourceAccountKeyPair: kp, + contractId: _kContractId, + network: Network.TESTNET, + rpcUrl: rpcUrl, + ); + final options = AssembledTransactionOptions( + clientOptions: clientOptions, + methodOptions: MethodOptions(simulate: simulate), + method: 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))], + ); + return await AssembledTransaction.build(options: options); +} + +// --------------------------------------------------------------------------- +// TESTS +// --------------------------------------------------------------------------- + +void main() { + // ------------------------------------------------------------------------- + // GROUP 1: SimulateTransactionRequest.getRequestArgs() + // ------------------------------------------------------------------------- + group('SimulateTransactionRequest.getRequestArgs()', () { + late Account sourceAccount; + + setUp(() { + sourceAccount = Account( + 'GDAT5HWTGIU4TSSZ4752OUC4SABDLTLZFRPZUJ3D6LKBNEPA7V2CIG54', + BigInt.from(100)); + }); + + Transaction _buildTx() => TransactionBuilder(sourceAccount) + .addOperation(BumpSequenceOperation(BigInt.from(110))) + .build(); + + test('serializes transaction, resourceConfig, and authMode', () { + final tx = _buildTx(); + final rc = ResourceConfig(12345); + final req = SimulateTransactionRequest(tx, + resourceConfig: rc, authMode: 'record'); + final args = req.getRequestArgs(); + + expect(args.containsKey('transaction'), isTrue); + expect(args['resourceConfig']['instructionLeeway'], equals(12345)); + expect(args['authMode'], equals('record')); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP 2: MethodOptions fields + // ------------------------------------------------------------------------- + group('MethodOptions fields', () { + test('fields are set from constructor arguments', () { + final opts = MethodOptions( + fee: 500, timeoutInSeconds: 120, simulate: false, restore: true); + expect(opts.fee, equals(500)); + expect(opts.timeoutInSeconds, equals(120)); + expect(opts.simulate, isFalse); + expect(opts.restore, isTrue); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP 3: needsNonInvokerSigningBy — all arm types + // ------------------------------------------------------------------------- + group('needsNonInvokerSigningBy', () { + test('ADDRESS entry: reports void top-level, omits signed top-level', + () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = await _buildAssembledTx( + rpcUrl, accountId, kp, + simulate: false); + + // Inject a tx with a legacy ADDRESS entry (void signature). + final legacyEntry = _makeLegacyEntry(accountId); + final account = Account(accountId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + final opBuilder = InvokeHostFuncOpBuilder(invokeOp); + assembled.tx = TransactionBuilder(account) + .addOperation(opBuilder.build()) + .build(); + assembled.tx!.setSorobanAuth([legacyEntry]); + + final needed = assembled.needsNonInvokerSigningBy(); + expect(needed, contains(accountId)); + + // After signing, should no longer appear. + final signed = assembled.needsNonInvokerSigningBy(includeAlreadySigned: false); + // Note: we haven't signed yet — just confirm it's listed above. + expect(needed.length, equals(1)); + expect(signed.length, equals(1)); + } finally { + await server.close(); + } + }); + + test('ADDRESS_V2 entry: reports void top-level', () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + final v2Entry = _makeV2Entry(accountId); + final account = Account(accountId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx!.setSorobanAuth([v2Entry]); + + final needed = assembled.needsNonInvokerSigningBy(); + expect(needed, contains(accountId)); + expect(needed.length, equals(1)); + } finally { + await server.close(); + } + }); + + test('WITH_DELEGATES entry: reports void top-level AND unsigned delegate', + () async { + + final topKp = KeyPair.fromSecretSeed(_kSeed); + final delegateKp = KeyPair.random(); + final topId = topKp.accountId; + final delegateId = delegateKp.accountId; + + // Ensure addresses are distinct. + expect(topId, isNot(equals(delegateId))); + + final (server, _) = await _startMockRpcServer( + accountId: topId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, topId, topKp, simulate: false); + + final wdEntry = _makeWithDelegatesEntry(topId, delegateId); + final account = Account(topId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx!.setSorobanAuth([wdEntry]); + + final needed = assembled.needsNonInvokerSigningBy(); + // Both the void top-level and the unsigned delegate must be reported. + expect(needed, contains(topId)); + expect(needed, contains(delegateId)); + expect(needed.length, equals(2)); + } finally { + await server.close(); + } + }); + + test( + 'WITH_DELEGATES entry: omits signed delegate from unsigned-only report', + () async { + + final topKp = KeyPair.fromSecretSeed(_kSeed); + final delegateKp = KeyPair.random(); + final topId = topKp.accountId; + final delegateId = delegateKp.accountId; + + final (server, _) = await _startMockRpcServer( + accountId: topId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, topId, topKp, simulate: false); + + final wdEntry = _makeWithDelegatesEntry(topId, delegateId); + // Sign the delegate node; leave top-level void. + wdEntry.sign(delegateKp, Network.TESTNET, forAddress: delegateId); + + final account = Account(topId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx!.setSorobanAuth([wdEntry]); + + final needed = assembled.needsNonInvokerSigningBy(); + // Top-level is still void, so it appears. + expect(needed, contains(topId)); + // Delegate is signed, so it should NOT appear. + expect(needed, isNot(contains(delegateId))); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP 4: Send precheck — WITH_DELEGATES delegates-only pattern + // ------------------------------------------------------------------------- + group('sign() precheck — delegates-only pattern does not block', () { + test( + 'WITH_DELEGATES entry with all delegates signed passes sign() despite ' + 'void top-level', () async { + + final topKp = KeyPair.fromSecretSeed(_kSeed); + final delegateKp = KeyPair.random(); + final topId = topKp.accountId; + final delegateId = delegateKp.accountId; + + final (server, _) = await _startMockRpcServer( + accountId: topId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, topId, topKp, simulate: false); + + final wdEntry = _makeWithDelegatesEntry(topId, delegateId); + // Sign the delegate node; top-level intentionally left void. + wdEntry.sign(delegateKp, Network.TESTNET, forAddress: delegateId); + + final account = Account(topId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx!.setSorobanAuth([wdEntry]); + + // sign() with force:true bypasses the read-call check so we can + // exercise the precheck specifically. sign() must NOT throw even though + // needsNonInvokerSigningBy() reports topId (void top-level), because + // every delegate is signed (delegates-only pattern), and it must + // actually produce a signed transaction. + assembled.sign(sourceAccountKeyPair: topKp, force: true); + expect(assembled.signed, isNotNull, + reason: + 'delegates-only pattern must not block sign() and must produce ' + 'a signed transaction'); + } finally { + await server.close(); + } + }); + + test( + 'WITH_DELEGATES entry with an unsigned delegate DOES block sign()', + () async { + + final topKp = KeyPair.fromSecretSeed(_kSeed); + final delegateKp = KeyPair.random(); + final topId = topKp.accountId; + final delegateId = delegateKp.accountId; + + final (server, _) = await _startMockRpcServer( + accountId: topId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, topId, topKp, simulate: false); + + // Leave both top-level and delegate unsigned. + final wdEntry = _makeWithDelegatesEntry(topId, delegateId); + + final account = Account(topId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx!.setSorobanAuth([wdEntry]); + + expect( + () => assembled.sign(sourceAccountKeyPair: topKp, force: true), + throwsA(isA()), + reason: 'unsigned delegates must block sign()', + ); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP 5: signAuthEntries — delegate node matching + // ------------------------------------------------------------------------- + group('signAuthEntries — delegate and top-level matching', () { + test( + 'signs delegate node when signer matches delegate address; ' + 'top-level stays void', () async { + + final topKp = KeyPair.fromSecretSeed(_kSeed); + final delegateKp = KeyPair.random(); + final topId = topKp.accountId; + final delegateId = delegateKp.accountId; + + // Distinct addresses required: assert they differ. + expect(topId, isNot(equals(delegateId))); + + final (server, _) = await _startMockRpcServer( + accountId: topId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, topId, topKp, simulate: false); + + final wdEntry = _makeWithDelegatesEntry(topId, delegateId); + final account = Account(topId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx!.setSorobanAuth([wdEntry]); + + // Sign as the delegate (private key provided, valid until fixed ledger). + await assembled.signAuthEntries( + signerKeyPair: delegateKp, + validUntilLedgerSeq: _kExpiration + 100, + ); + + // Re-read the entry from the transaction. + final ops = assembled.tx!.operations; + final invokeOp2 = ops.first as InvokeHostFunctionOperation; + final signedEntry = invokeOp2.auth.first; + + final withDelegates = + signedEntry.credentials.addressWithDelegatesCredentials!; + + // The delegate node must be signed. + final delegateNode = withDelegates.delegates.first; + expect( + delegateNode.signature.discriminant, + isNot(equals(XdrSCValType.SCV_VOID)), + reason: 'delegate node must carry a non-void signature after signing', + ); + expect(delegateNode.signature.vec, isNotNull); + expect(delegateNode.signature.vec!.length, equals(1)); + + // The top-level signature must remain void. + final topInner = signedEntry.credentials.innerAddressCredentials!; + expect( + topInner.signature.discriminant, + equals(XdrSCValType.SCV_VOID), + reason: 'top-level signature must remain void when only delegate signed', + ); + } finally { + await server.close(); + } + }); + + test('signs top-level when signer matches top-level address', () async { + + final topKp = KeyPair.fromSecretSeed(_kSeed); + final topId = topKp.accountId; + + final (server, _) = await _startMockRpcServer( + accountId: topId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, topId, topKp, simulate: false); + + final legacyEntry = _makeLegacyEntry(topId); + final account = Account(topId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx!.setSorobanAuth([legacyEntry]); + + await assembled.signAuthEntries( + signerKeyPair: topKp, + validUntilLedgerSeq: _kExpiration + 100, + ); + + final ops = assembled.tx!.operations; + final invokeOp2 = ops.first as InvokeHostFunctionOperation; + final signedEntry = invokeOp2.auth.first; + final inner = signedEntry.credentials.innerAddressCredentials!; + + expect( + inner.signature.discriminant, + isNot(equals(XdrSCValType.SCV_VOID)), + reason: 'top-level must be signed for legacy ADDRESS entry', + ); + } finally { + await server.close(); + } + }); + + // Regression: same address as top-level and as a delegate must produce + // exactly one signature on the top-level and one on the delegate node — + // not two on the top-level (which the host rejects due to duplicate-key order). + test( + 'same address as top-level and delegate: exactly one signature each, ' + 'no duplicate top-level', () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + // Build a WITH_DELEGATES entry where both the top-level credential address + // and the sole delegate address are the same key (kp / accountId). + final wdEntry = _makeWithDelegatesEntry(accountId, accountId); + + final account = Account(accountId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx!.setSorobanAuth([wdEntry]); + + await assembled.signAuthEntries( + signerKeyPair: kp, + validUntilLedgerSeq: _kExpiration + 100, + ); + + final signedEntry = + (assembled.tx!.operations.first as InvokeHostFunctionOperation) + .auth + .first; + final withDelegates = + signedEntry.credentials.addressWithDelegatesCredentials!; + + // Top-level must have exactly ONE signature (not two). + final topInner = signedEntry.credentials.innerAddressCredentials!; + expect( + topInner.signature.discriminant, + isNot(equals(XdrSCValType.SCV_VOID)), + reason: 'top-level must be signed', + ); + expect( + topInner.signature.vec, + isNotNull, + reason: 'top-level signature must be a vec', + ); + expect( + topInner.signature.vec!.length, + equals(1), + reason: 'top-level must carry exactly one signature, not two ' + '(duplicate would violate ascending-key-order constraint)', + ); + + // Delegate node must also have exactly ONE signature. + final delegateNode = withDelegates.delegates.first; + expect( + delegateNode.signature.discriminant, + isNot(equals(XdrSCValType.SCV_VOID)), + reason: 'delegate node must be signed', + ); + expect(delegateNode.signature.vec, isNotNull); + expect( + delegateNode.signature.vec!.length, + equals(1), + reason: 'delegate node must carry exactly one signature', + ); + } finally { + await server.close(); + } + }); + + test('signAuthEntries with no private key throws', () async { + + final topKp = KeyPair.fromSecretSeed(_kSeed); + final topId = topKp.accountId; + final pubOnlyKp = KeyPair.fromAccountId(topId); + + final (server, _) = await _startMockRpcServer( + accountId: topId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, topId, topKp, simulate: false); + + final legacyEntry = _makeLegacyEntry(topId); + final account = Account(topId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx!.setSorobanAuth([legacyEntry]); + + expect( + () async => await assembled.signAuthEntries( + signerKeyPair: pubOnlyKp, + validUntilLedgerSeq: _kExpiration + 100, + ), + throwsA(isA()), + reason: 'signing without private key must throw', + ); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP 6: Arm preservation through sign flow + // ------------------------------------------------------------------------- + group('Arm preservation through signAuthEntries', () { + test('V2 entry stays V2 after signing via signAuthEntries', () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + final v2Entry = _makeV2Entry(accountId); + final account = Account(accountId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx!.setSorobanAuth([v2Entry]); + + await assembled.signAuthEntries( + signerKeyPair: kp, + validUntilLedgerSeq: _kExpiration + 100, + ); + + final ops = assembled.tx!.operations; + final invokeOp2 = ops.first as InvokeHostFunctionOperation; + final signedEntry = invokeOp2.auth.first; + + // Arm must still be ADDRESS_V2, not ADDRESS. + expect( + signedEntry.credentials.arm, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2), + reason: 'ADDRESS_V2 arm must be preserved after signing', + ); + expect(signedEntry.credentials.addressV2Credentials, isNotNull); + expect(signedEntry.credentials.addressCredentials, isNull); + + // Signature must be non-void. + final inner = signedEntry.credentials.innerAddressCredentials!; + expect(inner.signature.discriminant, + isNot(equals(XdrSCValType.SCV_VOID))); + } finally { + await server.close(); + } + }); + + test('WITH_DELEGATES arm is preserved after signing the delegate node', + () async { + + final topKp = KeyPair.fromSecretSeed(_kSeed); + final delegateKp = KeyPair.random(); + final topId = topKp.accountId; + final delegateId = delegateKp.accountId; + + final (server, _) = await _startMockRpcServer( + accountId: topId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, topId, topKp, simulate: false); + + final wdEntry = _makeWithDelegatesEntry(topId, delegateId); + final account = Account(topId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx!.setSorobanAuth([wdEntry]); + + await assembled.signAuthEntries( + signerKeyPair: delegateKp, + validUntilLedgerSeq: _kExpiration + 100, + ); + + final ops = assembled.tx!.operations; + final signedEntry = + (ops.first as InvokeHostFunctionOperation).auth.first; + + expect( + signedEntry.credentials.arm, + equals(XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES), + reason: 'WITH_DELEGATES arm must be preserved after signing delegate', + ); + expect( + signedEntry.credentials.addressWithDelegatesCredentials, isNotNull); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP 7: simulate() routes auth entries returned by server into tx + // ------------------------------------------------------------------------- + group('simulate() wires returned auth entries into tx', () { + test('ADDRESS entry from simulate response appears in tx auth', () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final legacyEntry = _makeLegacyEntry(accountId); + + final (server, _) = await _startMockRpcServer( + accountId: accountId, + seqNum: BigInt.from(100), + authEntries: [legacyEntry], + ); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: true); + + final ops = assembled.tx!.operations; + final invokeOp = ops.first as InvokeHostFunctionOperation; + expect(invokeOp.auth.length, equals(1)); + expect( + invokeOp.auth.first.credentials.arm, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS), + ); + } finally { + await server.close(); + } + }); + + test('ADDRESS_V2 entry from simulate response appears in tx auth', () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final v2Entry = _makeV2Entry(accountId); + + final (server, _) = await _startMockRpcServer( + accountId: accountId, + seqNum: BigInt.from(100), + authEntries: [v2Entry], + ); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: true); + + final ops = assembled.tx!.operations; + final invokeOp = ops.first as InvokeHostFunctionOperation; + expect(invokeOp.auth.length, equals(1)); + expect( + invokeOp.auth.first.credentials.arm, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2), + ); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP 8: SOURCE_ACCOUNT handling. (The unknown-arm fail-fast guard in + // signAuthEntries / needsNonInvokerSigningBy is unreachable defensive code: + // XdrSorobanCredentialsType.decode only accepts discriminants 0-3, so a + // credential carrying an unknown arm cannot be constructed and the guard is + // not unit-testable.) + // ------------------------------------------------------------------------- + group('SOURCE_ACCOUNT handling', () { + test('SOURCE_ACCOUNT credentials are parsed and skipped by needsNonInvokerSigningBy', + () async { + + final topKp = KeyPair.fromSecretSeed(_kSeed); + final topId = topKp.accountId; + + final (server, _) = await _startMockRpcServer( + accountId: topId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, topId, topKp, simulate: false); + + // SOURCE_ACCOUNT credentials carry no address and need no explicit + // signature: fromXdr parses them and needsNonInvokerSigningBy skips + // them. + final rawCreds = XdrSorobanCredentials( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT); + // fromXdr of SOURCE_ACCOUNT: no exception — that's the correct path. + final parsed = SorobanCredentials.fromXdr(rawCreds); + expect( + parsed.arm, + equals( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT), + ); + + // Verify that SOURCE_ACCOUNT is silently skipped (not thrown) by + // needsNonInvokerSigningBy. + final sourceCreds = SorobanCredentials.forSourceAccount(); + final sourceEntry = _makeEntry(sourceCreds); + + final account = Account(topId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx!.setSorobanAuth([sourceEntry]); + + // SOURCE_ACCOUNT is skipped, not reported. + final needed = assembled.needsNonInvokerSigningBy(); + expect(needed, isEmpty); + } finally { + await server.close(); + } + }); + }); +} diff --git a/test/unit/soroban/soroban_p27_coverage_test.dart b/test/unit/soroban/soroban_p27_coverage_test.dart new file mode 100644 index 00000000..ae323d95 --- /dev/null +++ b/test/unit/soroban/soroban_p27_coverage_test.dart @@ -0,0 +1,2074 @@ +// Copyright 2026 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. + +// Additional Protocol 27 (CAP-71) coverage tests. +// +// Covers items required by section 8.2 of p27-plan.md that are absent from +// Companion P27 test files: +// - Unknown SorobanCredentialsType discriminant fail-fast via the one +// reachable path: SorobanCredentials.fromXdr with a hand-built unknown arm. +// - needsNonInvokerSigningBy and signAuthEntries guards for unknown arms +// (documented as unreachable from public API — justification inline). +// - Deep (3+ levels) nestedDelegates XDR round-trip. +// - Missing-private-key for V2 and WITH_DELEGATES arms. +// - authorizeEntryDelegate (callback) path for signAuthEntries. +// - Property setters for the three new generated XDR structs. +// - Uncovered branches in SorobanCredentials.toXdr, buildPreimage, and +// _buildAndSortDelegates. +// - includeAlreadySigned=true path for ADDRESS_V2 in needsNonInvokerSigningBy. +// - _neededSignersForSend branches (ADDRESS_V2 unsigned top-level). +// - WebAuthForContracts signatureExpirationLedger stamping with +// clientDomainKeyPair. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; + +// --------------------------------------------------------------------------- +// Shared fixtures (golden-vector constants from section 8.1 of p27-plan.md) +// --------------------------------------------------------------------------- + +const _kSeed = 'SDJHRQF4GCMIIKAAAQ6IHY42X73FQFLHUULAPSKKD4DFDM7UXWWCRHBE'; +const _kContractId = 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; + +/// ADDRESS_V2 preimage base64 from section 8.1 (deterministic vector). +const _kV2PreimageB64 = + 'AAAACs7gMC1ZhE0yvcqRXIID3USzP7t+3BkFHqN6vt8o7NRyAABwSIYPOjgAABCSAAAAAAAAAACye6+n' + 'vC/QBGzXlnxEUM9ckp1uevN+fsQL9108vQVKrQAAAAAAAAABNj6qOGeEH7rQ9O2Ix3nk/mblaiRw3Jj' + 'A7JwHPQXHsQMAAAAFaGVsbG8AAAAAAAABAAAABQAAAAAAAATSAAAAAA=='; + +/// ADDRESS_V2 payload SHA-256 from section 8.1. +const _kV2PayloadHex = + '252a0d6117840dff37b765839810fb6ecc446198e73062e01bc961e49355b7b9'; + +final BigInt _kNonce = BigInt.parse('123456789101112'); +const int _kExpiration = 4242; + +// --------------------------------------------------------------------------- +// Local helpers +// --------------------------------------------------------------------------- + +SorobanAuthorizedInvocation _buildHelloInvocation() { + final fn = SorobanAuthorizedFunction.forContractFunction( + Address.forContractId(_kContractId), + 'hello', + [XdrSCVal.forU64(BigInt.from(1234))], + ); + return SorobanAuthorizedInvocation(fn); +} + +SorobanAuthorizationEntry _makeV2Entry(String accountId) { + final address = Address.forAccountId(accountId); + final inner = SorobanAddressCredentials( + address, _kNonce, _kExpiration, XdrSCVal.forVoid()); + return SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), _buildHelloInvocation()); +} + + +SorobanAuthorizationEntry _makeWithDelegatesEntry( + String topAccountId, String delegateAccountId) { + final base = _makeV2Entry(topAccountId); + return SorobanAuthorizationEntry.withDelegates( + base, [SorobanDelegateDescriptor(delegateAccountId)], _kExpiration); +} + +Uint8List _encodePreimage(XdrHashIDPreimage preimage) { + final out = XdrDataOutputStream(); + XdrHashIDPreimage.encode(out, preimage); + return Uint8List.fromList(out.bytes); +} + +String _bytesToHex(Uint8List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + +/// Starts a minimal mock RPC server for AssembledTransaction unit tests. +Future<(HttpServer, Map)> _startMockRpcServer({ + required String accountId, + required BigInt seqNum, + List? authEntries, +}) async { + final accountXdr = _buildAccountLedgerEntryXdr(accountId, seqNum); + final sorobanDataXdr = _buildSorobanTransactionDataXdr(); + final capturedRequest = {}; + + final server = await HttpServer.bind('127.0.0.1', 0); + server.listen((req) async { + final body = await utf8.decoder.bind(req).join(); + final parsed = json.decode(body) as Map; + final method = parsed['method'] as String?; + + String responseJson; + if (method == 'getLedgerEntries') { + responseJson = json.encode({ + 'jsonrpc': '2.0', + 'id': parsed['id'], + 'result': { + 'entries': [ + {'key': 'AAAAAAAAAA==', 'xdr': accountXdr, 'lastModifiedLedgerSeq': 100} + ], + 'latestLedger': 1000, + } + }); + } else if (method == 'simulateTransaction') { + capturedRequest.addAll( + (parsed['params'] as Map).cast()); + final authBase64 = + (authEntries ?? []).map((e) => e.toBase64EncodedXdrString()).toList(); + responseJson = json.encode({ + 'jsonrpc': '2.0', + 'id': parsed['id'], + 'result': { + 'results': [{'xdr': 'AAAAAA==', 'auth': authBase64}], + 'transactionData': sorobanDataXdr, + 'minResourceFee': '100', + 'latestLedger': 1000, + } + }); + } else if (method == 'getLatestLedger') { + responseJson = json.encode({ + 'jsonrpc': '2.0', + 'id': parsed['id'], + 'result': {'sequence': 1000}, + }); + } else { + responseJson = json.encode({ + 'jsonrpc': '2.0', + 'id': parsed['id'], + 'error': {'code': -32601, 'message': 'Method not found: $method'}, + }); + } + + req.response + ..statusCode = 200 + ..headers.contentType = ContentType.json + ..write(responseJson); + await req.response.close(); + }); + + return (server, capturedRequest); +} + +String _buildAccountLedgerEntryXdr(String accountId, BigInt seqNum) { + final xdrAccountId = + XdrAccountID(KeyPair.fromAccountId(accountId).xdrPublicKey); + final accountEntry = XdrAccountEntry( + xdrAccountId, + XdrInt64(BigInt.from(100000000)), + XdrSequenceNumber(seqNum), + XdrUint32(0), + null, + XdrUint32(0), + XdrString32(''), + XdrThresholds(Uint8List.fromList([1, 0, 0, 0])), + [], + XdrAccountEntryExt(0), + ); + final ledgerEntryData = XdrLedgerEntryData(XdrLedgerEntryType.ACCOUNT); + ledgerEntryData.account = accountEntry; + return ledgerEntryData.toBase64EncodedXdrString(); +} + +String _buildSorobanTransactionDataXdr() { + final sorobanData = XdrSorobanTransactionData( + XdrSorobanTransactionDataExt(0), + XdrSorobanResources( + XdrLedgerFootprint([], []), + XdrUint32(0), + XdrUint32(0), + XdrUint32(0), + ), + XdrInt64(BigInt.zero), + ); + return sorobanData.toBase64EncodedXdrString(); +} + +Future _buildAssembledTx( + String rpcUrl, String accountId, KeyPair kp, + {bool simulate = false}) async { + final clientOptions = ClientOptions( + sourceAccountKeyPair: kp, + contractId: _kContractId, + network: Network.TESTNET, + rpcUrl: rpcUrl, + ); + final options = AssembledTransactionOptions( + clientOptions: clientOptions, + methodOptions: MethodOptions(simulate: simulate), + method: 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))], + ); + return await AssembledTransaction.build(options: options); +} + +void _injectAuthEntry(AssembledTransaction assembled, String accountId, + SorobanAuthorizationEntry entry) { + final account = Account(accountId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction( + _kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx!.setSorobanAuth([entry]); +} + +// --------------------------------------------------------------------------- +// TESTS +// --------------------------------------------------------------------------- + +void main() { + // ------------------------------------------------------------------------- + // GROUP: V2 deterministic vector (section 8.1 assertion) + // Confirms the exact preimage base64 and SHA-256 for the ADDRESS_V2 arm. + // The fixed inputs are: TESTNET, signer SDJHRQF4..., contract CA3D5K..., + // fn hello(u64 1234), nonce 123456789101112, expiration 4242. + // ------------------------------------------------------------------------- + group('ADDRESS_V2 deterministic preimage vector (section 8.1)', () { + test('V2 preimage base64 matches golden constant exactly', () { + final signer = KeyPair.fromSecretSeed(_kSeed); + final accountAddr = Address.forAccountId(signer.accountId); + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final creds = SorobanCredentials.forAddressV2(inner); + final entry = SorobanAuthorizationEntry(creds, _buildHelloInvocation()); + + final preimage = entry.buildPreimage(Network.TESTNET); + final encoded = base64Encode(_encodePreimage(preimage)); + + expect(encoded, equals(_kV2PreimageB64), + reason: 'ADDRESS_V2 preimage must byte-match the cross-SDK golden vector'); + }); + + test('V2 payload SHA-256 matches golden hex', () { + final signer = KeyPair.fromSecretSeed(_kSeed); + final accountAddr = Address.forAccountId(signer.accountId); + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final creds = SorobanCredentials.forAddressV2(inner); + final entry = SorobanAuthorizationEntry(creds, _buildHelloInvocation()); + + final preimage = entry.buildPreimage(Network.TESTNET); + final hash = Util.hash(_encodePreimage(preimage)); + + expect(_bytesToHex(hash), equals(_kV2PayloadHex)); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: Deep nested delegates XDR round-trip (3+ levels) + // Section 8.2 requires a 3-level nestedDelegates round-trip beyond the + // generated tests which only use empty arrays. + // ------------------------------------------------------------------------- + group('Deep nestedDelegates XDR round-trip (3+ levels)', () { + test('3-level nested delegate tree survives XDR encode/decode round-trip', + () { + final kp = KeyPair.fromSecretSeed(_kSeed); + // Level 3 (deepest leaf) + final level3 = XdrSorobanDelegateSignature( + XdrSCAddress.forAccountId(kp.accountId), + XdrSCVal.forVoid(), + [], + ); + // Level 2: contains level3 as nested + final level2 = XdrSorobanDelegateSignature( + XdrSCAddress.forContractId(_kContractId), + XdrSCVal.forVoid(), + [level3], + ); + // Level 1 (root): contains level2 + final level1 = XdrSorobanDelegateSignature( + XdrSCAddress.forAccountId(kp.accountId), + XdrSCVal.forVoid(), + [level2], + ); + + // Encode to bytes + final out = XdrDataOutputStream(); + XdrSorobanDelegateSignature.encode(out, level1); + final encoded = Uint8List.fromList(out.bytes); + + // Decode back + final decoded = + XdrSorobanDelegateSignature.decode(XdrDataInputStream(encoded)); + + expect(decoded.nestedDelegates.length, equals(1), + reason: 'level 1 must have 1 nested delegate'); + expect(decoded.nestedDelegates[0].nestedDelegates.length, equals(1), + reason: 'level 2 must have 1 nested delegate'); + expect( + decoded.nestedDelegates[0].nestedDelegates[0].nestedDelegates.length, + equals(0), + reason: 'level 3 (leaf) must have 0 nested delegates'); + }); + + test('5-level deep nested delegate tree round-trips via XdrSorobanAddressCredentialsWithDelegates', + () { + final kp = KeyPair.fromSecretSeed(_kSeed); + XdrSorobanDelegateSignature current = + XdrSorobanDelegateSignature( + XdrSCAddress.forAccountId(kp.accountId), XdrSCVal.forVoid(), []); + // Build 5 levels + for (int i = 0; i < 4; i++) { + current = XdrSorobanDelegateSignature( + XdrSCAddress.forContractId(_kContractId), XdrSCVal.forVoid(), [current]); + } + + final addressCreds = XdrSorobanAddressCredentials( + XdrSCAddress.forAccountId(kp.accountId), + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + final withDelegates = + XdrSorobanAddressCredentialsWithDelegates(addressCreds, [current]); + + // Encode and decode + final out = XdrDataOutputStream(); + XdrSorobanAddressCredentialsWithDelegates.encode(out, withDelegates); + final decoded = XdrSorobanAddressCredentialsWithDelegates.decode( + XdrDataInputStream(Uint8List.fromList(out.bytes))); + + expect(decoded.delegates.length, equals(1)); + // Walk 4 levels of nesting to the leaf + var node = decoded.delegates[0]; + int depth = 1; + while (node.nestedDelegates.isNotEmpty) { + node = node.nestedDelegates[0]; + depth++; + } + expect(depth, equals(5), + reason: 'should reach 5 levels of nesting after round-trip'); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: Unknown credential arm fail-fast + // + // The one reachable path for an unknown arm at the high-level is + // SorobanCredentials.fromXdr with an XdrSorobanCredentials whose + // discriminant is not one of the four known values. + // + // The guards in needsNonInvokerSigningBy and signAuthEntries check + // entry.credentials.arm. SorobanCredentials has only a private constructor + // (_) and no public path to set arm to an unknown value (fromXdr throws + // before returning such an object). Those guards are therefore unreachable + // from the public API and are justified exclusions. + // ------------------------------------------------------------------------- + group('Unknown credential arm fail-fast', () { + test( + 'SorobanCredentials.fromXdr throws descriptively for unknown discriminant', + () { + // XdrSorobanCredentialsType has a public value constructor that accepts + // any int. XdrSorobanCredentials has a public single-arg constructor that + // accepts a type. This combination produces an XDR credentials object + // with an unknown discriminant value. + final unknownType = XdrSorobanCredentialsType(99); + final xdrCreds = XdrSorobanCredentials(unknownType); + + expect( + () => SorobanCredentials.fromXdr(xdrCreds), + throwsA(isA()), + reason: + 'fromXdr must throw for an unrecognised SorobanCredentials discriminant', + ); + }); + + test( + 'SorobanCredentials.fromXdr throws for arm value 4 (gap between known arms)', + () { + final unknownType = XdrSorobanCredentialsType(4); + final xdrCreds = XdrSorobanCredentials(unknownType); + + expect( + () => SorobanCredentials.fromXdr(xdrCreds), + throwsA(isA()), + ); + }); + + // needsNonInvokerSigningBy and signAuthEntries unknown-arm guards: + // These check entry.credentials.arm, which is a SorobanCredentials field. + // SorobanCredentials._() is a private constructor; fromXdr throws for + // unknown arms before returning. There is no public path to place an + // unknown arm into a SorobanCredentials instance and then into the + // AssembledTransaction auth list. The guards at soroban_client.dart:877 + // and soroban_client.dart:1189 are therefore unreachable from tests that + // use only the public API. They are defensive checks against future + // protocol extensions adding new arm values. + }); + + // ------------------------------------------------------------------------- + // GROUP: Missing private key for V2 and WITH_DELEGATES arms + // Section 8.2 requires these for BOTH V2 and WITH_DELEGATES, not just legacy. + // ------------------------------------------------------------------------- + group('signAuthEntries: missing private key for P27 arms', () { + test('V2 entry: signing without private key throws', () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final pubOnlyKp = KeyPair.fromAccountId(accountId); + + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + final v2Entry = _makeV2Entry(accountId); + _injectAuthEntry(assembled, accountId, v2Entry); + + expect( + () async => await assembled.signAuthEntries( + signerKeyPair: pubOnlyKp, + validUntilLedgerSeq: _kExpiration + 100, + ), + throwsA(isA()), + reason: + 'V2 arm: signing without private key must throw', + ); + } finally { + await server.close(); + } + }); + + test('WITH_DELEGATES entry: signing without private key throws', () async { + + final topKp = KeyPair.fromSecretSeed(_kSeed); + final delegateKp = KeyPair.random(); + final topId = topKp.accountId; + final delegateId = delegateKp.accountId; + final pubOnlyDelegateKp = KeyPair.fromAccountId(delegateId); + + final (server, _) = await _startMockRpcServer( + accountId: topId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, topId, topKp, simulate: false); + + final wdEntry = _makeWithDelegatesEntry(topId, delegateId); + _injectAuthEntry(assembled, topId, wdEntry); + + expect( + () async => await assembled.signAuthEntries( + signerKeyPair: pubOnlyDelegateKp, + validUntilLedgerSeq: _kExpiration + 100, + ), + throwsA(isA()), + reason: + 'WITH_DELEGATES arm: signing without private key must throw', + ); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: authorizeEntryDelegate (callback) routing + // Section 8.2: "Delegate-ONLY callback routing" + // ------------------------------------------------------------------------- + group('signAuthEntries: authorizeEntryDelegate callback path', () { + test('callback signs a V2 entry and returned entry is used', () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + final v2Entry = _makeV2Entry(accountId); + _injectAuthEntry(assembled, accountId, v2Entry); + + int callbackInvocations = 0; + + await assembled.signAuthEntries( + signerKeyPair: kp, + validUntilLedgerSeq: _kExpiration + 100, + authorizeEntryDelegate: (entry, network) async { + callbackInvocations++; + // Sign the entry in the callback + entry.credentials.innerAddressCredentials! + .signatureExpirationLedger = _kExpiration + 100; + entry.sign(kp, network); + return entry; + }, + ); + + expect(callbackInvocations, equals(1), + reason: 'callback must be invoked exactly once for one V2 entry'); + + final signedEntry = + (assembled.tx!.operations.first as InvokeHostFunctionOperation) + .auth + .first; + // Arm must still be V2 after the callback path + expect( + signedEntry.credentials.arm, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2), + reason: 'V2 arm must be preserved through callback path', + ); + } finally { + await server.close(); + } + }); + + test( + 'callback signs a WITH_DELEGATES entry; top-level contract, sole delegate matches signer', + () async { + + final delegateKp = KeyPair.fromSecretSeed(_kSeed); + final delegateId = delegateKp.accountId; + // Use the signer as the delegate; the top-level is the contract address. + // This exercises the delegate-ONLY routing (contract at top, G at delegate). + final topAccountId = delegateId; // reuse for account setup; top creds will be contract + final (server, _) = await _startMockRpcServer( + accountId: topAccountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = await _buildAssembledTx( + rpcUrl, topAccountId, delegateKp, simulate: false); + + // Build a WITH_DELEGATES entry whose top-level address is a contract + // and whose sole delegate is the G-address signer. + final contractAddress = Address.forContractId(_kContractId); + final inner = SorobanAddressCredentials( + contractAddress, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final baseEntry = SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), _buildHelloInvocation()); + final wdEntry = SorobanAuthorizationEntry.withDelegates( + baseEntry, [SorobanDelegateDescriptor(delegateId)], _kExpiration); + + _injectAuthEntry(assembled, topAccountId, wdEntry); + + int callbackCount = 0; + await assembled.signAuthEntries( + signerKeyPair: delegateKp, + validUntilLedgerSeq: _kExpiration + 100, + authorizeEntryDelegate: (entry, network) async { + callbackCount++; + return entry; + }, + ); + + // The entry matches the signer as a delegate node, so it is handed to + // the callback exactly once (never broadcast to non-matching entries). + expect(callbackCount, equals(1)); + } finally { + await server.close(); + } + }); + + test( + 'delegate callback does not modify a sibling entry already signed by another party', + () async { + // Atomic-swap shape: two legacy ADDRESS entries (Alice and Bob). Alice + // signs directly; Bob then signs via a delegate callback. Bob's call must + // touch only Bob's entry and leave Alice's signed entry byte-for-byte + // intact — a delegate signer must never disturb an entry it does not own. + final aliceKp = KeyPair.fromSecretSeed(_kSeed); + final bobKp = KeyPair.random(); + final aliceId = aliceKp.accountId; + final bobId = bobKp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: aliceId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, aliceId, aliceKp, simulate: false); + + SorobanAuthorizationEntry addressEntry(String accountId) => + SorobanAuthorizationEntry( + SorobanCredentials( + addressCredentials: SorobanAddressCredentials( + Address.forAccountId(accountId), + _kNonce, + _kExpiration, + XdrSCVal.forVoid())), + _buildHelloInvocation()); + + final account = Account(aliceId, BigInt.from(100)); + final invokeOp = InvokeContractHostFunction(_kContractId, 'hello', + arguments: [XdrSCVal.forU64(BigInt.from(1234))]); + assembled.tx = TransactionBuilder(account) + .addOperation(InvokeHostFuncOpBuilder(invokeOp).build()) + .build(); + assembled.tx! + .setSorobanAuth([addressEntry(aliceId), addressEntry(bobId)]); + + List auth() => + (assembled.tx!.operations.first as InvokeHostFunctionOperation).auth; + + // Alice signs her own entry directly. + await assembled.signAuthEntries( + signerKeyPair: aliceKp, validUntilLedgerSeq: _kExpiration + 100); + expect( + auth()[0].credentials.innerAddressCredentials!.signature.discriminant, + XdrSCValType.SCV_VEC, + reason: "Alice's entry must be signed"); + expect( + auth()[1].credentials.innerAddressCredentials!.signature.discriminant, + XdrSCValType.SCV_VOID, + reason: "Bob's entry must still be unsigned"); + final aliceXdr = auth()[0].toBase64EncodedXdrString(); + + // Bob signs via a delegate callback that signs with Bob's key. + await assembled.signAuthEntries( + signerKeyPair: KeyPair.fromAccountId(bobId), + validUntilLedgerSeq: _kExpiration + 100, + authorizeEntryDelegate: (entry, network) async { + final copy = SorobanAuthorizationEntry.fromBase64EncodedXdr( + entry.toBase64EncodedXdrString()); + copy.sign(bobKp, network); + return copy; + }, + ); + + // Alice's entry must be untouched by Bob's delegate call. + expect(auth()[0].toBase64EncodedXdrString(), aliceXdr, + reason: + 'a delegate signer must not modify an entry it does not own'); + // Bob's entry is now signed. + expect( + auth()[1].credentials.innerAddressCredentials!.signature.discriminant, + XdrSCValType.SCV_VEC, + reason: "Bob's entry must be signed by the delegate callback"); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: Property setters for new generated XDR structs + // Tests that the setters on the three new generated XDR types work. + // ------------------------------------------------------------------------- + group('XDR struct property setters for new P27 types', () { + test('XdrHashIDPreimageSorobanAuthorizationWithAddress setters work', () { + final kp = KeyPair.fromSecretSeed(_kSeed); + final networkId = Util.hash( + Uint8List.fromList(utf8.encode('Test SDF Network ; September 2015'))); + + final original = XdrHashIDPreimageSorobanAuthorizationWithAddress( + XdrHash(networkId), + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCAddress.forAccountId(kp.accountId), + _buildHelloInvocation().toXdr(), + ); + + // Exercise all setters + final newNetworkId = Uint8List(32); + original.networkID = XdrHash(newNetworkId); + expect(original.networkID.hash, equals(newNetworkId)); + + original.nonce = XdrInt64(BigInt.from(999)); + expect(original.nonce.int64, equals(BigInt.from(999))); + + original.signatureExpirationLedger = XdrUint32(8888); + expect(original.signatureExpirationLedger.uint32, equals(8888)); + + final newAddr = XdrSCAddress.forContractId(_kContractId); + original.address = newAddr; + expect(original.address.discriminant, + equals(XdrSCAddressType.SC_ADDRESS_TYPE_CONTRACT)); + + final newInvoc = _buildHelloInvocation().toXdr(); + original.invocation = newInvoc; + expect(identical(original.invocation, newInvoc), isTrue); + }); + + test('XdrSorobanAddressCredentialsWithDelegates setters work', () { + final kp = KeyPair.fromSecretSeed(_kSeed); + final addressCreds = XdrSorobanAddressCredentials( + XdrSCAddress.forAccountId(kp.accountId), + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + final original = + XdrSorobanAddressCredentialsWithDelegates(addressCreds, []); + + // Exercise addressCredentials setter + final newCreds = XdrSorobanAddressCredentials( + XdrSCAddress.forContractId(_kContractId), + XdrInt64(BigInt.from(1)), + XdrUint32(100), + XdrSCVal.forVoid(), + ); + original.addressCredentials = newCreds; + expect(original.addressCredentials.signatureExpirationLedger.uint32, + equals(100)); + + // Exercise delegates setter + final newDelegate = XdrSorobanDelegateSignature( + XdrSCAddress.forAccountId(kp.accountId), XdrSCVal.forVoid(), []); + original.delegates = [newDelegate]; + expect(original.delegates.length, equals(1)); + }); + + test('XdrSorobanDelegateSignature setters work', () { + final kp = KeyPair.fromSecretSeed(_kSeed); + final original = XdrSorobanDelegateSignature( + XdrSCAddress.forAccountId(kp.accountId), XdrSCVal.forVoid(), []); + + // Exercise address setter + final newAddr = XdrSCAddress.forContractId(_kContractId); + original.address = newAddr; + expect(original.address.discriminant, + equals(XdrSCAddressType.SC_ADDRESS_TYPE_CONTRACT)); + + // Exercise signature setter + original.signature = XdrSCVal.forBool(true); + expect(original.signature.discriminant, equals(XdrSCValType.SCV_BOOL)); + + // Exercise nestedDelegates setter + final nested = XdrSorobanDelegateSignature( + XdrSCAddress.forAccountId(kp.accountId), XdrSCVal.forVoid(), []); + original.nestedDelegates = [nested]; + expect(original.nestedDelegates.length, equals(1)); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: needsNonInvokerSigningBy — ADDRESS_V2 with includeAlreadySigned + // Covers the branch where a signed V2 entry is still reported when + // includeAlreadySigned=true. + // ------------------------------------------------------------------------- + group('needsNonInvokerSigningBy includeAlreadySigned path', () { + test('ADDRESS_V2 signed entry still reported when includeAlreadySigned=true', + () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + final v2Entry = _makeV2Entry(accountId); + // Sign it so the signature is non-void + v2Entry.sign(kp, Network.TESTNET); + _injectAuthEntry(assembled, accountId, v2Entry); + + // With includeAlreadySigned=false: signed entry is NOT reported + final needed = assembled.needsNonInvokerSigningBy( + includeAlreadySigned: false); + expect(needed, isNot(contains(accountId)), + reason: 'signed V2 entry should not appear with includeAlreadySigned=false'); + + // With includeAlreadySigned=true: even signed entry is reported + final needAll = + assembled.needsNonInvokerSigningBy(includeAlreadySigned: true); + expect(needAll, contains(accountId), + reason: + 'signed V2 entry must appear when includeAlreadySigned=true'); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: SorobanCredentials.toXdr null-field guard paths + // Covers the defensive null checks in toXdr for each arm when the inner + // credentials field is unexpectedly null. + // ------------------------------------------------------------------------- + group('SorobanCredentials.toXdr null-field guard paths', () { + test('ADDRESS arm: toXdr throws if addressCredentials is null', () { + // Create ADDRESS creds normally then clear the inner field + final kp = KeyPair.fromSecretSeed(_kSeed); + final creds = SorobanCredentials.forAddress( + Address.forAccountId(kp.accountId), + _kNonce, + _kExpiration, + XdrSCVal.forVoid()); + creds.addressCredentials = null; + + expect(() => creds.toXdr(), throwsA(isA()), + reason: 'toXdr must throw when addressCredentials is null for ADDRESS arm'); + }); + + test('ADDRESS_V2 arm: toXdr throws if addressV2Credentials is null', () { + final kp = KeyPair.fromSecretSeed(_kSeed); + final inner = SorobanAddressCredentials( + Address.forAccountId(kp.accountId), _kNonce, _kExpiration, XdrSCVal.forVoid()); + final creds = SorobanCredentials.forAddressV2(inner); + creds.addressV2Credentials = null; + + expect(() => creds.toXdr(), throwsA(isA()), + reason: 'toXdr must throw when addressV2Credentials is null for ADDRESS_V2 arm'); + }); + + test('ADDRESS_WITH_DELEGATES arm: toXdr throws if addressWithDelegatesCredentials is null', + () { + final kp = KeyPair.fromSecretSeed(_kSeed); + final inner = SorobanAddressCredentials( + Address.forAccountId(kp.accountId), _kNonce, _kExpiration, XdrSCVal.forVoid()); + final withDels = SorobanAddressCredentialsWithDelegates(inner, []); + final creds = SorobanCredentials.forAddressWithDelegates(withDels); + creds.addressWithDelegatesCredentials = null; + + expect(() => creds.toXdr(), throwsA(isA()), + reason: + 'toXdr must throw when addressWithDelegatesCredentials is null for WITH_DELEGATES arm'); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: SorobanCredentials.fromXdr null-field guard paths + // The ADDRESS arm guard requires xdr.address == null; the V2 guard requires + // xdr.addressV2 == null. Both throw descriptively. + // ------------------------------------------------------------------------- + group('SorobanCredentials.fromXdr null-field guard paths', () { + test('ADDRESS arm: fromXdr throws when address field is null', () { + // Construct ADDRESS-discriminanted XdrSorobanCredentials without setting + // the address payload field. The null-check guard must throw. + final xdrAddress = XdrSorobanCredentials( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS); + expect( + () => SorobanCredentials.fromXdr(xdrAddress), + throwsA(isA()), + reason: 'fromXdr must throw when ADDRESS arm has no address payload', + ); + }); + + test('ADDRESS_V2 arm: fromXdr throws when addressV2 field is null', () { + final xdrV2 = XdrSorobanCredentials( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2); + // xdrV2.addressV2 is null — triggers the guard + expect( + () => SorobanCredentials.fromXdr(xdrV2), + throwsA(isA()), + reason: 'fromXdr must throw when ADDRESS_V2 arm has no addressV2 payload', + ); + }); + + test('ADDRESS_WITH_DELEGATES arm: fromXdr throws when addressWithDelegates is null', + () { + final xdrWithDel = XdrSorobanCredentials( + XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES); + expect( + () => SorobanCredentials.fromXdr(xdrWithDel), + throwsA(isA()), + reason: + 'fromXdr must throw when ADDRESS_WITH_DELEGATES arm has no addressWithDelegates payload', + ); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: SorobanAuthorizationEntry TxRep round-trip with non-empty + // delegates (entries, not just XDR round-trip). + // Section 8.2: Entry-level TxRep round-trip for V2 and non-empty/nested + // WITH_DELEGATES trees. + // ------------------------------------------------------------------------- + group('Entry-level TxRep round-trip (section 8.2)', () { + test('ADDRESS_V2 entry survives toBase64/fromBase64 XDR round-trip', () { + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountAddr = Address.forAccountId(kp.accountId); + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), _buildHelloInvocation()); + + final b64 = entry.toBase64EncodedXdrString(); + final restored = SorobanAuthorizationEntry.fromBase64EncodedXdr(b64); + + expect( + restored.credentials.arm, + equals(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2), + reason: 'V2 arm must survive entry-level XDR round-trip', + ); + expect(restored.credentials.addressV2Credentials, isNotNull); + expect( + restored.credentials.addressV2Credentials!.signatureExpirationLedger, + equals(_kExpiration)); + }); + + test( + 'WITH_DELEGATES entry with 2-level nested delegates survives XDR round-trip', + () { + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountAddr = Address.forAccountId(kp.accountId); + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + + // Build: contract (level 1) -> account (level 2, nested) + final entry = SorobanAuthorizationEntry.withDelegates( + SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + _buildHelloInvocation(), + ), + [ + SorobanDelegateDescriptor(_kContractId, nestedDelegates: [ + SorobanDelegateDescriptor(kp.accountId), + ]), + ], + _kExpiration, + ); + + final b64 = entry.toBase64EncodedXdrString(); + final restored = SorobanAuthorizationEntry.fromBase64EncodedXdr(b64); + + expect( + restored.credentials.arm, + equals( + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES), + ); + final delegates = restored.credentials.addressWithDelegatesCredentials!.delegates; + expect(delegates.length, equals(1)); + expect(delegates[0].nestedDelegates.length, equals(1), + reason: 'nested delegate must survive entry XDR round-trip'); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: OZ smart account auth — source-account arm in _innerAddressCredentials + // Covers oz_smart_account_auth.dart line 367 (default null return) via + // buildAuthPayloadHash which throws for source-account before reaching it. + // The path for the WITH_DELEGATES branch (line 364-365) and the _rebuildCredentials + // default throw (line 386-387) are unreachable from public API because + // _requireAddressOrV2Credentials throws for non-ADDRESS/V2 before those + // branches are reached. Document the justification here. + // + // oz_smart_account_auth.dart line 436 (_hashPreimage XDR encode catch): + // XdrHashIDPreimage.encode cannot throw for a valid preimage produced by + // buildPreimage. This is a defensive fallback against a hypothetical XDR + // encode error that cannot be triggered in practice. + // ------------------------------------------------------------------------- + group('OZ smart account auth — _requireAddressOrV2Credentials guards', () { + test( + 'buildAuthPayloadHash throws for SOURCE_ACCOUNT entry (source-account path)', + () async { + // Confirms the source-account guard is already tested in oz_d1_p27 but + // this confirms the _requireAddressOrV2Credentials default arm fires for + // SOURCE_ACCOUNT. + final entry = _buildOzSourceEntry(); + await expectLater( + OZSmartAccountAuth.buildAuthPayloadHash( + entry, + _kExpiration, + 'Test SDF Network ; September 2015', + ), + throwsA(isA()), + ); + }); + + // Justification for exclusions: + // - oz_smart_account_auth.dart lines 364-365 (_innerAddressCredentials + // WITH_DELEGATES branch): unreachable because _requireAddressOrV2Credentials + // throws at line 404 before _innerAddressCredentials is called with a + // WITH_DELEGATES entry. + // - oz_smart_account_auth.dart lines 386-387 (_rebuildCredentials default + // throw): unreachable because _requireAddressOrV2Credentials would have + // already thrown for any non-ADDRESS/V2 arm. + // - oz_smart_account_auth.dart line 436 (_hashPreimage encode catch): + // unreachable — XdrHashIDPreimage.encode does not throw for valid preimage. + }); + + // ------------------------------------------------------------------------- + // GROUP: XdrSorobanDelegateSignature — TxRep with non-empty nestedDelegates + // Ensures toTxRep/fromTxRep survive a non-empty nestedDelegates array. + // ------------------------------------------------------------------------- + group('XdrSorobanDelegateSignature TxRep with nested delegates', () { + test('toTxRep/fromTxRep round-trip for non-empty nestedDelegates', () { + final kp = KeyPair.fromSecretSeed(_kSeed); + + final nested = XdrSorobanDelegateSignature( + XdrSCAddress.forContractId(_kContractId), XdrSCVal.forVoid(), []); + final root = XdrSorobanDelegateSignature( + XdrSCAddress.forAccountId(kp.accountId), XdrSCVal.forVoid(), [nested]); + + final lines = []; + root.toTxRep('del', lines); + final map = {}; + for (final line in lines) { + final idx = line.indexOf(': '); + if (idx >= 0) map[line.substring(0, idx)] = line.substring(idx + 2); + } + + final restored = XdrSorobanDelegateSignature.fromTxRep(map, 'del'); + + expect(restored.nestedDelegates.length, equals(1), + reason: 'nested delegate must survive toTxRep/fromTxRep round-trip'); + expect(restored.nestedDelegates[0].nestedDelegates.length, equals(0)); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: XdrSorobanAddressCredentialsWithDelegates TxRep with non-empty delegates + // ------------------------------------------------------------------------- + group('XdrSorobanAddressCredentialsWithDelegates TxRep round-trip', () { + test('toTxRep/fromTxRep preserves non-empty delegates array', () { + final kp = KeyPair.fromSecretSeed(_kSeed); + final addressCreds = XdrSorobanAddressCredentials( + XdrSCAddress.forAccountId(kp.accountId), + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + final delegate = XdrSorobanDelegateSignature( + XdrSCAddress.forContractId(_kContractId), XdrSCVal.forVoid(), []); + final withDels = + XdrSorobanAddressCredentialsWithDelegates(addressCreds, [delegate]); + + final lines = []; + withDels.toTxRep('wd', lines); + final map = {}; + for (final line in lines) { + final idx = line.indexOf(': '); + if (idx >= 0) map[line.substring(0, idx)] = line.substring(idx + 2); + } + + final restored = + XdrSorobanAddressCredentialsWithDelegates.fromTxRep(map, 'wd'); + + expect(restored.delegates.length, equals(1)); + expect( + restored.addressCredentials.signatureExpirationLedger.uint32, + equals(_kExpiration)); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: WebAuthForContracts — signatureExpirationLedger stamping with + // clientDomainKeyPair + // Covers webauth_for_contracts.dart lines 769 and 782 (the + // signatureExpirationLedger != null branch in the clientDomainKeyPair and + // clientDomainSigningCallback paths). + // ------------------------------------------------------------------------- + group('WebAuthForContracts signatureExpirationLedger with clientDomainKeyPair', + () { + test( + 'signAuthorizationEntries stamps signatureExpirationLedger via clientDomainKeyPair', + () async { + final serverKeyPair = KeyPair.random(); + final serverAccountId = serverKeyPair.accountId; + final clientDomainKeyPair = KeyPair.random(); + final clientDomainAccountId = clientDomainKeyPair.accountId; + final clientKeyPair = KeyPair.random(); + final clientAccountId = clientKeyPair.accountId; + + final argsMap = XdrSCVal.forMap([ + XdrSCMapEntry(XdrSCVal.forSymbol('account'), XdrSCVal.forString(clientAccountId)), + XdrSCMapEntry(XdrSCVal.forSymbol('home_domain'), XdrSCVal.forString('test.stellar.org')), + XdrSCMapEntry(XdrSCVal.forSymbol('nonce'), XdrSCVal.forString('42')), + XdrSCMapEntry(XdrSCVal.forSymbol('web_auth_domain'), XdrSCVal.forString('test.stellar.org')), + XdrSCMapEntry(XdrSCVal.forSymbol('web_auth_domain_account'), XdrSCVal.forString(serverAccountId)), + ]); + + // Build a client entry using the clientDomainAccount address + final contractAddr = Address.forContractId(_kContractId); + final fn = SorobanAuthorizedFunction.forContractFunction( + contractAddr, 'web_auth_verify', [argsMap]); + final invocation = SorobanAuthorizedInvocation(fn); + + final innerCreds = SorobanAddressCredentials( + Address.forAccountId(clientDomainAccountId), + BigInt.from(1), 0, XdrSCVal.forVoid()); + final clientDomainEntry = SorobanAuthorizationEntry( + SorobanCredentials(addressCredentials: innerCreds), invocation); + + // Also add a regular client entry so signAuthorizationEntries has something to process + final clientInnerCreds = SorobanAddressCredentials( + Address.forAccountId(clientAccountId), + BigInt.from(2), 0, XdrSCVal.forVoid()); + final clientEntry = SorobanAuthorizationEntry( + SorobanCredentials(addressCredentials: clientInnerCreds), invocation); + + final webAuth = WebAuthForContracts( + 'https://test.stellar.org/auth', + _kContractId, + serverAccountId, + 'test.stellar.org', + Network.TESTNET, + ); + + const stampedExpiration = 5555; + final signed = await webAuth.signAuthorizationEntries( + [clientDomainEntry, clientEntry], + clientAccountId, + [clientKeyPair], + stampedExpiration, + clientDomainKeyPair, // non-null clientDomainKeyPair triggers line 769 + clientDomainAccountId, + null, + ); + + // The client-domain entry must be stamped with stampedExpiration. Locate + // it deterministically by its address and assert unconditionally: if the + // stamping path regressed, this fails rather than passing vacuously. + final clientDomainSigned = signed.firstWhere((e) => + e.credentials.innerAddressCredentials?.address.accountId == + clientDomainAccountId); + expect( + clientDomainSigned + .credentials.innerAddressCredentials!.signatureExpirationLedger, + equals(stampedExpiration)); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: _buildAndSortDelegates — depth limit + // Covers soroban_auth.dart line 1250 (depth limit in _buildAndSortDelegates). + // ------------------------------------------------------------------------- + group('withDelegates depth limit in nested descriptor building', () { + test('deeply nested SorobanDelegateDescriptor tree beyond limit throws', () { + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountAddr = Address.forAccountId(kp.accountId); + + // Build a SorobanDelegateDescriptor chain 130 levels deep + SorobanDelegateDescriptor buildDeep(int depth) { + if (depth <= 0) return SorobanDelegateDescriptor(_kContractId); + return SorobanDelegateDescriptor(_kContractId, + nestedDelegates: [buildDeep(depth - 1)]); + } + + final deepDescriptor = buildDeep(130); + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final base = SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), _buildHelloInvocation()); + + expect( + () => SorobanAuthorizationEntry.withDelegates( + base, [deepDescriptor], _kExpiration), + throwsArgumentError, + reason: + 'withDelegates must throw when descriptor tree depth exceeds the limit', + ); + }); + + test('non-G/non-C/non-M strkey as delegate address throws ArgumentError', + () { + // Tests _strKeyToXdrAddress else branch (soroban_auth.dart lines 1306-1307) + // for strkeys that are not G (account), C (contract), or M (muxed). + // For example a Stellar seed key starts with 'S'. + const invalidStrKey = 'SDJHRQF4GCMIIKAAAQ6IHY42X73FQFLHUULAPSKKD4DFDM7UXWWCRHBE'; + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountAddr = Address.forAccountId(kp.accountId); + final inner = SorobanAddressCredentials( + accountAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + final base = SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), _buildHelloInvocation()); + + expect( + () => SorobanAuthorizationEntry.withDelegates( + base, [SorobanDelegateDescriptor(invalidStrKey)], _kExpiration), + throwsArgumentError, + reason: 'delegate strkeys that are not G/C/M must throw ArgumentError', + ); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: sign() — null inner credentials after arm is set + // Covers soroban_auth.dart line 1038 (inner == null guard in sign()). + // ------------------------------------------------------------------------- + group('sign() null-inner guard', () { + test('sign() throws when arm is ADDRESS_V2 but inner credentials were cleared', + () { + final kp = KeyPair.fromSecretSeed(_kSeed); + final inner = SorobanAddressCredentials( + Address.forAccountId(kp.accountId), _kNonce, _kExpiration, XdrSCVal.forVoid()); + final creds = SorobanCredentials.forAddressV2(inner); + // Clear the inner field so credentials.innerAddressCredentials returns null + // while the arm remains ADDRESS_V2 (not SOURCE_ACCOUNT). + creds.addressV2Credentials = null; + final entry = SorobanAuthorizationEntry(creds, _buildHelloInvocation()); + + expect( + () => entry.sign(kp, Network.TESTNET), + throwsA(isA()), + reason: + 'sign() must throw when innerAddressCredentials is null (corrupted credentials)', + ); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: sign() with forAddress — nested delegate routing (line 1171) + // ------------------------------------------------------------------------- + group('sign() forAddress routes to nested delegate node', () { + test('sign with forAddress finds a nested delegate (level 2)', () { + final topKp = KeyPair.fromSecretSeed(_kSeed); + final level1Kp = KeyPair.random(); + final level2Kp = KeyPair.random(); + + final topAddr = Address.forAccountId(topKp.accountId); + final level1Id = level1Kp.accountId; + final level2Id = level2Kp.accountId; + + final inner = SorobanAddressCredentials( + topAddr, _kNonce, _kExpiration, XdrSCVal.forVoid()); + + // Create entry with 2-level delegate tree + final entry = SorobanAuthorizationEntry.withDelegates( + SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + _buildHelloInvocation(), + ), + [ + SorobanDelegateDescriptor(level1Id, + nestedDelegates: [SorobanDelegateDescriptor(level2Id)]), + ], + _kExpiration, + ); + + // Sign targeting the level-2 (nested) delegate + entry.sign(level2Kp, Network.TESTNET, forAddress: level2Id); + + final withDelegates = entry.credentials.addressWithDelegatesCredentials!; + final level1Node = withDelegates.delegates[0]; + final level2Node = level1Node.nestedDelegates[0]; + + // Level 2 must be signed + expect( + level2Node.signature.discriminant, + isNot(equals(XdrSCValType.SCV_VOID)), + reason: 'level-2 delegate must be signed via forAddress routing', + ); + expect(level2Node.signature.vec, isNotNull); + expect(level2Node.signature.vec!.length, equals(1)); + + // Level 1 and top-level must remain void + expect( + level1Node.signature.discriminant, + equals(XdrSCValType.SCV_VOID), + reason: 'level-1 delegate must not be signed when only level-2 was targeted', + ); + expect( + withDelegates.addressCredentials.signature.discriminant, + equals(XdrSCValType.SCV_VOID), + reason: 'top-level must not be signed when only a nested delegate was targeted', + ); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: signAuthEntries — nested delegate match routing (line 1262-1267) + // ------------------------------------------------------------------------- + group('signAuthEntries — nested delegate matching', () { + test('signer matching a nested (level 2) delegate signs only that node', + () async { + + final topKp = KeyPair.fromSecretSeed(_kSeed); + final level1Kp = KeyPair.random(); + final level2Kp = KeyPair.random(); + final topId = topKp.accountId; + final level1Id = level1Kp.accountId; + final level2Id = level2Kp.accountId; + + final (server, _) = await _startMockRpcServer( + accountId: topId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, topId, topKp, simulate: false); + + final inner = SorobanAddressCredentials( + Address.forAccountId(topId), _kNonce, _kExpiration, XdrSCVal.forVoid()); + final entry = SorobanAuthorizationEntry.withDelegates( + SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + _buildHelloInvocation(), + ), + [ + SorobanDelegateDescriptor(level1Id, + nestedDelegates: [SorobanDelegateDescriptor(level2Id)]), + ], + _kExpiration, + ); + _injectAuthEntry(assembled, topId, entry); + + // Sign as level2Kp — must match the nested delegate node + await assembled.signAuthEntries( + signerKeyPair: level2Kp, + validUntilLedgerSeq: _kExpiration + 100, + ); + + final signedEntry = + (assembled.tx!.operations.first as InvokeHostFunctionOperation) + .auth + .first; + final withDelegates = + signedEntry.credentials.addressWithDelegatesCredentials!; + final level1Node = withDelegates.delegates[0]; + final level2Node = level1Node.nestedDelegates[0]; + + expect( + level2Node.signature.discriminant, + isNot(equals(XdrSCValType.SCV_VOID)), + reason: 'level-2 nested delegate must be signed', + ); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: signAuthEntries — exception paths for null/no-op tx + // Covers soroban_client.dart lines 1161 and 1171. + // ------------------------------------------------------------------------- + group('signAuthEntries — error paths for malformed tx state', () { + test('signAuthEntries throws when tx has no operations', () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + // Build a tx with no operations + final account = Account(accountId, BigInt.from(100)); + assembled.tx = TransactionBuilder(account) + .addOperation(BumpSequenceOperation(BigInt.from(110))) + .build(); + + // Replace operations with an empty-ish scenario — inject V2 entry to + // get the "needs signing" check to pass, then clear the ops. + final v2Entry = _makeV2Entry(accountId); + _injectAuthEntry(assembled, accountId, v2Entry); + + // Now replace the tx with one having no InvokeHostFunction operation + final account2 = Account(accountId, BigInt.from(101)); + assembled.tx = TransactionBuilder(account2) + .addOperation(BumpSequenceOperation(BigInt.from(111))) + .build(); + // The assembled tx needs signing (V2 entry was injected earlier but + // tx now has a BumpSequence op, not an InvokeHostFunction) — this + // triggers the "no invoke host function" error path. + expect( + () async => await assembled.signAuthEntries( + signerKeyPair: kp, + validUntilLedgerSeq: _kExpiration + 100, + ), + throwsA(isA()), + reason: 'non-InvokeHostFunction tx must throw in signAuthEntries', + ); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: sign() with unsigned V2 entry — _neededSignersForSend ADDRESS/V2 path + // Covers soroban_client.dart lines 1013-1019 (the else branch in + // _neededSignersForSend for ADDRESS/ADDRESS_V2 entries that have a void + // top-level signature). sign(force:true) skips isReadCall() so we reach + // _neededSignersForSend with a tx that has an unsigned V2 entry. + // ------------------------------------------------------------------------- + group('sign() — _neededSignersForSend ADDRESS_V2 path', () { + test( + 'sign() throws due to unsigned V2 auth entry (_neededSignersForSend ADDRESS_V2 branch)', + () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + // Inject an unsigned V2 entry + final v2Entry = _makeV2Entry(accountId); + _injectAuthEntry(assembled, accountId, v2Entry); + + // sign(force: true) skips isReadCall(); _neededSignersForSend sees the + // unsigned V2 entry (void top-level signature), adds accountId to needed, + // and sign() throws "requires signatures from multiple signers". + expect( + () => assembled.sign(force: true), + throwsA(isA()), + reason: + '_neededSignersForSend must report unsigned V2 entry and cause sign() to throw', + ); + } finally { + await server.close(); + } + }); + + test( + 'sign() succeeds when V2 entry is signed (all signers satisfied)', + () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + // Inject a signed V2 entry — top-level signature is non-void + final v2Entry = _makeV2Entry(accountId); + v2Entry.sign(kp, Network.TESTNET); + _injectAuthEntry(assembled, accountId, v2Entry); + + // sign(force: true) skips isReadCall(); _neededSignersForSend returns + // empty (V2 entry already signed) so sign() proceeds to sign the tx. + expect( + () => assembled.sign(force: true), + returnsNormally, + reason: 'sign() must succeed when all V2 entries are already signed', + ); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: signAuthEntries without validUntilLedgerSeq — covers line 1161 + // When validUntilLedgerSeq is null, signAuthEntries fetches the latest ledger + // and assigns expirationLedger from it (line 1161). + // ------------------------------------------------------------------------- + group('signAuthEntries — auto-expiration from getLatestLedger', () { + test( + 'signAuthEntries without validUntilLedgerSeq uses getLatestLedger (line 1161)', + () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + final v2Entry = _makeV2Entry(accountId); + _injectAuthEntry(assembled, accountId, v2Entry); + + // No validUntilLedgerSeq — must fetch getLatestLedger and compute + // expiration from it (line 1161). + await assembled.signAuthEntries( + signerKeyPair: kp, + // validUntilLedgerSeq intentionally omitted + ); + + // Verify the entry was signed (expiration was set from server response) + final signedEntry = + (assembled.tx!.operations.first as InvokeHostFunctionOperation) + .auth + .first; + expect( + signedEntry.credentials.addressV2Credentials! + .signatureExpirationLedger, + greaterThan(0), + reason: 'expiration must have been set from getLatestLedger response', + ); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: signAuthEntries non-InvokeHostFunction tx — covers line 1171 + // When tx contains a non-InvokeHostFunction operation, signAuthEntries throws + // "no invoke host function operations found". The authorizeEntryDelegate path + // is used to bypass the pre-check (which inspects tx.auth from the first op). + // ------------------------------------------------------------------------- + group('signAuthEntries — non-InvokeHostFunction tx throws (line 1171)', () { + test( + 'signAuthEntries throws for non-InvokeHostFunction tx via authorizeEntryDelegate', + () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + // Replace tx with a BumpSequence (not InvokeHostFunction). + // authorizeEntryDelegate bypasses the needsNonInvokerSigningBy() + // pre-check, so the code reaches the op-type check at line 1169-1171. + final account = Account(accountId, BigInt.from(100)); + assembled.tx = TransactionBuilder(account) + .addOperation(BumpSequenceOperation(BigInt.from(110))) + .build(); + + expect( + () async => await assembled.signAuthEntries( + signerKeyPair: kp, + validUntilLedgerSeq: _kExpiration + 100, + authorizeEntryDelegate: (entry, network) async => entry, + ), + throwsA(isA()), + reason: + 'signAuthEntries must throw when tx has no InvokeHostFunction operation', + ); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: _appendSignatureToDelegate existing-vec path — line 1185 + // When a delegate node is signed twice, the second call enters the + // `existing.vec != null` branch in _appendSignatureToDelegate. + // ------------------------------------------------------------------------- + group('_appendSignatureToDelegate — sign delegate node twice (line 1185)', () { + test('signing the same delegate node twice appends both signatures', () { + final topKp = KeyPair.fromSecretSeed(_kSeed); + final delegateKp1 = KeyPair.random(); + final delegateKp2 = KeyPair.random(); + final delegateId = delegateKp1.accountId; + + final inner = SorobanAddressCredentials( + Address.forAccountId(topKp.accountId), + _kNonce, + _kExpiration, + XdrSCVal.forVoid()); + + final entry = SorobanAuthorizationEntry.withDelegates( + SorobanAuthorizationEntry( + SorobanCredentials.forAddressV2(inner), + _buildHelloInvocation(), + ), + [SorobanDelegateDescriptor(delegateId)], + _kExpiration, + ); + + // First sign — delegate node goes from void → 1-element vec + entry.sign(delegateKp1, Network.TESTNET, forAddress: delegateId); + // Second sign (different key) — hits existing.vec != null branch (line 1185) + entry.sign(delegateKp2, Network.TESTNET, forAddress: delegateId); + + final withDelegates = entry.credentials.addressWithDelegatesCredentials!; + final delegateNode = withDelegates.delegates[0]; + + expect( + delegateNode.signature.vec!.length, + equals(2), + reason: 'delegate node must have 2 signatures after being signed twice', + ); + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: Deep XDR-constructed WITH_DELEGATES tree — depth limit tests + // + // withDelegates() enforces the depth limit during construction. These tests + // bypass the construction limit by building the XdrSorobanDelegateSignature + // tree directly and wrapping it in a SorobanAuthorizationEntry via fromXdr. + // This drives: + // - soroban_auth.dart line 1159 (_walkDelegate depth > 128) + // - soroban_client.dart line 923 (_collectUnsignedDelegates depth > 128) + // - soroban_client.dart line 1262 (_delegateListContains depth > 128) + // ------------------------------------------------------------------------- + group('Deep XDR delegate tree — depth limit guards', () { + /// Builds a 130-deep XdrSorobanDelegateSignature chain. + /// Returns the root node and the strkey of the leaf address. + ({XdrSorobanDelegateSignature root, String leafStrKey}) + _buildDeepXdrDelegateTree(KeyPair kp) { + // Build from the leaf upward: leaf is level 130 (depth 130). + XdrSorobanDelegateSignature current = XdrSorobanDelegateSignature( + XdrSCAddress.forAccountId(kp.accountId), XdrSCVal.forVoid(), []); + final leafStrKey = kp.accountId; + for (int i = 0; i < 130; i++) { + current = XdrSorobanDelegateSignature( + XdrSCAddress.forContractId(_kContractId), + XdrSCVal.forVoid(), + [current]); + } + return (root: current, leafStrKey: leafStrKey); + } + + /// Builds a SorobanAuthorizationEntry with a 130-deep delegate tree + /// directly via XDR constructors (bypassing withDelegates depth check). + SorobanAuthorizationEntry _buildDeepEntry(KeyPair topKp) { + final (:root, leafStrKey: _) = _buildDeepXdrDelegateTree(topKp); + + final addressCreds = XdrSorobanAddressCredentials( + XdrSCAddress.forAccountId(topKp.accountId), + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + final withDelegates = + XdrSorobanAddressCredentialsWithDelegates(addressCreds, [root]); + final xdrCreds = XdrSorobanCredentials( + XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES); + xdrCreds.addressWithDelegates = withDelegates; + + final contractAddr = XdrSCAddress.forContractId(_kContractId); + final fn = XdrSorobanAuthorizedFunction( + XdrSorobanAuthorizedFunctionType + .SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN, + ); + fn.contractFn = XdrInvokeContractArgs( + contractAddr, 'hello', [XdrSCVal.forU64(BigInt.from(1234))]); + final invocation = XdrSorobanAuthorizedInvocation(fn, []); + final xdrEntry = XdrSorobanAuthorizationEntry(xdrCreds, invocation); + + return SorobanAuthorizationEntry.fromXdr(xdrEntry); + } + + test('sign(forAddress:) on 130-deep tree throws depth limit (_walkDelegate line 1159)', + () { + final kp = KeyPair.fromSecretSeed(_kSeed); + final (:root, :leafStrKey) = _buildDeepXdrDelegateTree(kp); + final entry = _buildDeepEntry(kp); + + // sign(forAddress:) triggers _routeSignatureToAddress(depth=0) which + // calls _walkDelegate(depth=1); the tree is 130 levels deep so the + // recursion reaches depth 129 before throwing at line 1159. + expect( + () => entry.sign(kp, Network.TESTNET, forAddress: leafStrKey), + throwsA(isA()), + reason: '_walkDelegate must throw when delegate tree depth exceeds 128', + ); + }); + + test( + 'needsNonInvokerSigningBy on 130-deep tree throws depth limit (_collectUnsignedDelegates line 923)', + () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + final deepEntry = _buildDeepEntry(kp); + _injectAuthEntry(assembled, accountId, deepEntry); + + // needsNonInvokerSigningBy calls _collectUnsignedDelegates recursively; + // the 130-deep tree causes it to exceed the limit at line 923. + expect( + () => assembled.needsNonInvokerSigningBy(), + throwsA(isA()), + reason: + '_collectUnsignedDelegates must throw when delegate depth exceeds 128', + ); + } finally { + await server.close(); + } + }); + + test( + 'signAuthEntries with authorizeEntryDelegate enforces the delegate-tree depth guard', + () async { + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + final deepEntry = _buildDeepEntry(kp); + _injectAuthEntry(assembled, accountId, deepEntry); + + // Even on the callback path, signAuthEntries first determines whether the + // signer is responsible for the entry by walking its delegate tree, so the + // depth guard (128) fires on a 130-deep tree before the callback runs. The + // guard is fail-closed on every signing path, the delegate callback included. + await expectLater( + assembled.signAuthEntries( + signerKeyPair: kp, + validUntilLedgerSeq: _kExpiration + 100, + authorizeEntryDelegate: (entry, network) async => entry, + ), + throwsA(isA()), + ); + } finally { + await server.close(); + } + }); + + test( + 'sign(force:true) on 130-deep fully-signed tree throws depth limit (_allDelegatesSigned line 1032)', + () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + // Build a 130-deep tree where all nodes have a non-void signature so + // that _allDelegatesSigned recurses all the way to depth 130 before + // encountering an unsigned node. The depth check at line 1032 fires + // when depth > 128. + // + // We set a dummy non-void signature directly on the XDR node to avoid + // calling sign() on the tree (which would throw in _walkDelegate first). + final dummySig = XdrSCVal.forBool(true); // non-void, non-vec + XdrSorobanDelegateSignature current = + XdrSorobanDelegateSignature( + XdrSCAddress.forAccountId(kp.accountId), dummySig, []); + for (int i = 0; i < 130; i++) { + current = XdrSorobanDelegateSignature( + XdrSCAddress.forContractId(_kContractId), dummySig, [current]); + } + + final addressCreds = XdrSorobanAddressCredentials( + XdrSCAddress.forAccountId(accountId), + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + final withDelegates = + XdrSorobanAddressCredentialsWithDelegates(addressCreds, [current]); + final xdrCreds = XdrSorobanCredentials( + XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES); + xdrCreds.addressWithDelegates = withDelegates; + + final contractAddr = XdrSCAddress.forContractId(_kContractId); + final fn = XdrSorobanAuthorizedFunction( + XdrSorobanAuthorizedFunctionType + .SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN, + ); + fn.contractFn = XdrInvokeContractArgs( + contractAddr, 'hello', [XdrSCVal.forU64(BigInt.from(1234))]); + final invocation = XdrSorobanAuthorizedInvocation(fn, []); + final xdrEntry = XdrSorobanAuthorizationEntry(xdrCreds, invocation); + final deepSignedEntry = SorobanAuthorizationEntry.fromXdr(xdrEntry); + + _injectAuthEntry(assembled, accountId, deepSignedEntry); + + // sign(force:true) calls _neededSignersForSend() → _allDelegatesSigned() + // which recurses 130 levels deep and throws at depth > 128 (line 1032). + expect( + () => assembled.sign(force: true), + throwsA(isA()), + reason: + '_allDelegatesSigned must throw when fully-signed delegate tree depth exceeds 128', + ); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: _xdrAddressToStrKey returns empty for non-account/contract address + // soroban_client.dart lines 948-950. + // A delegate node whose XdrSCAddress is neither account nor contract type + // is silently skipped (empty strkey) by _collectUnsignedDelegates. + // This is achieved by building a WITH_DELEGATES entry via XDR (bypassing + // withDelegates which only accepts G/C strkeys), using a CONTRACT address + // for the delegate node but then patching it to an unknown discriminant. + // ------------------------------------------------------------------------- + group('_xdrAddressToStrKey — contract address (non-account) returns contractId strkey', + () { + test( + 'needsNonInvokerSigningBy collects contract-address delegate strkey correctly', + () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + // Build a WITH_DELEGATES entry with a contract-address delegate node + // via XDR directly. The contract address triggers the second branch in + // _xdrAddressToStrKey (line 948-950) and returns a non-empty strkey. + final delegateNode = XdrSorobanDelegateSignature( + XdrSCAddress.forContractId(_kContractId), + XdrSCVal.forVoid(), + []); + + final addressCreds = XdrSorobanAddressCredentials( + XdrSCAddress.forAccountId(accountId), + XdrInt64(_kNonce), + XdrUint32(_kExpiration), + XdrSCVal.forVoid(), + ); + final withDelegates = + XdrSorobanAddressCredentialsWithDelegates(addressCreds, [delegateNode]); + final xdrCreds = XdrSorobanCredentials( + XdrSorobanCredentialsType + .SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES); + xdrCreds.addressWithDelegates = withDelegates; + + final contractAddr = XdrSCAddress.forContractId(_kContractId); + final fn = XdrSorobanAuthorizedFunction( + XdrSorobanAuthorizedFunctionType + .SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN, + ); + fn.contractFn = XdrInvokeContractArgs( + contractAddr, 'hello', [XdrSCVal.forU64(BigInt.from(1234))]); + final invocation = XdrSorobanAuthorizedInvocation(fn, []); + final xdrEntry = XdrSorobanAuthorizationEntry(xdrCreds, invocation); + final entry = SorobanAuthorizationEntry.fromXdr(xdrEntry); + + _injectAuthEntry(assembled, accountId, entry); + + // needsNonInvokerSigningBy collects unsigned delegate strkeys. The + // contract-address delegate must be collected as its contract strkey, + // proving the contract-address branch returns the right value (not just + // that the list is non-empty from the top-level account). + final needed = + assembled.needsNonInvokerSigningBy(includeAlreadySigned: true); + expect(needed, contains(_kContractId), + reason: + 'contract-address delegate must be collected by its contract strkey'); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: sign() with tx == null — covers _neededSignersForSend line 969 + // + // _neededSignersForSend() (line 967) is called from sign() at line 809. + // Line 969 (return [] when tx == null) and line 973 (return [] when + // ops.isEmpty) are defensive guards reached only when the caller has + // already set assembled.tx to a value different from what sign() checked. + // + // For line 969: sign() throws at line 788 when tx == null, so + // _neededSignersForSend is only ever reached with tx != null from sign(). + // The null guard at 969 is a pure defensive check. The test below + // documents the sign() contract (throws) and confirms line 788 fires. + // + // For line 973: set tx to a transaction with no operations by clearing the + // operations list between AssembledTransaction.build() and sign(). The + // Transaction.operations getter returns the backing list directly, so + // list.clear() produces a tx that is non-null but has ops.isEmpty == true. + // _neededSignersForSend then hits line 973 and returns []; sign() proceeds. + // ------------------------------------------------------------------------- + group('sign() — tx==null and empty-ops guards (lines 969 + 973)', () { + test('sign_throwsWhenTxIsNull', () async { + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + // Set tx to null — sign() throws at line 788 (before calling + // _neededSignersForSend). This documents the contract that a null tx + // produces an Exception and prevents an unsigned send. + assembled.tx = null; + + expect( + () => assembled.sign(force: true), + throwsA(isA()), + reason: 'sign() must throw when tx is null', + ); + } finally { + await server.close(); + } + }); + + test('sign_passesNeededSignersCheckWhenTxHasNoOperations_line973', () async { + // Exercises _neededSignersForSend line 973: ops.isEmpty → return []. + // + // Transaction.operations returns the backing mutable list. Clearing it + // produces a non-null tx with no operations. _neededSignersForSend + // enters the ops.isEmpty branch (line 973) and returns [] — the + // "needed signers" check passes. The subsequent clone step inside + // sign() fails because Transaction(account, fee, seq, [], ...) calls + // checkArgument(ops.length > 0) and throws. The test confirms that + // the exception does NOT come from the "multiple signers required" + // path (lines 810-813) — i.e., line 973 returned [] successfully. + + final kp = KeyPair.fromSecretSeed(_kSeed); + final accountId = kp.accountId; + final (server, _) = await _startMockRpcServer( + accountId: accountId, seqNum: BigInt.from(100)); + + try { + final rpcUrl = 'http://127.0.0.1:${server.port}'; + final assembled = + await _buildAssembledTx(rpcUrl, accountId, kp, simulate: false); + + // Build a valid 1-op tx, inject it, then clear the operations list. + // After clear(): tx != null (line 788 guard passes), ops.isEmpty == true + // (line 973 returns []), sign() proceeds past the signers check and + // fails at the XDR clone step with "At least one operation required". + final account = Account(accountId, BigInt.from(100)); + final tx = TransactionBuilder(account) + .addOperation(BumpSequenceOperation(BigInt.from(110))) + .build(); + assembled.tx = tx; + assembled.tx!.operations.clear(); + + // The exception comes from the clone step, not from "multiple signers" + // (which would say "requires signatures from multiple signers"). + final threw = []; + try { + assembled.sign(force: true); + } on Exception catch (e) { + threw.add(e); + } + expect(threw.length, equals(1), + reason: 'sign() must throw when operations list is empty'); + expect( + threw.first.toString(), + isNot(contains('requires signatures from multiple signers')), + reason: + '_neededSignersForSend returned [] (line 973 executed), so the ' + 'exception must not come from the multi-signer guard', + ); + expect( + threw.first.toString(), + contains('operation'), + reason: 'exception must come from the clone step (empty operations)', + ); + } finally { + await server.close(); + } + }); + }); + + // ------------------------------------------------------------------------- + // GROUP: WebAuthForContracts — clientDomainSigningCallback with expiration + // Covers webauth_for_contracts.dart line 782 (signatureExpirationLedger + // stamping in the clientDomainSigningCallback branch). + // ------------------------------------------------------------------------- + group('WebAuthForContracts — clientDomainSigningCallback with expiration', + () { + test( + 'signAuthorizationEntries stamps expiration before calling clientDomainSigningCallback', + () async { + final serverKeyPair = KeyPair.random(); + final serverAccountId = serverKeyPair.accountId; + final clientKeyPair = KeyPair.random(); + final clientAccountId = clientKeyPair.accountId; + final clientDomainAccountId = KeyPair.random().accountId; + + final contractAddr = Address.forContractId(_kContractId); + final fn = SorobanAuthorizedFunction.forContractFunction( + contractAddr, 'web_auth_verify', [ + XdrSCVal.forMap([ + XdrSCMapEntry(XdrSCVal.forSymbol('account'), XdrSCVal.forString(clientAccountId)), + XdrSCMapEntry(XdrSCVal.forSymbol('home_domain'), XdrSCVal.forString('test.stellar.org')), + XdrSCMapEntry(XdrSCVal.forSymbol('nonce'), XdrSCVal.forString('42')), + XdrSCMapEntry(XdrSCVal.forSymbol('web_auth_domain'), XdrSCVal.forString('test.stellar.org')), + XdrSCMapEntry(XdrSCVal.forSymbol('web_auth_domain_account'), XdrSCVal.forString(serverAccountId)), + ]), + ]); + final invocation = SorobanAuthorizedInvocation(fn); + + // Build the client domain entry + final clientDomainCreds = SorobanAddressCredentials( + Address.forAccountId(clientDomainAccountId), + BigInt.from(1), 0, XdrSCVal.forVoid()); + final clientDomainEntry = SorobanAuthorizationEntry( + SorobanCredentials(addressCredentials: clientDomainCreds), invocation); + + // Build a regular client entry + final clientCreds = SorobanAddressCredentials( + Address.forAccountId(clientAccountId), + BigInt.from(2), 0, XdrSCVal.forVoid()); + final clientEntry = SorobanAuthorizationEntry( + SorobanCredentials(addressCredentials: clientCreds), invocation); + + final webAuth = WebAuthForContracts( + 'https://test.stellar.org/auth', + _kContractId, + serverAccountId, + 'test.stellar.org', + Network.TESTNET, + ); + + const stampedExpiration = 9876; + int callbackInvocations = 0; + int? capturedExpiration; + + await webAuth.signAuthorizationEntries( + [clientDomainEntry, clientEntry], + clientAccountId, + [clientKeyPair], + stampedExpiration, + null, // no clientDomainKeyPair + clientDomainAccountId, + (entry) async { // clientDomainSigningCallback triggers line 782 + callbackInvocations++; + capturedExpiration = + entry.credentials.innerAddressCredentials?.signatureExpirationLedger; + return entry; + }, + ); + + expect(callbackInvocations, equals(1), + reason: 'callback must be invoked once for the client domain entry'); + expect(capturedExpiration, equals(stampedExpiration), + reason: + 'expiration must be stamped before the callback receives the entry (line 782)'); + }); + }); +} + +// --------------------------------------------------------------------------- +// Helpers used only in this file +// --------------------------------------------------------------------------- + +/// Builds a minimal SOURCE_ACCOUNT XdrSorobanAuthorizationEntry. +XdrSorobanAuthorizationEntry _buildOzSourceEntry() { + final contractAddr = XdrSCAddress.forContractId(_kContractId); + final fn = XdrSorobanAuthorizedFunction( + XdrSorobanAuthorizedFunctionType + .SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN, + ); + fn.contractFn = + XdrInvokeContractArgs(contractAddr, 'hello', [XdrSCVal.forU64(BigInt.from(1234))]); + final invocation = + XdrSorobanAuthorizedInvocation(fn, []); + return XdrSorobanAuthorizationEntry( + XdrSorobanCredentials.forSourceAccount(), invocation); +} diff --git a/test/unit/xdr/generated/xdr_ledger_entries_gen_test.dart b/test/unit/xdr/generated/xdr_ledger_entries_gen_test.dart index fdcf4ce4..2fcf6d06 100644 --- a/test/unit/xdr/generated/xdr_ledger_entries_gen_test.dart +++ b/test/unit/xdr/generated/xdr_ledger_entries_gen_test.dart @@ -1782,6 +1782,7 @@ void main() { XdrEnvelopeType.ENVELOPE_TYPE_POOL_REVOKE_OP_ID, XdrEnvelopeType.ENVELOPE_TYPE_CONTRACT_ID, XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION, + XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS, ]; for (var member in members) { XdrDataOutputStream output = XdrDataOutputStream(); @@ -2857,5 +2858,16 @@ void main() { reason: 'TxRep roundtrip failed for XdrEnvelopeType ENVELOPE_TYPE_SOROBAN_AUTHORIZATION'); }); + test('XdrEnvelopeType TxRep roundtrip ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS', () { + var original = XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS; + List lines = []; + original.toTxRep('tx', lines); + Map map = parseTxRepLines(lines); + var reconstructed = XdrEnvelopeType.fromTxRep(map, 'tx'); + expect(reconstructed.toBase64EncodedXdrString(), + equals(original.toBase64EncodedXdrString()), + reason: 'TxRep roundtrip failed for XdrEnvelopeType ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS'); + }); + }); } diff --git a/test/unit/xdr/generated/xdr_transaction_gen_test.dart b/test/unit/xdr/generated/xdr_transaction_gen_test.dart index 6c0aab73..9b4a3206 100644 --- a/test/unit/xdr/generated/xdr_transaction_gen_test.dart +++ b/test/unit/xdr/generated/xdr_transaction_gen_test.dart @@ -857,10 +857,34 @@ void main() { expect(base64Decoded.signatureExpirationLedger.uint32, equals(original.signatureExpirationLedger.uint32)); }); + test('XdrSorobanDelegateSignature struct roundtrip', () { + var original = XdrSorobanDelegateSignature((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrSCVal(XdrSCValType.SCV_VOID), [XdrSorobanDelegateSignature((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrSCVal(XdrSCValType.SCV_VOID), [])]); + XdrDataOutputStream output = XdrDataOutputStream(); + XdrSorobanDelegateSignature.encode(output, original); + Uint8List encoded = Uint8List.fromList(output.bytes); + XdrDataInputStream input = XdrDataInputStream(encoded); + XdrSorobanDelegateSignature.decode(input); + XdrSorobanDelegateSignature.fromBase64EncodedXdrString( + original.toBase64EncodedXdrString()); + }); + + test('XdrSorobanAddressCredentialsWithDelegates struct roundtrip', () { + var original = XdrSorobanAddressCredentialsWithDelegates(XdrSorobanAddressCredentials((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrInt64(BigInt.from(654321)), XdrUint32(42), XdrSCVal(XdrSCValType.SCV_VOID)), [XdrSorobanDelegateSignature((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrSCVal(XdrSCValType.SCV_VOID), [])]); + XdrDataOutputStream output = XdrDataOutputStream(); + XdrSorobanAddressCredentialsWithDelegates.encode(output, original); + Uint8List encoded = Uint8List.fromList(output.bytes); + XdrDataInputStream input = XdrDataInputStream(encoded); + XdrSorobanAddressCredentialsWithDelegates.decode(input); + XdrSorobanAddressCredentialsWithDelegates.fromBase64EncodedXdrString( + original.toBase64EncodedXdrString()); + }); + test('XdrSorobanCredentialsType enum roundtrip', () { final members = [ XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT, XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS, + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, ]; for (var member in members) { XdrDataOutputStream output = XdrDataOutputStream(); @@ -908,6 +932,42 @@ void main() { expect(base64Decoded.address, isNotNull); }); + test('XdrSorobanCredentials XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2 arm roundtrip', () { + var original = XdrSorobanCredentialsBase(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2); + original.addressV2 = (XdrSorobanAddressCredentials((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrInt64(BigInt.from(654321)), XdrUint32(42), XdrSCVal(XdrSCValType.SCV_VOID))); + XdrDataOutputStream output = XdrDataOutputStream(); + XdrSorobanCredentialsBase.encode(output, original); + Uint8List encoded = Uint8List.fromList(output.bytes); + XdrDataInputStream input = XdrDataInputStream(encoded); + var decoded = XdrSorobanCredentialsBase.decode(input); + expect(decoded.discriminant.value, equals(original.discriminant.value)); + // Verify arm field is not null + expect(decoded.addressV2, isNotNull); + var base64Decoded = XdrSorobanCredentialsBase.fromBase64EncodedXdrString( + original.toBase64EncodedXdrString()); + expect(base64Decoded.discriminant.value, equals(original.discriminant.value)); + // Verify arm field is not null + expect(base64Decoded.addressV2, isNotNull); + }); + + test('XdrSorobanCredentials XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES arm roundtrip', () { + var original = XdrSorobanCredentialsBase(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES); + original.addressWithDelegates = (XdrSorobanAddressCredentialsWithDelegates(XdrSorobanAddressCredentials((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrInt64(BigInt.from(654321)), XdrUint32(42), XdrSCVal(XdrSCValType.SCV_VOID)), [XdrSorobanDelegateSignature((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrSCVal(XdrSCValType.SCV_VOID), [])])); + XdrDataOutputStream output = XdrDataOutputStream(); + XdrSorobanCredentialsBase.encode(output, original); + Uint8List encoded = Uint8List.fromList(output.bytes); + XdrDataInputStream input = XdrDataInputStream(encoded); + var decoded = XdrSorobanCredentialsBase.decode(input); + expect(decoded.discriminant.value, equals(original.discriminant.value)); + // Verify arm field is not null + expect(decoded.addressWithDelegates, isNotNull); + var base64Decoded = XdrSorobanCredentialsBase.fromBase64EncodedXdrString( + original.toBase64EncodedXdrString()); + expect(base64Decoded.discriminant.value, equals(original.discriminant.value)); + // Verify arm field is not null + expect(base64Decoded.addressWithDelegates, isNotNull); + }); + test('XdrSorobanAuthorizationEntry struct roundtrip', () { var original = XdrSorobanAuthorizationEntry(XdrSorobanCredentials(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT), XdrSorobanAuthorizedInvocation((XdrSorobanAuthorizedFunction(XdrSorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)..contractFn = XdrInvokeContractArgs((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), 'test_fn', [])), [XdrSorobanAuthorizedInvocation((XdrSorobanAuthorizedFunction(XdrSorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)..contractFn = XdrInvokeContractArgs((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), 'test_fn', [])), [])])); XdrDataOutputStream output = XdrDataOutputStream(); @@ -1512,6 +1572,23 @@ void main() { expect(base64Decoded.signatureExpirationLedger.uint32, equals(original.signatureExpirationLedger.uint32)); }); + test('XdrHashIDPreimageSorobanAuthorizationWithAddress struct roundtrip', () { + var original = XdrHashIDPreimageSorobanAuthorizationWithAddress(XdrHash(Uint8List.fromList(List.filled(32, 0xAB))), XdrInt64(BigInt.from(654321)), XdrUint32(42), (XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrSorobanAuthorizedInvocation((XdrSorobanAuthorizedFunction(XdrSorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)..contractFn = XdrInvokeContractArgs((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), 'test_fn', [])), [XdrSorobanAuthorizedInvocation((XdrSorobanAuthorizedFunction(XdrSorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)..contractFn = XdrInvokeContractArgs((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), 'test_fn', [])), [])])); + XdrDataOutputStream output = XdrDataOutputStream(); + XdrHashIDPreimageSorobanAuthorizationWithAddress.encode(output, original); + Uint8List encoded = Uint8List.fromList(output.bytes); + XdrDataInputStream input = XdrDataInputStream(encoded); + var decoded = XdrHashIDPreimageSorobanAuthorizationWithAddress.decode(input); + expect(decoded.networkID.hash, equals(original.networkID.hash)); + expect(decoded.nonce.int64, equals(original.nonce.int64)); + expect(decoded.signatureExpirationLedger.uint32, equals(original.signatureExpirationLedger.uint32)); + var base64Decoded = XdrHashIDPreimageSorobanAuthorizationWithAddress.fromBase64EncodedXdrString( + original.toBase64EncodedXdrString()); + expect(base64Decoded.networkID.hash, equals(original.networkID.hash)); + expect(base64Decoded.nonce.int64, equals(original.nonce.int64)); + expect(base64Decoded.signatureExpirationLedger.uint32, equals(original.signatureExpirationLedger.uint32)); + }); + test('XdrHashIDPreimage XdrEnvelopeType.ENVELOPE_TYPE_OP_ID arm roundtrip', () { var original = XdrHashIDPreimage(XdrEnvelopeType.ENVELOPE_TYPE_OP_ID); original.operationID = (XdrHashIDPreimageOperationID(XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB))))), XdrSequenceNumber(BigInt.from(1)), XdrUint32(0))); @@ -1584,6 +1661,24 @@ void main() { expect(base64Decoded.sorobanAuthorization, isNotNull); }); + test('XdrHashIDPreimage XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS arm roundtrip', () { + var original = XdrHashIDPreimage(XdrEnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS); + original.sorobanAuthorizationWithAddress = (XdrHashIDPreimageSorobanAuthorizationWithAddress(XdrHash(Uint8List.fromList(List.filled(32, 0xAB))), XdrInt64(BigInt.from(654321)), XdrUint32(42), (XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrSorobanAuthorizedInvocation((XdrSorobanAuthorizedFunction(XdrSorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)..contractFn = XdrInvokeContractArgs((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), 'test_fn', [])), []))); + XdrDataOutputStream output = XdrDataOutputStream(); + XdrHashIDPreimage.encode(output, original); + Uint8List encoded = Uint8List.fromList(output.bytes); + XdrDataInputStream input = XdrDataInputStream(encoded); + var decoded = XdrHashIDPreimage.decode(input); + expect(decoded.discriminant.value, equals(original.discriminant.value)); + // Verify arm field is not null + expect(decoded.sorobanAuthorizationWithAddress, isNotNull); + var base64Decoded = XdrHashIDPreimage.fromBase64EncodedXdrString( + original.toBase64EncodedXdrString()); + expect(base64Decoded.discriminant.value, equals(original.discriminant.value)); + // Verify arm field is not null + expect(base64Decoded.sorobanAuthorizationWithAddress, isNotNull); + }); + test('XdrMemoType enum roundtrip', () { final members = [ XdrMemoType.MEMO_NONE, @@ -5368,6 +5463,28 @@ void main() { reason: 'TxRep roundtrip failed for XdrSorobanAddressCredentials'); }); + test('XdrSorobanDelegateSignature TxRep roundtrip', () { + var original = XdrSorobanDelegateSignature((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrSCVal(XdrSCValType.SCV_VOID), [XdrSorobanDelegateSignature((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrSCVal(XdrSCValType.SCV_VOID), [])]); + List lines = []; + original.toTxRep('tx', lines); + Map map = parseTxRepLines(lines); + var reconstructed = XdrSorobanDelegateSignature.fromTxRep(map, 'tx'); + expect(reconstructed.toBase64EncodedXdrString(), + equals(original.toBase64EncodedXdrString()), + reason: 'TxRep roundtrip failed for XdrSorobanDelegateSignature'); + }); + + test('XdrSorobanAddressCredentialsWithDelegates TxRep roundtrip', () { + var original = XdrSorobanAddressCredentialsWithDelegates(XdrSorobanAddressCredentials((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrInt64(BigInt.from(654321)), XdrUint32(42), XdrSCVal(XdrSCValType.SCV_VOID)), [XdrSorobanDelegateSignature((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrSCVal(XdrSCValType.SCV_VOID), [])]); + List lines = []; + original.toTxRep('tx', lines); + Map map = parseTxRepLines(lines); + var reconstructed = XdrSorobanAddressCredentialsWithDelegates.fromTxRep(map, 'tx'); + expect(reconstructed.toBase64EncodedXdrString(), + equals(original.toBase64EncodedXdrString()), + reason: 'TxRep roundtrip failed for XdrSorobanAddressCredentialsWithDelegates'); + }); + test('XdrSorobanCredentialsType TxRep roundtrip SOROBAN_CREDENTIALS_SOURCE_ACCOUNT', () { var original = XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT; List lines = []; @@ -5390,6 +5507,28 @@ void main() { reason: 'TxRep roundtrip failed for XdrSorobanCredentialsType SOROBAN_CREDENTIALS_ADDRESS'); }); + test('XdrSorobanCredentialsType TxRep roundtrip SOROBAN_CREDENTIALS_ADDRESS_V2', () { + var original = XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2; + List lines = []; + original.toTxRep('tx', lines); + Map map = parseTxRepLines(lines); + var reconstructed = XdrSorobanCredentialsType.fromTxRep(map, 'tx'); + expect(reconstructed.toBase64EncodedXdrString(), + equals(original.toBase64EncodedXdrString()), + reason: 'TxRep roundtrip failed for XdrSorobanCredentialsType SOROBAN_CREDENTIALS_ADDRESS_V2'); + }); + + test('XdrSorobanCredentialsType TxRep roundtrip SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES', () { + var original = XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES; + List lines = []; + original.toTxRep('tx', lines); + Map map = parseTxRepLines(lines); + var reconstructed = XdrSorobanCredentialsType.fromTxRep(map, 'tx'); + expect(reconstructed.toBase64EncodedXdrString(), + equals(original.toBase64EncodedXdrString()), + reason: 'TxRep roundtrip failed for XdrSorobanCredentialsType SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES'); + }); + test('XdrSorobanCredentials TxRep roundtrip XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT', () { var original = XdrSorobanCredentialsBase(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT); List lines = []; @@ -5412,6 +5551,28 @@ void main() { reason: 'TxRep roundtrip failed for XdrSorobanCredentials XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS'); }); + test('XdrSorobanCredentials TxRep roundtrip XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2', () { + var original = (XdrSorobanCredentialsBase(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2)..addressV2 = (XdrSorobanAddressCredentials((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrInt64(BigInt.from(654321)), XdrUint32(42), XdrSCVal(XdrSCValType.SCV_VOID)))); + List lines = []; + original.toTxRep('tx', lines); + Map map = parseTxRepLines(lines); + var reconstructed = XdrSorobanCredentials.fromTxRep(map, 'tx'); + expect(reconstructed.toBase64EncodedXdrString(), + equals(original.toBase64EncodedXdrString()), + reason: 'TxRep roundtrip failed for XdrSorobanCredentials XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2'); + }); + + test('XdrSorobanCredentials TxRep roundtrip XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES', () { + var original = (XdrSorobanCredentialsBase(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES)..addressWithDelegates = (XdrSorobanAddressCredentialsWithDelegates(XdrSorobanAddressCredentials((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrInt64(BigInt.from(654321)), XdrUint32(42), XdrSCVal(XdrSCValType.SCV_VOID)), [XdrSorobanDelegateSignature((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), XdrSCVal(XdrSCValType.SCV_VOID), [])]))); + List lines = []; + original.toTxRep('tx', lines); + Map map = parseTxRepLines(lines); + var reconstructed = XdrSorobanCredentials.fromTxRep(map, 'tx'); + expect(reconstructed.toBase64EncodedXdrString(), + equals(original.toBase64EncodedXdrString()), + reason: 'TxRep roundtrip failed for XdrSorobanCredentials XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES'); + }); + test('XdrSorobanAuthorizationEntry TxRep roundtrip', () { var original = XdrSorobanAuthorizationEntry(XdrSorobanCredentials(XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT), XdrSorobanAuthorizedInvocation((XdrSorobanAuthorizedFunction(XdrSorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)..contractFn = XdrInvokeContractArgs((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), 'test_fn', [])), [XdrSorobanAuthorizedInvocation((XdrSorobanAuthorizedFunction(XdrSorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)..contractFn = XdrInvokeContractArgs((XdrSCAddress(XdrSCAddressType.SC_ADDRESS_TYPE_ACCOUNT)..accountId = XdrAccountID((XdrPublicKey(XdrPublicKeyType.PUBLIC_KEY_TYPE_ED25519)..ed25519 = XdrUint256(Uint8List.fromList(List.filled(32, 0xAB)))))), 'test_fn', [])), [])])); List lines = []; diff --git a/test/wasm/soroban_modular_account_contract.wasm b/test/wasm/soroban_modular_account_contract.wasm new file mode 100644 index 00000000..89e3804e Binary files /dev/null and b/test/wasm/soroban_modular_account_contract.wasm differ diff --git a/tools/xdr-generator/generator/generator.rb b/tools/xdr-generator/generator/generator.rb index 59396e8b..88957302 100644 --- a/tools/xdr-generator/generator/generator.rb +++ b/tools/xdr-generator/generator/generator.rb @@ -271,11 +271,26 @@ def render_struct_encode(out, struct_name, fields) def render_struct_decode(out, struct_name, fields) out.puts " static #{struct_name} decode(XdrDataInputStream stream) {" - fields.each do |f| - render_decode_field(out, f[:name], f) + if RECURSIVE_DECODE_TYPES.include?(struct_name) + out.puts " stream.enterRecursiveDecode();" + out.puts " try {" + # Fields are emitted at 4-space indent by render_decode_field. + # Capture them and re-emit with 2 extra spaces for the try block. + buffer = StringIO.new + fields.each { |f| render_decode_field(buffer, f[:name], f) } + buffer.string.each_line { |line| out.puts " #{line.chomp}" } + args = fields.map { |f| f[:name] }.join(", ") + out.puts " return #{struct_name}(#{args});" + out.puts " } finally {" + out.puts " stream.exitRecursiveDecode();" + out.puts " }" + else + fields.each do |f| + render_decode_field(out, f[:name], f) + end + args = fields.map { |f| f[:name] }.join(", ") + out.puts " return #{struct_name}(#{args});" end - args = fields.map { |f| f[:name] }.join(", ") - out.puts " return #{struct_name}(#{args});" out.puts " }" end diff --git a/tools/xdr-generator/generator/name_overrides.rb b/tools/xdr-generator/generator/name_overrides.rb index bd2191b5..ab180b46 100644 --- a/tools/xdr-generator/generator/name_overrides.rb +++ b/tools/xdr-generator/generator/name_overrides.rb @@ -1,3 +1,13 @@ +require 'set' + +# Struct types whose XDR decode is recursive (the struct contains a +# variable-length array of itself). The generated decode method wraps its body +# in XdrDataInputStream.enterRecursiveDecode / exitRecursiveDecode calls so +# that an unbounded nesting depth is rejected before stack exhaustion occurs. +RECURSIVE_DECODE_TYPES = Set[ + 'XdrSorobanDelegateSignature', +].freeze + # Maps xdrgen canonical type names to existing Dart class names. # Only includes types where the default "Xdr#{canonical_name}" convention # does not produce the correct Dart class name. diff --git a/tools/xdr-generator/generator/txrep_types.rb b/tools/xdr-generator/generator/txrep_types.rb index ab2ff314..e4662545 100644 --- a/tools/xdr-generator/generator/txrep_types.rb +++ b/tools/xdr-generator/generator/txrep_types.rb @@ -121,6 +121,7 @@ 'XdrSignerKey', 'XdrSignerKeyType', 'XdrSorobanAddressCredentials', + 'XdrSorobanAddressCredentialsWithDelegates', 'XdrSorobanAuthorizationEntries', 'XdrSorobanAuthorizationEntry', 'XdrSorobanAuthorizedFunction', @@ -128,6 +129,7 @@ 'XdrSorobanAuthorizedInvocation', 'XdrSorobanCredentials', 'XdrSorobanCredentialsType', + 'XdrSorobanDelegateSignature', 'XdrSorobanResources', 'XdrSorobanResourcesExtV0', 'XdrSorobanTransactionData', diff --git a/xdr/Stellar-ledger-entries.x b/xdr/Stellar-ledger-entries.x index b9a9a168..348311c8 100644 --- a/xdr/Stellar-ledger-entries.x +++ b/xdr/Stellar-ledger-entries.x @@ -664,7 +664,8 @@ enum EnvelopeType ENVELOPE_TYPE_OP_ID = 6, ENVELOPE_TYPE_POOL_REVOKE_OP_ID = 7, ENVELOPE_TYPE_CONTRACT_ID = 8, - ENVELOPE_TYPE_SOROBAN_AUTHORIZATION = 9 + ENVELOPE_TYPE_SOROBAN_AUTHORIZATION = 9, + ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS = 10 }; enum BucketListType diff --git a/xdr/Stellar-ledger.x b/xdr/Stellar-ledger.x index a17036b8..5d751cef 100644 --- a/xdr/Stellar-ledger.x +++ b/xdr/Stellar-ledger.x @@ -425,7 +425,7 @@ struct SorobanTransactionMetaExtV1 // transactions, this will be `0` for failed transactions. int64 totalRefundableResourceFeeCharged; // Amount (in stroops) that has been charged for rent. - // This is a part of `totalNonRefundableResourceFeeCharged`. + // This is a part of `totalRefundableResourceFeeCharged`. int64 rentFeeCharged; }; diff --git a/xdr/Stellar-transaction.x b/xdr/Stellar-transaction.x index adce33de..11cacd74 100644 --- a/xdr/Stellar-transaction.x +++ b/xdr/Stellar-transaction.x @@ -569,11 +569,25 @@ struct SorobanAddressCredentials SCVal signature; }; +struct SorobanDelegateSignature +{ + SCAddress address; + SCVal signature; + SorobanDelegateSignature nestedDelegates<>; +}; + +struct SorobanAddressCredentialsWithDelegates +{ + SorobanAddressCredentials addressCredentials; + SorobanDelegateSignature delegates<>; +}; enum SorobanCredentialsType { SOROBAN_CREDENTIALS_SOURCE_ACCOUNT = 0, - SOROBAN_CREDENTIALS_ADDRESS = 1 + SOROBAN_CREDENTIALS_ADDRESS = 1, + SOROBAN_CREDENTIALS_ADDRESS_V2 = 2, + SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES = 3 }; union SorobanCredentials switch (SorobanCredentialsType type) @@ -582,6 +596,10 @@ case SOROBAN_CREDENTIALS_SOURCE_ACCOUNT: void; case SOROBAN_CREDENTIALS_ADDRESS: SorobanAddressCredentials address; +case SOROBAN_CREDENTIALS_ADDRESS_V2: + SorobanAddressCredentials addressV2; +case SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + SorobanAddressCredentialsWithDelegates addressWithDelegates; }; /* Unit of authorization data for Soroban. @@ -732,6 +750,15 @@ case ENVELOPE_TYPE_SOROBAN_AUTHORIZATION: uint32 signatureExpirationLedger; SorobanAuthorizedInvocation invocation; } sorobanAuthorization; +case ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS: + struct + { + Hash networkID; + int64 nonce; + uint32 signatureExpirationLedger; + SCAddress address; + SorobanAuthorizedInvocation invocation; + } sorobanAuthorizationWithAddress; }; enum MemoType