From 694ef880ec816a685d2e2dfd4f27edd1cc315f15 Mon Sep 17 00:00:00 2001 From: Christian Rogobete Date: Sat, 13 Jun 2026 16:16:06 +0200 Subject: [PATCH 1/6] Add Protocol 27 (CAP-71) support The generated XDR layer was already at the Protocol 27 commit (55a00d9); this adds the hand-written layer (#93): - Soroban credential wrappers now model all four arms (source-account, legacy ADDRESS, ADDRESS_V2, ADDRESS_WITH_DELEGATES); SorobanCredentials fromXdr/toXdr round-trip every arm (fixing the prior silent data loss) with fail-fast on unknown arms; new SorobanAddressCredentialsWithDelegates / SorobanDelegateSignature / SorobanDelegateDescriptor classes and forAddressCredentialsV2 / forAddressWithDelegates factories - Arm-aware signing through one preimage builder: SorobanAuthorizationEntry buildPreimage, sign() with an optional forAddress that routes into matching top-level or delegate nodes, and the withDelegates tree builder (XDR-byte sort, duplicate rejection) - Opt-in authV2 flag on SimulateTransactionRequest and MethodOptions (key omitted when false; old RPCs silently ignore it and return legacy entries) - AssembledTransaction and SEP-45 web auth handle all credential arms; delegates-only entries pass the send precheck - Bounded recursive XDR decode (depth 128) to prevent stack exhaustion from hostile delegate trees, with fail-closed caps on the tree-walk helpers - Tests incl. byte-exact golden vectors and a testnet integration test that activates on Protocol 27; documentation and agent-skill updates - Port the skill API-reference generator into tools/skill-generator and regenerate skills/stellar-php-sdk/references/api_reference.md Legacy ADDRESS remains the default and fully valid; the new arms are opt-in and only valid on Protocol 27+. --- .../WebAuthForContracts.php | 76 +- .../Soroban/Contract/AssembledTransaction.php | 340 +++- .../Soroban/Contract/MethodOptions.php | 6 + .../Requests/SimulateTransactionRequest.php | 43 + ...SorobanAddressCredentialsWithDelegates.php | 128 ++ .../Soroban/SorobanAuthorizationEntry.php | 531 ++++++- .../StellarSDK/Soroban/SorobanCredentials.php | 343 +++- .../Soroban/SorobanDelegateDescriptor.php | 56 + .../Soroban/SorobanDelegateSignature.php | 153 ++ Soneso/StellarSDK/Xdr/XdrBuffer.php | 61 +- .../StellarSDK/Xdr/XdrSorobanCredentials.php | 57 +- .../Xdr/XdrSorobanDelegateSignature.php | 19 +- .../Integration/P27AddressV2RoundTripTest.php | 277 ++++ .../P27WebAuthForContractsTest.php | 898 +++++++++++ .../Soroban/P27AssembledTransactionTest.php | 772 +++++++++ .../Unit/Soroban/P27AuthorizationTest.php | 890 +++++++++++ .../Unit/Soroban/P27CoverageClosureTest.php | 1393 +++++++++++++++++ docs/sep/sep-45.md | 4 + docs/soroban.md | 127 ++ skills/stellar-php-sdk.zip | Bin 220540 -> 222268 bytes skills/stellar-php-sdk/SKILL.md | 4 +- .../references/api_reference.md | 106 +- skills/stellar-php-sdk/references/rpc.md | 2 + skills/stellar-php-sdk/references/sep-45.md | 2 + .../references/soroban_contracts.md | 25 + skills/stellar-php-sdk/references/xdr.md | 2 + tools/skill-generator/README.md | 93 ++ .../generate_api_reference.php | 488 ++++++ tools/xdr-generator/generator/generator.rb | 28 +- .../xdr-generator/generator/type_overrides.rb | 15 + 30 files changed, 6730 insertions(+), 209 deletions(-) create mode 100644 Soneso/StellarSDK/Soroban/SorobanAddressCredentialsWithDelegates.php create mode 100644 Soneso/StellarSDK/Soroban/SorobanDelegateDescriptor.php create mode 100644 Soneso/StellarSDK/Soroban/SorobanDelegateSignature.php create mode 100644 Soneso/StellarSDKTests/Integration/P27AddressV2RoundTripTest.php create mode 100644 Soneso/StellarSDKTests/Unit/SEP/WebAuthForContracts/P27WebAuthForContractsTest.php create mode 100644 Soneso/StellarSDKTests/Unit/Soroban/P27AssembledTransactionTest.php create mode 100644 Soneso/StellarSDKTests/Unit/Soroban/P27AuthorizationTest.php create mode 100644 Soneso/StellarSDKTests/Unit/Soroban/P27CoverageClosureTest.php create mode 100644 tools/skill-generator/README.md create mode 100644 tools/skill-generator/generate_api_reference.php diff --git a/Soneso/StellarSDK/SEP/WebAuthForContracts/WebAuthForContracts.php b/Soneso/StellarSDK/SEP/WebAuthForContracts/WebAuthForContracts.php index e85c41fd..52eb9eb5 100644 --- a/Soneso/StellarSDK/SEP/WebAuthForContracts/WebAuthForContracts.php +++ b/Soneso/StellarSDK/SEP/WebAuthForContracts/WebAuthForContracts.php @@ -26,12 +26,8 @@ use Soneso\StellarSDK\Util\Hash; use Soneso\StellarSDK\Xdr\XdrBuffer; use Soneso\StellarSDK\Xdr\XdrEncoder; -use Soneso\StellarSDK\Xdr\XdrEnvelopeType; -use Soneso\StellarSDK\Xdr\XdrHashIDPreimage; -use Soneso\StellarSDK\Xdr\XdrHashIDPreimageSorobanAuthorization; use Soneso\StellarSDK\Xdr\XdrSCValType; use Soneso\StellarSDK\Xdr\XdrSorobanAuthorizationEntry; -use Soneso\StellarSDK\Xdr\XdrSorobanCredentialsType; /** * Implements SEP-45 Web Authentication for Contract Accounts protocol @@ -534,11 +530,18 @@ public function validateChallenge( } } - // Check which entry this is (server, client, or client domain) + // Check which entry this is (server, client, or client domain). + // All three address arms (ADDRESS, ADDRESS_V2, ADDRESS_WITH_DELEGATES) are recognized. + // Source-account credentials cannot carry an address and are rejected. $credentials = $entry->credentials; - if ($credentials->addressCredentials !== null) { - $credentialsAddress = $credentials->addressCredentials->address; - $credentialsAddressStr = $credentialsAddress->toStrKey(); + if ($credentials->isSourceAccount()) { + throw new ContractChallengeValidationError( + "Authorization entry uses source-account credentials; an address credential arm is required" + ); + } + $innerCreds = $credentials->getAddressCredentials(); + if ($innerCreds !== null) { + $credentialsAddressStr = $innerCreds->address->toStrKey(); if ($credentialsAddressStr === $this->serverSigningKey) { $serverEntryFound = true; @@ -614,34 +617,27 @@ public function signAuthorizationEntries( foreach ($authEntries as $index => $entry) { $credentials = $entry->credentials; - if ($credentials->addressCredentials !== null) { - $credentialsAddress = $credentials->addressCredentials->address; - $credentialsAddressStr = $credentialsAddress->toStrKey(); + // Resolve the inner address credentials for all three address arms. + // Source-account entries have no address and are passed through unsigned. + $innerCreds = $credentials->getAddressCredentials(); + if ($innerCreds !== null) { + $credentialsAddressStr = $innerCreds->address->toStrKey(); - // Sign client entry + // Sign client entry — pass expiration to sign() so it is applied before hashing. if ($credentialsAddressStr === $clientAccountId) { - // Set signature expiration ledger if provided - if ($signatureExpirationLedger !== null) { - $credentials->addressCredentials->signatureExpirationLedger = $signatureExpirationLedger; - } - - // Sign with all provided signers foreach ($signers as $signer) { if ($signer instanceof KeyPair) { - $entry->sign($signer, $this->network); + $entry->sign($signer, $this->network, $signatureExpirationLedger); } } } - // Sign client domain entry with local keypair + // Sign client domain entry with local keypair. if ($clientDomainKeyPair !== null && $credentialsAddressStr === $clientDomainKeyPair->getAccountId()) { - if ($signatureExpirationLedger !== null) { - $credentials->addressCredentials->signatureExpirationLedger = $signatureExpirationLedger; - } - $entry->sign($clientDomainKeyPair, $this->network); + $entry->sign($clientDomainKeyPair, $this->network, $signatureExpirationLedger); } - // Track client domain entry index for callback + // Track client domain entry index for callback. if ($clientDomainAccountId !== null && $credentialsAddressStr === $clientDomainAccountId) { $clientDomainEntryIndex = $index; } @@ -864,30 +860,20 @@ private function extractArgsFromEntry(SorobanAuthorizationEntry $entry): array private function verifyServerSignature(SorobanAuthorizationEntry $entry, Network $network): bool { try { - $xdrCredentials = $entry->credentials->toXdr(); - if ($entry->credentials->addressCredentials === null || - $xdrCredentials->type->value != XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS || - $xdrCredentials->address === null) { + // All three address arms are accepted; source-account credentials cannot carry a signature. + $innerCreds = $entry->credentials->getAddressCredentials(); + if ($innerCreds === null) { return false; } - // Build authorization preimage - $networkId = Hash::generate($network->getNetworkPassphrase()); - $authPreimageXdr = new XdrHashIDPreimageSorobanAuthorization( - $networkId, - $xdrCredentials->address->nonce, - $xdrCredentials->address->signatureExpirationLedger, - $entry->rootInvocation->toXdr() - ); - $rootInvocationPreimage = new XdrHashIDPreimage( - new XdrEnvelopeType(XdrEnvelopeType::ENVELOPE_TYPE_SOROBAN_AUTHORIZATION) - ); - $rootInvocationPreimage->sorobanAuthorization = $authPreimageXdr; - - $payload = Hash::generate($rootInvocationPreimage->encode()); + // Build the arm-correct preimage and hash it. + // ADDRESS -> ENVELOPE_TYPE_SOROBAN_AUTHORIZATION + // ADDRESS_V2 / ADDRESS_WITH_DELEGATES -> ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS + $preimage = $entry->buildPreimage($network); + $payload = Hash::generate($preimage->encode()); - // Get signature from credentials - $signatureVal = $entry->credentials->addressCredentials->signature; + // Get signature from inner credentials (arm-agnostic accessor). + $signatureVal = $innerCreds->signature; if ($signatureVal->type->value !== XdrSCValType::SCV_VEC || $signatureVal->vec === null || count($signatureVal->vec) == 0) { return false; } diff --git a/Soneso/StellarSDK/Soroban/Contract/AssembledTransaction.php b/Soneso/StellarSDK/Soroban/Contract/AssembledTransaction.php index 8689758e..822b21f0 100644 --- a/Soneso/StellarSDK/Soroban/Contract/AssembledTransaction.php +++ b/Soneso/StellarSDK/Soroban/Contract/AssembledTransaction.php @@ -26,8 +26,10 @@ use Soneso\StellarSDK\TimeBounds; use Soneso\StellarSDK\Transaction; use Soneso\StellarSDK\TransactionBuilder; +use Soneso\StellarSDK\Soroban\SorobanAuthorizationEntry; use Soneso\StellarSDK\Xdr\XdrSCVal; use Soneso\StellarSDK\Xdr\XdrSCValType; +use Soneso\StellarSDK\Xdr\XdrSorobanCredentialsType; use Soneso\StellarSDK\Xdr\XdrSorobanTransactionData; /** @@ -357,7 +359,12 @@ public function simulate(?bool $restore = null) : void { $shouldRestore = $restore ?? $this->options->methodOptions->restore; $this->simulationResult = null; - $this->simulationResponse = $this->server->simulateTransaction(new SimulateTransactionRequest(transaction: $this->tx)); + // Thread authV2 from MethodOptions into the request; "authV2" appears in RPC params + // only when true. RPCs without Protocol 27 support silently ignore the flag. + $this->simulationResponse = $this->server->simulateTransaction(new SimulateTransactionRequest( + transaction: $this->tx, + authV2: $this->options->methodOptions->authV2, + )); if ($shouldRestore && $this->simulationResponse->restorePreamble !== null) { if ($this->options->clientOptions->sourceAccountKeyPair->getPrivateKey() === null) { throw new Exception('Source account keypair has no private key, but needed for automatic restore.'); @@ -469,17 +476,11 @@ public function sign(?KeyPair $sourceAccountKeyPair = null, bool $force = false) throw new Exception('Source account keypair has no private key, but needed for signing.'); } - $allNeededSigners = $this->needsNonInvokerSigningBy(); - /** - * @var array $neededAccountSigners - */ - $neededAccountSigners = array(); - foreach ($allNeededSigners as $signer) { - if (!str_starts_with($signer, 'C')) { - array_push($neededAccountSigners, $signer); - } - } - if (count($neededAccountSigners) > 0) { + // Determine which G-address signers are BLOCKING submission. A WITH_DELEGATES entry + // whose top-level signature is void but all delegate nodes are signed is the legitimate + // "delegates-only" pattern; the void top-level does not block the send. + $blockingSigners = $this->getBlockingNonInvokerSigners(); + if (count($blockingSigners) > 0) { throw new Exception("Transaction requires signatures from multiple signers. " . "See `needsNonInvokerSigningBy` for details."); } @@ -552,77 +553,328 @@ public function signAuthEntries(KeyPair $signerKeyPair, ?callable $authorizeEntr $invokeHostFuncOp = $ops[0]; if ($invokeHostFuncOp instanceof InvokeHostFunctionOperation) { $authEntries = $invokeHostFuncOp->auth; - for($i = 0; $i < count($authEntries); $i++) { + for ($i = 0; $i < count($authEntries); $i++) { $entry = $authEntries[$i]; - $addressCredentials = $entry->credentials->addressCredentials; - if ($addressCredentials === null || - $addressCredentials->address->accountId === null || - $addressCredentials->address->accountId !== $signerAddress) { + $credType = $entry->credentials->credentialType; + + // Source-account entries require no signature; skip them. + if ($credType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { + continue; + } + + // Reject unknown arms rather than silently skipping them. + if ($credType !== XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS + && $credType !== XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2 + && $credType !== XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + throw new Exception("Unsupported SorobanCredentials arm ({$credType}) in auth entry {$i}"); + } + + // Determine whether the signer address appears in this entry: check the top-level + // address and every delegate node address (depth-first). For ADDRESS_WITH_DELEGATES + // the top-level address may differ from any delegate's address; both are valid matches. + $entryMatchesTopLevel = false; + $entryMatchesDelegate = false; + + $topLevelAddressCreds = $entry->credentials->getAddressCredentials(); + if ($topLevelAddressCreds !== null) { + $topStrkey = $topLevelAddressCreds->address->accountId + ?? $topLevelAddressCreds->address->contractId; + if ($topStrkey === $signerAddress) { + $entryMatchesTopLevel = true; + } + } + + if ($credType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + $withDelegates = $entry->credentials->addressWithDelegates; + if ($withDelegates !== null && $this->delegateTreeContainsAddress($withDelegates->delegates, $signerAddress, 0)) { + $entryMatchesDelegate = true; + } + } + + if (!$entryMatchesTopLevel && !$entryMatchesDelegate) { + // This entry does not involve the signer; skip it. continue; } - $entry->credentials->addressCredentials->signatureExpirationLedger = $expirationLedger; + if ($authorizeEntryCallback !== null) { $authorized = $authorizeEntryCallback($entry, $this->options->clientOptions->network); } else { - $entry->sign(signer: $signerKeyPair, network: $this->options->clientOptions->network); + // Set expiration on the top-level credentials via the arm-preserving helper. + $topCreds = $entry->credentials->getAddressCredentials(); + if ($topCreds !== null) { + $topCreds->signatureExpirationLedger = $expirationLedger; + $entry->credentials->writeBackAddressCredentials($topCreds); + } + + if ($entryMatchesDelegate) { + // Route the signature to the matching delegate node(s) via forAddress. + $entry->sign( + signer: $signerKeyPair, + network: $this->options->clientOptions->network, + forAddress: $signerAddress, + ); + } else { + // Top-level match: sign the top-level credentials (forAddress = null). + $entry->sign( + signer: $signerKeyPair, + network: $this->options->clientOptions->network, + ); + } $authorized = $entry; } $authEntries[$i] = $authorized; } $this->tx->setSorobanAuth($authEntries); - } else { + } else { throw new Exception("Unexpected Transaction type; no invoke host function operations found."); } } + /** + * Recursively checks whether $address appears in any node within a flat delegate array. + * + * Respects the depth limit applied during delegate tree traversal. Returns true as soon + * as one match is found. + * + * @param array<\Soneso\StellarSDK\Soroban\SorobanDelegateSignature> $delegates the delegates to search + * @param string $address the strkey to match against node addresses + * @param int $depth current nesting depth + * @return bool true if any node in the subtree matches $address + */ + private function delegateTreeContainsAddress(array $delegates, string $address, int $depth): bool + { + if ($depth > 128) { + return false; + } + foreach ($delegates as $delegate) { + if ($delegate->address->toStrKey() === $address) { + return true; + } + if ($this->delegateTreeContainsAddress($delegate->nestedDelegates, $address, $depth + 1)) { + return true; + } + } + return false; + } + + + /** + * Returns the set of G-address signers that are BLOCKING submission. + * + * An address is blocking if: + * - It appears as the top-level address of an ADDRESS or ADDRESS_V2 entry with a void signature, OR + * - It appears as the top-level address of a WITH_DELEGATES entry with a void top-level signature + * AND at least one delegate node is also unsigned (the full entry is not yet satisfied), OR + * - It appears as a delegate node with a void signature in a WITH_DELEGATES entry whose top-level + * signature is also void. + * + * A WITH_DELEGATES entry where the top-level signature is void but every delegate node carries + * a signature is the "delegates-only" pattern and does NOT block the send. + * + * Contract (C-prefixed) addresses are excluded: they cannot sign independently. + * + * @return array G-address strkeys that block submission + * @throws Exception if the transaction has not yet been simulated + */ + private function getBlockingNonInvokerSigners(): array + { + if ($this->tx === null) { + throw new Exception("Transaction has not yet been simulated"); + } + $ops = $this->tx->getOperations(); + if (count($ops) === 0) { + return []; + } + $invokeHostFuncOp = $ops[0]; + if (!($invokeHostFuncOp instanceof InvokeHostFunctionOperation)) { + return []; + } + + /** @var array $blocking */ + $blocking = []; + $authEntries = $invokeHostFuncOp->auth; + + foreach ($authEntries as $entry) { + $credType = $entry->credentials->credentialType; + if ($credType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { + continue; + } + + if ($credType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + $withDelegates = $entry->credentials->addressWithDelegates; + if ($withDelegates === null) { + continue; + } + $topIsVoid = $withDelegates->addressCredentials->signature->type->value === XdrSCValType::SCV_VOID; + + // Check whether all delegate nodes are signed. + $allDelegatesSigned = $this->allDelegateNodesSigned($withDelegates->delegates, 0); + + if ($topIsVoid && $allDelegatesSigned) { + // Delegates-only pattern: top-level void is acceptable; nothing blocks. + continue; + } + + // Entry not yet satisfied — collect unsigned G-address nodes. + if ($topIsVoid) { + $topStrkey = $withDelegates->addressCredentials->getAddress()->accountId; + if ($topStrkey !== null && !in_array($topStrkey, $blocking, true)) { + $blocking[] = $topStrkey; + } + } + $this->collectUnsignedDelegateGAddresses($withDelegates->delegates, $blocking, 0); + } else { + // ADDRESS or ADDRESS_V2: top-level only. + $topCreds = $entry->credentials->getAddressCredentials(); + if ($topCreds !== null && $topCreds->signature->type->value === XdrSCValType::SCV_VOID) { + $topStrkey = $topCreds->getAddress()->accountId; + if ($topStrkey !== null && !in_array($topStrkey, $blocking, true)) { + $blocking[] = $topStrkey; + } + } + } + } + return $blocking; + } + + /** + * Returns true when every node in the delegate array (and all their children) carries a + * non-void signature. + * + * @param array<\Soneso\StellarSDK\Soroban\SorobanDelegateSignature> $delegates + * @param int $depth guard against hostile deep trees + */ + private function allDelegateNodesSigned(array $delegates, int $depth): bool + { + if ($depth > 128) { + return true; // Truncate at limit; overly deep trees are rejected on decode. + } + foreach ($delegates as $delegate) { + if ($delegate->signature->type->value === XdrSCValType::SCV_VOID) { + return false; + } + if (!$this->allDelegateNodesSigned($delegate->nestedDelegates, $depth + 1)) { + return false; + } + } + return true; + } /** - * Get a list of accounts, other than the invoker of the simulation, that - * need to sign auth entries in this transaction. + * Walks a delegate array and appends G-address strkeys with void signatures to $blocking. * - * 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. + * Only G-address (account) nodes are included; contract (C-address) nodes cannot sign independently. * - * 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. + * @param array<\Soneso\StellarSDK\Soroban\SorobanDelegateSignature> $delegates + * @param array $blocking accumulator (modified in place) + * @param int $depth current nesting depth + */ + private function collectUnsignedDelegateGAddresses(array $delegates, array &$blocking, int $depth): void + { + if ($depth > 128) { + return; + } + foreach ($delegates as $delegate) { + if ($delegate->signature->type->value === XdrSCValType::SCV_VOID) { + $nodeStrkey = $delegate->address->toStrKey(); + // Only G-address (account) nodes block submission; C-address contracts cannot sign. + if (str_starts_with($nodeStrkey, 'G') && !in_array($nodeStrkey, $blocking, true)) { + $blocking[] = $nodeStrkey; + } + } + $this->collectUnsignedDelegateGAddresses($delegate->nestedDelegates, $blocking, $depth + 1); + } + } + + /** + * Returns a list of addresses — other than the invoker — that still need to sign auth entries. * - * @param bool $includeAlreadySigned if the list should include the needed signers that already signed their auth entries. - * @return array the list of account ids of the accounts that need to sign auth entries. + * Reports EVERY node whose signature is void: the top-level address of any address-arm entry + * AND each unsigned delegate node within a WITH_DELEGATES entry. A top-level signature + * alongside delegates is a legal pattern; both may appear in the returned list independently. * - * @throws Exception + * Note: a WITH_DELEGATES entry where the top-level signature is void but every delegate node + * is signed is the "delegates-only" pattern; the top-level address still appears in the list + * because its own slot is void. The send precheck in sign() treats such entries as satisfied + * (all required delegate signatures are present) and does not block submission. + * + * @param bool $includeAlreadySigned when true, includes signers whose nodes already carry signatures. + * @return array strkeys of addresses that need (or needed) to sign auth entries. + * @throws Exception if the transaction has not yet been simulated. */ public function needsNonInvokerSigningBy(bool $includeAlreadySigned = false) : array { - if($this->tx === null) { + if ($this->tx === null) { throw new Exception("Transaction has not yet been simulated"); } $ops = $this->tx->getOperations(); - if(count($ops) === 0) { + if (count($ops) === 0) { throw new Exception("Unexpected Transaction type; no operations found."); } /** * @var array $needed */ - $needed = array(); + $needed = []; $invokeHostFuncOp = $ops[0]; - if ($invokeHostFuncOp instanceof InvokeHostFunctionOperation) { - $authEntries = $invokeHostFuncOp->auth; - foreach ($authEntries as $entry) { - $addressCredentials = $entry->credentials->addressCredentials; - if($addressCredentials !== null) { - if($includeAlreadySigned || $addressCredentials->signature->type->value === XdrSCValType::SCV_VOID) { - array_push($needed, $addressCredentials->getAddress()->accountId ?? $addressCredentials->getAddress()->contractId); + if (!($invokeHostFuncOp instanceof InvokeHostFunctionOperation)) { + return $needed; + } + + $authEntries = $invokeHostFuncOp->auth; + foreach ($authEntries as $entry) { + $credType = $entry->credentials->credentialType; + if ($credType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { + continue; + } + + // Top-level address node. + $topCreds = $entry->credentials->getAddressCredentials(); + if ($topCreds !== null) { + $isVoid = $topCreds->signature->type->value === XdrSCValType::SCV_VOID; + if ($includeAlreadySigned || $isVoid) { + $addrStr = $topCreds->getAddress()->accountId ?? $topCreds->getAddress()->contractId; + if ($addrStr !== null && !in_array($addrStr, $needed, true)) { + $needed[] = $addrStr; } } } - } else { - return $needed; + + // Delegate nodes (ADDRESS_WITH_DELEGATES only). + if ($credType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + $withDelegates = $entry->credentials->addressWithDelegates; + if ($withDelegates !== null) { + $this->collectUnsignedDelegateAddresses($withDelegates->delegates, $needed, $includeAlreadySigned, 0); + } + } } return $needed; } + /** + * Walks a delegate array depth-first and appends unsigned (or all, when $includeAlreadySigned) + * delegate node addresses to $needed. + * + * @param array<\Soneso\StellarSDK\Soroban\SorobanDelegateSignature> $delegates the delegate array to walk + * @param array $needed accumulator (modified in place via reference) + * @param bool $includeAlreadySigned when true, includes signed nodes too + * @param int $depth current nesting depth (guard against hostile deep trees) + */ + private function collectUnsignedDelegateAddresses(array $delegates, array &$needed, bool $includeAlreadySigned, int $depth): void + { + if ($depth > 128) { + return; + } + foreach ($delegates as $delegate) { + $isVoid = $delegate->signature->type->value === XdrSCValType::SCV_VOID; + if ($includeAlreadySigned || $isVoid) { + $nodeStrkey = $delegate->address->toStrKey(); + if (!in_array($nodeStrkey, $needed, true)) { + $needed[] = $nodeStrkey; + } + } + $this->collectUnsignedDelegateAddresses($delegate->nestedDelegates, $needed, $includeAlreadySigned, $depth + 1); + } + } + /** * Determines if this is a read-only transaction * diff --git a/Soneso/StellarSDK/Soroban/Contract/MethodOptions.php b/Soneso/StellarSDK/Soroban/Contract/MethodOptions.php index 6fadf261..043fb534 100644 --- a/Soneso/StellarSDK/Soroban/Contract/MethodOptions.php +++ b/Soneso/StellarSDK/Soroban/Contract/MethodOptions.php @@ -36,12 +36,18 @@ class MethodOptions * builder before simulation. Default true. * @param bool $restore If true, will automatically attempt to restore archived ledger entries * that need renewal. Requires source account with private key. Default true. + * @param bool $authV2 When true, the simulate call requests ADDRESS_V2 credential entries + * (Protocol 27, CAP-71). The flag is forwarded to SimulateTransactionRequest + * and passed as "authV2": true in the RPC params only when enabled. RPCs + * without Protocol 27 support silently ignore it and return legacy ADDRESS + * entries. Do not enable on pre-27 networks. Default false. */ public function __construct( public int $fee = StellarConstants::MIN_BASE_FEE_STROOPS, public int $timeoutInSeconds = NetworkConstants::DEFAULT_SOROBAN_TIMEOUT_SECONDS, public bool $simulate = true, public bool $restore = true, + public bool $authV2 = false, ) { } diff --git a/Soneso/StellarSDK/Soroban/Requests/SimulateTransactionRequest.php b/Soneso/StellarSDK/Soroban/Requests/SimulateTransactionRequest.php index 4ee3e65b..11d5e88d 100644 --- a/Soneso/StellarSDK/Soroban/Requests/SimulateTransactionRequest.php +++ b/Soneso/StellarSDK/Soroban/Requests/SimulateTransactionRequest.php @@ -11,6 +11,16 @@ /** * Soroban Simulate Transaction Request. * + * The authV2 flag requests that the RPC node return ADDRESS_V2 credential entries + * (Protocol 27, CAP-71) instead of legacy ADDRESS entries during recording-mode simulation. + * The flag is effective only when authMode is "record" or "record_allow_nonroot"; it is + * ignored under "enforce". RPCs without Protocol 27 support silently ignore the flag and + * return legacy ADDRESS entries — support is detected by inspecting the credential arm of + * returned entries, not by any error signal. + * + * The key "authV2" is omitted from the request when the flag is false (the default), so + * existing call sites require no changes and pre-27 RPCs never see the key. + * * @see https://developers.stellar.org/network/soroban-rpc/api-reference/methods/simulateTransaction * @package Soneso\StellarSDK\Soroban\Requests */ @@ -26,17 +36,24 @@ class SimulateTransactionRequest * transactions. * @param string|null $authMode Support for non-root authorization. Only available for protocol >= 23. * Possible values: "enforce" | "record" | "record_allow_nonroot" + * @param bool $authV2 When true, requests ADDRESS_V2 credential entries (Protocol 27, CAP-71). + * The key is omitted when false; RPCs without support silently ignore it and return legacy entries. + * Invalid on pre-27 networks: emitting ADDRESS_V2 entries on a pre-27 network invalidates the transaction. */ public function __construct( public Transaction $transaction, public ?ResourceConfig $resourceConfig = null, public ?string $authMode = null, + public bool $authV2 = false, ) { } /** * Builds and returns the request parameters array for the RPC API call. * + * The "authV2" key is included only when $authV2 is true. Omitting the key (the default) + * preserves compatibility with pre-27 RPCs that do not recognize it. + * * @return array The request parameters formatted for Soroban RPC */ public function getRequestParams() : array { @@ -50,6 +67,9 @@ public function getRequestParams() : array { if ($this->authMode !== null) { $params['authMode'] = $this->authMode; } + if ($this->authV2) { + $params['authV2'] = true; + } return $params; } @@ -114,4 +134,27 @@ public function setAuthMode(?string $authMode): void $this->authMode = $authMode; } + /** + * Returns whether ADDRESS_V2 credential entries are requested during simulation. + * + * @return bool true when the authV2 flag is set + */ + public function getAuthV2(): bool + { + return $this->authV2; + } + + /** + * Sets the authV2 flag. + * + * When true, "authV2": true is included in the request params. RPCs without Protocol 27 support + * silently ignore the flag. Do not enable on pre-27 networks. + * + * @param bool $authV2 whether to request ADDRESS_V2 credential entries + */ + public function setAuthV2(bool $authV2): void + { + $this->authV2 = $authV2; + } + } \ No newline at end of file diff --git a/Soneso/StellarSDK/Soroban/SorobanAddressCredentialsWithDelegates.php b/Soneso/StellarSDK/Soroban/SorobanAddressCredentialsWithDelegates.php new file mode 100644 index 00000000..09591033 --- /dev/null +++ b/Soneso/StellarSDK/Soroban/SorobanAddressCredentialsWithDelegates.php @@ -0,0 +1,128 @@ + top-level delegate nodes, sorted by XDR-encoded address bytes + */ + public array $delegates; + + /** + * @param SorobanAddressCredentials $addressCredentials the top-level address credentials + * @param array $delegates delegate signature nodes (must be sorted) + */ + public function __construct( + SorobanAddressCredentials $addressCredentials, + array $delegates = [], + ) { + $this->addressCredentials = $addressCredentials; + $this->delegates = $delegates; + } + + /** + * Creates a SorobanAddressCredentialsWithDelegates from its XDR representation. + * + * @param XdrSorobanAddressCredentialsWithDelegates $xdr the XDR object to decode + * @return SorobanAddressCredentialsWithDelegates the decoded object + */ + public static function fromXdr(XdrSorobanAddressCredentialsWithDelegates $xdr): SorobanAddressCredentialsWithDelegates + { + $delegates = []; + foreach ($xdr->delegates as $xdrDelegate) { + $delegates[] = SorobanDelegateSignature::fromXdr($xdrDelegate); + } + return new SorobanAddressCredentialsWithDelegates( + SorobanAddressCredentials::fromXdr($xdr->addressCredentials), + $delegates, + ); + } + + /** + * Converts this object to its XDR representation. + * + * @return XdrSorobanAddressCredentialsWithDelegates the XDR representation + */ + public function toXdr(): XdrSorobanAddressCredentialsWithDelegates + { + $delegatesXdr = []; + foreach ($this->delegates as $delegate) { + $delegatesXdr[] = $delegate->toXdr(); + } + return new XdrSorobanAddressCredentialsWithDelegates( + $this->addressCredentials->toXdr(), + $delegatesXdr, + ); + } + + /** + * Returns the top-level address credentials. + * + * @return SorobanAddressCredentials the top-level credentials + */ + public function getAddressCredentials(): SorobanAddressCredentials + { + return $this->addressCredentials; + } + + /** + * Sets the top-level address credentials. + * + * @param SorobanAddressCredentials $addressCredentials the top-level credentials + */ + public function setAddressCredentials(SorobanAddressCredentials $addressCredentials): void + { + $this->addressCredentials = $addressCredentials; + } + + /** + * Returns the delegate nodes array. + * + * @return array the delegate nodes + */ + public function getDelegates(): array + { + return $this->delegates; + } + + /** + * Sets the delegate nodes array. + * + * @param array $delegates the delegate nodes + */ + public function setDelegates(array $delegates): void + { + $this->delegates = $delegates; + } +} diff --git a/Soneso/StellarSDK/Soroban/SorobanAuthorizationEntry.php b/Soneso/StellarSDK/Soroban/SorobanAuthorizationEntry.php index 1670419a..ea775d98 100644 --- a/Soneso/StellarSDK/Soroban/SorobanAuthorizationEntry.php +++ b/Soneso/StellarSDK/Soroban/SorobanAuthorizationEntry.php @@ -6,38 +6,58 @@ namespace Soneso\StellarSDK\Soroban; +use InvalidArgumentException; +use RuntimeException; use Soneso\StellarSDK\Crypto\KeyPair; +use Soneso\StellarSDK\Crypto\StrKey; use Soneso\StellarSDK\Network; use Soneso\StellarSDK\Util\Hash; use Soneso\StellarSDK\Xdr\XdrBuffer; use Soneso\StellarSDK\Xdr\XdrEnvelopeType; use Soneso\StellarSDK\Xdr\XdrHashIDPreimage; use Soneso\StellarSDK\Xdr\XdrHashIDPreimageSorobanAuthorization; +use Soneso\StellarSDK\Xdr\XdrHashIDPreimageSorobanAuthorizationWithAddress; use Soneso\StellarSDK\Xdr\XdrSCVal; use Soneso\StellarSDK\Xdr\XdrSorobanAuthorizationEntry; -use InvalidArgumentException; use Soneso\StellarSDK\Xdr\XdrSorobanCredentialsType; - /** - * Soroban authorization entry for smart contract invocations + * Soroban authorization entry for smart contract invocations. + * + * Each entry grants permission to execute a specific contract invocation tree. Credentials + * select which signing scheme applies; the rootInvocation tree identifies the authorized calls. * - * This class represents an authorization entry that grants permission to execute a specific - * contract invocation. Each authorization entry contains credentials (either source account - * or address-based) and a tree of authorized invocations representing the call hierarchy. + * Three credential arms support active signing: + * - ADDRESS (legacy): preimage is ENVELOPE_TYPE_SOROBAN_AUTHORIZATION (not address-bound). + * - ADDRESS_V2 (Protocol 27, CAP-71): preimage is ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS + * (address-bound). Invalid on networks below Protocol 27. + * - ADDRESS_WITH_DELEGATES (Protocol 27, CAP-71): ADDRESS_V2 with a recursive delegate tree. + * All nodes (top-level and every delegate at any depth) sign the same payload hash. + * Invalid on networks below Protocol 27. * - * Authorization entries are typically signed by the authorizing party before submission. + * Signature write-back: sign() appends to the existing signature vector; a void signature + * becomes a one-element vec. Calling sign() twice on the same node with the same key appends + * a duplicate that the host will reject. Callers are responsible for call order; the SDK does + * not sort signatures. + * + * For G-address verification the host requires signatures to be in ascending public-key order. + * The SDK appends in call order — callers must sign in ascending key order for multi-sig nodes. * * @package Soneso\StellarSDK\Soroban * @see SorobanCredentials * @see SorobanAuthorizedInvocation - * @see https://developers.stellar.org/docs/learn/smart-contract-internals/authorization Soroban Authorization - * @since 1.0.0 */ class SorobanAuthorizationEntry { /** - * @var SorobanCredentials credentials authorizing the invocation (source account or address-based) + * Maximum delegate tree traversal depth, matching the XDR decode limit. + * + * Prevents stack exhaustion when walking a hostile deep tree at the application layer. + */ + private const DELEGATE_DEPTH_LIMIT = 128; + + /** + * @var SorobanCredentials credentials authorizing the invocation */ public SorobanCredentials $credentials; @@ -54,7 +74,7 @@ class SorobanAuthorizationEntry */ public function __construct(SorobanCredentials $credentials, SorobanAuthorizedInvocation $rootInvocation) { - $this->credentials = $credentials; + $this->credentials = $credentials; $this->rootInvocation = $rootInvocation; } @@ -64,9 +84,12 @@ public function __construct(SorobanCredentials $credentials, SorobanAuthorizedIn * @param XdrSorobanAuthorizationEntry $xdr the XDR object to decode * @return SorobanAuthorizationEntry the decoded authorization entry */ - public static function fromXdr(XdrSorobanAuthorizationEntry $xdr) : SorobanAuthorizationEntry { - return new SorobanAuthorizationEntry(SorobanCredentials::fromXdr($xdr->credentials), - SorobanAuthorizedInvocation::fromXdr($xdr->rootInvocation)); + public static function fromXdr(XdrSorobanAuthorizationEntry $xdr): SorobanAuthorizationEntry + { + return new SorobanAuthorizationEntry( + SorobanCredentials::fromXdr($xdr->credentials), + SorobanAuthorizedInvocation::fromXdr($xdr->rootInvocation), + ); } /** @@ -74,8 +97,12 @@ public static function fromXdr(XdrSorobanAuthorizationEntry $xdr) : SorobanAutho * * @return XdrSorobanAuthorizationEntry the XDR encoded authorization entry */ - public function toXdr(): XdrSorobanAuthorizationEntry { - return new XdrSorobanAuthorizationEntry($this->credentials->toXdr(), $this->rootInvocation->toXdr()); + public function toXdr(): XdrSorobanAuthorizationEntry + { + return new XdrSorobanAuthorizationEntry( + $this->credentials->toXdr(), + $this->rootInvocation->toXdr(), + ); } /** @@ -83,9 +110,10 @@ public function toXdr(): XdrSorobanAuthorizationEntry { * * @param string $base64Xdr the base64-encoded XDR string * @return SorobanAuthorizationEntry the decoded authorization entry - * @throws \Exception if XDR decoding fails + * @throws InvalidArgumentException if base64 or XDR decoding fails, or the depth guard trips */ - public static function fromBase64Xdr(string $base64Xdr) : SorobanAuthorizationEntry { + public static function fromBase64Xdr(string $base64Xdr): SorobanAuthorizationEntry + { $xdr = base64_decode($base64Xdr, true); if ($xdr === false) { throw new InvalidArgumentException('Invalid base64-encoded XDR'); @@ -99,45 +127,462 @@ public static function fromBase64Xdr(string $base64Xdr) : SorobanAuthorizationEn * * @return string the base64-encoded XDR representation */ - public function toBase64Xdr() : string { + public function toBase64Xdr(): string + { return base64_encode($this->toXdr()->encode()); } + /** + * Builds the XdrHashIDPreimage for this entry based on its credential arm. + * + * Preimage selection: + * - ADDRESS arm: ENVELOPE_TYPE_SOROBAN_AUTHORIZATION (not address-bound, legacy preimage). + * - ADDRESS_V2 and ADDRESS_WITH_DELEGATES arms: ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS + * (address-bound preimage; the address is always the top-level credential address, never a + * delegate address). + * + * signatureExpirationLedger must be set on the credentials before calling this method; + * the network reconstructs the same preimage from the submitted credentials. + * + * @param Network $network the network whose passphrase is included in the preimage + * @return XdrHashIDPreimage the preimage ready for SHA-256 hashing + * @throws RuntimeException if the credentials are source-account or have no address credentials + */ + public function buildPreimage(Network $network): XdrHashIDPreimage + { + $credType = $this->credentials->credentialType; + $networkId = Hash::generate($network->getNetworkPassphrase()); + + switch ($credType) { + case XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS: + $addressCreds = $this->credentials->addressCredentials; + if ($addressCreds === null) { + throw new RuntimeException('ADDRESS arm requires addressCredentials'); + } + $inner = new XdrHashIDPreimageSorobanAuthorization( + $networkId, + $addressCreds->nonce, + $addressCreds->signatureExpirationLedger, + $this->rootInvocation->toXdr(), + ); + $preimage = new XdrHashIDPreimage( + new XdrEnvelopeType(XdrEnvelopeType::ENVELOPE_TYPE_SOROBAN_AUTHORIZATION) + ); + $preimage->sorobanAuthorization = $inner; + return $preimage; + + case XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2: + $addressCreds = $this->credentials->addressCredentials; + if ($addressCreds === null) { + throw new RuntimeException('ADDRESS_V2 arm requires addressCredentials'); + } + $inner = new XdrHashIDPreimageSorobanAuthorizationWithAddress( + $networkId, + $addressCreds->nonce, + $addressCreds->signatureExpirationLedger, + $addressCreds->address->toXdr(), + $this->rootInvocation->toXdr(), + ); + $preimage = new XdrHashIDPreimage( + new XdrEnvelopeType(XdrEnvelopeType::ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS) + ); + $preimage->sorobanAuthorizationWithAddress = $inner; + return $preimage; + + case XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + $withDelegates = $this->credentials->addressWithDelegates; + if ($withDelegates === null) { + throw new RuntimeException('ADDRESS_WITH_DELEGATES arm requires addressWithDelegates'); + } + $addressCreds = $withDelegates->addressCredentials; + // The address in the preimage is always the TOP-LEVEL credential address. + $inner = new XdrHashIDPreimageSorobanAuthorizationWithAddress( + $networkId, + $addressCreds->nonce, + $addressCreds->signatureExpirationLedger, + $addressCreds->address->toXdr(), + $this->rootInvocation->toXdr(), + ); + $preimage = new XdrHashIDPreimage( + new XdrEnvelopeType(XdrEnvelopeType::ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS) + ); + $preimage->sorobanAuthorizationWithAddress = $inner; + return $preimage; + + default: + throw new RuntimeException( + 'Cannot build preimage for credential type: ' . $credType + . ' (source-account credentials require no signature)' + ); + } + } + /** * Signs the authorization entry with the given keypair. * - * The signature will be added to the signatures vector of the address credentials. - * This method creates an Ed25519 signature over the authorization payload including - * the network passphrase, nonce, signature expiration, and root invocation. + * Applies to all three address arms (ADDRESS, ADDRESS_V2, ADDRESS_WITH_DELEGATES). + * Source-account credentials throw RuntimeException. * - * @param KeyPair $signer the keypair to sign with (must match the authorized address) - * @param Network $network the network this authorization is for (determines network passphrase) - * @throws \RuntimeException if no address credentials are found in this entry + * Expiration: when $signatureExpirationLedger is non-null it is applied to the + * top-level credentials before the preimage is built. When null, the already-set + * value is used unchanged. Set expiration before signing — the network reconstructs + * the preimage from the submitted credentials including expiration. + * + * Routing: when $forAddress is null, the signature is written to the top-level + * credentials. When non-null (strkey, G- or C-prefixed), the signature is written + * to EVERY node (top-level and delegate, depth-first) whose address matches. + * If no node matches, InvalidArgumentException is thrown. Muxed M-addresses are + * rejected as they are not valid Soroban auth addresses. + * + * Append semantics: the new signature element is appended to the existing signature + * vector. A void top-level signature is valid and is not rejected. Calling sign() + * twice with the same key appends a duplicate; the SDK does not deduplicate or sort. + * + * @param KeyPair $signer the keypair to sign with + * @param Network $network the network this authorization is for + * @param int|null $signatureExpirationLedger when non-null, sets expiration before hashing + * @param string|null $forAddress strkey (G- or C-prefixed) routing to a specific address node; + * null signs the top-level credentials + * @throws RuntimeException if no address credentials are found or the credential arm is unsupported + * @throws InvalidArgumentException if $forAddress matches no node, or is a muxed M-address */ - public function sign(KeyPair $signer, Network $network): void + public function sign( + KeyPair $signer, + Network $network, + ?int $signatureExpirationLedger = null, + ?string $forAddress = null, + ): void { + $credType = $this->credentials->credentialType; + + if ($credType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { + throw new RuntimeException('no soroban address credentials found'); + } + + // Reject muxed address routing targets — they are not valid Soroban auth addresses. + if ($forAddress !== null && str_starts_with($forAddress, 'M')) { + throw new InvalidArgumentException( + 'forAddress must be a G- or C-prefixed strkey; muxed (M-prefixed) addresses are not valid Soroban auth addresses' + ); + } + + // Apply expiration before building the preimage. + if ($signatureExpirationLedger !== null) { + $addressCreds = $this->credentials->getAddressCredentials(); + if ($addressCreds !== null) { + $addressCreds->signatureExpirationLedger = $signatureExpirationLedger; + $this->credentials->writeBackAddressCredentials($addressCreds); + } + } + + // Build preimage and compute payload hash (same hash for all nodes in this entry). + $preimage = $this->buildPreimage($network); + $payload = Hash::generate($preimage->encode()); + + if ($forAddress === null) { + // Sign top-level credentials. + $this->appendSignatureToTopLevel($signer, $payload); + } else { + // Route to every node (top-level or delegate) whose address matches. + $matched = $this->appendSignatureToMatchingNodes($signer, $payload, $forAddress, 0); + if (!$matched) { + throw new InvalidArgumentException( + 'forAddress "' . $forAddress . '" matched no node in this authorization entry' + ); + } + } + } + + /** + * Appends a signature to the top-level credential node. + * + * @param KeyPair $signer the signing keypair + * @param string $payload the payload hash (32 bytes) + */ + private function appendSignatureToTopLevel(KeyPair $signer, string $payload): void { - $xdrCredentials = $this->credentials->toXdr(); - if ($this->credentials->addressCredentials === null || - $xdrCredentials->type->value != XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS || - $xdrCredentials->address === null) { - throw new \RuntimeException("no soroban address credentials found"); + $addressCreds = $this->credentials->getAddressCredentials(); + if ($addressCreds === null) { + throw new RuntimeException('no soroban address credentials found'); + } + $sigVal = $this->buildSignatureScVal($signer, $payload); + if ($addressCreds->signature->vec !== null) { + $addressCreds->signature->vec[] = $sigVal; + } else { + $addressCreds->signature = XdrSCVal::forVec([$sigVal]); } + $this->credentials->writeBackAddressCredentials($addressCreds); + } - $networkId = Hash::generate($network->getNetworkPassphrase()); - $authPreimageXdr = new XdrHashIDPreimageSorobanAuthorization($networkId, $xdrCredentials->address->nonce, - $xdrCredentials->address->signatureExpirationLedger, $this->rootInvocation->toXdr()); - $rootInvocationPreimage = new XdrHashIDPreimage(new XdrEnvelopeType(XdrEnvelopeType::ENVELOPE_TYPE_SOROBAN_AUTHORIZATION)); - $rootInvocationPreimage->sorobanAuthorization = $authPreimageXdr; + /** + * Walks the node tree depth-first, appending a signature to every node matching $targetStrkey. + * + * Returns true if at least one node matched. + * + * @param KeyPair $signer the signing keypair + * @param string $payload the payload hash (32 bytes) + * @param string $targetStrkey the strkey of the target address + * @param int $depth current recursion depth for the depth guard + * @return bool true if any node matched + */ + private function appendSignatureToMatchingNodes( + KeyPair $signer, + string $payload, + string $targetStrkey, + int $depth, + ): bool { + if ($depth > self::DELEGATE_DEPTH_LIMIT) { + throw new InvalidArgumentException( + 'Delegate tree traversal depth limit (' . self::DELEGATE_DEPTH_LIMIT . ') exceeded' + ); + } + + $matched = false; - $payload = Hash::generate($rootInvocationPreimage->encode()); // sha256 + // Check top-level node. + $addressCreds = $this->credentials->getAddressCredentials(); + if ($addressCreds !== null) { + $topStrkey = $addressCreds->address->toStrKey(); + if ($topStrkey === $targetStrkey) { + $sigVal = $this->buildSignatureScVal($signer, $payload); + if ($addressCreds->signature->vec !== null) { + $addressCreds->signature->vec[] = $sigVal; + } else { + $addressCreds->signature = XdrSCVal::forVec([$sigVal]); + } + $this->credentials->writeBackAddressCredentials($addressCreds); + $matched = true; + } + } + + // Walk delegates for ADDRESS_WITH_DELEGATES arm. + if ($this->credentials->credentialType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + $withDelegates = $this->credentials->addressWithDelegates; + if ($withDelegates !== null) { + foreach ($withDelegates->delegates as $delegate) { + if ($this->appendSignatureToDelegateNode($signer, $payload, $targetStrkey, $delegate, $depth + 1)) { + $matched = true; + } + } + } + } + + return $matched; + } + + /** + * Walks a delegate node tree depth-first, appending a signature to every matching node. + * + * @param KeyPair $signer the signing keypair + * @param string $payload the payload hash (32 bytes) + * @param string $targetStrkey the strkey of the target address + * @param SorobanDelegateSignature $node the current delegate node + * @param int $depth current recursion depth + * @return bool true if any node matched + */ + private function appendSignatureToDelegateNode( + KeyPair $signer, + string $payload, + string $targetStrkey, + SorobanDelegateSignature $node, + int $depth, + ): bool { + if ($depth > self::DELEGATE_DEPTH_LIMIT) { + throw new InvalidArgumentException( + 'Delegate tree traversal depth limit (' . self::DELEGATE_DEPTH_LIMIT . ') exceeded' + ); + } + + $matched = false; + $nodeStrkey = $node->address->toStrKey(); + + if ($nodeStrkey === $targetStrkey) { + $sigVal = $this->buildSignatureScVal($signer, $payload); + if ($node->signature->vec !== null) { + $node->signature->vec[] = $sigVal; + } else { + $node->signature = XdrSCVal::forVec([$sigVal]); + } + $matched = true; + } + + foreach ($node->nestedDelegates as $child) { + if ($this->appendSignatureToDelegateNode($signer, $payload, $targetStrkey, $child, $depth + 1)) { + $matched = true; + } + } + + return $matched; + } + + /** + * Constructs the AccountEd25519Signature XdrSCVal map entry from a keypair and payload. + * + * @param KeyPair $signer the signing keypair + * @param string $payload the 32-byte payload to sign + * @return XdrSCVal the map entry representing {public_key, signature} + */ + private function buildSignatureScVal(KeyPair $signer, string $payload): XdrSCVal + { $signatureBytes = $signer->sign($payload); $signature = new AccountEd25519Signature($signer->getPublicKey(), $signatureBytes); - $sigVal = $signature->toXdrSCVal(); - if ($this->credentials->addressCredentials->signature->vec !== null) { - array_push($this->credentials->addressCredentials->signature->vec, $sigVal); - } else { - $this->credentials->addressCredentials->signature = XdrSCVal::forVec([$sigVal]); + return $signature->toXdrSCVal(); + } + + /** + * Constructs an ADDRESS_WITH_DELEGATES entry from an existing ADDRESS or ADDRESS_V2 entry. + * + * The input entry must use ADDRESS or ADDRESS_V2 credentials. A WITH_DELEGATES input throws. + * The resulting entry: + * - Copies the top-level address and nonce from the source entry. + * - Sets signatureExpirationLedger to $signatureExpirationLedger. + * - Defaults the top-level signature to void. + * - Attaches the provided delegate descriptors, sorted by their XDR-encoded address bytes. + * - Preserves the rootInvocation from the source entry. + * + * Delegate sorting: each array (top-level delegates and every nestedDelegates) is sorted + * ascending by the complete XDR-encoded bytes of XdrSCAddress. This is not strkey order — + * accounts (XdrSCAddressType 0) sort before contracts (XdrSCAddressType 1) in XDR encoding. + * + * Duplicate rejection: no two delegates in the same array may share an address. The same + * address at different nesting levels is allowed. + * + * @param SorobanAuthorizationEntry $source the ADDRESS or ADDRESS_V2 entry to wrap + * @param int $signatureExpirationLedger the expiration ledger for the resulting entry + * @param array $delegates delegate descriptors for top-level delegates + * @return SorobanAuthorizationEntry the new ADDRESS_WITH_DELEGATES entry + * @throws InvalidArgumentException if source is already WITH_DELEGATES, or contains duplicate addresses + */ + public static function withDelegates( + SorobanAuthorizationEntry $source, + int $signatureExpirationLedger, + array $delegates = [], + ): SorobanAuthorizationEntry { + $credType = $source->credentials->credentialType; + if ($credType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + throw new InvalidArgumentException( + 'Input entry is already ADDRESS_WITH_DELEGATES; cannot nest delegate trees' + ); + } + if ($credType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { + throw new InvalidArgumentException( + 'Input entry uses source-account credentials; cannot attach delegates' + ); + } + + $sourceAddressCreds = $source->credentials->getAddressCredentials(); + if ($sourceAddressCreds === null) { + throw new InvalidArgumentException('Source entry has no address credentials'); + } + + // Build top-level address credentials with void signature. + $topLevelCreds = new SorobanAddressCredentials( + $sourceAddressCreds->address, + $sourceAddressCreds->nonce, + $signatureExpirationLedger, + XdrSCVal::forVoid(), + ); + + // Build and sort the delegate tree. + $builtDelegates = []; + foreach ($delegates as $descriptor) { + $builtDelegates[] = self::buildDelegateNode($descriptor, 0); + } + $builtDelegates = self::sortAndValidateDelegates($builtDelegates); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topLevelCreds, $builtDelegates); + $newCredentials = SorobanCredentials::forAddressWithDelegates($withDelegates); + + return new SorobanAuthorizationEntry($newCredentials, $source->rootInvocation); + } + + /** + * Builds a SorobanDelegateSignature from a SorobanDelegateDescriptor recursively. + * + * @param SorobanDelegateDescriptor $descriptor the descriptor to build from + * @param int $depth current recursion depth + * @return SorobanDelegateSignature the built delegate node + */ + private static function buildDelegateNode( + SorobanDelegateDescriptor $descriptor, + int $depth, + ): SorobanDelegateSignature { + if ($depth > self::DELEGATE_DEPTH_LIMIT) { + throw new InvalidArgumentException( + 'Delegate tree depth limit (' . self::DELEGATE_DEPTH_LIMIT . ') exceeded during construction' + ); + } + + $address = self::parseAddressStrkey($descriptor->address); + $signature = $descriptor->signature ?? XdrSCVal::forVoid(); + + $nested = []; + foreach ($descriptor->nestedDelegates as $childDescriptor) { + $nested[] = self::buildDelegateNode($childDescriptor, $depth + 1); } + $nested = self::sortAndValidateDelegates($nested); + + return new SorobanDelegateSignature($address, $signature, $nested); + } + + /** + * Parses a G- or C-prefixed strkey into an XdrSCAddress. + * + * @param string $strkey the strkey to parse + * @return \Soneso\StellarSDK\Xdr\XdrSCAddress the XDR address + * @throws InvalidArgumentException for invalid or muxed strkeys + */ + private static function parseAddressStrkey(string $strkey): \Soneso\StellarSDK\Xdr\XdrSCAddress + { + if (StrKey::isValidAccountId($strkey)) { + return \Soneso\StellarSDK\Xdr\XdrSCAddress::forAccountId($strkey); + } + if (StrKey::isValidContractId($strkey)) { + return \Soneso\StellarSDK\Xdr\XdrSCAddress::forContractId(StrKey::decodeContractIdHex($strkey)); + } + throw new InvalidArgumentException( + 'Delegate address must be a G- or C-prefixed strkey; got: ' . $strkey + ); + } + + /** + * Sorts a flat delegate array ascending by XDR-encoded address bytes and checks for duplicates. + * + * Sorting uses the full XDR-encoded bytes of XdrSCAddress (not strkey) for lexicographic + * comparison. This means account addresses (type 0) sort before contract addresses (type 1), + * which is the opposite of strkey order where "C" < "G". + * + * @param array $delegates the delegates to sort + * @return array sorted delegates + * @throws InvalidArgumentException if any two delegates in the array share the same address + */ + private static function sortAndValidateDelegates(array $delegates): array + { + if (count($delegates) <= 1) { + return $delegates; + } + + // Sort by XDR-encoded address bytes (ascending lexicographic order). + usort($delegates, static function ( + SorobanDelegateSignature $a, + SorobanDelegateSignature $b, + ): int { + return strcmp($a->address->encode(), $b->address->encode()); + }); + + // Check for duplicates after sorting (adjacent elements have equal bytes if duplicate). + $prevEncoded = null; + foreach ($delegates as $delegate) { + $encoded = $delegate->address->encode(); + if ($prevEncoded !== null && $encoded === $prevEncoded) { + throw new InvalidArgumentException( + 'Duplicate delegate address within the same array: ' + . $delegate->address->toStrKey() + ); + } + $prevEncoded = $encoded; + } + + return $delegates; } /** @@ -179,4 +624,4 @@ public function setRootInvocation(SorobanAuthorizedInvocation $rootInvocation): { $this->rootInvocation = $rootInvocation; } -} \ No newline at end of file +} diff --git a/Soneso/StellarSDK/Soroban/SorobanCredentials.php b/Soneso/StellarSDK/Soroban/SorobanCredentials.php index 3a63ade0..f11a3716 100644 --- a/Soneso/StellarSDK/Soroban/SorobanCredentials.php +++ b/Soneso/StellarSDK/Soroban/SorobanCredentials.php @@ -6,120 +6,379 @@ namespace Soneso\StellarSDK\Soroban; +use InvalidArgumentException; use Soneso\StellarSDK\Xdr\XdrSCVal; use Soneso\StellarSDK\Xdr\XdrSorobanCredentials; use Soneso\StellarSDK\Xdr\XdrSorobanCredentialsType; /** - * Credentials for Soroban authorization + * Credentials for Soroban authorization. * - * This class represents credentials used in Soroban authorization entries. There are two types: - * source account credentials (no addressCredentials) or address-based credentials (with addressCredentials). - * Source account credentials use the transaction source account for authorization. + * Represents one of four credential arms: + * - SOURCE_ACCOUNT: uses the transaction source account; no address credentials. + * - ADDRESS (legacy): address credentials without an address-bound preimage. + * - ADDRESS_V2 (Protocol 27, CAP-71): address credentials with an address-bound preimage + * (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS). Opt-in; invalid on pre-27 networks. + * - ADDRESS_WITH_DELEGATES (Protocol 27, CAP-71): ADDRESS_V2 with a recursive delegate + * tree. Opt-in; invalid on pre-27 networks. + * + * The property $addressCredentials carries the inner SorobanAddressCredentials for the + * ADDRESS and ADDRESS_V2 arms. For ADDRESS_WITH_DELEGATES, $addressWithDelegates carries + * the full credentials-plus-delegates payload; $addressCredentials is null in that arm. + * + * Legacy behavior is preserved: the default arm is ADDRESS when address credentials are + * set; existing callers that only use forSourceAccount() / forAddressCredentials() are + * unaffected. * * @package Soneso\StellarSDK\Soroban * @see SorobanAddressCredentials + * @see SorobanAddressCredentialsWithDelegates + * @see SorobanDelegateSignature * @see SorobanAuthorizationEntry - * @see https://developers.stellar.org/docs/learn/smart-contract-internals/authorization Soroban Authorization - * @since 1.0.0 */ class SorobanCredentials { /** - * @var SorobanAddressCredentials|null address-based credentials or null for source account credentials + * @var int one of XdrSorobanCredentialsType constants + */ + public int $credentialType; + + /** + * @var SorobanAddressCredentials|null address credentials for ADDRESS and ADDRESS_V2 arms; null otherwise */ public ?SorobanAddressCredentials $addressCredentials = null; + /** + * @var SorobanAddressCredentialsWithDelegates|null credentials-with-delegates for ADDRESS_WITH_DELEGATES arm; null otherwise + */ + public ?SorobanAddressCredentialsWithDelegates $addressWithDelegates = null; + /** * Creates new Soroban credentials. * - * @param SorobanAddressCredentials|null $addressCredentials address credentials or null for source account + * The first argument is normally one of the XdrSorobanCredentialsType constants (int). + * Passing a SorobanAddressCredentials object as the first argument is also accepted for + * backward compatibility with the legacy single-argument calling convention — in that case + * the arm defaults to ADDRESS and addressCredentials is set from the object. + * + * @param int|SorobanAddressCredentials $credentialType one of XdrSorobanCredentialsType constants, + * or a SorobanAddressCredentials object for legacy compatibility + * @param SorobanAddressCredentials|null $addressCredentials required for ADDRESS and ADDRESS_V2 arms + * @param SorobanAddressCredentialsWithDelegates|null $addressWithDelegates required for ADDRESS_WITH_DELEGATES arm */ - public function __construct(?SorobanAddressCredentials $addressCredentials = null) - { - $this->addressCredentials = $addressCredentials; + public function __construct( + int|SorobanAddressCredentials $credentialType = XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT, + ?SorobanAddressCredentials $addressCredentials = null, + ?SorobanAddressCredentialsWithDelegates $addressWithDelegates = null, + ) { + // Backward-compatible: if a SorobanAddressCredentials is passed as the first arg, + // treat it as legacy ADDRESS credentials. + if ($credentialType instanceof SorobanAddressCredentials) { + $this->credentialType = XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS; + $this->addressCredentials = $credentialType; + return; + } + $this->credentialType = $credentialType; + $this->addressCredentials = $addressCredentials; + $this->addressWithDelegates = $addressWithDelegates; } /** - * Creates source account credentials. + * Creates source-account credentials. * - * Source account credentials use the transaction source account for authorization - * without requiring additional signatures. + * Source-account credentials authorize using the transaction source account without + * additional signatures. * * @return SorobanCredentials credentials using the source account */ - public static function forSourceAccount() : SorobanCredentials { - return new SorobanCredentials(); + public static function forSourceAccount(): SorobanCredentials + { + return new SorobanCredentials(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT); } /** - * Creates address-based credentials. + * Creates legacy ADDRESS credentials. + * + * Uses ENVELOPE_TYPE_SOROBAN_AUTHORIZATION (not address-bound). This is the default + * arm and is valid on all protocol versions. * * @param Address $address the address to authorize * @param int $nonce unique nonce for replay protection - * @param int $signatureExpirationLedger ledger after which signatures expire + * @param int $signatureExpirationLedger ledger after which the signature expires * @param XdrSCVal $signature the signature data - * @return SorobanCredentials credentials using address-based authorization + * @return SorobanCredentials legacy ADDRESS credentials */ - public static function forAddress(Address $address, int $nonce, int $signatureExpirationLedger, XdrSCVal $signature) : SorobanCredentials { + public static function forAddress( + Address $address, + int $nonce, + int $signatureExpirationLedger, + XdrSCVal $signature, + ): SorobanCredentials { $addressCredentials = new SorobanAddressCredentials($address, $nonce, $signatureExpirationLedger, $signature); - return new SorobanCredentials($addressCredentials); + return new SorobanCredentials( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, + $addressCredentials, + ); } /** - * Creates credentials from existing address credentials. + * Creates legacy ADDRESS credentials from existing address credentials. * - * @param SorobanAddressCredentials $addressCredentials the address credentials to use - * @return SorobanCredentials credentials using the provided address credentials + * @param SorobanAddressCredentials $addressCredentials the address credentials + * @return SorobanCredentials legacy ADDRESS credentials */ - public static function forAddressCredentials(SorobanAddressCredentials $addressCredentials) : SorobanCredentials { - return new SorobanCredentials($addressCredentials); + public static function forAddressCredentials(SorobanAddressCredentials $addressCredentials): SorobanCredentials + { + return new SorobanCredentials( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, + $addressCredentials, + ); + } + + /** + * Creates ADDRESS_V2 credentials (Protocol 27, CAP-71). + * + * Uses ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS (address-bound preimage). + * Invalid on networks below Protocol 27. + * + * @param SorobanAddressCredentials $addressCredentials the address credentials + * @return SorobanCredentials ADDRESS_V2 credentials + */ + public static function forAddressCredentialsV2(SorobanAddressCredentials $addressCredentials): SorobanCredentials + { + return new SorobanCredentials( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + $addressCredentials, + ); } /** - * Creates SorobanCredentials from its XDR representation. + * Creates ADDRESS_WITH_DELEGATES credentials (Protocol 27, CAP-71). + * + * Uses ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS with a recursive delegate tree. + * Invalid on networks below Protocol 27. + * + * @param SorobanAddressCredentialsWithDelegates $addressWithDelegates the credentials-plus-delegates payload + * @return SorobanCredentials ADDRESS_WITH_DELEGATES credentials + */ + public static function forAddressWithDelegates( + SorobanAddressCredentialsWithDelegates $addressWithDelegates, + ): SorobanCredentials { + return new SorobanCredentials( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + null, + $addressWithDelegates, + ); + } + + /** + * Decodes a SorobanCredentials from its XDR representation. + * + * All four credential arms are decoded faithfully. An unknown arm value throws + * InvalidArgumentException. * * @param XdrSorobanCredentials $xdr the XDR object to decode * @return SorobanCredentials the decoded credentials + * @throws InvalidArgumentException for unknown credential arm values */ - public static function fromXdr(XdrSorobanCredentials $xdr) : SorobanCredentials { - if ($xdr->type->value == XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS && $xdr->address !== null) { - return new SorobanCredentials(SorobanAddressCredentials::fromXdr($xdr->address)); + public static function fromXdr(XdrSorobanCredentials $xdr): SorobanCredentials + { + switch ($xdr->type->value) { + case XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT: + return new SorobanCredentials(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT); + + case XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS: + if ($xdr->address === null) { + throw new InvalidArgumentException( + 'XdrSorobanCredentials arm ADDRESS is missing address payload' + ); + } + return new SorobanCredentials( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, + SorobanAddressCredentials::fromXdr($xdr->address), + ); + + case XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2: + if ($xdr->addressV2 === null) { + throw new InvalidArgumentException( + 'XdrSorobanCredentials arm ADDRESS_V2 is missing addressV2 payload' + ); + } + return new SorobanCredentials( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + SorobanAddressCredentials::fromXdr($xdr->addressV2), + ); + + case XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + if ($xdr->addressWithDelegates === null) { + throw new InvalidArgumentException( + 'XdrSorobanCredentials arm ADDRESS_WITH_DELEGATES is missing addressWithDelegates payload' + ); + } + return new SorobanCredentials( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + null, + SorobanAddressCredentialsWithDelegates::fromXdr($xdr->addressWithDelegates), + ); + + default: + throw new InvalidArgumentException( + 'Unknown XdrSorobanCredentialsType value: ' . $xdr->type->value + ); } - return new SorobanCredentials(); } /** * Converts this object to its XDR representation. * * @return XdrSorobanCredentials the XDR encoded credentials + * @throws InvalidArgumentException if required payload is missing for the current arm */ - public function toXdr(): XdrSorobanCredentials { - if ($this->addressCredentials !== null) { - $xdr = new XdrSorobanCredentials(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS()); - $xdr->address = $this->addressCredentials->toXdr(); - return $xdr; + public function toXdr(): XdrSorobanCredentials + { + switch ($this->credentialType) { + case XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT: + return XdrSorobanCredentials::forSourceAccount(); + + case XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS: + if ($this->addressCredentials === null) { + throw new InvalidArgumentException( + 'ADDRESS arm requires addressCredentials to be set' + ); + } + return XdrSorobanCredentials::forAddressCredentials($this->addressCredentials->toXdr()); + + case XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2: + if ($this->addressCredentials === null) { + throw new InvalidArgumentException( + 'ADDRESS_V2 arm requires addressCredentials to be set' + ); + } + return XdrSorobanCredentials::forAddressCredentialsV2($this->addressCredentials->toXdr()); + + case XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + if ($this->addressWithDelegates === null) { + throw new InvalidArgumentException( + 'ADDRESS_WITH_DELEGATES arm requires addressWithDelegates to be set' + ); + } + return XdrSorobanCredentials::forAddressWithDelegates($this->addressWithDelegates->toXdr()); + + default: + throw new InvalidArgumentException( + 'Unknown credential type: ' . $this->credentialType + ); } - return new XdrSorobanCredentials(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT()); } /** - * Returns the address credentials if using address-based authorization. + * Returns true when this is a source-account credential. * - * @return SorobanAddressCredentials|null the address credentials or null for source account + * @return bool true for SOURCE_ACCOUNT arm + */ + public function isSourceAccount(): bool + { + return $this->credentialType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT; + } + + /** + * Returns true when this is an address-based credential (any of ADDRESS, ADDRESS_V2, + * or ADDRESS_WITH_DELEGATES). + * + * @return bool true for all three address arms + */ + public function isAddressBased(): bool + { + return $this->credentialType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS + || $this->credentialType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2 + || $this->credentialType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES; + } + + /** + * Returns the inner SorobanAddressCredentials for any address arm. + * + * - ADDRESS: returns $addressCredentials directly. + * - ADDRESS_V2: returns $addressCredentials directly. + * - ADDRESS_WITH_DELEGATES: returns $addressWithDelegates->addressCredentials. + * - SOURCE_ACCOUNT: returns null. + * + * @return SorobanAddressCredentials|null the inner address credentials, or null for source-account */ public function getAddressCredentials(): ?SorobanAddressCredentials { + if ($this->credentialType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + return $this->addressWithDelegates?->addressCredentials; + } return $this->addressCredentials; } /** - * Sets the address credentials. + * Writes back updated inner SorobanAddressCredentials while preserving the credential arm. + * + * ADDRESS and ADDRESS_V2: sets $addressCredentials. + * ADDRESS_WITH_DELEGATES: sets $addressWithDelegates->addressCredentials. + * SOURCE_ACCOUNT: no-op. * - * @param SorobanAddressCredentials|null $addressCredentials the address credentials or null for source account + * @param SorobanAddressCredentials $addressCredentials the updated address credentials + */ + public function writeBackAddressCredentials(SorobanAddressCredentials $addressCredentials): void + { + if ($this->credentialType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + if ($this->addressWithDelegates !== null) { + $this->addressWithDelegates->addressCredentials = $addressCredentials; + } + } elseif ($this->credentialType !== XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { + $this->addressCredentials = $addressCredentials; + } + } + + /** + * Sets the address credentials for ADDRESS and ADDRESS_V2 arms. + * + * @param SorobanAddressCredentials|null $addressCredentials the address credentials */ public function setAddressCredentials(?SorobanAddressCredentials $addressCredentials): void { $this->addressCredentials = $addressCredentials; } -} \ No newline at end of file + + /** + * Returns the ADDRESS_WITH_DELEGATES payload, or null for other arms. + * + * @return SorobanAddressCredentialsWithDelegates|null + */ + public function getAddressWithDelegates(): ?SorobanAddressCredentialsWithDelegates + { + return $this->addressWithDelegates; + } + + /** + * Sets the ADDRESS_WITH_DELEGATES payload. + * + * @param SorobanAddressCredentialsWithDelegates|null $addressWithDelegates + */ + public function setAddressWithDelegates(?SorobanAddressCredentialsWithDelegates $addressWithDelegates): void + { + $this->addressWithDelegates = $addressWithDelegates; + } + + /** + * Returns the credential type constant. + * + * @return int one of XdrSorobanCredentialsType constants + */ + public function getCredentialType(): int + { + return $this->credentialType; + } + + /** + * Sets the credential type constant. + * + * @param int $credentialType one of XdrSorobanCredentialsType constants + */ + public function setCredentialType(int $credentialType): void + { + $this->credentialType = $credentialType; + } +} diff --git a/Soneso/StellarSDK/Soroban/SorobanDelegateDescriptor.php b/Soneso/StellarSDK/Soroban/SorobanDelegateDescriptor.php new file mode 100644 index 00000000..499966eb --- /dev/null +++ b/Soneso/StellarSDK/Soroban/SorobanDelegateDescriptor.php @@ -0,0 +1,56 @@ + nested delegate descriptors + */ + public array $nestedDelegates; + + /** + * @param string $address strkey of the delegate address (G- or C-prefixed) + * @param XdrSCVal|null $signature initial signature value; null for void + * @param array $nestedDelegates nested delegate descriptors + */ + public function __construct( + string $address, + ?XdrSCVal $signature = null, + array $nestedDelegates = [], + ) { + $this->address = $address; + $this->signature = $signature; + $this->nestedDelegates = $nestedDelegates; + } +} diff --git a/Soneso/StellarSDK/Soroban/SorobanDelegateSignature.php b/Soneso/StellarSDK/Soroban/SorobanDelegateSignature.php new file mode 100644 index 00000000..e5f7649b --- /dev/null +++ b/Soneso/StellarSDK/Soroban/SorobanDelegateSignature.php @@ -0,0 +1,153 @@ + sorted nested delegate nodes, empty if none + */ + public array $nestedDelegates; + + /** + * @param XdrSCAddress $address the delegate address + * @param XdrSCVal|null $signature signature or null for void + * @param array $nestedDelegates nested delegates (already sorted) + */ + public function __construct( + XdrSCAddress $address, + ?XdrSCVal $signature = null, + array $nestedDelegates = [], + ) { + $this->address = $address; + $this->signature = $signature ?? XdrSCVal::forVoid(); + $this->nestedDelegates = $nestedDelegates; + } + + /** + * Creates a SorobanDelegateSignature from its XDR representation. + * + * @param XdrSorobanDelegateSignature $xdr the XDR delegate signature to decode + * @return SorobanDelegateSignature the decoded delegate signature + */ + public static function fromXdr(XdrSorobanDelegateSignature $xdr): SorobanDelegateSignature + { + $nested = []; + foreach ($xdr->nestedDelegates as $xdrNested) { + $nested[] = self::fromXdr($xdrNested); + } + return new SorobanDelegateSignature($xdr->address, $xdr->signature, $nested); + } + + /** + * Converts this delegate signature to its XDR representation. + * + * @return XdrSorobanDelegateSignature the XDR representation + */ + public function toXdr(): XdrSorobanDelegateSignature + { + $nestedXdr = []; + foreach ($this->nestedDelegates as $nested) { + $nestedXdr[] = $nested->toXdr(); + } + return new XdrSorobanDelegateSignature($this->address, $this->signature, $nestedXdr); + } + + /** + * Returns the address of this delegate node. + * + * @return XdrSCAddress the delegate address + */ + public function getAddress(): XdrSCAddress + { + return $this->address; + } + + /** + * Sets the address of this delegate node. + * + * @param XdrSCAddress $address the delegate address + */ + public function setAddress(XdrSCAddress $address): void + { + $this->address = $address; + } + + /** + * Returns the signature value. + * + * @return XdrSCVal the signature (void when unsigned) + */ + public function getSignature(): XdrSCVal + { + return $this->signature; + } + + /** + * Sets the signature value. + * + * @param XdrSCVal $signature the signature data + */ + public function setSignature(XdrSCVal $signature): void + { + $this->signature = $signature; + } + + /** + * Returns the nested delegates array. + * + * @return array the nested delegate nodes + */ + public function getNestedDelegates(): array + { + return $this->nestedDelegates; + } + + /** + * Sets the nested delegates array. + * + * @param array $nestedDelegates the nested delegate nodes + */ + public function setNestedDelegates(array $nestedDelegates): void + { + $this->nestedDelegates = $nestedDelegates; + } +} diff --git a/Soneso/StellarSDK/Xdr/XdrBuffer.php b/Soneso/StellarSDK/Xdr/XdrBuffer.php index 08e24112..6c9fc13b 100644 --- a/Soneso/StellarSDK/Xdr/XdrBuffer.php +++ b/Soneso/StellarSDK/Xdr/XdrBuffer.php @@ -11,15 +11,33 @@ /** - * Enables easy iteration through a blob of XDR data + * Enables easy iteration through a blob of XDR data. + * + * Provides an optional recursion-depth guard for XDR types that decode themselves + * recursively (e.g. XdrSorobanDelegateSignature). Call enterRecursion() at the start + * of each recursive call and leaveRecursion() on return. The guard throws + * InvalidArgumentException when the depth exceeds RECURSION_LIMIT (128), preventing + * stack exhaustion from hostile deep-nesting in data received from the network. + * + * The guard counts true nesting depth, not array width or sequential fields, so wide + * transactions and large maps are unaffected. */ class XdrBuffer { + /** + * Maximum allowed decode recursion depth (prevents stack exhaustion from hostile data). + */ + public const RECURSION_LIMIT = 128; protected string $xdrBytes; protected int $position; // Current position within the bytes protected int $size; + /** + * @var int current recursion depth tracked via enterRecursion/leaveRecursion + */ + private int $recursionDepth = 0; + public function __construct(string $xdrBytes) { $this->xdrBytes = $xdrBytes; @@ -193,6 +211,47 @@ protected function assertBytesRemaining($numBytes) } } + /** + * Signals entry into one level of recursive XDR decoding. + * + * Increments the depth counter and throws InvalidArgumentException when the limit + * is exceeded. Pair every call with a corresponding leaveRecursion() call. + * + * @throws InvalidArgumentException when depth exceeds RECURSION_LIMIT + */ + public function enterRecursion(): void + { + $this->recursionDepth++; + if ($this->recursionDepth > self::RECURSION_LIMIT) { + throw new InvalidArgumentException( + 'XDR decode recursion limit (' . self::RECURSION_LIMIT . ') exceeded — possible hostile nesting' + ); + } + } + + /** + * Signals exit from one level of recursive XDR decoding. + * + * Decrements the depth counter. Must be called once for every successful + * enterRecursion() call, including on exception paths (use try/finally). + */ + public function leaveRecursion(): void + { + if ($this->recursionDepth > 0) { + $this->recursionDepth--; + } + } + + /** + * Returns the current recursion depth. + * + * @return int current depth (0 at top level) + */ + public function getRecursionDepth(): int + { + return $this->recursionDepth; + } + /** * rounds $number up to the nearest value that's a multiple of 4 * diff --git a/Soneso/StellarSDK/Xdr/XdrSorobanCredentials.php b/Soneso/StellarSDK/Xdr/XdrSorobanCredentials.php index 07404a9c..61433794 100644 --- a/Soneso/StellarSDK/Xdr/XdrSorobanCredentials.php +++ b/Soneso/StellarSDK/Xdr/XdrSorobanCredentials.php @@ -2,15 +2,68 @@ namespace Soneso\StellarSDK\Xdr; +/** + * Hand-written factory wrapper for XdrSorobanCredentialsBase. + * + * Provides named constructors for all four credential arms. The generated base class + * (XdrSorobanCredentialsBase) handles encode/decode/JSON/TxRep; this subclass only adds + * the factory methods. + */ class XdrSorobanCredentials extends XdrSorobanCredentialsBase { - public static function forSourceAccount(): XdrSorobanCredentials { + /** + * Creates source-account credentials. + * + * @return XdrSorobanCredentials + */ + public static function forSourceAccount(): XdrSorobanCredentials + { return new XdrSorobanCredentials(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT()); } - public static function forAddressCredentials(XdrSorobanAddressCredentials $addressCredentials): XdrSorobanCredentials { + /** + * Creates legacy ADDRESS credentials. + * + * @param XdrSorobanAddressCredentials $addressCredentials + * @return XdrSorobanCredentials + */ + public static function forAddressCredentials(XdrSorobanAddressCredentials $addressCredentials): XdrSorobanCredentials + { $result = new XdrSorobanCredentials(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS()); $result->address = $addressCredentials; return $result; } + + /** + * Creates ADDRESS_V2 credentials (Protocol 27, CAP-71). + * + * Uses ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS (address-bound preimage). + * Invalid on networks below Protocol 27. + * + * @param XdrSorobanAddressCredentials $addressCredentials + * @return XdrSorobanCredentials + */ + public static function forAddressCredentialsV2(XdrSorobanAddressCredentials $addressCredentials): XdrSorobanCredentials + { + $result = new XdrSorobanCredentials(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2()); + $result->addressV2 = $addressCredentials; + return $result; + } + + /** + * Creates ADDRESS_WITH_DELEGATES credentials (Protocol 27, CAP-71). + * + * Uses ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS with a recursive delegate tree. + * Invalid on networks below Protocol 27. + * + * @param XdrSorobanAddressCredentialsWithDelegates $addressWithDelegates + * @return XdrSorobanCredentials + */ + public static function forAddressWithDelegates( + XdrSorobanAddressCredentialsWithDelegates $addressWithDelegates, + ): XdrSorobanCredentials { + $result = new XdrSorobanCredentials(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES()); + $result->addressWithDelegates = $addressWithDelegates; + return $result; + } } diff --git a/Soneso/StellarSDK/Xdr/XdrSorobanDelegateSignature.php b/Soneso/StellarSDK/Xdr/XdrSorobanDelegateSignature.php index 746fb049..e936a564 100644 --- a/Soneso/StellarSDK/Xdr/XdrSorobanDelegateSignature.php +++ b/Soneso/StellarSDK/Xdr/XdrSorobanDelegateSignature.php @@ -32,14 +32,19 @@ public function encode(): string { } public static function decode(XdrBuffer $xdr): XdrSorobanDelegateSignature { - $address = XdrSCAddress::decode($xdr); - $signature = XdrSCVal::decode($xdr); - $nestedDelegates = []; - $nestedDelegatesSize = $xdr->readInteger32(); - for ($i = 0; $i < $nestedDelegatesSize; $i++) { - $nestedDelegates[] = XdrSorobanDelegateSignature::decode($xdr); + $xdr->enterRecursion(); + try { + $address = XdrSCAddress::decode($xdr); + $signature = XdrSCVal::decode($xdr); + $nestedDelegates = []; + $nestedDelegatesSize = $xdr->readInteger32(); + for ($i = 0; $i < $nestedDelegatesSize; $i++) { + $nestedDelegates[] = XdrSorobanDelegateSignature::decode($xdr); + } + return new XdrSorobanDelegateSignature($address, $signature, $nestedDelegates); + } finally { + $xdr->leaveRecursion(); } - return new XdrSorobanDelegateSignature($address, $signature, $nestedDelegates); } public function getAddress(): XdrSCAddress { return $this->address; } diff --git a/Soneso/StellarSDKTests/Integration/P27AddressV2RoundTripTest.php b/Soneso/StellarSDKTests/Integration/P27AddressV2RoundTripTest.php new file mode 100644 index 00000000..184d0356 --- /dev/null +++ b/Soneso/StellarSDKTests/Integration/P27AddressV2RoundTripTest.php @@ -0,0 +1,277 @@ + inspect-credential-arm -> sign -> submit flow when the + * RPC returns ADDRESS_V2 credentials (after Protocol 27 upgrade). Until the testnet + * upgrades to Protocol 27 (2026-06-18) and an RPC server supporting the authV2 flag + * is released (stellar-rpc #783), the RPC returns legacy ADDRESS credentials even when + * authV2=true is sent. This test tolerates that: it inspects the credential arm of the + * returned entries and adapts its assertions accordingly so that it exercises the + * correct P27 code paths once available without blocking CI before then. + * + * The test requires network access to the testnet RPC and is gated by $testOn. + * It will only run when explicitly invoked via the integration suite. + */ +class P27AddressV2RoundTripTest extends TestCase +{ + const AUTH_CONTRACT_PATH = './../wasm/soroban_auth_contract.wasm'; + const TESTNET_SERVER_URL = "https://soroban-testnet.stellar.org"; + + /** + * Set to 'testnet' to run; any other value skips the test. + * + * This gate follows the same convention used throughout the integration suite. + */ + private string $testOn = 'skip'; // Change to 'testnet' to enable + + private Network $network; + private SorobanServer $server; + + public function setUp(): void + { + error_reporting(E_ALL); + if ($this->testOn === 'testnet') { + $this->network = Network::testnet(); + $this->server = new SorobanServer(self::TESTNET_SERVER_URL); + $this->server->setLogger(new PrintLogger()); + } + } + + /** + * ADDRESS_V2 round-trip: simulate with authV2=true, inspect the returned credential arm, + * sign using the correct preimage for the detected arm, and submit. + * + * Detection: the credential arm of the returned entry reveals whether the RPC honored the + * authV2 flag. We assert the correct arm-specific preimage was used for signing without + * hard-requiring V2 — no released RPC supports authV2 yet (stellar-rpc #783). + * + * @throws Exception + * @throws GuzzleException + */ + public function testAddressV2SimulateSignRoundTrip(): void + { + if ($this->testOn !== 'testnet') { + $this->markTestSkipped( + 'P27 ADDRESS_V2 integration test requires testnet access. ' + . 'Set $testOn = "testnet" to enable. ' + . 'Note: authV2 RPC support is gated on stellar-rpc #783 (unreleased). ' + . 'This test tolerates legacy ADDRESS responses until V2 RPC support is released.' + ); + } + + // Fund two accounts: submitter and invoker. + $submitterKeyPair = KeyPair::random(); + $invokerKeyPair = KeyPair::random(); + $submitterId = $submitterKeyPair->getAccountId(); + $invokerId = $invokerKeyPair->getAccountId(); + + FriendBot::fundTestAccount($submitterId); + FriendBot::fundTestAccount($invokerId); + sleep(5); + + // Deploy the auth contract. + $contractId = $this->deployContract($this->server, self::AUTH_CONTRACT_PATH, $submitterKeyPair); + + // Build the invoke transaction. + $invokerAddress = Address::fromAccountId($invokerId); + $args = [$invokerAddress->toXdrSCVal(), XdrSCVal::forU32(1)]; + + $invokeHostFunction = new InvokeContractHostFunction($contractId, 'increment', $args); + $op = (new InvokeHostFunctionOperationBuilder($invokeHostFunction))->build(); + + $submitterAccount = $this->server->getAccount($submitterId); + $this->assertNotNull($submitterAccount); + $transaction = (new TransactionBuilder($submitterAccount))->addOperation($op)->build(); + + // Simulate with authV2=true to request V2 credential entries. + // RPCs without authV2 support silently ignore the flag and return legacy ADDRESS entries. + $request = new SimulateTransactionRequest(transaction: $transaction, authV2: true); + $simulateResponse = $this->server->simulateTransaction($request); + + $this->assertNull($simulateResponse->error); + $this->assertNotNull($simulateResponse->results); + $this->assertNotNull($simulateResponse->transactionData); + $this->assertNotNull($simulateResponse->minResourceFee); + + $transactionData = $simulateResponse->getTransactionData(); + $transaction->setSorobanTransactionData($transactionData); + $transaction->addResourceFee($simulateResponse->minResourceFee); + + $auth = $simulateResponse->getSorobanAuth(); + $this->assertNotNull($auth); + + $latestLedgerResponse = $this->server->getLatestLedger(); + $this->assertNotNull($latestLedgerResponse->sequence); + + // Inspect the credential arm and sign correctly for each arm. + // We do NOT assert that the RPC returned V2 — it may still return legacy ADDRESS + // until stellar-rpc #783 is released and deployed. + foreach ($auth as $entry) { + $this->assertInstanceOf(SorobanAuthorizationEntry::class, $entry); + + $credType = $entry->credentials->credentialType; + + // The arm must be one of the three address-based types (not SOURCE_ACCOUNT). + $this->assertContains( + $credType, + [ + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + ], + 'Simulate must return an address-based credential arm', + ); + + // Set the expiration ledger via the arm-preserving getAddressCredentials() helper + // and sign. The sign() method selects the correct preimage automatically based on + // the credential arm, so V2 entries use the address-bound preimage. + $innerCreds = $entry->credentials->getAddressCredentials(); + $this->assertNotNull($innerCreds); + $innerCreds->signatureExpirationLedger = $latestLedgerResponse->sequence + 100; + $entry->credentials->writeBackAddressCredentials($innerCreds); + + $entry->sign($invokerKeyPair, $this->network); + + // Confirm that the arm was preserved after signing. + $this->assertSame( + $credType, + $entry->credentials->credentialType, + 'Credential arm must be preserved after sign()', + ); + + // Confirm that a signature was written. + $signedCreds = $entry->credentials->getAddressCredentials(); + $this->assertNotNull($signedCreds); + $this->assertNotNull( + $signedCreds->signature->vec, + 'Signature must be written after sign()', + ); + $this->assertCount(1, $signedCreds->signature->vec); + } + + $transaction->setSorobanAuth($auth); + $transaction->sign($submitterKeyPair, $this->network); + + $sendResponse = $this->server->sendTransaction($transaction); + $this->assertNull($sendResponse->error); + + $statusResponse = $this->pollStatus($this->server, $sendResponse->hash); + $this->assertNotNull($statusResponse->getResultValue()); + + $resVal = $statusResponse->getResultValue(); + $this->assertNotNull($resVal); + $this->assertEquals(1, $resVal->u32); + } + + private function pollStatus(SorobanServer $server, string $transactionId): GetTransactionResponse + { + $statusResponse = $server->getTransaction($transactionId); + $count = 15; + while ($count-- > 0 && $statusResponse->status === GetTransactionResponse::STATUS_NOT_FOUND) { + sleep(3); + $statusResponse = $server->getTransaction($transactionId); + } + return $statusResponse; + } + + private function deployContract(SorobanServer $server, string $wasmPath, KeyPair $accountKeyPair): string + { + // Load WASM. + $wasm = file_get_contents($wasmPath); + if ($wasm === false) { + throw new Exception("Could not load WASM from $wasmPath"); + } + + $accountId = $accountKeyPair->getAccountId(); + $account = $server->getAccount($accountId); + $this->assertNotNull($account); + + // Upload WASM. + $uploadHostFunction = new \Soneso\StellarSDK\UploadContractWasmHostFunction($wasm); + $op = (new \Soneso\StellarSDK\InvokeHostFunctionOperationBuilder($uploadHostFunction))->build(); + + $transaction = (new TransactionBuilder($account))->addOperation($op)->build(); + + $uploadRequest = new SimulateTransactionRequest($transaction); + $simulateResponse = $server->simulateTransaction($uploadRequest); + + $this->assertNull($simulateResponse->error); + $transactionData = $simulateResponse->getTransactionData(); + $transaction->setSorobanTransactionData($transactionData); + $transaction->addResourceFee($simulateResponse->minResourceFee); + $transaction->sign($accountKeyPair, $this->network); + + $sendResponse = $server->sendTransaction($transaction); + $this->assertNull($sendResponse->error); + + $statusResponse = $this->pollStatus($server, $sendResponse->hash); + $this->assertNotNull($statusResponse); + + $wasmId = $statusResponse->getResultValue()?->bytes?->getValue(); + $this->assertNotNull($wasmId); + + // Re-fetch account (sequence updated). + $account = $server->getAccount($accountId); + $this->assertNotNull($account); + + // Create contract. + $createHostFunction = new \Soneso\StellarSDK\CreateContractHostFunction( + new \Soneso\StellarSDK\Soroban\Address( + \Soneso\StellarSDK\Soroban\Address::TYPE_ACCOUNT, + accountId: $accountId, + ), + bin2hex($wasmId), + ); + $op2 = (new \Soneso\StellarSDK\InvokeHostFunctionOperationBuilder($createHostFunction))->build(); + + $transaction2 = (new TransactionBuilder($account))->addOperation($op2)->build(); + + $createRequest = new SimulateTransactionRequest($transaction2); + $simulateResponse2 = $server->simulateTransaction($createRequest); + + $this->assertNull($simulateResponse2->error); + $transactionData2 = $simulateResponse2->getTransactionData(); + $transaction2->setSorobanTransactionData($transactionData2); + $transaction2->addResourceFee($simulateResponse2->minResourceFee); + $transaction2->setSorobanAuth($simulateResponse2->getSorobanAuth()); + $transaction2->sign($accountKeyPair, $this->network); + + $sendResponse2 = $server->sendTransaction($transaction2); + $this->assertNull($sendResponse2->error); + + $statusResponse2 = $this->pollStatus($server, $sendResponse2->hash); + $this->assertNotNull($statusResponse2); + + $contractIdBytes = $statusResponse2->getResultValue()?->address?->contractId; + $this->assertNotNull($contractIdBytes); + + return \Soneso\StellarSDK\Crypto\StrKey::encodeContractIdHex($contractIdBytes); + } +} diff --git a/Soneso/StellarSDKTests/Unit/SEP/WebAuthForContracts/P27WebAuthForContractsTest.php b/Soneso/StellarSDKTests/Unit/SEP/WebAuthForContracts/P27WebAuthForContractsTest.php new file mode 100644 index 00000000..3cb15df5 --- /dev/null +++ b/Soneso/StellarSDKTests/Unit/SEP/WebAuthForContracts/P27WebAuthForContractsTest.php @@ -0,0 +1,898 @@ +makeGoldenInvocation()); + } + + private function makeGoldenV2Entry(): SorobanAuthorizationEntry + { + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addrCreds = new SorobanAddressCredentials($address, self::GOLDEN_NONCE, self::GOLDEN_EXPIRY, XdrSCVal::forVoid()); + $creds = SorobanCredentials::forAddressCredentialsV2($addrCreds); + return new SorobanAuthorizationEntry($creds, $this->makeGoldenInvocation()); + } + + /** + * Builds an authorization entry using an arbitrary address arm with the SEP-45 challenge shape. + * + * @param string $credentialsAddress strkey of the credential address + * @param string $contractId the web auth contract ID (C... strkey) + * @param string $functionName contract function name + * @param XdrSCVal $argsMap pre-built arguments map + * @param int $nonce credential nonce + * @param int $expirationLedger credential expiration ledger + * @param int $credType one of XdrSorobanCredentialsType constants (ADDRESS, ADDRESS_V2) + * @return SorobanAuthorizationEntry the built entry + */ + private function buildAuthEntry( + string $credentialsAddress, + string $contractId, + string $functionName, + XdrSCVal $argsMap, + int $nonce, + int $expirationLedger, + int $credType = XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, + ): SorobanAuthorizationEntry { + $address = Address::fromAnyId($credentialsAddress); + $addrCreds = new SorobanAddressCredentials($address, $nonce, $expirationLedger, XdrSCVal::forVec([])); + $credentials = match ($credType) { + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2 + => SorobanCredentials::forAddressCredentialsV2($addrCreds), + default + => SorobanCredentials::forAddressCredentials($addrCreds), + }; + + $contractAddress = Address::fromContractId(StrKey::decodeContractIdHex($contractId)); + $contractFn = new XdrInvokeContractArgs($contractAddress->toXdr(), $functionName, [$argsMap]); + $function = new SorobanAuthorizedFunction($contractFn); + $invocation = new SorobanAuthorizedInvocation($function, []); + + return new SorobanAuthorizationEntry($credentials, $invocation); + } + + /** + * Builds the SEP-45 args map as an XdrSCVal map. + * + * @param string $account client account strkey + * @param string $homeDomain home domain string + * @param string $webAuthDomain web auth domain string + * @param string $webAuthDomainAccount web auth domain account strkey + * @param string $nonce nonce string + * @return XdrSCVal the map value + */ + private function buildArgsMap( + string $account, + string $homeDomain, + string $webAuthDomain, + string $webAuthDomainAccount, + string $nonce, + ): XdrSCVal { + return XdrSCVal::forMap([ + new XdrSCMapEntry(XdrSCVal::forSymbol('account'), XdrSCVal::forString($account)), + new XdrSCMapEntry(XdrSCVal::forSymbol('home_domain'), XdrSCVal::forString($homeDomain)), + new XdrSCMapEntry(XdrSCVal::forSymbol('web_auth_domain'), XdrSCVal::forString($webAuthDomain)), + new XdrSCMapEntry(XdrSCVal::forSymbol('web_auth_domain_account'), XdrSCVal::forString($webAuthDomainAccount)), + new XdrSCMapEntry(XdrSCVal::forSymbol('nonce'), XdrSCVal::forString($nonce)), + ]); + } + + /** + * Encodes an array of SorobanAuthorizationEntry to a base64 XDR array as used by SEP-45. + * + * @param array $entries + */ + private function encodeAuthEntries(array $entries): string + { + $bytes = XdrEncoder::unsignedInteger32(count($entries)); + foreach ($entries as $entry) { + $bytes .= $entry->toXdr()->encode(); + } + return base64_encode($bytes); + } + + /** + * Creates a WebAuthForContracts instance backed by the fixed test server key. + */ + private function makeWebAuth(): WebAuthForContracts + { + return new WebAuthForContracts( + $this->authServer, + $this->webAuthContractId, + $this->serverAccountId, + $this->domain, + Network::testnet(), + ); + } + + // --------------------------------------------------------------------------- + // TASK 1 — verifyServerSignature: ADDRESS_V2 server entry + // --------------------------------------------------------------------------- + + /** + * verifyServerSignature accepts an ADDRESS_V2 server entry whose signature was made over + * the WITH_ADDRESS preimage (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS). + * + * This is the core P27 requirement: legacy-arm-only acceptance must not remain. + */ + public function testVerifyServerSignatureAcceptsAddressV2Entry(): void + { + $serverKeyPair = KeyPair::fromSeed($this->serverSecretSeed); + + $argsMap = $this->buildArgsMap( + $this->clientContractId, + $this->domain, + 'auth.example.stellar.org', + $this->serverAccountId, + 'test_nonce_v2', + ); + + // Build server entry as ADDRESS_V2 + $serverEntry = $this->buildAuthEntry( + $this->serverAccountId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 99001, + 2000000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + + // Sign with the WITH_ADDRESS preimage (what sign() does for ADDRESS_V2) + $serverEntry->sign($serverKeyPair, Network::testnet()); + + // Validate that the signature is considered valid by verifyServerSignature + // (called indirectly via validateChallenge) + $clientEntry = $this->buildAuthEntry( + $this->clientContractId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 99002, + 2000000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + + $webAuth = $this->makeWebAuth(); + // validateChallenge must NOT throw — the V2 server signature is valid + $webAuth->validateChallenge( + [$serverEntry, $clientEntry], + $this->clientContractId, + $this->domain, + ); + + // If we reach here, the V2 server entry was accepted + $this->assertTrue(true); + } + + /** + * verifyServerSignature rejects an ADDRESS_V2 server entry that was signed over the + * legacy preimage (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION) instead of the correct + * WITH_ADDRESS preimage. + * + * A V2 entry whose payload was built without the address field must not verify. + */ + public function testVerifyServerSignatureRejectsV2EntrySignedOverLegacyPreimage(): void + { + $this->expectException(ContractChallengeValidationErrorInvalidServerSignature::class); + + $serverKeyPair = KeyPair::fromSeed($this->serverSecretSeed); + + $argsMap = $this->buildArgsMap( + $this->clientContractId, + $this->domain, + 'auth.example.stellar.org', + $this->serverAccountId, + 'mismatch_nonce', + ); + + // Build the entry as ADDRESS_V2 but sign it using the legacy (ADDRESS) preimage. + // To simulate this, we build the same entry as ADDRESS (legacy) first, sign it + // to capture the payload, then put those signature bytes into an ADDRESS_V2 entry. + $legacyEntry = $this->buildAuthEntry( + $this->serverAccountId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 88001, + 1500000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, + ); + // Build legacy preimage and sign it + $legacyPreimage = $legacyEntry->buildPreimage(Network::testnet()); + $legacyPayload = Hash::generate($legacyPreimage->encode()); + $sigBytes = $serverKeyPair->sign($legacyPayload); + + // Inject that legacy-preimage signature into an ADDRESS_V2 entry manually + $serverAddress = Address::fromAccountId($this->serverAccountId); + $fakeSigEntry = new \Soneso\StellarSDK\Soroban\AccountEd25519Signature( + $serverKeyPair->getPublicKey(), + $sigBytes, + ); + $fakeSigVec = XdrSCVal::forVec([$fakeSigEntry->toXdrSCVal()]); + + $addrCreds = new SorobanAddressCredentials($serverAddress, 88001, 1500000, $fakeSigVec); + $v2Creds = SorobanCredentials::forAddressCredentialsV2($addrCreds); + + $contractAddress = Address::fromContractId(StrKey::decodeContractIdHex($this->webAuthContractId)); + $contractFn = new XdrInvokeContractArgs($contractAddress->toXdr(), 'web_auth_verify', [$argsMap]); + $fn = new SorobanAuthorizedFunction($contractFn); + $invocation = new SorobanAuthorizedInvocation($fn, []); + $v2ServerEntry = new SorobanAuthorizationEntry($v2Creds, $invocation); + + $clientEntry = $this->buildAuthEntry( + $this->clientContractId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 88002, + 1500000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + + $webAuth = $this->makeWebAuth(); + // validateChallenge must throw because the V2 entry carries a legacy-preimage signature + $webAuth->validateChallenge( + [$v2ServerEntry, $clientEntry], + $this->clientContractId, + $this->domain, + ); + } + + // --------------------------------------------------------------------------- + // TASK 2 — Legacy ADDRESS path: golden payload hash byte-identity + // --------------------------------------------------------------------------- + + /** + * The legacy ADDRESS arm must produce the exact golden payload sha256. + * + * This ensures byte-identity with the pre-change behavior and cross-SDK compatibility. + */ + public function testLegacyAddressPayloadMatchesGolden(): void + { + $legacyEntry = $this->makeGoldenLegacyEntry(); + $preimage = $legacyEntry->buildPreimage(Network::testnet()); + $payload = Hash::generate($preimage->encode()); + + $this->assertSame( + self::GOLDEN_LEGACY_PAYLOAD_HEX, + bin2hex($payload), + 'Legacy ADDRESS preimage sha256 must match the golden cross-SDK vector', + ); + } + + /** + * The ADDRESS_V2 arm must produce the exact golden V2 payload sha256. + */ + public function testAddressV2PayloadMatchesGolden(): void + { + $v2Entry = $this->makeGoldenV2Entry(); + $preimage = $v2Entry->buildPreimage(Network::testnet()); + $payload = Hash::generate($preimage->encode()); + + $this->assertSame( + self::GOLDEN_V2_PAYLOAD_HEX, + bin2hex($payload), + 'ADDRESS_V2 preimage sha256 must match the golden cross-SDK vector', + ); + } + + /** + * Legacy and V2 payloads must differ for otherwise-identical fields. + */ + public function testLegacyAndV2PayloadsDiffer(): void + { + $legacyPreimage = $this->makeGoldenLegacyEntry()->buildPreimage(Network::testnet()); + $v2Preimage = $this->makeGoldenV2Entry()->buildPreimage(Network::testnet()); + + $this->assertNotSame( + base64_encode($legacyPreimage->encode()), + base64_encode($v2Preimage->encode()), + 'Legacy and V2 preimages must differ', + ); + } + + // --------------------------------------------------------------------------- + // TASK 3 — signAuthorizationEntries: arm preservation and expiration stamping + // --------------------------------------------------------------------------- + + /** + * signAuthorizationEntries preserves the ADDRESS_V2 arm after signing and applies + * the expiration ledger before hashing (so the preimage is built with the new expiration). + */ + public function testSignAuthorizationEntriesPreservesV2Arm(): void + { + $serverKeyPair = KeyPair::fromSeed($this->serverSecretSeed); + $clientKeyPair = KeyPair::random(); + + $argsMap = $this->buildArgsMap( + $this->clientContractId, + $this->domain, + 'auth.example.stellar.org', + $this->serverAccountId, + 'nonce_arm_preserve', + ); + + // Server entry as ADDRESS_V2 + $serverEntry = $this->buildAuthEntry( + $this->serverAccountId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 77001, + 900000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + $serverEntry->sign($serverKeyPair, Network::testnet()); + + // Client entry as ADDRESS_V2 (unsigned, to be signed by signAuthorizationEntries) + $clientEntry = $this->buildAuthEntry( + $this->clientContractId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 77002, + 900000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + + $webAuth = $this->makeWebAuth(); + $signed = $webAuth->signAuthorizationEntries( + [$serverEntry, $clientEntry], + $this->clientContractId, + [$clientKeyPair], + 999999, // expiration ledger applied before hashing + ); + + $this->assertCount(2, $signed); + + // Both entries must still carry the ADDRESS_V2 arm + foreach ($signed as $entry) { + $this->assertSame( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + $entry->credentials->credentialType, + 'Arm must be preserved as ADDRESS_V2 after signing', + ); + } + + // The client entry must have a non-void signature after signing + $clientSigned = $signed[1]; + $innerCreds = $clientSigned->credentials->getAddressCredentials(); + $this->assertNotNull($innerCreds); + $this->assertSame( + 999999, + $innerCreds->signatureExpirationLedger, + 'signatureExpirationLedger must be stamped by signAuthorizationEntries', + ); + $this->assertNotNull($innerCreds->signature->vec); + $this->assertCount(1, $innerCreds->signature->vec); + } + + /** + * signAuthorizationEntries preserves the legacy ADDRESS arm after signing. + */ + public function testSignAuthorizationEntriesPreservesLegacyArm(): void + { + $serverKeyPair = KeyPair::fromSeed($this->serverSecretSeed); + $clientKeyPair = KeyPair::random(); + + $argsMap = $this->buildArgsMap( + $this->clientContractId, + $this->domain, + 'auth.example.stellar.org', + $this->serverAccountId, + 'nonce_legacy_arm', + ); + + $serverEntry = $this->buildAuthEntry( + $this->serverAccountId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 66001, + 800000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, + ); + $serverEntry->sign($serverKeyPair, Network::testnet()); + + $clientEntry = $this->buildAuthEntry( + $this->clientContractId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 66002, + 800000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, + ); + + $webAuth = $this->makeWebAuth(); + $signed = $webAuth->signAuthorizationEntries( + [$serverEntry, $clientEntry], + $this->clientContractId, + [$clientKeyPair], + 888888, + ); + + foreach ($signed as $entry) { + $this->assertSame( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, + $entry->credentials->credentialType, + 'Arm must remain ADDRESS after signing', + ); + } + + $innerCreds = $signed[1]->credentials->getAddressCredentials(); + $this->assertNotNull($innerCreds); + $this->assertSame(888888, $innerCreds->signatureExpirationLedger); + } + + /** + * signAuthorizationEntries handles ADDRESS_WITH_DELEGATES entries: the arm is preserved + * and the expiration is written to the top-level credentials. + */ + public function testSignAuthorizationEntriesPreservesWithDelegatesArm(): void + { + $serverKeyPair = KeyPair::fromSeed($this->serverSecretSeed); + $clientKeyPair = KeyPair::random(); + $delegateKeyPair = KeyPair::random(); + + $argsMap = $this->buildArgsMap( + $this->clientContractId, + $this->domain, + 'auth.example.stellar.org', + $this->serverAccountId, + 'nonce_with_delegates', + ); + + // Server entry using legacy ADDRESS (straightforward server signing) + $serverEntry = $this->buildAuthEntry( + $this->serverAccountId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 55001, + 700000, + ); + $serverEntry->sign($serverKeyPair, Network::testnet()); + + // Build a base V2 entry for the client, then wrap it with a delegate + $clientV2Entry = $this->buildAuthEntry( + $this->clientContractId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 55002, + 700000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + $delegateDescriptor = new SorobanDelegateDescriptor($delegateKeyPair->getAccountId()); + $withDelegatesEntry = SorobanAuthorizationEntry::withDelegates($clientV2Entry, 700000, [$delegateDescriptor]); + + $webAuth = $this->makeWebAuth(); + $signed = $webAuth->signAuthorizationEntries( + [$serverEntry, $withDelegatesEntry], + $this->clientContractId, + [$clientKeyPair], + 750000, + ); + + $this->assertCount(2, $signed); + $signedWithDelegates = $signed[1]; + + // Arm must be preserved + $this->assertSame( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + $signedWithDelegates->credentials->credentialType, + ); + + // Expiration must be stamped on the top-level credentials + $innerCreds = $signedWithDelegates->credentials->getAddressCredentials(); + $this->assertNotNull($innerCreds); + $this->assertSame(750000, $innerCreds->signatureExpirationLedger); + + // Top-level signature must have been written + $this->assertNotNull($innerCreds->signature->vec); + $this->assertCount(1, $innerCreds->signature->vec); + } + + // --------------------------------------------------------------------------- + // TASK 4 — validateChallenge: all three arms recognized; source-account rejected + // --------------------------------------------------------------------------- + + /** + * validateChallenge recognizes an ADDRESS_V2 server entry and accepts it + * when the signature is valid. + */ + public function testValidateChallengeAcceptsV2ServerEntry(): void + { + $serverKeyPair = KeyPair::fromSeed($this->serverSecretSeed); + + $argsMap = $this->buildArgsMap( + $this->clientContractId, + $this->domain, + 'auth.example.stellar.org', + $this->serverAccountId, + 'validate_v2_nonce', + ); + + $serverEntry = $this->buildAuthEntry( + $this->serverAccountId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 44001, + 600000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + $serverEntry->sign($serverKeyPair, Network::testnet()); + + $clientEntry = $this->buildAuthEntry( + $this->clientContractId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 44002, + 600000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + + $webAuth = $this->makeWebAuth(); + $webAuth->validateChallenge( + [$serverEntry, $clientEntry], + $this->clientContractId, + $this->domain, + ); + + $this->assertTrue(true); // No exception means success + } + + /** + * validateChallenge recognizes a client entry using the ADDRESS_V2 arm. + */ + public function testValidateChallengeAcceptsV2ClientEntry(): void + { + $serverKeyPair = KeyPair::fromSeed($this->serverSecretSeed); + + $argsMap = $this->buildArgsMap( + $this->clientContractId, + $this->domain, + 'auth.example.stellar.org', + $this->serverAccountId, + 'validate_v2_client_nonce', + ); + + // Server entry as legacy ADDRESS + $serverEntry = $this->buildAuthEntry( + $this->serverAccountId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 33001, + 500000, + ); + $serverEntry->sign($serverKeyPair, Network::testnet()); + + // Client entry as ADDRESS_V2 + $clientEntry = $this->buildAuthEntry( + $this->clientContractId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 33002, + 500000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + + $webAuth = $this->makeWebAuth(); + $webAuth->validateChallenge( + [$serverEntry, $clientEntry], + $this->clientContractId, + $this->domain, + ); + + $this->assertTrue(true); + } + + /** + * validateChallenge throws with a descriptive error when an entry carries + * source-account credentials (no address arm). + */ + public function testValidateChallengeRejectsSourceAccountCredentials(): void + { + $this->expectException(ContractChallengeValidationError::class); + $this->expectExceptionMessageMatches('/source-account credentials/i'); + + $serverKeyPair = KeyPair::fromSeed($this->serverSecretSeed); + + $argsMap = $this->buildArgsMap( + $this->clientContractId, + $this->domain, + 'auth.example.stellar.org', + $this->serverAccountId, + 'source_account_nonce', + ); + + $serverEntry = $this->buildAuthEntry( + $this->serverAccountId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 22001, + 400000, + ); + $serverEntry->sign($serverKeyPair, Network::testnet()); + + // Build a client entry with source-account credentials (no address) + $contractAddress = Address::fromContractId(StrKey::decodeContractIdHex($this->webAuthContractId)); + $contractFn = new XdrInvokeContractArgs($contractAddress->toXdr(), 'web_auth_verify', [$argsMap]); + $fn = new SorobanAuthorizedFunction($contractFn); + $invocation = new SorobanAuthorizedInvocation($fn, []); + $sourceAccEntry = new SorobanAuthorizationEntry( + SorobanCredentials::forSourceAccount(), + $invocation, + ); + + $webAuth = $this->makeWebAuth(); + $webAuth->validateChallenge( + [$serverEntry, $sourceAccEntry], + $this->clientContractId, + $this->domain, + ); + } + + // --------------------------------------------------------------------------- + // TASK 5 — verifyServerSignature: legacy server entry still verifies + // --------------------------------------------------------------------------- + + /** + * The legacy ADDRESS server entry continues to be accepted after the changes. + * This is the core regression test ensuring backward compatibility. + */ + public function testVerifyServerSignatureAcceptsLegacyAddressEntry(): void + { + $serverKeyPair = KeyPair::fromSeed($this->serverSecretSeed); + + $argsMap = $this->buildArgsMap( + $this->clientContractId, + $this->domain, + 'auth.example.stellar.org', + $this->serverAccountId, + 'legacy_server_nonce', + ); + + $serverEntry = $this->buildAuthEntry( + $this->serverAccountId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 11001, + 300000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, + ); + $serverEntry->sign($serverKeyPair, Network::testnet()); + + $clientEntry = $this->buildAuthEntry( + $this->clientContractId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 11002, + 300000, + ); + + $webAuth = $this->makeWebAuth(); + $webAuth->validateChallenge( + [$serverEntry, $clientEntry], + $this->clientContractId, + $this->domain, + ); + + $this->assertTrue(true); + } + + /** + * A legacy ADDRESS server entry that carries no signature is rejected as having an + * invalid server signature, which confirms the signature check runs for legacy entries. + */ + public function testVerifyServerSignatureRejectsUnsignedLegacyEntry(): void + { + $this->expectException(ContractChallengeValidationErrorInvalidServerSignature::class); + + $argsMap = $this->buildArgsMap( + $this->clientContractId, + $this->domain, + 'auth.example.stellar.org', + $this->serverAccountId, + 'unsigned_legacy_nonce', + ); + + // Server entry with no signature + $serverEntry = $this->buildAuthEntry( + $this->serverAccountId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 10001, + 200000, + ); + + $clientEntry = $this->buildAuthEntry( + $this->clientContractId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 10002, + 200000, + ); + + $webAuth = $this->makeWebAuth(); + $webAuth->validateChallenge( + [$serverEntry, $clientEntry], + $this->clientContractId, + $this->domain, + ); + } + + // --------------------------------------------------------------------------- + // TASK 6 — signAuthorizationEntries: ADDRESS_WITH_DELEGATES expiration write-back + // --------------------------------------------------------------------------- + + /** + * signAuthorizationEntries writes the expiration to the inner address credentials + * of an ADDRESS_WITH_DELEGATES entry via writeBackAddressCredentials, not directly + * through $credentials->addressCredentials (which is null for that arm). + */ + public function testSignAuthorizationEntriesWritesExpirationViaWriteBack(): void + { + $serverKeyPair = KeyPair::fromSeed($this->serverSecretSeed); + $clientKeyPair = KeyPair::random(); + $delegateKeyPair = KeyPair::random(); + + $argsMap = $this->buildArgsMap( + $this->clientContractId, + $this->domain, + 'auth.example.stellar.org', + $this->serverAccountId, + 'writeback_expiry_nonce', + ); + + $serverEntry = $this->buildAuthEntry( + $this->serverAccountId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 9001, + 100000, + ); + $serverEntry->sign($serverKeyPair, Network::testnet()); + + // Client entry is ADDRESS_WITH_DELEGATES + $baseClientEntry = $this->buildAuthEntry( + $this->clientContractId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 9002, + 100000, + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + ); + $descriptor = new SorobanDelegateDescriptor($delegateKeyPair->getAccountId()); + $withDelegatesEntry = SorobanAuthorizationEntry::withDelegates($baseClientEntry, 100000, [$descriptor]); + + // Confirm $addressCredentials is null before signing (arm invariant) + $this->assertNull($withDelegatesEntry->credentials->addressCredentials); + + $webAuth = $this->makeWebAuth(); + $signed = $webAuth->signAuthorizationEntries( + [$serverEntry, $withDelegatesEntry], + $this->clientContractId, + [$clientKeyPair], + 123456, + ); + + $signedClient = $signed[1]; + $this->assertSame( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + $signedClient->credentials->credentialType, + ); + + // The expiration must be stamped on the inner credentials + $innerCreds = $signedClient->credentials->getAddressCredentials(); + $this->assertNotNull($innerCreds); + $this->assertSame(123456, $innerCreds->signatureExpirationLedger); + + // $addressCredentials is still null (arm is preserved) + $this->assertNull($signedClient->credentials->addressCredentials); + } +} diff --git a/Soneso/StellarSDKTests/Unit/Soroban/P27AssembledTransactionTest.php b/Soneso/StellarSDKTests/Unit/Soroban/P27AssembledTransactionTest.php new file mode 100644 index 00000000..4167effa --- /dev/null +++ b/Soneso/StellarSDKTests/Unit/Soroban/P27AssembledTransactionTest.php @@ -0,0 +1,772 @@ +invokerKp = KeyPair::fromSeed(self::TEST_SECRET_KEY); + $this->network = Network::testnet(); + } + + // ========================================================================= + // TASK 1 — SimulateTransactionRequest.authV2 wire flag + // ========================================================================= + + /** + * The "authV2" key must be ABSENT from request params when $authV2 is false (default). + */ + public function testAuthV2KeyAbsentByDefault(): void + { + $tx = $this->buildMockTx(); + $request = new SimulateTransactionRequest(transaction: $tx); + + $params = $request->getRequestParams(); + + $this->assertArrayNotHasKey('authV2', $params, '"authV2" key must not appear when flag is false (default)'); + $this->assertArrayHasKey('transaction', $params); + } + + /** + * The "authV2" key must be ABSENT when explicitly set to false. + */ + public function testAuthV2KeyAbsentWhenExplicitFalse(): void + { + $tx = $this->buildMockTx(); + $request = new SimulateTransactionRequest(transaction: $tx, authV2: false); + + $params = $request->getRequestParams(); + + $this->assertArrayNotHasKey('authV2', $params, '"authV2" key must not appear when explicitly false'); + } + + /** + * The "authV2" key must be present and equal to boolean true when opted in. + */ + public function testAuthV2KeyPresentAsBooleanTrueWhenOptedIn(): void + { + $tx = $this->buildMockTx(); + $request = new SimulateTransactionRequest(transaction: $tx, authV2: true); + + $params = $request->getRequestParams(); + + $this->assertArrayHasKey('authV2', $params, '"authV2" key must appear when flag is true'); + $this->assertSame(true, $params['authV2'], '"authV2" must be boolean true (not a string or int)'); + } + + /** + * Existing params (transaction, resourceConfig, authMode) must be unaffected by authV2. + */ + public function testExistingParamsUnaffectedByAuthV2(): void + { + $tx = $this->buildMockTx(); + $resourceConfig = new \Soneso\StellarSDK\Soroban\Requests\ResourceConfig(5000000); + $request = new SimulateTransactionRequest( + transaction: $tx, + resourceConfig: $resourceConfig, + authMode: 'record', + authV2: true, + ); + + $params = $request->getRequestParams(); + + $this->assertArrayHasKey('transaction', $params); + $this->assertArrayHasKey('resourceConfig', $params); + $this->assertEquals('record', $params['authMode']); + $this->assertSame(true, $params['authV2']); + } + + /** + * Setter/getter round-trip for authV2. + */ + public function testAuthV2SetterGetterRoundTrip(): void + { + $tx = $this->buildMockTx(); + $request = new SimulateTransactionRequest(transaction: $tx); + + $this->assertFalse($request->getAuthV2()); + + $request->setAuthV2(true); + $this->assertTrue($request->getAuthV2()); + $this->assertArrayHasKey('authV2', $request->getRequestParams()); + + $request->setAuthV2(false); + $this->assertFalse($request->getAuthV2()); + $this->assertArrayNotHasKey('authV2', $request->getRequestParams()); + } + + // ========================================================================= + // TASK 2 — MethodOptions.authV2 threads into the simulate() request + // ========================================================================= + + /** + * When MethodOptions.authV2 = true, the simulate() call must send "authV2": true in the + * RPC request body. Verified by intercepting the mock HTTP request body. + */ + public function testMethodOptionsAuthV2TrueThreadsIntoSimulateRequest(): void + { + $capturedBodies = []; + $tx = $this->buildAssembledTransactionWithMock( + methodOptions: new MethodOptions(simulate: false, restore: false, authV2: true), + mockResponses: [$this->createSimulateResponse()], + capturedBodies: $capturedBodies, + ); + + $tx->simulate(); + + $this->assertCount(1, $capturedBodies, 'Expected exactly one RPC request'); + $body = json_decode($capturedBodies[0], true); + $this->assertIsArray($body); + // The SorobanServer prepareRequest() places getRequestParams() directly under 'params'. + $params = $body['params'] ?? []; + $this->assertArrayHasKey('authV2', $params, '"authV2" must appear in RPC params when MethodOptions.authV2 = true'); + $this->assertSame(true, $params['authV2']); + } + + /** + * Mirror: default MethodOptions must NOT include "authV2" in the RPC request body. + */ + public function testMethodOptionsDefaultOmitsAuthV2FromSimulateRequest(): void + { + $capturedBodies = []; + $tx = $this->buildAssembledTransactionWithMock( + methodOptions: new MethodOptions(simulate: false, restore: false), + mockResponses: [$this->createSimulateResponse()], + capturedBodies: $capturedBodies, + ); + + $tx->simulate(); + + $this->assertCount(1, $capturedBodies, 'Expected exactly one RPC request'); + $body = json_decode($capturedBodies[0], true); + $this->assertIsArray($body); + $params = $body['params'] ?? []; + $this->assertArrayNotHasKey('authV2', $params, '"authV2" must NOT appear in RPC params by default'); + } + + /** + * MethodOptions default values include authV2 = false. + */ + public function testMethodOptionsAuthV2DefaultIsFalse(): void + { + $options = new MethodOptions(); + $this->assertFalse($options->authV2); + } + + // ========================================================================= + // TASK 3 — Arm preservation: V2 entry stays V2 after signAuthEntries + // ========================================================================= + + /** + * A V2 credential entry must remain ADDRESS_V2 after signing via signAuthEntries. + * The arm must not be downgraded to legacy ADDRESS. + */ + public function testArmPreservationV2EntryRemainsV2AfterSigning(): void + { + $signerKp = KeyPair::fromSeed(self::TEST_SECRET_KEY); + + $address = Address::fromAccountId($signerKp->getAccountId()); + $addressCreds = new SorobanAddressCredentials($address, 42, 9999, XdrSCVal::forVoid()); + $creds = SorobanCredentials::forAddressCredentialsV2($addressCreds); + $invocation = $this->makeInvocation(); + $entry = new SorobanAuthorizationEntry($creds, $invocation); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], $signerKp); + + $latestLedgerResponse = $this->makeLatestLedgerResponse(1000); + $this->injectMockedServerResponses($tx, [$latestLedgerResponse]); + + $tx->signAuthEntries(signerKeyPair: $signerKp, validUntilLedgerSeq: 9999); + + $ops = $tx->tx?->getOperations(); + $this->assertNotNull($ops); + $op = $ops[0]; + $this->assertInstanceOf(InvokeHostFunctionOperation::class, $op); + $auth = $op->auth; + $this->assertCount(1, $auth); + + $this->assertEquals( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + $auth[0]->credentials->credentialType, + 'ADDRESS_V2 arm must be preserved after signAuthEntries', + ); + // Signature must be written. + $sig = $auth[0]->credentials->getAddressCredentials()?->signature; + $this->assertNotNull($sig?->vec, 'Signature must be written after signing'); + $this->assertCount(1, $sig->vec); + } + + // ========================================================================= + // TASK 3 — needsNonInvokerSigningBy: all arms, delegates, delegates-only pattern + // ========================================================================= + + /** + * needsNonInvokerSigningBy reports the top-level address of a legacy ADDRESS entry + * with a void signature. + */ + public function testNeedsNonInvokerSigningByReportsLegacyAddressEntry(): void + { + $signerKp = KeyPair::random(); + $address = Address::fromAccountId($signerKp->getAccountId()); + $addressCreds = new SorobanAddressCredentials($address, 1, 100, XdrSCVal::forVoid()); + $creds = SorobanCredentials::forAddressCredentials($addressCreds); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], $this->invokerKp); + $needed = $tx->needsNonInvokerSigningBy(); + + $this->assertContains($signerKp->getAccountId(), $needed); + } + + /** + * needsNonInvokerSigningBy reports the top-level address AND unsigned delegate nodes + * for a WITH_DELEGATES entry. Does NOT report signed delegate nodes. + */ + public function testNeedsNonInvokerSigningByReportsAllNodesForWithDelegates(): void + { + $topKp = KeyPair::random(); + $delegateKp1 = KeyPair::random(); + $delegateKp2 = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + + $delegate1Addr = XdrSCAddress::forAccountId($delegateKp1->getAccountId()); + $delegate2Addr = XdrSCAddress::forAccountId($delegateKp2->getAccountId()); + + // delegate2 is already signed (non-void). + $delegate1 = new SorobanDelegateSignature($delegate1Addr, XdrSCVal::forVoid(), []); + $delegate2 = new SorobanDelegateSignature($delegate2Addr, XdrSCVal::forVec([XdrSCVal::forVoid()]), []); + + // Sort by XDR bytes to avoid constructor ordering issues. + $delegates = [$delegate1, $delegate2]; + usort($delegates, static fn ($a, $b) => strcmp($a->address->encode(), $b->address->encode())); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, $delegates); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], $this->invokerKp); + $needed = $tx->needsNonInvokerSigningBy(); + + // Top-level (void) must appear. + $this->assertContains($topKp->getAccountId(), $needed, 'Void top-level must be reported'); + // Unsigned delegate (delegate1) must appear. + $this->assertContains($delegateKp1->getAccountId(), $needed, 'Unsigned delegate must be reported'); + // Signed delegate (delegate2) must NOT appear (default $includeAlreadySigned = false). + $this->assertNotContains($delegateKp2->getAccountId(), $needed, 'Signed delegate must NOT be reported by default'); + } + + /** + * The no-blocking rule: a WITH_DELEGATES entry where the top-level signature is void + * but ALL delegate nodes are signed must NOT cause sign() to throw. + * + * This is the "delegates-only" pattern — the void top-level is legitimate. + */ + public function testSendPrecheckAllowsDelegatesOnlyPatternDespiteVoidTopLevel(): void + { + $topKp = KeyPair::random(); + $delegateKp = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + + $delegateAddr = XdrSCAddress::forAccountId($delegateKp->getAccountId()); + // Delegate IS already signed (non-void signature vec). + $delegate = new SorobanDelegateSignature( + $delegateAddr, + XdrSCVal::forVec([XdrSCVal::forVoid()]), + [], + ); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$delegate]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + // The invoker is signing the envelope; the entry is a delegates-only pattern. + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], $this->invokerKp); + + // sign() must NOT throw even though top-level is void — all delegates are signed. + try { + $tx->sign(force: true); + } catch (Exception $e) { + $this->fail('sign() must not throw for delegates-only pattern: ' . $e->getMessage()); + } + + $this->assertNotNull($tx->signed, 'Signed transaction must be set after successful sign()'); + } + + /** + * Contrast: a WITH_DELEGATES entry where a DELEGATE is unsigned DOES block the send. + */ + public function testSendPrecheckBlocksWhenDelegateIsUnsigned(): void + { + $topKp = KeyPair::random(); + $delegateKp = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + + // Delegate is unsigned (void). + $delegateAddr = XdrSCAddress::forAccountId($delegateKp->getAccountId()); + $delegate = new SorobanDelegateSignature($delegateAddr, XdrSCVal::forVoid(), []); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$delegate]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], $this->invokerKp); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/multiple signers/'); + + $tx->sign(force: true); + } + + // ========================================================================= + // TASK 3 — signAuthEntries signs delegate node when signer matches a delegate address + // ========================================================================= + + /** + * When the signer address matches a DELEGATE node (not the top-level), the signature + * must land in the delegate node and the top-level must remain void. + * + * Uses DISTINCT top-level and delegate addresses — this is required to prove correct routing. + */ + public function testSignAuthEntriesSignsDelegateNodeWithDistinctAddresses(): void + { + $topKp = KeyPair::random(); + $delegateKp = KeyPair::fromSeed(self::TEST_SECRET_KEY); // distinct from top + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $delegateAddress = Address::fromAccountId($delegateKp->getAccountId()); + + // Build top-level address credentials (void signature). + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + + // Build delegate node (void signature). + $delegateXdrAddr = XdrSCAddress::forAccountId($delegateKp->getAccountId()); + $delegateNode = new SorobanDelegateSignature($delegateXdrAddr, XdrSCVal::forVoid(), []); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$delegateNode]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], $this->invokerKp); + $this->injectMockedServerResponses($tx, [$this->makeLatestLedgerResponse(1000)]); + + // Sign with the DELEGATE keypair — must route to the delegate node. + $tx->signAuthEntries(signerKeyPair: $delegateKp, validUntilLedgerSeq: 9999); + + $ops = $tx->tx?->getOperations(); + $this->assertNotNull($ops); + $op = $ops[0]; + $this->assertInstanceOf(InvokeHostFunctionOperation::class, $op); + $auth = $op->auth; + $this->assertCount(1, $auth); + + $withDelegatesResult = $auth[0]->credentials->addressWithDelegates; + $this->assertNotNull($withDelegatesResult); + + // Top-level must remain void. + $topSig = $withDelegatesResult->addressCredentials->signature; + $this->assertNull($topSig->vec, 'Top-level signature must remain void after signing only the delegate'); + + // Delegate must carry a signature. + $delegateResult = $withDelegatesResult->delegates[0]; + $this->assertNotNull($delegateResult->signature->vec, 'Delegate node must have a non-void signature'); + $this->assertCount(1, $delegateResult->signature->vec); + } + + // ========================================================================= + // TASK 3 — needsNonInvokerSigningBy includeAlreadySigned = true + // ========================================================================= + + /** + * When $includeAlreadySigned = true, already-signed delegate nodes are included. + */ + public function testNeedsNonInvokerSigningByIncludesSignedWhenFlagSet(): void + { + $topKp = KeyPair::random(); + $delegateKp = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + + $delegateAddr = XdrSCAddress::forAccountId($delegateKp->getAccountId()); + // Delegate is already signed. + $delegate = new SorobanDelegateSignature( + $delegateAddr, + XdrSCVal::forVec([XdrSCVal::forVoid()]), + [], + ); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$delegate]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], $this->invokerKp); + + $needed = $tx->needsNonInvokerSigningBy(includeAlreadySigned: true); + + $this->assertContains($delegateKp->getAccountId(), $needed, 'Signed delegate must appear when includeAlreadySigned = true'); + $this->assertContains($topKp->getAccountId(), $needed); + } + + // ========================================================================= + // TASK 3 — Unknown arm fails fast in signAuthEntries + // ========================================================================= + + /** + * signAuthEntries must throw when an auth entry carries an unknown credential arm. + * Source-account entries are silently skipped (not an error). + * + * Strategy: include one valid ADDRESS entry (so needsNonInvokerSigningBy returns non-empty + * and the callback path is taken), plus an unknown-arm entry. The loop hits the unknown-arm + * entry after the valid one and throws. + */ + public function testSignAuthEntriesFailsFastOnUnknownArm(): void + { + $signerKp = KeyPair::fromSeed(self::TEST_SECRET_KEY); + + // Valid ADDRESS entry with signerKp's address. + $address = Address::fromAccountId($signerKp->getAccountId()); + $addressCreds = new SorobanAddressCredentials($address, 1, 100, XdrSCVal::forVoid()); + $validEntry = new SorobanAuthorizationEntry( + SorobanCredentials::forAddressCredentials($addressCreds), + $this->makeInvocation(), + ); + + // Unknown-arm entry: credentialType = 99, but a non-SOURCE_ACCOUNT type so it reaches + // the unknown-arm check inside the loop. + $addressCreds2 = new SorobanAddressCredentials($address, 2, 100, XdrSCVal::forVoid()); + $badCreds = SorobanCredentials::forAddressCredentials($addressCreds2); + $badCreds->credentialType = 99; + $badEntry = new SorobanAuthorizationEntry($badCreds, $this->makeInvocation()); + + // Use callback path so we bypass the needsNonInvokerSigningBy check. + $tx = $this->buildAssembledTransactionWithAuthEntries([$validEntry, $badEntry], $this->invokerKp); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/Unsupported SorobanCredentials arm/'); + + // Provide validUntilLedgerSeq to avoid a real network call for getLatestLedger. + $tx->signAuthEntries( + signerKeyPair: $signerKp, + authorizeEntryCallback: static function (SorobanAuthorizationEntry $e, Network $n): SorobanAuthorizationEntry { + return $e; + }, + validUntilLedgerSeq: 9999, + ); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Builds a minimal Transaction object (no network calls). + */ + private function buildMockTx(): \Soneso\StellarSDK\Transaction + { + $account = new Account(self::TEST_ACCOUNT_ID, new BigInteger(1)); + $hostFn = new InvokeContractHostFunction(self::TEST_CONTRACT_ID, 'test', []); + $op = (new InvokeHostFunctionOperationBuilder($hostFn))->build(); + return (new TransactionBuilder($account))->addOperation($op)->build(); + } + + /** + * Builds an AssembledTransaction pre-loaded with given auth entries. + * $tx->tx is set but not simulated against the network. + * + * The simulation result is set up so isReadCall() returns false (auth entries are + * in the simulation result, not just in the tx), allowing sign() to proceed. + * + * @param array $entries + */ + private function buildAssembledTransactionWithAuthEntries( + array $entries, + KeyPair $invokerKp, + ): AssembledTransaction { + $clientOptions = new ClientOptions( + sourceAccountKeyPair: $invokerKp, + contractId: self::TEST_CONTRACT_ID, + network: $this->network, + rpcUrl: self::TEST_RPC_URL, + ); + $methodOptions = new MethodOptions(simulate: false, restore: false); + $txOptions = new AssembledTransactionOptions( + clientOptions: $clientOptions, + methodOptions: $methodOptions, + method: 'test', + arguments: [], + ); + + $reflection = new \ReflectionClass(AssembledTransaction::class); + $tx = $reflection->newInstanceWithoutConstructor(); + + $optionsProp = $reflection->getProperty('options'); + $optionsProp->setAccessible(true); + $optionsProp->setValue($tx, $txOptions); + + $server = new SorobanServer($txOptions->clientOptions->rpcUrl); + $serverProp = $reflection->getProperty('server'); + $serverProp->setAccessible(true); + $serverProp->setValue($tx, $server); + + $account = new Account($invokerKp->getAccountId(), new BigInteger(123456789)); + $hostFn = new InvokeContractHostFunction(self::TEST_CONTRACT_ID, 'test', []); + $op = (new InvokeHostFunctionOperationBuilder($hostFn))->build(); + $txBuilder = new TransactionBuilder(sourceAccount: $account); + $txBuilder->addOperation($op); + $built = $txBuilder->build(); + $built->setSorobanAuth($entries); + + // Use an empty footprint (no ledger keys) to avoid XdrLedgerKey encoding issues. + $footprint = new XdrLedgerFootprint([], []); + $resources = new XdrSorobanResources($footprint, 100, 100, 100); + $ext = new XdrSorobanTransactionDataExt(0); + $txData = new XdrSorobanTransactionData($ext, $resources, 100); + $built->setSorobanTransactionData($txData); + + $txProp = $reflection->getProperty('tx'); + $txProp->setAccessible(true); + $txProp->setValue($tx, $built); + + $simResponse = new SimulateTransactionResponse([]); + $simResponse->transactionData = $txData; + $simResponse->minResourceFee = 100; + $simResponse->latestLedger = 1000; + $tx->simulationResponse = $simResponse; + + // Set auth in the sim result so isReadCall() returns false (authsCount > 0). + $simResultProp = $reflection->getProperty('simulationResult'); + $simResultProp->setAccessible(true); + $simResultProp->setValue($tx, new SimulateHostFunctionResult($txData, XdrSCVal::forVoid(), $entries)); + + return $tx; + } + + /** + * Builds an AssembledTransaction with a mocked HTTP server and request-body capture. + * + * @param MethodOptions $methodOptions + * @param array $mockResponses + * @param array $capturedBodies output: bodies of HTTP POST requests + */ + private function buildAssembledTransactionWithMock( + MethodOptions $methodOptions, + array $mockResponses, + array &$capturedBodies, + ): AssembledTransaction { + $capturedBodiesRef = &$capturedBodies; + $mock = new MockHandler($mockResponses); + $stack = HandlerStack::create($mock); + // Middleware to capture request bodies. + $stack->push(static function (callable $handler) use (&$capturedBodiesRef): callable { + return static function ($request, $options) use ($handler, &$capturedBodiesRef) { + $capturedBodiesRef[] = (string) $request->getBody(); + return $handler($request, $options); + }; + }); + $client = new Client(['handler' => $stack]); + + $invokerKp = KeyPair::fromSeed(self::TEST_SECRET_KEY); + $clientOptions = new ClientOptions( + sourceAccountKeyPair: $invokerKp, + contractId: self::TEST_CONTRACT_ID, + network: $this->network, + rpcUrl: self::TEST_RPC_URL, + ); + $txOptions = new AssembledTransactionOptions( + clientOptions: $clientOptions, + methodOptions: $methodOptions, + method: 'test', + arguments: [], + ); + + $reflection = new \ReflectionClass(AssembledTransaction::class); + $tx = $reflection->newInstanceWithoutConstructor(); + + $optionsProp = $reflection->getProperty('options'); + $optionsProp->setAccessible(true); + $optionsProp->setValue($tx, $txOptions); + + $server = new SorobanServer($txOptions->clientOptions->rpcUrl); + $serverReflection = new \ReflectionClass($server); + $httpClientProp = $serverReflection->getProperty('httpClient'); + $httpClientProp->setAccessible(true); + $httpClientProp->setValue($server, $client); + + $serverProp = $reflection->getProperty('server'); + $serverProp->setAccessible(true); + $serverProp->setValue($tx, $server); + + // Build the raw transaction builder (no network). + $account = new Account($invokerKp->getAccountId(), new BigInteger(123456789)); + $hostFn = new InvokeContractHostFunction(self::TEST_CONTRACT_ID, 'test', []); + $op = (new InvokeHostFunctionOperationBuilder($hostFn))->build(); + $txBuilder = new TransactionBuilder(sourceAccount: $account); + $txBuilder->setTimeBounds(new TimeBounds( + (new DateTime())->modify('- ' . NetworkConstants::DEFAULT_TIME_BOUNDS_OFFSET_SECONDS . ' seconds'), + (new DateTime())->modify('+ ' . $methodOptions->timeoutInSeconds . ' seconds') + )); + $txBuilder->addOperation($op); + $txBuilder->setMaxOperationFee($methodOptions->fee); + + $rawProp = $reflection->getProperty('raw'); + $rawProp->setAccessible(true); + $rawProp->setValue($tx, $txBuilder); + + return $tx; + } + + /** + * Injects mock responses into the SorobanServer inside an AssembledTransaction. + * + * @param array $responses + */ + private function injectMockedServerResponses(AssembledTransaction $tx, array $responses): void + { + $mock = new MockHandler($responses); + $stack = HandlerStack::create($mock); + $client = new Client(['handler' => $stack]); + + $reflection = new \ReflectionClass($tx); + $serverProp = $reflection->getProperty('server'); + $serverProp->setAccessible(true); + $server = $serverProp->getValue($tx); + + $serverReflection = new \ReflectionClass($server); + $httpClientProp = $serverReflection->getProperty('httpClient'); + $httpClientProp->setAccessible(true); + $httpClientProp->setValue($server, $client); + } + + /** + * Creates a mock simulateTransaction response (success, no auth). + */ + private function createSimulateResponse(): Response + { + $footprint = new XdrLedgerFootprint([], []); + $resources = new XdrSorobanResources($footprint, 0, 0, 0); + $ext = new XdrSorobanTransactionDataExt(0); + $txData = new XdrSorobanTransactionData($ext, $resources, 0); + + return new Response(200, [], json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'minResourceFee' => '100', + 'latestLedger' => 1000, + 'transactionData' => $txData->toBase64Xdr(), + 'results' => [ + [ + 'auth' => [], + 'xdr' => XdrSCVal::forVoid()->toBase64Xdr(), + ], + ], + ], + ])); + } + + /** + * Creates a mock getLatestLedger response. + */ + private function makeLatestLedgerResponse(int $sequence): Response + { + return new Response(200, [], json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'id' => 'abc123', + 'sequence' => $sequence, + 'hash' => str_repeat('a', 64), + ], + ])); + } + + /** + * Creates a minimal SorobanAuthorizedInvocation for testing. + */ + private function makeInvocation(): SorobanAuthorizedInvocation + { + $contractAddress = Address::fromContractId(StrKey::decodeContractIdHex(self::AUX_CONTRACT_ID)); + $fn = SorobanAuthorizedFunction::forContractFunction($contractAddress, 'test', []); + return new SorobanAuthorizedInvocation($fn, []); + } +} diff --git a/Soneso/StellarSDKTests/Unit/Soroban/P27AuthorizationTest.php b/Soneso/StellarSDKTests/Unit/Soroban/P27AuthorizationTest.php new file mode 100644 index 00000000..72295a1d --- /dev/null +++ b/Soneso/StellarSDKTests/Unit/Soroban/P27AuthorizationTest.php @@ -0,0 +1,890 @@ +makeGoldenInvocation()); + } + + private function makeGoldenV2Entry(): SorobanAuthorizationEntry + { + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addressCreds = new SorobanAddressCredentials($address, self::GOLDEN_NONCE, self::GOLDEN_EXPIRY, XdrSCVal::forVoid()); + $creds = SorobanCredentials::forAddressCredentialsV2($addressCreds); + return new SorobanAuthorizationEntry($creds, $this->makeGoldenInvocation()); + } + + // --------------------------------------------------------------------------- + // TASK 1: Wrapper round-trip fidelity for all four arms + // --------------------------------------------------------------------------- + + public function testRoundTripSourceAccount(): void + { + $original = SorobanCredentials::forSourceAccount(); + $xdr = $original->toXdr(); + + $this->assertEquals(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT, $xdr->type->value); + + $decoded = SorobanCredentials::fromXdr($xdr); + $this->assertEquals(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT, $decoded->credentialType); + $this->assertNull($decoded->addressCredentials); + $this->assertNull($decoded->addressWithDelegates); + + // Re-encode must produce the same XDR bytes. + $this->assertEquals($xdr->encode(), $decoded->toXdr()->encode()); + } + + public function testRoundTripAddressLegacy(): void + { + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $creds = SorobanCredentials::forAddress($address, 42, 100, XdrSCVal::forVoid()); + $xdr = $creds->toXdr(); + + $this->assertEquals(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, $xdr->type->value); + + $decoded = SorobanCredentials::fromXdr($xdr); + $this->assertEquals(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, $decoded->credentialType); + $this->assertNotNull($decoded->addressCredentials); + $this->assertEquals(42, $decoded->addressCredentials->nonce); + $this->assertEquals(100, $decoded->addressCredentials->signatureExpirationLedger); + $this->assertEquals($xdr->encode(), $decoded->toXdr()->encode()); + } + + public function testRoundTripAddressV2(): void + { + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addressCreds = new SorobanAddressCredentials($address, 77, 200, XdrSCVal::forVoid()); + $creds = SorobanCredentials::forAddressCredentialsV2($addressCreds); + $xdr = $creds->toXdr(); + + $this->assertEquals(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, $xdr->type->value); + $this->assertNotNull($xdr->addressV2); + $this->assertNull($xdr->address); + + $decoded = SorobanCredentials::fromXdr($xdr); + $this->assertEquals(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, $decoded->credentialType); + $this->assertNotNull($decoded->addressCredentials); + $this->assertEquals(77, $decoded->addressCredentials->nonce); + $this->assertEquals($xdr->encode(), $decoded->toXdr()->encode()); + } + + public function testRoundTripAddressWithDelegates(): void + { + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addressCreds = new SorobanAddressCredentials($address, 55, 300, XdrSCVal::forVoid()); + + $delegateAddr = Address::fromContractId(StrKey::decodeContractIdHex(self::GOLDEN_CONTRACT)); + $delegateXdr = new SorobanDelegateSignature($delegateAddr->toXdr(), XdrSCVal::forVoid(), []); + $withDelegates = new SorobanAddressCredentialsWithDelegates($addressCreds, [$delegateXdr]); + + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $xdr = $creds->toXdr(); + + $this->assertEquals(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, $xdr->type->value); + $this->assertNotNull($xdr->addressWithDelegates); + $this->assertNull($xdr->address); + $this->assertNull($xdr->addressV2); + $this->assertCount(1, $xdr->addressWithDelegates->delegates); + + $decoded = SorobanCredentials::fromXdr($xdr); + $this->assertEquals(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, $decoded->credentialType); + $this->assertNull($decoded->addressCredentials); + $this->assertNotNull($decoded->addressWithDelegates); + $this->assertCount(1, $decoded->addressWithDelegates->delegates); + $this->assertEquals($xdr->encode(), $decoded->toXdr()->encode()); + } + + /** + * Regression: fromXdr previously mapped V2/WITH_DELEGATES to source-account, losing data. + */ + public function testFromXdrDoesNotSilentlyDropV2Arm(): void + { + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addressCreds = new SorobanAddressCredentials($address, 99, 500, XdrSCVal::forVoid()); + $xdrCreds = XdrSorobanCredentials::forAddressCredentialsV2($addressCreds->toXdr()); + + $decoded = SorobanCredentials::fromXdr($xdrCreds); + + // Must not silently become source-account. + $this->assertNotEquals( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT, + $decoded->credentialType, + 'fromXdr must not reclassify ADDRESS_V2 as source-account', + ); + $this->assertEquals(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, $decoded->credentialType); + $this->assertNotNull($decoded->addressCredentials); + } + + public function testFromXdrUnknownArmThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Unknown XdrSorobanCredentialsType value/'); + + $xdrCreds = new XdrSorobanCredentials(new \Soneso\StellarSDK\Xdr\XdrSorobanCredentialsType(99)); + SorobanCredentials::fromXdr($xdrCreds); + } + + // --------------------------------------------------------------------------- + // XdrSorobanCredentials factory wrappers + // --------------------------------------------------------------------------- + + public function testXdrFactoryForAddressCredentialsV2(): void + { + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addressCreds = new SorobanAddressCredentials($address, 1, 1, XdrSCVal::forVoid()); + $xdrCreds = XdrSorobanCredentials::forAddressCredentialsV2($addressCreds->toXdr()); + + $this->assertEquals(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, $xdrCreds->type->value); + $this->assertNotNull($xdrCreds->addressV2); + $this->assertNull($xdrCreds->address); + } + + public function testXdrFactoryForAddressWithDelegates(): void + { + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addressCreds = new SorobanAddressCredentials($address, 1, 1, XdrSCVal::forVoid()); + $withDelegates = new \Soneso\StellarSDK\Xdr\XdrSorobanAddressCredentialsWithDelegates( + $addressCreds->toXdr(), [] + ); + $xdrCreds = XdrSorobanCredentials::forAddressWithDelegates($withDelegates); + + $this->assertEquals(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, $xdrCreds->type->value); + $this->assertNotNull($xdrCreds->addressWithDelegates); + } + + // --------------------------------------------------------------------------- + // TASK 3: Preimage builder — golden vectors + // --------------------------------------------------------------------------- + + public function testLegacyPreimageMatchesGoldenVector(): void + { + $entry = $this->makeGoldenLegacyEntry(); + $preimage = $entry->buildPreimage(Network::testnet()); + + $b64 = base64_encode($preimage->encode()); + $this->assertEquals(self::GOLDEN_LEGACY_PREIMAGE_B64, $b64, 'Legacy preimage bytes must match golden vector'); + } + + public function testLegacyPayloadHashMatchesGoldenVector(): void + { + $entry = $this->makeGoldenLegacyEntry(); + $preimage = $entry->buildPreimage(Network::testnet()); + $payload = Hash::generate($preimage->encode()); + + $this->assertEquals(self::GOLDEN_LEGACY_PAYLOAD_HEX, bin2hex($payload)); + } + + public function testV2PreimageMatchesGoldenVector(): void + { + $entry = $this->makeGoldenV2Entry(); + $preimage = $entry->buildPreimage(Network::testnet()); + + $b64 = base64_encode($preimage->encode()); + $this->assertEquals(self::GOLDEN_V2_PREIMAGE_B64, $b64, 'V2 preimage bytes must match golden vector'); + } + + public function testV2PayloadHashMatchesGoldenVector(): void + { + $entry = $this->makeGoldenV2Entry(); + $preimage = $entry->buildPreimage(Network::testnet()); + $payload = Hash::generate($preimage->encode()); + + $this->assertEquals(self::GOLDEN_V2_PAYLOAD_HEX, bin2hex($payload)); + } + + /** + * Preimage discriminant: legacy ADDRESS uses ENVELOPE_TYPE_SOROBAN_AUTHORIZATION (9), + * V2 and WITH_DELEGATES use ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS (10). + */ + public function testPreimageDiscriminantPerArm(): void + { + $legacyEntry = $this->makeGoldenLegacyEntry(); + $v2Entry = $this->makeGoldenV2Entry(); + + $legacyPreimage = $legacyEntry->buildPreimage(Network::testnet()); + $v2Preimage = $v2Entry->buildPreimage(Network::testnet()); + + $this->assertEquals( + \Soneso\StellarSDK\Xdr\XdrEnvelopeType::ENVELOPE_TYPE_SOROBAN_AUTHORIZATION, + $legacyPreimage->type->value, + ); + $this->assertEquals( + \Soneso\StellarSDK\Xdr\XdrEnvelopeType::ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS, + $v2Preimage->type->value, + ); + } + + /** + * Legacy and V2 preimages for identical fields must differ (different envelope type discriminant). + */ + public function testLegacyAndV2PreimagesDifferForIdenticalFields(): void + { + $legacyEntry = $this->makeGoldenLegacyEntry(); + $v2Entry = $this->makeGoldenV2Entry(); + + $legacyBytes = $legacyEntry->buildPreimage(Network::testnet())->encode(); + $v2Bytes = $v2Entry->buildPreimage(Network::testnet())->encode(); + + $this->assertNotEquals(bin2hex($legacyBytes), bin2hex($v2Bytes)); + } + + /** + * WITH_DELEGATES preimage address is the TOP-LEVEL credential address, never a delegate's. + */ + public function testWithDelegatesPreimageAddressIsTopLevel(): void + { + $topAddress = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addressCreds = new SorobanAddressCredentials($topAddress, self::GOLDEN_NONCE, self::GOLDEN_EXPIRY, XdrSCVal::forVoid()); + + $delegateAddr = Address::fromContractId(StrKey::decodeContractIdHex(self::GOLDEN_CONTRACT)); + $delegateSig = new SorobanDelegateSignature($delegateAddr->toXdr(), XdrSCVal::forVoid(), []); + $withDelegates = new SorobanAddressCredentialsWithDelegates($addressCreds, [$delegateSig]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + + $entry = new SorobanAuthorizationEntry($creds, $this->makeGoldenInvocation()); + $preimage = $entry->buildPreimage(Network::testnet()); + + $this->assertNotNull($preimage->sorobanAuthorizationWithAddress); + $preimageAddressStrkey = $preimage->sorobanAuthorizationWithAddress->address->toStrKey(); + $this->assertEquals(self::GOLDEN_ACCOUNT, $preimageAddressStrkey, 'Preimage address must be top-level'); + $this->assertNotEquals(self::GOLDEN_CONTRACT, $preimageAddressStrkey); + + // Must match V2 preimage for same top-level creds. + $v2Entry = $this->makeGoldenV2Entry(); + $v2Preimage = $v2Entry->buildPreimage(Network::testnet()); + $this->assertEquals($preimage->encode(), $v2Preimage->encode()); + } + + public function testBuildPreimageThrowsForSourceAccount(): void + { + $this->expectException(RuntimeException::class); + $entry = new SorobanAuthorizationEntry( + SorobanCredentials::forSourceAccount(), + $this->makeGoldenInvocation(), + ); + $entry->buildPreimage(Network::testnet()); + } + + // --------------------------------------------------------------------------- + // TASK 4: sign() — golden signature + all arms + expiration-before-hash + // --------------------------------------------------------------------------- + + public function testLegacySignProducesGoldenSignature(): void + { + $signer = KeyPair::fromSeed(self::GOLDEN_SEED); + $entry = $this->makeGoldenLegacyEntry(); + + $entry->sign($signer, Network::testnet()); + + $sig = $entry->credentials->addressCredentials?->signature; + $this->assertNotNull($sig); + $this->assertNotNull($sig->vec); + $this->assertCount(1, $sig->vec); + + // Extract the signature bytes from the map entry. + $sigMap = $sig->vec[0]->map; + $this->assertNotNull($sigMap); + $sigBytes = null; + foreach ($sigMap as $entry2) { + if ($entry2->key->sym === 'signature') { + $sigBytes = $entry2->val->bytes?->getValue(); + break; + } + } + $this->assertNotNull($sigBytes, 'signature field not found in map'); + $this->assertEquals(self::GOLDEN_LEGACY_SIG_HEX, bin2hex($sigBytes)); + } + + public function testV2SignWorks(): void + { + $signer = KeyPair::fromSeed(self::GOLDEN_SEED); + $entry = $this->makeGoldenV2Entry(); + + $entry->sign($signer, Network::testnet()); + + $sig = $entry->credentials->addressCredentials?->signature; + $this->assertNotNull($sig); + $this->assertNotNull($sig->vec); + $this->assertCount(1, $sig->vec); + } + + /** + * Expiration-before-hash: if signatureExpirationLedger is passed to sign(), it must be + * applied before the preimage is built. Changing expiration changes the payload hash. + */ + public function testExpirationSetBeforeHash(): void + { + $signer = KeyPair::fromSeed(self::GOLDEN_SEED); + + // Entry with expiry 0 initially; sign sets it to GOLDEN_EXPIRY. + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $creds = SorobanCredentials::forAddress($address, self::GOLDEN_NONCE, 0, XdrSCVal::forVoid()); + $entry = new SorobanAuthorizationEntry($creds, $this->makeGoldenInvocation()); + + $entry->sign($signer, Network::testnet(), self::GOLDEN_EXPIRY); + + // The resulting signed entry should match the golden signature (expiry set correctly). + $sig = $entry->credentials->addressCredentials?->signature; + $this->assertNotNull($sig?->vec); + $sigMap = $sig->vec[0]->map; + $sigBytes = null; + foreach ($sigMap as $entry2) { + if ($entry2->key->sym === 'signature') { + $sigBytes = $entry2->val->bytes?->getValue(); + break; + } + } + $this->assertEquals(self::GOLDEN_LEGACY_SIG_HEX, bin2hex((string)$sigBytes)); + } + + /** + * Regression: signing without setting expiration before building preimage produces a + * DIFFERENT hash. This test verifies that a different expiry produces a different signature. + */ + public function testDifferentExpiryProducesDifferentHash(): void + { + $signer = KeyPair::fromSeed(self::GOLDEN_SEED); + + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $creds1 = SorobanCredentials::forAddress($address, self::GOLDEN_NONCE, self::GOLDEN_EXPIRY, XdrSCVal::forVoid()); + $creds2 = SorobanCredentials::forAddress($address, self::GOLDEN_NONCE, self::GOLDEN_EXPIRY + 1, XdrSCVal::forVoid()); + + $entry1 = new SorobanAuthorizationEntry($creds1, $this->makeGoldenInvocation()); + $entry2 = new SorobanAuthorizationEntry($creds2, $this->makeGoldenInvocation()); + + $entry1->sign($signer, Network::testnet()); + $entry2->sign($signer, Network::testnet()); + + $sig1 = $this->extractSignatureHex($entry1); + $sig2 = $this->extractSignatureHex($entry2); + $this->assertNotEquals($sig1, $sig2); + } + + /** + * Sign throws for source-account credentials. + */ + public function testSignThrowsForSourceAccountCredentials(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('no soroban address credentials found'); + + $entry = new SorobanAuthorizationEntry( + SorobanCredentials::forSourceAccount(), + $this->makeGoldenInvocation(), + ); + $entry->sign(KeyPair::random(), Network::testnet()); + } + + // --------------------------------------------------------------------------- + // Signature append semantics + // --------------------------------------------------------------------------- + + /** + * Void top-level signature is preserved; not filled in by the SDK. + */ + public function testVoidTopLevelSignaturePreserved(): void + { + // An entry with void top-level (delegates-only scenario): + // just verify that buildPreimage works and sign(forAddress=topLevel) works. + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addressCreds = new SorobanAddressCredentials($address, self::GOLDEN_NONCE, self::GOLDEN_EXPIRY, XdrSCVal::forVoid()); + + // Explicitly keep top-level void and only sign a delegate. + $delegateKp = KeyPair::random(); + $delegateAddr = Address::fromAccountId($delegateKp->getAccountId()); + $delegateSig = new SorobanDelegateSignature($delegateAddr->toXdr(), XdrSCVal::forVoid(), []); + $withDelegates = new SorobanAddressCredentialsWithDelegates($addressCreds, [$delegateSig]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeGoldenInvocation()); + + // Sign only the delegate; top-level stays void. + $entry->sign($delegateKp, Network::testnet(), null, $delegateKp->getAccountId()); + + // Top-level signature is still void. + $topCreds = $entry->credentials->addressWithDelegates?->addressCredentials; + $this->assertNotNull($topCreds); + $this->assertNull($topCreds->signature->vec, 'Top-level must remain void (no vec)'); + + // Delegate node now has a signature. + $delegateNode = $entry->credentials->addressWithDelegates?->delegates[0]; + $this->assertNotNull($delegateNode); + $this->assertNotNull($delegateNode->signature->vec); + $this->assertCount(1, $delegateNode->signature->vec); + } + + /** + * Appending to a void signature replaces it with a one-element vec. + */ + public function testAppendToVoidProducesOneElementVec(): void + { + $signer = KeyPair::random(); + $address = Address::fromAccountId($signer->getAccountId()); + $creds = SorobanCredentials::forAddress($address, 1, 100, XdrSCVal::forVoid()); + $entry = new SorobanAuthorizationEntry($creds, $this->makeGoldenInvocation()); + + $entry->sign($signer, Network::testnet()); + + $sig = $entry->credentials->addressCredentials?->signature; + $this->assertNotNull($sig?->vec); + $this->assertCount(1, $sig->vec); + } + + /** + * Appending to an existing vec grows it without reordering. + */ + public function testAppendToExistingVecGrows(): void + { + $signer1 = KeyPair::random(); + $signer2 = KeyPair::random(); + $address = Address::fromAccountId($signer1->getAccountId()); + $creds = SorobanCredentials::forAddress($address, 1, 100, XdrSCVal::forVoid()); + $entry = new SorobanAuthorizationEntry($creds, $this->makeGoldenInvocation()); + + $entry->sign($signer1, Network::testnet()); + $entry->sign($signer2, Network::testnet()); + + $sig = $entry->credentials->addressCredentials?->signature; + $this->assertNotNull($sig?->vec); + $this->assertCount(2, $sig->vec); + } + + // --------------------------------------------------------------------------- + // TASK 4: forAddress routing — distinct top-level and delegate addresses + // --------------------------------------------------------------------------- + + /** + * Critical: sign(forAddress=delegateB) must write to the delegate node and leave + * the top-level credential (address A) void. Tests that use the SAME address for + * both cannot prove correct routing. + */ + public function testForAddressRoutesOnlyToDelegate(): void + { + $topKp = KeyPair::fromSeed(self::GOLDEN_SEED); + $delegateKp = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $delegateAddress = Address::fromAccountId($delegateKp->getAccountId()); + + $addressCreds = new SorobanAddressCredentials($topAddress, self::GOLDEN_NONCE, self::GOLDEN_EXPIRY, XdrSCVal::forVoid()); + $delegateSig = new SorobanDelegateSignature($delegateAddress->toXdr(), XdrSCVal::forVoid(), []); + $withDelegates = new SorobanAddressCredentialsWithDelegates($addressCreds, [$delegateSig]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeGoldenInvocation()); + + // Sign delegate only. + $entry->sign($delegateKp, Network::testnet(), null, $delegateKp->getAccountId()); + + // Top-level must still be void. + $topCreds = $entry->credentials->addressWithDelegates?->addressCredentials; + $this->assertNull($topCreds?->signature->vec, 'Top-level must be void after signing only delegate'); + + // Delegate must have a signature. + $delegateNode = $entry->credentials->addressWithDelegates?->delegates[0]; + $this->assertNotNull($delegateNode?->signature->vec); + $this->assertCount(1, $delegateNode->signature->vec); + } + + /** + * Both top-level and delegate sign the SAME payload hash (verified by re-deriving the hash). + */ + public function testTopLevelAndDelegateBothSignSameHash(): void + { + $topKp = KeyPair::fromSeed(self::GOLDEN_SEED); + $delegateKp = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $delegateAddress = Address::fromAccountId($delegateKp->getAccountId()); + + $addressCreds = new SorobanAddressCredentials($topAddress, self::GOLDEN_NONCE, self::GOLDEN_EXPIRY, XdrSCVal::forVoid()); + $delegateSig = new SorobanDelegateSignature($delegateAddress->toXdr(), XdrSCVal::forVoid(), []); + $withDelegates = new SorobanAddressCredentialsWithDelegates($addressCreds, [$delegateSig]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeGoldenInvocation()); + + // Compute expected hash before signing. + $preimage = $entry->buildPreimage(Network::testnet()); + $expectedHash = Hash::generate($preimage->encode()); + + // Sign top-level. + $entry->sign($topKp, Network::testnet()); + // Sign delegate. + $entry->sign($delegateKp, Network::testnet(), null, $delegateKp->getAccountId()); + + // Verify top-level signature against expectedHash. + $topSig = $entry->credentials->addressWithDelegates?->addressCredentials?->signature; + $this->assertNotNull($topSig?->vec); + $topSigBytes = $this->extractSigBytesFromVecEntry($topSig->vec[0]); + $this->assertTrue( + $topKp->verifySignature($topSigBytes, $expectedHash), + 'Top-level signature must verify against the same hash', + ); + + // Verify delegate signature against the SAME hash. + $delegateNode = $entry->credentials->addressWithDelegates?->delegates[0]; + $delegateSigVec = $delegateNode?->signature; + $this->assertNotNull($delegateSigVec?->vec); + $delegateSigBytes = $this->extractSigBytesFromVecEntry($delegateSigVec->vec[0]); + $this->assertTrue( + $delegateKp->verifySignature($delegateSigBytes, $expectedHash), + 'Delegate signature must verify against the same hash as top-level', + ); + } + + public function testForAddressNoMatchThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/matched no node/'); + + $signer = KeyPair::random(); + $other = KeyPair::random(); + $address = Address::fromAccountId($signer->getAccountId()); + $creds = SorobanCredentials::forAddressCredentialsV2( + new SorobanAddressCredentials($address, 1, 100, XdrSCVal::forVoid()) + ); + $entry = new SorobanAuthorizationEntry($creds, $this->makeGoldenInvocation()); + + $entry->sign($signer, Network::testnet(), null, $other->getAccountId()); + } + + /** + * Muxed M-addresses are rejected as forAddress targets. + */ + public function testForAddressMuxedRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/muxed.*not valid|M-prefixed/i'); + + $signer = KeyPair::random(); + $address = Address::fromAccountId($signer->getAccountId()); + $creds = SorobanCredentials::forAddressCredentialsV2( + new SorobanAddressCredentials($address, 1, 100, XdrSCVal::forVoid()) + ); + $entry = new SorobanAuthorizationEntry($creds, $this->makeGoldenInvocation()); + + // Fake an M-address (muxed). + $entry->sign($signer, Network::testnet(), null, 'MAQAA5L65LSYH7CQ3VTJ7F3HHLGCL3DSLAR2Y47263D56MNNGHSQSAAAAAAAAAPCIBVZA'); + } + + // --------------------------------------------------------------------------- + // TASK 5: Delegate tree building + // --------------------------------------------------------------------------- + + public function testWithDelegatesBuildsTree(): void + { + $entry = $this->makeGoldenLegacyEntry(); + $delegateKp = KeyPair::random(); + + $result = SorobanAuthorizationEntry::withDelegates( + $entry, + self::GOLDEN_EXPIRY, + [new SorobanDelegateDescriptor($delegateKp->getAccountId())], + ); + + $this->assertEquals( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + $result->credentials->credentialType, + ); + $this->assertNotNull($result->credentials->addressWithDelegates); + $this->assertCount(1, $result->credentials->addressWithDelegates->delegates); + $this->assertEquals(self::GOLDEN_EXPIRY, $result->credentials->addressWithDelegates->addressCredentials->signatureExpirationLedger); + $this->assertNull($result->credentials->addressWithDelegates->addressCredentials->signature->vec, 'Top-level signature must default to void'); + } + + public function testWithDelegatesPreservesNonceAndAddress(): void + { + $entry = $this->makeGoldenLegacyEntry(); + $delegateKp = KeyPair::random(); + + $result = SorobanAuthorizationEntry::withDelegates($entry, self::GOLDEN_EXPIRY, [ + new SorobanDelegateDescriptor($delegateKp->getAccountId()), + ]); + + $topCreds = $result->credentials->addressWithDelegates?->addressCredentials; + $this->assertEquals(self::GOLDEN_NONCE, $topCreds?->nonce); + $this->assertEquals(self::GOLDEN_ACCOUNT, $topCreds?->address->accountId); + } + + public function testWithDelegatesRejectsAlreadyWithDelegates(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/already ADDRESS_WITH_DELEGATES/'); + + $entry = $this->makeGoldenLegacyEntry(); + $delegateKp = KeyPair::random(); + $result = SorobanAuthorizationEntry::withDelegates($entry, self::GOLDEN_EXPIRY, [ + new SorobanDelegateDescriptor($delegateKp->getAccountId()), + ]); + // Second call should throw. + SorobanAuthorizationEntry::withDelegates($result, self::GOLDEN_EXPIRY, []); + } + + public function testWithDelegatesRejectsSourceAccount(): void + { + $this->expectException(InvalidArgumentException::class); + + $entry = new SorobanAuthorizationEntry( + SorobanCredentials::forSourceAccount(), + $this->makeGoldenInvocation(), + ); + SorobanAuthorizationEntry::withDelegates($entry, 100, []); + } + + // --------------------------------------------------------------------------- + // Delegate sorting (XDR bytes, not strkey) and duplicate rejection + // --------------------------------------------------------------------------- + + /** + * An account address (type 0) sorts BEFORE a contract address (type 1) in XDR encoding. + * But strkey ordering is "C" < "G", which is the inverse. This test verifies XDR-byte + * ordering is used, not strkey ordering. + */ + public function testDelegateSortingByXdrBytesNotStrkey(): void + { + $accountKp = KeyPair::random(); + $contractStrkey = self::GOLDEN_CONTRACT; + $contractHex = StrKey::decodeContractIdHex($contractStrkey); + + // Create entries in "wrong" order (contract first, account second) — they must be sorted. + $entry = $this->makeGoldenLegacyEntry(); + $result = SorobanAuthorizationEntry::withDelegates($entry, self::GOLDEN_EXPIRY, [ + new SorobanDelegateDescriptor($contractStrkey), // "C..." — lexicographically before "G..." + new SorobanDelegateDescriptor($accountKp->getAccountId()), // "G..." — lexicographically after "C..." + ]); + + $delegates = $result->credentials->addressWithDelegates?->delegates; + $this->assertNotNull($delegates); + $this->assertCount(2, $delegates); + + // In XDR encoding, SC_ADDRESS_TYPE_ACCOUNT (0x00000000) < SC_ADDRESS_TYPE_CONTRACT (0x00000001), + // so the ACCOUNT address must be FIRST after sorting by XDR bytes. + $firstAddrStrkey = $delegates[0]->address->toStrKey(); + $this->assertEquals( + $accountKp->getAccountId(), + $firstAddrStrkey, + 'Account address must sort FIRST by XDR bytes (type 0), even though "G" > "C" as a string', + ); + } + + public function testDuplicateDelegateInSameArrayThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Duplicate delegate address/'); + + $delegateKp = KeyPair::random(); + $entry = $this->makeGoldenLegacyEntry(); + + SorobanAuthorizationEntry::withDelegates($entry, self::GOLDEN_EXPIRY, [ + new SorobanDelegateDescriptor($delegateKp->getAccountId()), + new SorobanDelegateDescriptor($delegateKp->getAccountId()), // Same address — must throw. + ]); + } + + /** + * The same address at different nesting levels is legal (not a duplicate). + */ + public function testSameAddressAtDifferentLevelsIsAllowed(): void + { + $delegateKp = KeyPair::random(); + $entry = $this->makeGoldenLegacyEntry(); + + // Same address at top-level delegate and as its own nested delegate. + $nested = new SorobanDelegateDescriptor($delegateKp->getAccountId()); + $result = SorobanAuthorizationEntry::withDelegates($entry, self::GOLDEN_EXPIRY, [ + new SorobanDelegateDescriptor($delegateKp->getAccountId(), null, [$nested]), + ]); + + $this->assertCount(1, $result->credentials->addressWithDelegates?->delegates ?? []); + $this->assertCount(1, $result->credentials->addressWithDelegates?->delegates[0]->nestedDelegates ?? []); + } + + // --------------------------------------------------------------------------- + // TASK 6: Decode depth guard + // --------------------------------------------------------------------------- + + /** + * A tree deeper than RECURSION_LIMIT (128) must throw InvalidArgumentException when decoded. + */ + public function testDepthGuardRejectsDeepTree(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/recursion limit|depth/i'); + + $deepXdr = $this->buildDeepDelegateXdr(130); + $buffer = new XdrBuffer($deepXdr); + \Soneso\StellarSDK\Xdr\XdrSorobanDelegateSignature::decode($buffer); + } + + /** + * A shallow tree (depth <= RECURSION_LIMIT) must decode without throwing. + */ + public function testDepthGuardAcceptsShallowTree(): void + { + $shallowXdr = $this->buildDeepDelegateXdr(3); + $buffer = new XdrBuffer($shallowXdr); + $decoded = \Soneso\StellarSDK\Xdr\XdrSorobanDelegateSignature::decode($buffer); + $this->assertInstanceOf(\Soneso\StellarSDK\Xdr\XdrSorobanDelegateSignature::class, $decoded); + } + + /** + * Builds XDR bytes for a linear delegate chain of $depth levels. + * + * Structure: node -> nestedDelegates[node -> nestedDelegates[...]] + * Uses the golden account address so the bytes are valid. + */ + private function buildDeepDelegateXdr(int $depth): string + { + // Build innermost to outermost. + $accountAddr = XdrSCAddress::forAccountId(self::GOLDEN_ACCOUNT); + $voidSig = XdrSCVal::forVoid(); + $node = new \Soneso\StellarSDK\Xdr\XdrSorobanDelegateSignature($accountAddr, $voidSig, []); + + // Wrap $depth times. + for ($i = 1; $i < $depth; $i++) { + $node = new \Soneso\StellarSDK\Xdr\XdrSorobanDelegateSignature($accountAddr, $voidSig, [$node]); + } + return $node->encode(); + } + + // --------------------------------------------------------------------------- + // Entry-level XDR round-trips including WITH_DELEGATES + // --------------------------------------------------------------------------- + + public function testAuthorizationEntryXdrRoundTripV2(): void + { + $entry = $this->makeGoldenV2Entry(); + $b64 = $entry->toBase64Xdr(); + $decoded = SorobanAuthorizationEntry::fromBase64Xdr($b64); + + $this->assertEquals( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + $decoded->credentials->credentialType, + ); + $this->assertNotNull($decoded->credentials->addressCredentials); + $this->assertEquals(self::GOLDEN_NONCE, $decoded->credentials->addressCredentials->nonce); + $this->assertEquals($b64, $decoded->toBase64Xdr()); + } + + public function testAuthorizationEntryXdrRoundTripWithDelegates(): void + { + $entry = $this->makeGoldenLegacyEntry(); + $delegKp = KeyPair::random(); + $result = SorobanAuthorizationEntry::withDelegates($entry, self::GOLDEN_EXPIRY, [ + new SorobanDelegateDescriptor($delegKp->getAccountId()), + ]); + + $b64 = $result->toBase64Xdr(); + $decoded = SorobanAuthorizationEntry::fromBase64Xdr($b64); + + $this->assertEquals( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + $decoded->credentials->credentialType, + ); + $this->assertCount(1, $decoded->credentials->addressWithDelegates?->delegates ?? []); + $this->assertEquals($b64, $decoded->toBase64Xdr()); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private function extractSignatureHex(SorobanAuthorizationEntry $entry): string + { + $sig = $entry->credentials->addressCredentials?->signature; + if ($sig?->vec) { + foreach ($sig->vec[0]->map ?? [] as $mapEntry) { + if ($mapEntry->key->sym === 'signature') { + return bin2hex($mapEntry->val->bytes?->getValue() ?? ''); + } + } + } + return ''; + } + + private function extractSigBytesFromVecEntry(XdrSCVal $vecEntry): string + { + foreach ($vecEntry->map ?? [] as $mapEntry) { + if ($mapEntry->key->sym === 'signature') { + return $mapEntry->val->bytes?->getValue() ?? ''; + } + } + return ''; + } +} diff --git a/Soneso/StellarSDKTests/Unit/Soroban/P27CoverageClosureTest.php b/Soneso/StellarSDKTests/Unit/Soroban/P27CoverageClosureTest.php new file mode 100644 index 00000000..32d4e602 --- /dev/null +++ b/Soneso/StellarSDKTests/Unit/Soroban/P27CoverageClosureTest.php @@ -0,0 +1,1393 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/ADDRESS is missing address payload/'); + + $xdrCreds = new XdrSorobanCredentials(new \Soneso\StellarSDK\Xdr\XdrSorobanCredentialsType( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS + )); + // address field intentionally left null (default) + SorobanCredentials::fromXdr($xdrCreds); + } + + /** + * fromXdr ADDRESS_V2 arm with a null addressV2 payload must throw. + */ + public function testFromXdrAddressV2ArmMissingPayloadThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/ADDRESS_V2 is missing addressV2 payload/'); + + $xdrCreds = new XdrSorobanCredentials(new \Soneso\StellarSDK\Xdr\XdrSorobanCredentialsType( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2 + )); + SorobanCredentials::fromXdr($xdrCreds); + } + + /** + * fromXdr ADDRESS_WITH_DELEGATES arm with a null addressWithDelegates payload must throw. + */ + public function testFromXdrAddressWithDelegatesArmMissingPayloadThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/ADDRESS_WITH_DELEGATES is missing addressWithDelegates payload/'); + + $xdrCreds = new XdrSorobanCredentials(new \Soneso\StellarSDK\Xdr\XdrSorobanCredentialsType( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES + )); + SorobanCredentials::fromXdr($xdrCreds); + } + + /** + * toXdr ADDRESS arm with null addressCredentials must throw. + */ + public function testToXdrAddressArmMissingCredentialsThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/ADDRESS arm requires addressCredentials/'); + + $creds = new SorobanCredentials(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, null); + $creds->toXdr(); + } + + /** + * toXdr ADDRESS_V2 arm with null addressCredentials must throw. + */ + public function testToXdrAddressV2ArmMissingCredentialsThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/ADDRESS_V2 arm requires addressCredentials/'); + + $creds = new SorobanCredentials(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, null); + $creds->toXdr(); + } + + /** + * toXdr ADDRESS_WITH_DELEGATES arm with null addressWithDelegates must throw. + */ + public function testToXdrAddressWithDelegatesArmMissingPayloadThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/ADDRESS_WITH_DELEGATES arm requires addressWithDelegates/'); + + $creds = new SorobanCredentials( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + null, + null, + ); + $creds->toXdr(); + } + + /** + * toXdr unknown credentialType must throw. + */ + public function testToXdrUnknownTypeThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Unknown credential type/'); + + $creds = new SorobanCredentials(99); + $creds->toXdr(); + } + + /** + * isSourceAccount returns true for SOURCE_ACCOUNT and false for ADDRESS arms. + */ + public function testIsSourceAccount(): void + { + $this->assertTrue(SorobanCredentials::forSourceAccount()->isSourceAccount()); + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $this->assertFalse( + SorobanCredentials::forAddress($address, 1, 100, XdrSCVal::forVoid())->isSourceAccount() + ); + } + + /** + * isAddressBased returns true for all three address arms. + */ + public function testIsAddressBased(): void + { + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addressCreds = new SorobanAddressCredentials($address, 1, 100, XdrSCVal::forVoid()); + + $this->assertFalse(SorobanCredentials::forSourceAccount()->isAddressBased()); + $this->assertTrue(SorobanCredentials::forAddressCredentials($addressCreds)->isAddressBased()); + $this->assertTrue(SorobanCredentials::forAddressCredentialsV2($addressCreds)->isAddressBased()); + + $withDel = new SorobanAddressCredentialsWithDelegates($addressCreds, []); + $this->assertTrue(SorobanCredentials::forAddressWithDelegates($withDel)->isAddressBased()); + } + + /** + * getAddressWithDelegates / setAddressWithDelegates round-trip. + */ + public function testGetSetAddressWithDelegates(): void + { + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addressCreds = new SorobanAddressCredentials($address, 1, 100, XdrSCVal::forVoid()); + $withDel = new SorobanAddressCredentialsWithDelegates($addressCreds, []); + + $creds = SorobanCredentials::forSourceAccount(); + $this->assertNull($creds->getAddressWithDelegates()); + + $creds->setAddressWithDelegates($withDel); + $this->assertSame($withDel, $creds->getAddressWithDelegates()); + + $creds->setAddressWithDelegates(null); + $this->assertNull($creds->getAddressWithDelegates()); + } + + /** + * getCredentialType / setCredentialType round-trip. + */ + public function testGetSetCredentialType(): void + { + $creds = SorobanCredentials::forSourceAccount(); + $this->assertSame(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT, $creds->getCredentialType()); + + $creds->setCredentialType(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2); + $this->assertSame(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, $creds->getCredentialType()); + } + + /** + * writeBackAddressCredentials no-ops for SOURCE_ACCOUNT credentials. + */ + public function testWriteBackAddressCredentialsNoOpForSourceAccount(): void + { + $creds = SorobanCredentials::forSourceAccount(); + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addressCreds = new SorobanAddressCredentials($address, 99, 500, XdrSCVal::forVoid()); + + // Must not throw; addressCredentials stays null. + $creds->writeBackAddressCredentials($addressCreds); + $this->assertNull($creds->addressCredentials); + } + + /** + * Backward-compatible constructor: passing SorobanAddressCredentials as first arg + * sets ADDRESS arm automatically. + */ + public function testBackwardCompatibleConstructorAcceptsAddressCredentials(): void + { + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addressCreds = new SorobanAddressCredentials($address, 7, 77, XdrSCVal::forVoid()); + + $creds = new SorobanCredentials($addressCreds); + + $this->assertSame(XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, $creds->credentialType); + $this->assertSame($addressCreds, $creds->addressCredentials); + } + + // ========================================================================= + // SorobanAuthorizationEntry — error-guard paths + // ========================================================================= + + /** + * parseAddressStrkey rejects an invalid (non-G, non-C) strkey. + * + * Exercise via withDelegates(), which calls buildDelegateNode() -> parseAddressStrkey(). + */ + public function testWithDelegatesRejectsInvalidDelegateStrkey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/G- or C-prefixed strkey/'); + + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $creds = SorobanCredentials::forAddress($address, 1, 100, XdrSCVal::forVoid()); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + SorobanAuthorizationEntry::withDelegates($entry, 100, [ + new SorobanDelegateDescriptor('NOT_A_VALID_STRKEY'), + ]); + } + + /** + * buildDelegateNode depth limit is hit when withDelegates is called with a descriptor tree + * deeper than 128 levels. The descriptors themselves are nested; each buildDelegateNode + * call increments depth by 1 when recursing into nestedDelegates. + */ + public function testWithDelegatesRejectsDescriptorTreeDeeperThanLimit(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/depth limit/i'); + + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $creds = SorobanCredentials::forAddress($address, 1, 100, XdrSCVal::forVoid()); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + // Build a descriptor nested 130 levels deep. + $delegateKp = KeyPair::random(); + $inner = new SorobanDelegateDescriptor($delegateKp->getAccountId()); + for ($i = 0; $i < 130; $i++) { + $inner = new SorobanDelegateDescriptor($delegateKp->getAccountId(), null, [$inner]); + } + + SorobanAuthorizationEntry::withDelegates($entry, 100, [$inner]); + } + + /** + * appendSignatureToMatchingNodes depth-limit: sign(forAddress=...) on a live entry + * whose delegate tree is artificially set beyond 128 levels raises InvalidArgumentException. + * + * We exercise this by calling forAddress on an entry built with a normal (shallow) tree and + * then directly calling the public sign() method, which internally calls + * appendSignatureToMatchingNodes. Because the depth limit is on the traversal rather + * than on construction, we must build an entry whose delegate tree is deeply nested at + * construction time. We use buildDeepDelegateXdr to round-trip decode, then sign(). + * + * Note: this exercises the depth-limit guard on the SIGNING traversal + * (appendSignatureToDelegateNode), which is separate from the XDR decode guard. + */ + public function testSignForAddressOnDeepDelegateSdkTreeThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/depth limit/i'); + + // Build a 130-deep nested delegate tree via wrapper objects directly + // (skipping withDelegates depth guard by using SorobanDelegateSignature directly). + $topKp = KeyPair::fromSeed(self::TEST_SECRET); + $delegateKp = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + $delegateAddr = XdrSCAddress::forAccountId($delegateKp->getAccountId()); + + // Build a chain 130 levels deep. + $node = new SorobanDelegateSignature($delegateAddr, XdrSCVal::forVoid(), []); + for ($i = 0; $i < 130; $i++) { + $node = new SorobanDelegateSignature($delegateAddr, XdrSCVal::forVoid(), [$node]); + } + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$node]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + // sign(forAddress=delegate) triggers appendSignatureToMatchingNodes/appendSignatureToDelegateNode + // which hits the depth limit. + $entry->sign($delegateKp, Network::testnet(), null, $delegateKp->getAccountId()); + } + + /** + * forAddress routing: when the top-level node has an EXISTING non-void signature vec, + * the new signature is appended (not replaced). + * + * This exercises the `$addressCreds->signature->vec !== null` branch inside + * appendSignatureToMatchingNodes when matching the top-level address. + */ + public function testForAddressAppendsToExistingTopLevelVec(): void + { + $topKp = KeyPair::fromSeed(self::TEST_SECRET); + $signer2 = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $addressCreds = new SorobanAddressCredentials($topAddress, self::GOLDEN_NONCE, self::GOLDEN_EXPIRY, XdrSCVal::forVoid()); + $creds = SorobanCredentials::forAddressCredentialsV2($addressCreds); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + // First sign: void -> one-element vec. + $entry->sign($topKp, Network::testnet(), null, $topKp->getAccountId()); + + // Second sign with a different key via forAddress: should APPEND to the existing vec. + $entry->sign($signer2, Network::testnet(), null, $topKp->getAccountId()); + + $sig = $entry->credentials->addressCredentials?->signature; + $this->assertNotNull($sig?->vec); + $this->assertCount(2, $sig->vec, 'Second forAddress sign must append to existing vec'); + } + + /** + * appendSignatureToDelegateNode: when a delegate node has an existing non-void signature, + * sign(forAddress=delegate) appends to it rather than replacing. + */ + public function testForAddressAppendsToExistingDelegateVec(): void + { + $topKp = KeyPair::random(); + $delegateKp = KeyPair::fromSeed(self::TEST_SECRET); + $delegateKp2 = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $delegateAddress = Address::fromAccountId($delegateKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, self::GOLDEN_NONCE, self::GOLDEN_EXPIRY, XdrSCVal::forVoid()); + $delegateXdrAddr = XdrSCAddress::forAccountId($delegateKp->getAccountId()); + $delegateNode = new SorobanDelegateSignature($delegateXdrAddr, XdrSCVal::forVoid(), []); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$delegateNode]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + // First sign: void -> one-element vec on delegate. + $entry->sign($delegateKp, Network::testnet(), null, $delegateKp->getAccountId()); + + // Second sign: non-void vec -> append. + $entry->sign($delegateKp2, Network::testnet(), null, $delegateKp->getAccountId()); + + $delegateResult = $entry->credentials->addressWithDelegates?->delegates[0]; + $this->assertNotNull($delegateResult?->signature->vec); + $this->assertCount(2, $delegateResult->signature->vec, 'forAddress must append to delegate vec'); + } + + /** + * sign(forAddress=) on a nested delegate (depth > 1): the signature lands in the nested node. + * + * This exercises appendSignatureToDelegateNode -> recursion into nestedDelegates. + */ + public function testForAddressSignsNestedDelegateNode(): void + { + $topKp = KeyPair::random(); + $outerDelegate = KeyPair::random(); + $innerDelegate = KeyPair::fromSeed(self::TEST_SECRET); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, self::GOLDEN_NONCE, self::GOLDEN_EXPIRY, XdrSCVal::forVoid()); + $outerAddr = XdrSCAddress::forAccountId($outerDelegate->getAccountId()); + $innerAddr = XdrSCAddress::forAccountId($innerDelegate->getAccountId()); + $innerNode = new SorobanDelegateSignature($innerAddr, XdrSCVal::forVoid(), []); + $outerNode = new SorobanDelegateSignature($outerAddr, XdrSCVal::forVoid(), [$innerNode]); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$outerNode]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + // Sign the INNER delegate (depth 2). + $entry->sign($innerDelegate, Network::testnet(), null, $innerDelegate->getAccountId()); + + // Outer delegate must remain unsigned. + $outer = $entry->credentials->addressWithDelegates?->delegates[0]; + $this->assertNull($outer?->signature->vec, 'Outer delegate must remain void'); + + // Inner delegate must have a signature. + $inner = $outer?->nestedDelegates[0]; + $this->assertNotNull($inner?->signature->vec, 'Inner (nested) delegate must be signed'); + $this->assertCount(1, $inner->signature->vec); + } + + /** + * Deep (3-level) delegate-tree XDR round-trip. + * + * Generated tests use empty nestedDelegates arrays. This test verifies that a tree + * with real nesting encodes and decodes exactly at the entry level. + */ + public function testDeepDelegateTreeEntryXdrRoundTrip(): void + { + $topKp = KeyPair::random(); + $level1Kp = KeyPair::random(); + $level2Kp = KeyPair::random(); + $level3Kp = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, self::GOLDEN_NONCE, self::GOLDEN_EXPIRY, XdrSCVal::forVoid()); + + // Build 3-level tree. + $level3Addr = XdrSCAddress::forAccountId($level3Kp->getAccountId()); + $level3Node = new SorobanDelegateSignature($level3Addr, XdrSCVal::forVoid(), []); + + $level2Addr = XdrSCAddress::forAccountId($level2Kp->getAccountId()); + $level2Node = new SorobanDelegateSignature($level2Addr, XdrSCVal::forVoid(), [$level3Node]); + + $level1Addr = XdrSCAddress::forAccountId($level1Kp->getAccountId()); + $level1Node = new SorobanDelegateSignature($level1Addr, XdrSCVal::forVoid(), [$level2Node]); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$level1Node]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + // Round-trip at the entry level (not just credentials). + $b64 = $entry->toBase64Xdr(); + $decoded = SorobanAuthorizationEntry::fromBase64Xdr($b64); + + $this->assertSame( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + $decoded->credentials->credentialType, + ); + + $delegateL1 = $decoded->credentials->addressWithDelegates?->delegates[0]; + $this->assertNotNull($delegateL1); + $this->assertCount(1, $delegateL1->nestedDelegates, 'Level-1 delegate must have 1 nested child'); + + $delegateL2 = $delegateL1->nestedDelegates[0]; + $this->assertCount(1, $delegateL2->nestedDelegates, 'Level-2 delegate must have 1 nested child'); + + $delegateL3 = $delegateL2->nestedDelegates[0]; + $this->assertEmpty($delegateL3->nestedDelegates, 'Level-3 delegate must be a leaf'); + + // Byte-identity. + $this->assertSame($b64, $decoded->toBase64Xdr(), '3-level delegate tree must round-trip byte-exactly'); + } + + // ========================================================================= + // SorobanDelegateSignature — getters / setters + // ========================================================================= + + public function testSorobanDelegateSignatureGettersSetters(): void + { + $addr = XdrSCAddress::forAccountId(self::GOLDEN_ACCOUNT); + $sig = XdrSCVal::forVoid(); + $nested = []; + + $node = new SorobanDelegateSignature($addr, $sig, $nested); + + // Getters. + $this->assertSame($addr, $node->getAddress()); + $this->assertSame($sig, $node->getSignature()); + $this->assertSame($nested, $node->getNestedDelegates()); + + // Setters. + $addr2 = XdrSCAddress::forAccountId(self::GOLDEN_ACCOUNT); + $node->setAddress($addr2); + $this->assertSame($addr2, $node->getAddress()); + + $sig2 = XdrSCVal::forBool(true); + $node->setSignature($sig2); + $this->assertSame($sig2, $node->getSignature()); + + $innerAddr = XdrSCAddress::forAccountId(self::GOLDEN_ACCOUNT); + $innerNode = new SorobanDelegateSignature($innerAddr, XdrSCVal::forVoid(), []); + $node->setNestedDelegates([$innerNode]); + $this->assertCount(1, $node->getNestedDelegates()); + } + + // ========================================================================= + // SorobanAddressCredentialsWithDelegates — getters / setters + // ========================================================================= + + public function testSorobanAddressCredentialsWithDelegatesGettersSetters(): void + { + $address = Address::fromAccountId(self::GOLDEN_ACCOUNT); + $addrCreds = new SorobanAddressCredentials($address, 1, 100, XdrSCVal::forVoid()); + $withDel = new SorobanAddressCredentialsWithDelegates($addrCreds, []); + + // getAddressCredentials. + $this->assertSame($addrCreds, $withDel->getAddressCredentials()); + + // setAddressCredentials. + $addrCreds2 = new SorobanAddressCredentials($address, 2, 200, XdrSCVal::forVoid()); + $withDel->setAddressCredentials($addrCreds2); + $this->assertSame($addrCreds2, $withDel->getAddressCredentials()); + + // getDelegates / setDelegates. + $this->assertEmpty($withDel->getDelegates()); + + $delegateAddr = XdrSCAddress::forAccountId(self::GOLDEN_ACCOUNT); + $delegateNode = new SorobanDelegateSignature($delegateAddr, XdrSCVal::forVoid(), []); + $withDel->setDelegates([$delegateNode]); + $this->assertCount(1, $withDel->getDelegates()); + } + + // ========================================================================= + // XdrBuffer — getRecursionDepth + // ========================================================================= + + /** + * XdrBuffer.getRecursionDepth returns 0 initially and increments with enterRecursion. + */ + public function testXdrBufferGetRecursionDepth(): void + { + $buf = new XdrBuffer(str_repeat("\x00", 64)); + + $this->assertSame(0, $buf->getRecursionDepth()); + + $buf->enterRecursion(); + $this->assertSame(1, $buf->getRecursionDepth()); + + $buf->leaveRecursion(); + $this->assertSame(0, $buf->getRecursionDepth()); + } + + /** + * leaveRecursion is a no-op when depth is already 0 (does not go negative). + */ + public function testXdrBufferLeaveRecursionNoOpAtZero(): void + { + $buf = new XdrBuffer(str_repeat("\x00", 32)); + + // Already at 0, leaving must not throw and must stay at 0. + $buf->leaveRecursion(); + $this->assertSame(0, $buf->getRecursionDepth()); + } + + // ========================================================================= + // MethodOptions — authV2 constructor (covers lines 39-50) + // ========================================================================= + + /** + * MethodOptions authV2 property defaults to false and can be set via constructor. + */ + public function testMethodOptionsAuthV2PropertyDefault(): void + { + $default = new MethodOptions(); + $this->assertFalse($default->authV2); + + $enabled = new MethodOptions(authV2: true); + $this->assertTrue($enabled->authV2); + } + + // ========================================================================= + // AssembledTransaction — getBlockingNonInvokerSigners and helpers + // ========================================================================= + + /** + * getBlockingNonInvokerSigners returns the top-level G-address for an unsigned ADDRESS entry. + */ + public function testGetBlockingNonInvokerSignersReportsLegacyAddressEntry(): void + { + $topKp = KeyPair::random(); + $address = Address::fromAccountId($topKp->getAccountId()); + $addrCreds = new SorobanAddressCredentials($address, 1, 100, XdrSCVal::forVoid()); + $creds = SorobanCredentials::forAddressCredentials($addrCreds); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], KeyPair::fromSeed(self::TEST_SECRET)); + + // Directly call getBlockingNonInvokerSigners via sign(force:true) which invokes it. + // We verify the behavior by observing the Exception thrown when blockers exist. + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/multiple signers/'); + + $tx->sign(force: true); + } + + /** + * getBlockingNonInvokerSigners: SOURCE_ACCOUNT entry is silently skipped (no blocking). + */ + public function testGetBlockingNonInvokerSignersSkipsSourceAccountEntry(): void + { + $sourceEntry = new SorobanAuthorizationEntry( + SorobanCredentials::forSourceAccount(), + $this->makeInvocation(), + ); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$sourceEntry], KeyPair::fromSeed(self::TEST_SECRET)); + + // No blocking signers → sign() should succeed (no Exception). + try { + $tx->sign(force: true); + } catch (Exception $e) { + $this->fail('SOURCE_ACCOUNT entry must not block sign(): ' . $e->getMessage()); + } + + $this->assertNotNull($tx->signed); + } + + /** + * WITH_DELEGATES entry where all delegates are signed and top-level is void: + * getBlockingNonInvokerSigners must treat this as the "delegates-only" pattern + * and NOT block submission. + */ + public function testGetBlockingNonInvokerSignersAllowsDelegatesOnlyPattern(): void + { + $topKp = KeyPair::random(); + $delegateKp = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + + // Delegate is SIGNED (non-void). + $delegateAddr = XdrSCAddress::forAccountId($delegateKp->getAccountId()); + $delegate = new SorobanDelegateSignature( + $delegateAddr, + XdrSCVal::forVec([XdrSCVal::forVoid()]), // non-void => signed + [], + ); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$delegate]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], KeyPair::fromSeed(self::TEST_SECRET)); + + // No blocking signers → sign() must succeed. + try { + $tx->sign(force: true); + } catch (Exception $e) { + $this->fail('Delegates-only pattern must not block sign(): ' . $e->getMessage()); + } + + $this->assertNotNull($tx->signed); + } + + /** + * WITH_DELEGATES entry where top-level is void AND a delegate is also unsigned: + * getBlockingNonInvokerSigners must report both addresses. + */ + public function testGetBlockingNonInvokerSignersWithDelegatesBlocksWhenDelegateUnsigned(): void + { + $topKp = KeyPair::random(); + $delegateKp = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + + // Delegate is UNSIGNED (void). + $delegateAddr = XdrSCAddress::forAccountId($delegateKp->getAccountId()); + $delegate = new SorobanDelegateSignature($delegateAddr, XdrSCVal::forVoid(), []); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$delegate]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], KeyPair::fromSeed(self::TEST_SECRET)); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/multiple signers/'); + + $tx->sign(force: true); + } + + /** + * needsNonInvokerSigningBy with includeAlreadySigned=false skips a signed ADDRESS entry. + * + * Verifies the $isVoid=false / !$includeAlreadySigned branch in needsNonInvokerSigningBy. + */ + public function testNeedsNonInvokerSigningBySkipsSignedAddressEntry(): void + { + $signerKp = KeyPair::random(); + $address = Address::fromAccountId($signerKp->getAccountId()); + $addressCreds = new SorobanAddressCredentials($address, 1, 100, XdrSCVal::forVec([XdrSCVal::forVoid()])); + $creds = SorobanCredentials::forAddressCredentials($addressCreds); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], KeyPair::fromSeed(self::TEST_SECRET)); + + $needed = $tx->needsNonInvokerSigningBy(includeAlreadySigned: false); + $this->assertNotContains($signerKp->getAccountId(), $needed, 'Signed ADDRESS entry must not appear by default'); + } + + /** + * needsNonInvokerSigningBy: a delegate with nested delegates reports the nested unsigned ones + * (exercises collectUnsignedDelegateAddresses recursion). + */ + public function testNeedsNonInvokerSigningByReportsNestedUnsignedDelegate(): void + { + $topKp = KeyPair::random(); + $outerKp = KeyPair::random(); + $innerKp = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + + $innerAddr = XdrSCAddress::forAccountId($innerKp->getAccountId()); + $innerNode = new SorobanDelegateSignature($innerAddr, XdrSCVal::forVoid(), []); + + $outerAddr = XdrSCAddress::forAccountId($outerKp->getAccountId()); + $outerNode = new SorobanDelegateSignature($outerAddr, XdrSCVal::forVoid(), [$innerNode]); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$outerNode]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], KeyPair::fromSeed(self::TEST_SECRET)); + $needed = $tx->needsNonInvokerSigningBy(); + + $this->assertContains($topKp->getAccountId(), $needed); + $this->assertContains($outerKp->getAccountId(), $needed); + $this->assertContains($innerKp->getAccountId(), $needed); + } + + /** + * signAuthEntries: when the signer address matches both the top-level AND a delegate node + * (same address at different levels — allowed by the spec), the signature is written to + * both nodes. + * + * Exercises delegateTreeContainsAddress finding a match, plus the signAuthEntries loop + * setting both $entryMatchesTopLevel and $entryMatchesDelegate. + */ + public function testSignAuthEntriesSignsBothTopLevelAndDelegateWhenAddressRepeated(): void + { + $sharedKp = KeyPair::fromSeed(self::TEST_SECRET); + + $sharedAddress = Address::fromAccountId($sharedKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($sharedAddress, 1, 100, XdrSCVal::forVoid()); + + // Delegate uses the SAME address as the top-level. + $sharedXdrAddr = XdrSCAddress::forAccountId($sharedKp->getAccountId()); + $delegateNode = new SorobanDelegateSignature($sharedXdrAddr, XdrSCVal::forVoid(), []); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$delegateNode]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $invokerKp = KeyPair::fromSeed(self::TEST_SECRET); // same key, different role + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], $invokerKp); + $this->injectMockedServerResponses($tx, [$this->makeLatestLedgerResponse(1000)]); + + $tx->signAuthEntries(signerKeyPair: $sharedKp, validUntilLedgerSeq: 9999); + + $ops = $tx->tx?->getOperations(); + $op = $ops[0] ?? null; + $this->assertInstanceOf(InvokeHostFunctionOperation::class, $op); + $auth = $op->auth; + $this->assertCount(1, $auth); + + $result = $auth[0]->credentials->addressWithDelegates; + $this->assertNotNull($result); + + // Top-level must be signed. + $this->assertNotNull($result->addressCredentials->signature->vec, + 'Top-level signature must be written when address matches both top-level and delegate'); + + // Delegate must also be signed. + $this->assertNotNull($result->delegates[0]->signature->vec, + 'Delegate signature must be written when address matches both top-level and delegate'); + } + + /** + * Callback-based signing in signAuthEntries passes the entry and network to the callback. + * + * Exercises the `$authorizeEntryCallback !== null` branch (line 600). + */ + public function testSignAuthEntriesCallbackBranchIsInvoked(): void + { + $signerKp = KeyPair::fromSeed(self::TEST_SECRET); + $invokerKp = KeyPair::fromSeed(self::TEST_SECRET); + + $address = Address::fromAccountId($signerKp->getAccountId()); + $addressCreds = new SorobanAddressCredentials($address, 1, 100, XdrSCVal::forVoid()); + $validEntry = new SorobanAuthorizationEntry( + SorobanCredentials::forAddressCredentials($addressCreds), + $this->makeInvocation(), + ); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$validEntry], $invokerKp); + $this->injectMockedServerResponses($tx, [$this->makeLatestLedgerResponse(1000)]); + + $callbackInvoked = false; + $tx->signAuthEntries( + signerKeyPair: $signerKp, + authorizeEntryCallback: static function (SorobanAuthorizationEntry $e, Network $n) use (&$callbackInvoked, $signerKp): SorobanAuthorizationEntry { + $callbackInvoked = true; + $e->sign($signerKp, $n); + return $e; + }, + validUntilLedgerSeq: 9999, + ); + + $this->assertTrue($callbackInvoked, 'Authorize entry callback must be invoked for matching entries'); + } + + // ========================================================================= + // Helpers (replicated from P27AssembledTransactionTest for isolation) + // ========================================================================= + + private function makeInvocation(): SorobanAuthorizedInvocation + { + $contractAddress = Address::fromContractId(StrKey::decodeContractIdHex(self::TEST_CONTRACT)); + $fn = SorobanAuthorizedFunction::forContractFunction($contractAddress, 'test', []); + return new SorobanAuthorizedInvocation($fn, []); + } + + /** + * @param array $entries + */ + private function buildAssembledTransactionWithAuthEntries( + array $entries, + KeyPair $invokerKp, + ): AssembledTransaction { + $clientOptions = new ClientOptions( + sourceAccountKeyPair: $invokerKp, + contractId: self::TEST_CONTRACT, + network: Network::testnet(), + rpcUrl: self::TEST_RPC_URL, + ); + $methodOptions = new MethodOptions(simulate: false, restore: false); + $txOptions = new AssembledTransactionOptions( + clientOptions: $clientOptions, + methodOptions: $methodOptions, + method: 'test', + arguments: [], + ); + + $reflection = new \ReflectionClass(AssembledTransaction::class); + $tx = $reflection->newInstanceWithoutConstructor(); + + $optionsProp = $reflection->getProperty('options'); + $optionsProp->setAccessible(true); + $optionsProp->setValue($tx, $txOptions); + + $server = new SorobanServer($txOptions->clientOptions->rpcUrl); + $serverProp = $reflection->getProperty('server'); + $serverProp->setAccessible(true); + $serverProp->setValue($tx, $server); + + $account = new Account($invokerKp->getAccountId(), new BigInteger(123456789)); + $hostFn = new InvokeContractHostFunction(self::TEST_CONTRACT, 'test', []); + $op = (new InvokeHostFunctionOperationBuilder($hostFn))->build(); + $txBuilder = new TransactionBuilder(sourceAccount: $account); + $txBuilder->addOperation($op); + $built = $txBuilder->build(); + $built->setSorobanAuth($entries); + + $footprint = new XdrLedgerFootprint([], []); + $resources = new XdrSorobanResources($footprint, 100, 100, 100); + $ext = new XdrSorobanTransactionDataExt(0); + $txData = new XdrSorobanTransactionData($ext, $resources, 100); + $built->setSorobanTransactionData($txData); + + $txProp = $reflection->getProperty('tx'); + $txProp->setAccessible(true); + $txProp->setValue($tx, $built); + + $simResponse = new SimulateTransactionResponse([]); + $simResponse->transactionData = $txData; + $simResponse->minResourceFee = 100; + $simResponse->latestLedger = 1000; + $tx->simulationResponse = $simResponse; + + $simResultProp = $reflection->getProperty('simulationResult'); + $simResultProp->setAccessible(true); + $simResultProp->setValue($tx, new SimulateHostFunctionResult($txData, XdrSCVal::forVoid(), $entries)); + + return $tx; + } + + /** + * @param array $responses + */ + private function injectMockedServerResponses(AssembledTransaction $tx, array $responses): void + { + $mock = new MockHandler($responses); + $stack = HandlerStack::create($mock); + $client = new Client(['handler' => $stack]); + + $reflection = new \ReflectionClass($tx); + $serverProp = $reflection->getProperty('server'); + $serverProp->setAccessible(true); + $server = $serverProp->getValue($tx); + + $serverReflection = new \ReflectionClass($server); + $httpClientProp = $serverReflection->getProperty('httpClient'); + $httpClientProp->setAccessible(true); + $httpClientProp->setValue($server, $client); + } + + private function makeLatestLedgerResponse(int $sequence): Response + { + return new Response(200, [], json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'id' => 'abc123', + 'sequence' => $sequence, + 'hash' => str_repeat('a', 64), + ], + ])); + } + + // ========================================================================= + // Additional coverage: AssembledTransaction signAuthEntries SOURCE_ACCOUNT skip, + // delegateTreeContainsAddress, allDelegateNodesSigned, needsNonInvokerSigningBy + // SOURCE_ACCOUNT skip, and collectUnsignedDelegateGAddresses + // ========================================================================= + + /** + * signAuthEntries with a mix of a SOURCE_ACCOUNT entry and a valid ADDRESS entry: + * the SOURCE_ACCOUNT entry is silently skipped (continue at line 562) and only the + * ADDRESS entry matching the signer is processed. + */ + public function testSignAuthEntriesSkipsSourceAccountEntry(): void + { + $signerKp = KeyPair::fromSeed(self::TEST_SECRET); + + $address = Address::fromAccountId($signerKp->getAccountId()); + $addressCreds = new SorobanAddressCredentials($address, 1, 100, XdrSCVal::forVoid()); + $validEntry = new SorobanAuthorizationEntry( + SorobanCredentials::forAddressCredentials($addressCreds), + $this->makeInvocation(), + ); + $sourceEntry = new SorobanAuthorizationEntry( + SorobanCredentials::forSourceAccount(), + $this->makeInvocation(), + ); + + // Put the SOURCE_ACCOUNT entry FIRST so the continue is reached before the valid entry. + $tx = $this->buildAssembledTransactionWithAuthEntries([$sourceEntry, $validEntry], $signerKp); + $this->injectMockedServerResponses($tx, [$this->makeLatestLedgerResponse(1000)]); + + // Must not throw; the valid entry is signed and the source-account entry is skipped. + $tx->signAuthEntries(signerKeyPair: $signerKp, validUntilLedgerSeq: 9999); + + $ops = $tx->tx?->getOperations(); + $op = $ops[0] ?? null; + $this->assertInstanceOf(InvokeHostFunctionOperation::class, $op); + $auth = $op->auth; + $this->assertCount(2, $auth); + + // Source-account entry (index 0) must remain unchanged. + $this->assertSame( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_SOURCE_ACCOUNT, + $auth[0]->credentials->credentialType, + ); + + // ADDRESS entry (index 1) must be signed. + $sig = $auth[1]->credentials->addressCredentials?->signature; + $this->assertNotNull($sig?->vec); + $this->assertCount(1, $sig->vec); + } + + /** + * needsNonInvokerSigningBy skips SOURCE_ACCOUNT entries (line 826 continue). + */ + public function testNeedsNonInvokerSigningBySkipsSourceAccountEntry(): void + { + $sourceEntry = new SorobanAuthorizationEntry( + SorobanCredentials::forSourceAccount(), + $this->makeInvocation(), + ); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$sourceEntry], KeyPair::fromSeed(self::TEST_SECRET)); + $needed = $tx->needsNonInvokerSigningBy(); + + $this->assertEmpty($needed, 'SOURCE_ACCOUNT entry must not appear in needsNonInvokerSigningBy result'); + } + + /** + * allDelegateNodesSigned returns false when one delegate is unsigned. + * + * Exercises the "return false when delegate has void signature" branch (line 757) + * inside allDelegateNodesSigned, which is called from getBlockingNonInvokerSigners + * through sign() force check. + */ + public function testAllDelegatesSignedReturnsFalseForUnsignedDelegate(): void + { + $topKp = KeyPair::random(); + $delegateKp = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + + // Delegate is UNSIGNED (void). + $delegateAddr = XdrSCAddress::forAccountId($delegateKp->getAccountId()); + $delegate = new SorobanDelegateSignature($delegateAddr, XdrSCVal::forVoid(), []); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$delegate]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], KeyPair::fromSeed(self::TEST_SECRET)); + + // sign() calls getBlockingNonInvokerSigners() which calls allDelegateNodesSigned(). + // Since the delegate is unsigned, allDelegateNodesSigned returns false, + // getBlockingNonInvokerSigners reports both addresses, and sign() throws. + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/multiple signers/'); + + $tx->sign(force: true); + } + + /** + * collectUnsignedDelegateGAddresses: a WITH_DELEGATES entry with unsigned top-level AND + * at least one unsigned delegate causes getBlockingNonInvokerSigners to collect the delegate + * G-address (exercises lines 778-784 in collectUnsignedDelegateGAddresses). + */ + public function testCollectUnsignedDelegateGAddressesCollectsAccountAddress(): void + { + $topKp = KeyPair::random(); + $delegateKp = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + + $delegateAddr = XdrSCAddress::forAccountId($delegateKp->getAccountId()); + $delegate = new SorobanDelegateSignature($delegateAddr, XdrSCVal::forVoid(), []); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$delegate]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], KeyPair::fromSeed(self::TEST_SECRET)); + + // Confirm the test structure: needsNonInvokerSigningBy includes both. + $needed = $tx->needsNonInvokerSigningBy(); + $this->assertContains($topKp->getAccountId(), $needed); + $this->assertContains($delegateKp->getAccountId(), $needed); + + // sign() must block because both top-level and delegate are unsigned. + $this->expectException(Exception::class); + $tx->sign(force: true); + } + + // ========================================================================= + // SorobanAuthorizationEntry — buildPreimage() null-payload guards + // ========================================================================= + + /** + * buildPreimage() on an ADDRESS entry with null addressCredentials must throw. + * + * Constructing SorobanCredentials with the ADDRESS type int but passing null for the + * credentials payload bypasses the factory invariant and reaches the guard at line 160. + */ + public function testBuildPreimageAddressArmNullCredentialsThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/ADDRESS arm requires addressCredentials/'); + + $creds = new SorobanCredentials( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS, + null, + ); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + $entry->buildPreimage(Network::testnet()); + } + + /** + * buildPreimage() on an ADDRESS_V2 entry with null addressCredentials must throw. + * + * Same mechanism as the ADDRESS guard but for the ADDRESS_V2 arm (line 177). + */ + public function testBuildPreimageAddressV2ArmNullCredentialsThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/ADDRESS_V2 arm requires addressCredentials/'); + + $creds = new SorobanCredentials( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2, + null, + ); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + $entry->buildPreimage(Network::testnet()); + } + + /** + * buildPreimage() on an ADDRESS_WITH_DELEGATES entry with null addressWithDelegates must throw. + * + * Constructing SorobanCredentials with the WITH_DELEGATES type int but passing null for the + * payload reaches the guard at line 195. + */ + public function testBuildPreimageAddressWithDelegatesArmNullCredentialsThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/ADDRESS_WITH_DELEGATES arm requires addressWithDelegates/'); + + $creds = new SorobanCredentials( + XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + null, + null, + ); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + $entry->buildPreimage(Network::testnet()); + } + + // ========================================================================= + // AssembledTransaction — allDelegateNodesSigned() nested false path (line 757) + // ========================================================================= + + /** + * allDelegateNodesSigned() returns false when an OUTER delegate is signed but one of its + * NESTED delegates is unsigned. + * + * The outer delegate carries a non-void signature; the inner (level-2) delegate is void. + * getBlockingNonInvokerSigners calls allDelegateNodesSigned, which recurses into the outer + * node's nestedDelegates and returns false (line 757) because the inner node is void. + * As a result sign() throws because the entry is not fully satisfied. + */ + public function testAllDelegatesSignedReturnsFalseForUnsignedNestedDelegate(): void + { + $topKp = KeyPair::random(); + $outerKp = KeyPair::random(); + $innerKp = KeyPair::random(); + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + + // Inner delegate: UNSIGNED (void). + $innerAddr = XdrSCAddress::forAccountId($innerKp->getAccountId()); + $innerNode = new SorobanDelegateSignature($innerAddr, XdrSCVal::forVoid(), []); + + // Outer delegate: SIGNED (non-void) but has an unsigned child. + $outerAddr = XdrSCAddress::forAccountId($outerKp->getAccountId()); + $outerNode = new SorobanDelegateSignature( + $outerAddr, + XdrSCVal::forVec([XdrSCVal::forVoid()]), // non-void == "signed" + [$innerNode], + ); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$outerNode]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $invokerKp = KeyPair::fromSeed(self::TEST_SECRET); + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], $invokerKp); + + // sign(force:true) calls getBlockingNonInvokerSigners() -> allDelegateNodesSigned(). + // allDelegateNodesSigned sees outer is signed, recurses into nestedDelegates, finds + // the inner node is void, and returns false (line 757). + // The entry is therefore still blocking, and sign() throws. + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/multiple signers/'); + + $tx->sign(force: true); + } + + // ========================================================================= + // AssembledTransaction — delegateTreeContainsAddress() returns false (line 657) + // ========================================================================= + + /** + * delegateTreeContainsAddress() exhausts all nodes without finding a match and returns + * false (line 657), causing signAuthEntries to skip that entry entirely. + * + * Two auth entries are present: + * - Entry 0: a WITH_DELEGATES entry whose top-level and delegate addresses are both + * different from the signer. + * - Entry 1: a plain ADDRESS entry for the signer, so the signer passes the + * needsNonInvokerSigningBy() pre-check. + * + * The loop processes entry 0 (delegateTreeContainsAddress returns false, entry skipped) + * then entry 1 (signer matches, entry is signed). Entry 0 must remain untouched. + */ + public function testSignAuthEntriesSkipsEntryWhenSignerAddressNotInTree(): void + { + $topKp = KeyPair::random(); + $delegateKp = KeyPair::random(); + $signerKp = KeyPair::fromSeed(self::TEST_SECRET); + + // Entry 0: WITH_DELEGATES — signer is NOT in this tree. + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + + $delegateAddr = XdrSCAddress::forAccountId($delegateKp->getAccountId()); + $delegateNode = new SorobanDelegateSignature($delegateAddr, XdrSCVal::forVoid(), []); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$delegateNode]); + $withDelegatesEntry = new SorobanAuthorizationEntry( + SorobanCredentials::forAddressWithDelegates($withDelegates), + $this->makeInvocation(), + ); + + // Entry 1: ADDRESS — signer IS the top-level address. + $signerAddress = Address::fromAccountId($signerKp->getAccountId()); + $signerCreds = new SorobanAddressCredentials($signerAddress, 2, 100, XdrSCVal::forVoid()); + $signerEntry = new SorobanAuthorizationEntry( + SorobanCredentials::forAddressCredentials($signerCreds), + $this->makeInvocation(), + ); + + $invokerKp = $signerKp; + $tx = $this->buildAssembledTransactionWithAuthEntries([$withDelegatesEntry, $signerEntry], $invokerKp); + $this->injectMockedServerResponses($tx, [$this->makeLatestLedgerResponse(1000)]); + + // signAuthEntries must complete: entry 0 is skipped (delegateTreeContainsAddress returns + // false at line 657), entry 1 is signed. + $tx->signAuthEntries(signerKeyPair: $signerKp, validUntilLedgerSeq: 9999); + + $ops = $tx->tx?->getOperations(); + $op = $ops[0] ?? null; + $this->assertInstanceOf(InvokeHostFunctionOperation::class, $op); + $auth = $op->auth; + + // Entry 0 (WITH_DELEGATES): top-level and delegate must remain void. + $result0 = $auth[0]->credentials->addressWithDelegates; + $this->assertNotNull($result0); + $this->assertNull($result0->addressCredentials->signature->vec, + 'WITH_DELEGATES top-level must remain void when signer is not in the tree'); + $this->assertNull($result0->delegates[0]->signature->vec, + 'WITH_DELEGATES delegate must remain void when signer is not in the tree'); + + // Entry 1 (ADDRESS): must be signed. + $sig1 = $auth[1]->credentials->addressCredentials?->signature; + $this->assertNotNull($sig1?->vec, 'ADDRESS entry for the signer must be signed'); + $this->assertCount(1, $sig1->vec); + } + + // ========================================================================= + // AssembledTransaction — line 581: contractId fallback in signAuthEntries + // ========================================================================= + + /** + * signAuthEntries reads the top-level address strkey using the contractId fallback (line 581) + * when the top-level credential address is a contract (C-prefixed) address. + * + * Two entries are present: + * - Entry 0: a WITH_DELEGATES entry whose top-level is a C-address (accountId is null, so + * the `->accountId ?? ->contractId` null-coalescing at line 580-581 evaluates the + * `->contractId` branch). The signer's G-address does not match the hex contract id string. + * - Entry 1: a plain ADDRESS entry for the G-address signer, so the signer passes the + * needsNonInvokerSigningBy() pre-check. + * + * Entry 0 must remain untouched (contract address cannot match a G-address signer). + */ + public function testSignAuthEntriesUsesContractIdFallbackForContractTopLevelAddress(): void + { + $contractHex = StrKey::decodeContractIdHex(self::TEST_CONTRACT); + $signerKp = KeyPair::fromSeed(self::TEST_SECRET); + + // Entry 0: top-level is a CONTRACT address (accountId is null → contractId branch executes). + $contractAddress = Address::fromContractId($contractHex); + $topCreds = new SorobanAddressCredentials($contractAddress, 1, 100, XdrSCVal::forVoid()); + + $delegateKp = KeyPair::random(); + $delegateAddr = XdrSCAddress::forAccountId($delegateKp->getAccountId()); + $delegateNode = new SorobanDelegateSignature($delegateAddr, XdrSCVal::forVoid(), []); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$delegateNode]); + $contractEntry = new SorobanAuthorizationEntry( + SorobanCredentials::forAddressWithDelegates($withDelegates), + $this->makeInvocation(), + ); + + // Entry 1: ADDRESS entry for the G-address signer (satisfies the pre-check). + $signerAddress = Address::fromAccountId($signerKp->getAccountId()); + $signerCreds = new SorobanAddressCredentials($signerAddress, 2, 100, XdrSCVal::forVoid()); + $signerEntry = new SorobanAuthorizationEntry( + SorobanCredentials::forAddressCredentials($signerCreds), + $this->makeInvocation(), + ); + + $invokerKp = $signerKp; + $tx = $this->buildAssembledTransactionWithAuthEntries([$contractEntry, $signerEntry], $invokerKp); + $this->injectMockedServerResponses($tx, [$this->makeLatestLedgerResponse(1000)]); + + // Must complete: contractId fallback executes at line 581 for entry 0, signer does not + // match, entry 0 is skipped. Entry 1 is signed normally. + $tx->signAuthEntries(signerKeyPair: $signerKp, validUntilLedgerSeq: 9999); + + $ops = $tx->tx?->getOperations(); + $op = $ops[0] ?? null; + $this->assertInstanceOf(InvokeHostFunctionOperation::class, $op); + $auth = $op->auth; + + // Entry 0 (C-address top-level): must remain void. + $result0 = $auth[0]->credentials->addressWithDelegates; + $this->assertNotNull($result0); + $this->assertNull($result0->addressCredentials->signature->vec, + 'Contract top-level signature must remain void when signer is a G-address'); + + // Entry 1 (ADDRESS signer): must be signed. + $sig1 = $auth[1]->credentials->addressCredentials?->signature; + $this->assertNotNull($sig1?->vec, 'Signer ADDRESS entry must be signed'); + $this->assertCount(1, $sig1->vec); + } + + /** + * delegateTreeContainsAddress: when the signer address matches a NESTED delegate (depth 2), + * the method returns true via the recursive call (lines 653-654). + */ + public function testSignAuthEntriesFindsNestedDelegateViaContainsAddress(): void + { + $topKp = KeyPair::random(); + $outerDelegate = KeyPair::random(); + $innerDelegate = KeyPair::fromSeed(self::TEST_SECRET); // will be the signer + + $topAddress = Address::fromAccountId($topKp->getAccountId()); + $topCreds = new SorobanAddressCredentials($topAddress, 1, 100, XdrSCVal::forVoid()); + + $innerAddr = XdrSCAddress::forAccountId($innerDelegate->getAccountId()); + $innerNode = new SorobanDelegateSignature($innerAddr, XdrSCVal::forVoid(), []); + + $outerAddr = XdrSCAddress::forAccountId($outerDelegate->getAccountId()); + $outerNode = new SorobanDelegateSignature($outerAddr, XdrSCVal::forVoid(), [$innerNode]); + + $withDelegates = new SorobanAddressCredentialsWithDelegates($topCreds, [$outerNode]); + $creds = SorobanCredentials::forAddressWithDelegates($withDelegates); + $entry = new SorobanAuthorizationEntry($creds, $this->makeInvocation()); + + $invokerKp = KeyPair::fromSeed(self::TEST_SECRET); + $tx = $this->buildAssembledTransactionWithAuthEntries([$entry], $invokerKp); + $this->injectMockedServerResponses($tx, [$this->makeLatestLedgerResponse(1000)]); + + // Sign with the innerDelegate keypair. delegateTreeContainsAddress must find it at depth 2 + // and set $entryMatchesDelegate = true, then route via forAddress. + $tx->signAuthEntries(signerKeyPair: $innerDelegate, validUntilLedgerSeq: 9999); + + $ops = $tx->tx?->getOperations(); + $op = $ops[0] ?? null; + $this->assertInstanceOf(InvokeHostFunctionOperation::class, $op); + $auth = $op->auth; + $withDelegatesResult = $auth[0]->credentials->addressWithDelegates; + $this->assertNotNull($withDelegatesResult); + + // Top-level must remain void. + $this->assertNull($withDelegatesResult->addressCredentials->signature->vec); + + // Outer delegate must remain void. + $outerResult = $withDelegatesResult->delegates[0]; + $this->assertNull($outerResult->signature->vec); + + // Inner delegate must be signed. + $innerResult = $outerResult->nestedDelegates[0]; + $this->assertNotNull($innerResult->signature->vec); + $this->assertCount(1, $innerResult->signature->vec); + } +} diff --git a/docs/sep/sep-45.md b/docs/sep/sep-45.md index f319ea1f..4230cdf5 100644 --- a/docs/sep/sep-45.md +++ b/docs/sep/sep-45.md @@ -490,6 +490,10 @@ $webAuth = WebAuthForContracts::fromDomain("testnet.anchor.com", Network::testne $webAuth = 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/docs/soroban.md b/docs/soroban.md index f959c988..b666a604 100644 --- a/docs/soroban.md +++ b/docs/soroban.md @@ -499,6 +499,133 @@ $tx->signAuthEntries( $response = $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->getAddressCredentials()` to read the inner `SorobanAddressCredentials` of any address arm (it returns `null` only for source-account credentials), and `$credentials->getCredentialType()` / `$credentials->isSourceAccount()` to inspect the arm. Factories `SorobanCredentials::forAddressCredentialsV2()` and `SorobanCredentials::forAddressWithDelegates()` build the new arms directly. + +#### Requesting V2 Entries from Simulation + +Set `authV2` to request `ADDRESS_V2` credential arms in the simulation response. RPC servers without support silently ignore the flag and return legacy `ADDRESS` entries — detect support by inspecting the credential arm of the returned entries, never by expecting an error. When `authV2` is `false` (the default), the key is omitted from the JSON-RPC params entirely. + +```php +buildInvokeMethodTx( + name: 'swap', + args: $args, + methodOptions: new MethodOptions(authV2: true), +); + +// Detect whether the RPC honored the flag +$entries = $tx->getSimulationData()->auth ?? []; +$gotV2 = false; +foreach ($entries as $entry) { + if ($entry->credentials->getCredentialType() + === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2) { + $gotV2 = true; + } +} + +// Low-level: opt in on the simulate request +$request = new SimulateTransactionRequest($transaction, authV2: true); +$response = $server->simulateTransaction($request); +``` + +#### 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. + +```php +getSimulationData()->auth[0]); +// it is built explicitly here so the snippet is self-contained. +$sourceEntry = new SorobanAuthorizationEntry( + SorobanCredentials::forAddressCredentialsV2(new SorobanAddressCredentials( + Address::fromAccountId($topLevelKeyPair->getAccountId()), + 1234, // nonce + 0, // signatureExpirationLedger (set by withDelegates below) + XdrSCVal::forVoid(), + )), + new SorobanAuthorizedInvocation( + SorobanAuthorizedFunction::forContractFunction( + Address::fromContractId('CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'), + 'swap', + [], + ), + ), +); + +// Latest ledger sequence, used to set the signature expiration +$latestLedger = $server->getLatestLedger(); +$expirationLedger = $latestLedger->getSequence() + 100; + +// Build the WITH_DELEGATES entry. The builder sorts the delegate array, +// rejects duplicates, and resets the top-level signature to void. +$delegated = SorobanAuthorizationEntry::withDelegates( + $sourceEntry, + $expirationLedger, + [new SorobanDelegateDescriptor($delegateKeyPair->getAccountId())], +); + +// 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->getAccountId(), +); +``` + +`SorobanDelegateDescriptor` supports nesting via `nestedDelegates` and accepts a pre-built `signature` (default void) for nodes signed externally, such as contract addresses. + +After attaching a `WITH_DELEGATES` entry to the transaction with `$transaction->setSorobanAuth(...)`, re-simulate before submitting: the first simulation did not include the delegate authorization, so its resource fees are understated. Call `$server->simulateTransaction(...)` again with the delegated entry attached, then apply the returned data before signing — assign `$response->getTransactionData()` via `$transaction->setSorobanTransactionData(...)` and add `$response->getMinResourceFee()` via `$transaction->addResourceFee(...)`. + +#### Source Compatibility + +The `SorobanCredentials` constructor's first parameter was generalized to `int|SorobanAddressCredentials` and renamed. Positional callers passing a `SorobanAddressCredentials` are unaffected, but a caller using the named argument `new SorobanCredentials(addressCredentials: ...)` must switch to positional or use the `SorobanCredentials::forAddressCredentials(...)` factory. The XDR types (`XdrSorobanCredentialsType`, `XdrEnvelopeType`, `XdrHashIDPreimage`) gain new cases for the V2 and delegated arms; any exhaustive `match`/`switch` over them needs a `default` arm. + ## Type Conversions Convert between PHP native types and Soroban XDR values. diff --git a/skills/stellar-php-sdk.zip b/skills/stellar-php-sdk.zip index 82923b44cdce35f7415e951a4fd1144011af614f..2d37e63bf069fcf430d6fc6cbba0324433150e19 100644 GIT binary patch delta 62719 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 z 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 z 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 mveI&L|3CVFR@474mI)G%tu)C}P delta 60800 zcmZsCV~j3L(B;_n%$>Po+qP}n<{j%9+qP}nwr$(??E7st*<`booJyzqq?0~B`bX92 zt}*zp4tRJ4X;3g2p#O=f%*l9oGRV3+Dy#nxAB%(hUqy!%ojq{lzg;jaARz3-d|^}o zorto&va`LDsj3Pz5a|EV`~HKAJ1h_|*fa2dOtk;#*J-ja0}M#PyGwKtb`8$y)b4=_ z7to%-{k_pD3eMZfrwk}x#AfyA{x>~csS>F((16a#dNO{PP z7`7hyvnJ(3i~qeoA$68AZz|-)JLAfTOu4@^%vyIBVifn{>Ux?K`hpLnSbh1W^ql<_ z3d0{+D4=nA(r;?+USVf6o_SYWYebef2;Rqf<@ukZR?Gy#+~Kd?9psW3r5P``q7$CIf`zFXfuU+o@~$_jc5KOj=6 z6&csrv;Gvnn~RH!4t3TZ)9x8fW8=nQx;H;t*+lEVBs+7GfG!4uLI$I21_>hbw7Tkj zhAaq48%_C~EZIUytjCo^@)NWfPeU|>EWcr6a;Y^1S(hUfHCy&f*^@TAp~JLMR<_3k z>l3g+YYobi`Dped#;@y!FvNDZ!Sul$ohMBc-vf|rcdoE^{n9!jqM4AG+fmseiUVYx zS0nm;#)IWyK(7Jz#_k6*2Km-&c3FCmajq)4qWfX{-Ad1yjlNy>>npvRd}r$QVxq5G z<@T+vtDNhM9HQKZ>o!(_p8nsfy~myYo$>zhonyn(KwVH=PwvpGA?!!@1GsQDS9&;i zT;Hqk=KXK+z7t{vobr}GVi9fi(TnEs&%{fZ47zHhrNvE#(=7LxZ@>eQ2&8 zM(0=y#3tSn&Xnit)~Z`L@0kH)Jm%6x+q&Fcx$b@P73n=BK)LUy`d20Lp+2kkW(-H2 zxWh2a3-Dm*gNyM9BtjQN5ZVso^sh_G(>+fm>&qzrPbTnZ-^F40tiQkOw*v%&0TiA` zA+Y@)fbe?@V?ZkiICz{c5NNQn0mLnpLxSbda(}ENdsqz4Mp}4&vT6u1 z>p=Y>_!VAkS9}t9kO=)Bx^9~-EP6{i+=n8@H1@mSEQ0?!SxXpKh>LxHLK zK$X~`z>%=OV~qDCa7Ht7kiAZVR}y?yK?lgufJ+f%hJHTWX2esVLJmF)ok>WR62!WD zI7y&bVGcW-S>m9KuvC%y5iGrk_B$md01w2q!wD%d8b1*epQX$Zs$eWG_aY&8JSMCs z5H=gzA9hDjfLDuLD9z4_ALJi+Bl$pN+R2vd@#mqBd-Au`c5f(@;YvpokHI6Rbb0qL zz=h^@v8k-_lCVd+wfLhXiZ9R6BlBpaUP=_#{y0q(Uvf7c@=k%d-`Ha5l`w~H(nb{q znh-~C|z)IB5 z{$Um&p#z#ny`0(OtQw=ir!SGpsI{aC%}`hjbcXsJd3AiLBT--lbd_Wt_-O(mP@L7c{6t`yp7 zbL6-y3d`|wfOWQ{BWY~W_lve1C-U!wJ+goH(Bbx$0LLM%s-ksx#a)(;lA{Sv&R<6` zjR8McfsU@6Uc)7tT`v#|KQP6q#=Y6BfeiTLid3~|?>0cW8QyMwxAi*IV9ydrzLB*b z(CXqz@jA2uav$mbk=Qh2y(-BR-`i`{kLD~;txAC&gf2Bwk1ErT88F@Nxk!jI)G)J8 zU`|k6_8nv=JfnU^&a?Mt6q0&MXRpriU{*7ky*9%ok4^$cf-g<;q1Ern(UvvD%LIY> zjcTuv=mP4aQ0p4(hU6)rX!xu&;3--gAfJ2Cc|x?v3m(~BuZbK=lxk`1vL*!E?9#Nm zy}5hU!@uUyxX8=Jy}9`|`FYwsjk){Un%m3Ezf!`925m^@WC!CY8ruD~##A&jY=WIrlGH{H;^eGKk zkFd+<(XE0mx<`h{RzCm*lI$tZnx1YK(p&{EmYW;?so(^c$mwx_qrR5HF!-^#0MQiA z#T;Cx4odtR7DOD4DM8BdfQ@MYI2kG`Q18 zXyL}r5%0kjTDy;DekMGV5F#68ArWRlZ!mNo&Z%fUQLZF=E2ejjI>F}RyhG`G>ZjTR z5e9gFn07Go%Pp>mW`XkuCkh)-9)7p4es0r}hLaT) zh?OfQ_$O0MV>$XL#)55zvFfSNUfwGD>acQ-^v4^DFNpC;Xcd{Xe zbzQG?He~gV9I>Wt4m>~(P-S&E9~%GqXZ=Rn55t3}W!;S?Ys@B$cHEg0xFkzQTyE+* z2hZ99VThdLUCXXZ%=`HTqX%NZq{9Qh*N%R$#6F zH@apoe=E3!&_%&^?=zvHPk|O|#(G5zRTPpd9}`HdRRsCo z0Cik;BC-}901&@uwPKb?B(YBg&Dzm+-9GohAqE=k?g)ZxWYY~4?XYnfX{swrCX_-E z7Xk`KIQ9BR@bO_0n*%N+-yN`b3kYU-Jh#mt6^Oko9Kk$YX6>A6eC^!|l=YFxlD?}M@C?f1+ z8lp7X0dJ1smL99$qf2(t}Nw zT$5n6)a^8+*iFE`&2}z#B2QBp>Rn_F zpb(4LVEZ@3mN<-_9wRJTb3Qhu6b=Uor0U6Y^+LTu!su*{No)>{VOUXhYrI|`qEb%cY$Prw@3UejLL~y zt%E&d@-F-4Pm^;;NLQMLZXRHz#>67_bnq_Jqf#68q5{)dl?Ucco`;TNmQo=<=+LcuyjSK_~PxiBdXqZAw|W|pjy*q8V}A6GFL?&i!#WwFBv$}AB7`Cw5+T? zl2d#a1av7N$d+S33R5Y8WKD6{V>;4~2ltPWu*DAoypOfvmE@Z#$wj5F6Ku;$?rZ0% zb=SKmmn1P(h&?6zhF||jS|BVM0H7iDE9a1a=`|C4okhoJ!hE}aDjJlcRGyI&FuhE% z&?wEUIKEp2+iXe6BY;g=zxb0YIjB<|Ua*&8B`MgQ9An#+6XdA0TrNUc1y7hdE&41- zjKgF(>D$tz_rZz~2;=p<;ZF9nEc9S6H#jtdQ=90p8U7v_{c2Z)lJ9u~Fq)|yaIgd4 zH0hh&T~-ZFtr{hV#z*u6&Z*^BhBp0rC;e`An(pU~H1anm6_)W-TR$OKLr&I2@1@|L zyL8?K?tGF*OU5s=XvZh{Gs%I9DQSsF0L9-ngF2R=0ktw7LlRZXn5Uf);L2 zIG2RglPeds{?U;z5V>>%U;_%&atdZxlBg>KZng5RtS!MzTkuR+EJ?-c(y5R*jO%Dm z{PoRJvcxR$z}Iz-vg=X%-iM4Q@E>OhK5cq;`IU3F0}pr6iT9F?_Ee{98SPnxs{jii zeJHvMM$)MEVs}3Cyu9mP8+_NU+_s99OSthP!8PnShEKxhlG~4fGFyTNv;fO;hz1ZO zsP(2bqC!nPq}zs9?rp7Y3)=YgHr6?+*%esyA}Zj4dWd>qL_rW|qS)_f!v+nft}Q)j zbQ-(CT9rK|TTI}A`6xFASC9l;7iRYi9Euq@I^aH2>y zY3##mbrW9(N+r(YKFfdz3@M1Ql4}hAzY|6c2Sa~ITpQ+DHAkf@)#oLGg8F%`PHIW^ zJ=}iUv1J1>Gx3=8cQO(qWws*n%y`T}bz1xIGK$tI_JwW=6wNo0I~y^LnZwBEVz{c2 zOPK(U*86EdO4&$?nvs8$QzsfNT0K&a=8V8d`Vn5tl*c)=^i~OYE;JzJO_sA%GMbJO zlqRk*E?d6|hI$UmDBtbnB>JU6%+&w#ODv#k_t_vg1&q0RBNn=)hW z&{;|oV}Ljw2enCz&PgCDv0neel27 z$LR=1C}Fk(WpesTJKdaqa4Rr*HEzE^(!~M@Wc6pQV>hb@ULT5Z_;^4U%<)UtWQ(}% zz=(qFgewu$w9OD803C$WOThySb=Mcc6v4il2M-O8pml$TKNjNnKT->NfiiS2+Sh@W zFs_1nBc5c1{~~!NDESV~FGRI;NsKHREu``-rZcuL$e$~fR&>faSLVS~4pk7w7jpud zy>m_?Ija6LUMCQ9L!ta7^v>2I!N-o3c$$l2J#D~m3U93ufk$EIA08jTUe^8E2`dml zBLk6nxntKPpfgwBf=4JwuC>fuhK-|@L$c21Hl(8Wsg_N{V zjd_K>gkzM}uHtgp%$0FI%s7Mg7xRm8m~Y-DTvN4pwiD7SA#tw=6!(TrU;`C#EJ46x zqTNj@`NY%&rYqTo)Uarh4dJ*=X5P;$bgTm4c3`Ks z2!>%3%S=w4N2kSv)H1vj7 zjk&_g2i(;`fl5PdAzhvYs!k?=c%(kKdlBh^!G1i3h6B)FYrHF00LdD!4`(1zjJTGl zoKMyw2S%%wD66X4B)%!QV5PVwa1?kP6&e}s4*2wbO@~N$W7>RnYsD+N|7(9<=;Bd* zUoK>BfCB~())#HI#?`N*aPWMN!!CkVb^!GwdQ=o{Q2l0%wa$w5zHT-kdBw95RV ztb;s}y-!#zFhF2;Tqy~L|0Q96NCpvD2D z9^|bU3me{#DFbTB>)5OB|6)xByvjn(o;}#SRGcq zyvg3i9oQ&Q)e4bl{JH}`Bb}#L3Qs8d3u-;s3wd2#m?Buq!k2-7qX#2yf*In>u3wDb z7Qg{cCUF*tV0nisq(D$gbiW;!ofR>~hLK!9n4&~WvK$Z>_En6pr%H`8SY0UBx~tU( z?k=m^(umIV?gFJbnCC=$<%52K?q#S6azQU&POYhz5#%WR7cv>4DGNi%{;cvH5xMm-fLB+Kxd@W{Suaul7=MV8Oo#k>(v?QXGWoS_KvyH`nc+h`xc=QIg^Q4XMi&CA1Kt znm^1=cEBkm&&vS(fkDeU8BPg`YnOemqosZ8SofiA2`7}ls8UIxjJ{1PQ^9)f7jdwF ze>U+am&a%{(+`C;ElSyrlgjp+r%0tMWR%XG?Vou_RP^gbxEWC$f&0+VZC6RnGJw_W zYFP6@u3ntEvU9kknebnLyh47J6q+jis>V>4B zhHTOGedVAmX~6wFnCrODX7>30n|_}D8=g3AP7Hv*QW2&Rwu+>lx|ksGnW``lHFbUa zmTR4HXR=v=(LLR=xv4q8s6_){wsR4@_rk++Wh8?JW`$sS^N~*%zYt>N0A^Z;yH8 zp_5-zmPo*5_M`HYbLF5~@~qihw`k1ek45a1n~KJAkB3YW#yuDtca9u<b z)JwkN-y_GC$y4_ufK~l-X>dWP5*FP-X9IZn7;cDSc5{dw*XEtI-o;o+9^->ofK=Ep zJb!^5wBuB{I^|x0rK3G6izeOn*ArNrX8V}VF^#D2v zwQ;$W;S;7dgwQ@m0YCENt5}h9R8Z;a1Jsz;8$*}n;j_W5T1w39#*uduLmkwHCL`rg zo~u!q;4T;-OG0Q)`zKT!G+-$zKJ;HpamWfpcHo9bm@T)ux~XD`)P!V%LPDd8y~Y$m z*7PDiCmR#;6An{U)}@AcwDuzZC;>2{f?44~-T^8ZBb7vP;WJR7S@^jJ@zLEBRLpe> z;mML%F$$D5LpWITYeJHU@4Chm#*Wxh+oyMgdU+!-#0UNY;*e|}PC8Zyf(cmbBp%l- zIhO_s@sR^Ba&5F?L;)Npsl_1#2xn!SzE#Uxl^{VkjdjRkTQ2)fob8JPCID1Uj-H*m zzGnTWtMf(V1Z1UN4TJD#M2mZPa)|`fzFNGndSd@YDc`RAp~GOOeKR!Be0Bl?i)lpY(LaZpzu#IugwuqpD ztC;r_M5gN!W#Rnv3@8 z_%DWAF}E#=;2Xa@qfmTWI2ld4r!KkHA-pw;XHrp12rUZAFWC z19Z)Tly9NfPI7lft%x;*+TKuA2M&>`nAnDg&yL{zS)jGf2dJuDjzyHhysU&gmTLCy zP+s5J^-BDGtG{!+Ju1+nOIn?LZhRc zqjiIvGCqKaFmTVu<>*$0y3a?bf1&^QfL|ah5buu+rU`0o!J)NdMjE{x6y7NbE%+i< z5u<8>OJ@J214!gM5jqu0UM-Y4BPw@dh_-8T8OOqP3fr5jIE>KY=C3;+UK$Z53gxa| zqHC+(E@ch!8QV)Boa0Suh|$3Fve<0=#%( z>b&;f#Zf_ai=gHj1Lq){YGy`R(x^@-2|o#MOO;*)0Sub63@T3)fEW$Jsw*Z|$E>T* zCJE%8g}Wg$&vpZFL-bTUA}(32eN*Cv`2iNJ(e%OeD9y|`x;vxz0`n`*;;1bI z*G{K~9atV`NRT;tun!a(jw1Eb&1x z^LrImfV1(uNQ|4+okrDXu=YB^r)Go1ZL0*2Slj)@@JxCF+0wp|IPI_(Sn@G!rL`DX zzY$tG?#6?-+~5Z)E-v#sr%&4}m`j5lGXV+;Cqy%J*Dv^LO}C$wO6Fdf#R@6rohAEF zfiAxv684-Z&GC+r<5n?k$XR)&iNaWD+g83605b+1>=YCw5N4cQ|C z15ldQfP~tMDapPzEGhi2dJpMa3>oUe|l6mt7_JIU*~4qsqWJGTTG1} zjzOn=P~CIV7dqB0f@h;CF3lDNmnJILtxC*f`jAe4s2mW_tW^nU$hk_yL_OjaKh)Nr zZYk%?Q9^iFHgC)sGh0q8jH^`R%l9z>;(!$PD!0fMX?iT?CM$${iPmt7b@3l8=j3Q{RM(?KA%E1iq_B)jj)D6#<=+p}63`v8qnf z*x{F814QU3NE2&bm8W!9B}lScFp5K%jrC+bkH@w~dH~erEBF&J;IT zRavxp{yYhM2yvCI5)fzgm5t*=s5rf`v~ z8K6hApNexr8$sVgLQDXY%;h8pz*%YoH=vc6gWTl$yvukM0?oSAmO%T^IPf1$8~pX~ zhvWhFraGw>C)&m;mqA;U8SYUcP$r7m_$B_J9Lt2hMd6Mw$nH@YTt=TiPjN?MLKZY; zbv+a0VPTxn%i~JR#@LLNp>I#D7-Em|mbZykUkHo}p9IzD@G2TdZ#Uk8V^0L)p(YC{W8 zr+WpzZCA_tQPia`-&B^Y6m>mTr=q%>WS~XHImIx~IG`~S$a=rz_#Y7BQKJJVqx_RM z0m;FfSmlytN$##BPHtZUU@|WTp@NJ{4L{9)1d9pZrtCc1+1 zM_|8uH0>hsFrW=?`jo@X%ze#o1jw)dQaJ3Z2=%D)x}1JC*pZY0q;F~jF4t6dmN@gg z^PE_=Ywd*6u4Tb4)k~#;QNZzcdheIFEH6{eih==X4c@^v4-5x6TI-6lEd=2+gq!7G zALMd5p@AEjay3enr-D!^B$T6_%?pu`G8>eID5f2$mMU3!5=rlcQ{I{0uIFGWJLVau zie#s@D^hKYCnmX>D)Ac`IRft7|vdC5Y4Thl4#Hs z;(}M_YDW*KA&yQ31PVA*7R!*-%s&EJ%tV+DG~Sj|7banVN84IlmR+ie9g345*2_p> z3i;r%NfdP#qeZ?1EwnT%-k6$!VR(4OHO&;AG)0Pk&Ta^d4R!(>8zf%eafTP1R2(pP zmW4_CT&e<3j0v!!E@oJvjYi>9?5~%@q@q0xbf!sw@cCmt*WW#pZ5y|u+}QV&C6EBG zrLr`O18qP`vnSm$} z6d_$ZYC#MYNeA%gs$uVXfJ-K@(dqaC%Qa3G@$a*Xzlkwc!vkdg~@i&4|?i`bHx zeBD|0r%i^$X!%q{XN!1pde|xSTqMw>T)bgb1c>cp52uJP;CtY5cOcP-d~Ez^SuhgE z(_H#F#r3BUV`$E8qn4gu;l+<-Qx*k5Mv4%Is~Y8_ zPxBst+KNZmfvDB$UXc@3q~xGVr-iPT(~Sc8X9!Ak&r$f^(m`^n1{oo-=M8Y)BP(eS zUeB#@gl5C$&uB@dFilsh&^Srmp&6;)%q{HAoX?5aEInA-u1*(7er)sW#T)iT1y9sl z0qJkxEnv2rV8DI;8j?M4A16}7YvGIe2-8fyQ>ewia$R+FjC1V5?RBaxc%GM_m|rJ| z4)dpDV80D=wYiL${T09Q%NaBXIXGidXLxrk+T|Y*(B?7-5cPjvoxh26@8|%j8!D^6 z{~w1=x9z__oCyS9y1hcko5(3ITRybCPiuIN`FRpj2O312B&MDA#(~;I(n{5m7^M+S z#nGpu{O;mpaRRD}37gF9sEZh|5+o?$A|U_e-xhuz{F_*A{1@%Poga#B$w+5b=~u*& z^=kmtx=myg&(@~(>7)zO<`wZ?_si zcdGw$ncCgxoQyF}mVK+wfAzas{gFX-X=A1EHPH8anffi)(>uZbdCp_}Oj>E%puzLi z(E57&%eO<4`S1$(ef@3)cr^2ClbEKb&;wrey8$jmXH>5*zCZ7$-GI;5Z`TC*?b}rlr{a4CVOa%I-~P=9$)E8rx~Jch_c4jxV3zHz%*$8Px&3bD_Me?_8<0 zaVfxbTnK49rx&TUHQOd;dUbJG+* zPMsXrWbj+Mie$uF`Q?d_K~T=C1V9OGJn+mTlV(9+f8oW&?U_~g=yK_Wd@o{V$e|0n zx0{pT6Re=n@%lHZLO-w2Kptt$BpPm4L&c(~0tEHenf7wssaqoT+)DWdG%nG$C9O30 zFTDbkGhX=A<+T0{alg!O&+%bzudd#|b&i=gKdpd$m-HF^ZXd6w9`@Z|e!yqtwUHxK z=E{Oars|M(MVKkCef-+Y9*BPg?jGUjgIA>{p?kjwHAIFb2fozg-z9P$ymI`$-$hZU zPV%(Q-HwbbdGYOD-Dk1wnWv`$myx%BLCpLJ8`X)CH8Vco|GZLD)lt(vAx>K8wQ}XO zt`QgQatG63b_A{H77y5(Jwb`PcN6)(6oKCpvJ1jg~-KAF?(h>NV35tbgQb)*xpg!71(OZpZJOE#J&UYWr4 z&>Dal4faZG%px<>cGJc3jBtRuj3CtKdjBlI`Y^bNX-b#VutdMysgR@5|Hvc4*on{=N*s|i8e6gh5q_q@mi4I&LmGw0)JSK zxCJxYcG+MBNizv3_H>Qt%9Nc-{Wc;+r}%26Ff?+Mf=lLgD{aLfC18_U$9N}|e6^T5 zT#el)5kUZ4?E+6Yl4(#&LAlu>$h<6+d|?Vl;Na~96s`S_j8c&|SN0m#WZZcKqdGdW zrQc!AvlG5=4sOPJy{TBNB$uFMGZeR^edDtG^u-M4(s{~llbB^XhW?_{YOoOHeeu5@ zq(f=1Pg(oEEy{K>RkM^DNPVw^nov5nRAPjsj6gumyX2CxqUoP#gw4*o@4Z-||%Smw63>44`#^)jewL!V;zrq%uX(o{jx9j7L zma#(J;43VVHtDuej)IkRMeTGP61=j43+5e}6V=>BtM3eX{hAJ24T2n_>!mP8}jBMP}Ay+rF8sAaz`Jp8~3)60dTGd54 zU47U_^Z<@iIQ0a-Ze$I4y_?Iwe;%6Gqpne209u#1N$cZwpR&;1&f*qLVOS>W)MC)u zKa4!%1$yx-R=xsrtrGTZB>SSSUajT99tr>+)C1q1d6LJx}Y?L&V(xi@b!M*DJi6k&dqO*>j9A}iDgB%n=VYB4~1uiFh+S& z5rJ&;QjRk6RcRNhEroU!p|};LFmyr)ymt8kmZ#f@G)@mJaeB=T+F|CRr*YCgGYSCx z2BL(f&Lb9$;Uz;MVnI)({&Ea7afYC3sr_|T<2=fl93*mZ>2B2rtlaGpthN%7xhCy; zg2&5A2+gh&N9iEM=OALOcR3W4VT<7)vkcQQ<5FzX=B#qcsv$L8rTnvVgsjj+tCD!G za*HTpQF#W*;d=$AT2DY3o@QlQJBdsW3Xw}rYUbj#Rmc%M*-i7wBN46i zirKrd{NtXs5}nt-M@=%=(WW!BrmJMq{-5Mj)=l*`I^m=@Yi^Nlym&#qhNdb0-fXUlnFw`Mb&Gnm+ zF^EQ4QpL_$w&b$xb+Re*@j*c7Ga07SG`Wt;0B=qdpYv~8%f@xTDuC(Qr37>PXZ5E%LG(9F!EW+*cv2bY)SHYhJ-7NRmDkXAGp(FU2o}5yR99Rs zFNqh{6b_1Jl|}s$+g_q1StrEu%sBm1qM@ESepGPoO#v5;qJmo*Oio%s$BimVQNG+R zbJP(5lanhM>yj8W9^`~slbX@Ib+UpIe?jG%5M|Y*MnJT6tOMYXRdy`0?oRK%LF8=X zW-C`weslV72v47%`t&2eZk3FY=v9wn^n9_CpilU!zi&x1aZht}pu0%?*NUgNy}5hU z-|tg{WeHdl^1N9tPRI?4G@4I8SOSF4%A@Ir19p!>gO72hZEm$cDMfAD5tU%T z%-okH-MtD?W6W%fW2@@{_f9)`hi> z$4WZ)y%^LvYyX!4K4V%jZjX4NHO0orriHNj>vpcnlOg@U4_&e!9E)hOXcuntJ5jD) z*+|q754{iAY}1wmAS)()veyS~nVnQFOqw1H^?yF3@cSxsU~p|dZ}npHdZ8rar~PpYQkqeBc?9F z(b`{UODd?MT#oY!GHaoPzUONx#};f|RPf4f)kvdoykJ^`{?hAst-&6Q{Z6RrtqIlppt&U7{$^13MI5x$&nyj{z1M3BLf`M-TY@95?t z)hGxQ9RECDT8#_m7U$qbUrajrWp)D)vMzM>Z4!hUvSgI1W+?6W$r(oc%t!^u+z~+I zy%Rx8N6r_YdtwMAagY>=?{LA^pR(KeACU$N;`~`azV=o_neY}A`Ndm+NgLPvo*dX^ zClO__f8K4$hj#Y!i)Z|_dxx04Nc&dR5oLAaKjImR zvobwP?GeLuYTY@PL%0oQX!gWOp5DP|eh6vQOv z{H6fNABW8^O!0AoTlv*bRqSe@K6+afdd;=c|!4I8@nc`0eNy2E)z8++qGGj~OEiQdyl63ZzQJQidgC5dSbCk8ibhOfR{z zhvl(@mHiq*@c+yLr}P0bjsGcPAmZiu{yivbCwri7DC+ch=|PlvQ2?%x2@owQXVS;P z%cG(K2!;T2!u7Kt4IxE{NmXKG;04vfSQ8Tb_a&x{I35L${YTud^~TPnKJ&~C zU6q6H&1Jqbm9$*TMkMgVJ(#dPz+^N=53{*}Y{K73AclAB-@pw(y|u(J-buV=+-JLE zr9&W4YQ$~4vb8sx{8)EeOG>#wjaA?nVw4|$^DyaL>1UY(D;vI`y+bq1rjD;6pG_1n zi(#pBXE9rx*pG)0eq^d-7liVa^uRzTM;B}3sa|xf+mqG_zUa}S8{%|}#Y8lVb@6Dr zF`g<3F@{KC-ii$%!bA3=;NA9F>C+U|kzTE0gmA1+-f_1~O`~ME1aSaE@4xcXJdMMo z7qY?)_4r{8t=jVL4rCt<6jVg&JU3ehuFBzCs2 zP-`5Dm$m{^ClW-tihi)d!4)h`5+p_8A@Pn@jP66g(=P?6H0sZQ0Y?{_&DgW9SvvEx zMlB&BQ%AwJ3?TV*RR%`DlwaQ#;ms>UixmmzIL*?|F*6y!OHzzk$)Z}(s&D|Ok$IB) z6^JQ3oZwbfPEuKHC>E<41fyUbRPJG?-n=t-XdI2}Nbw#lm_K3&)uL+qOM*S;E;zvc z$SHx;!4Ct*sdGPN6tk5uGdnwz0uZ$+XtxUBuuwzfLkODNnpx7>EqliBQRvs~C!AcN zJ@#s;j$;z3%vM_8QZ`O@!e)!!3 zXS*wY;;V16omeS}D{b7o*RG|9;}#596UL7JmiXGPo|w` z>num|u-U;J8Z%);T*_E5(w)N@NYxi(H7!HW`bMmd7uuC&{brQmhkQBL*)6H`N|XWY zZo~GFCMRhzQsaW;^OPi0ydX*%VO>{7Gq=vi`d~pn4*pLAf(MNH2y0q@)spu12EZk; z;hCZ5lldv>n(gc(pV<6$hQsaH`>ElJ-NyTkU0O08g$2f~zXgHT4+TMQNWft!cR@>W zWn3aK9++DSR6J_D-EJEb>XWDlb07gqY0Jw3@MNN1SwGujE&Y`+K$;NJW{G4}Kn!;G z%1U{R|Mh=gIvNuViKPNXFf)-H0tYrYMLS@lNQP=6hg1pF{kkHiFh*5Mlud#^CedhG zS0~{dlft0<*CtIzXwk$@`5`2p(jZj|W7OX3GOk5hwHrY0iM5fcY+X}ldK>_uI($0~ zMA@sB{#7`*rVkEtL*F}cM2E7Gp9Wu}lRj}tTaWl0AwU90_}v>a_1rDF-bL#H4@x;< z3e!x^!54okQfbBj&Jrk1h|&CAWjqpBMg8{2u(xNVzpEPdg2RJEtn)L{_1wU@Yg>~i zk~V}hkzZVzz$RbrVW=`Cpr8Q83Y9cjVYFqB0c0>k%Hvcrb$Y#o_bkccy~1%i8xWoz zXZ(1VZb1kOAvR!D&eZ5qUj13@^!RCEA`#}9wbXGP%XcJBqP@O)w^DulEQovdTjs!j zp;)XO9Mk3pm&h>d9aFi9x18LepZNAC>D$v?4X@}=WI?}E`e zQu+?_po}K8S{)J^EV-t_zMdk+TXIRQYwMazvkV5n$0y@IoiV0kq6N{pH|R z2n8(?gu^w$qN|O(qSkKJCWjOTMqO?94iG-9@^Oi!V31RW)owt2CE^B|s($rAS*j zXYlDek?SL(gf}wCn5x%{0YoreAPvz|AR}&K=}Z{xl5dHusH1>A8hk47R;SwJ~(wc zt0UNq>_V;ci0se{AUKK{KpO?$&9&2C)?kHoHr~Bm`0>pIjGAb^1LAk7V-N-3WZ1PQ z81OCzXOGpyt&O314Rku{{+s;I>ZHQ`8g02!$^to zK#}0YW+0Ja&Yf3O0LHR>Ly<|x5dP)wcLi7EwW)}zb;(Ruu)y*xdT&|D*O5dKj^Nay z6=YUq*tiNmT<%*)L>L`|#|U6V$cQm9s;~_RmO>S+*T!49`FL6<*(r5v3g;ZCQtHH5 zX)v?qKwxxWv4YfU-31hXi?^^Jn<2MgF6`i+Ohetk-^V!X0+2m!rxQ`P>3;b<3w_A5 zwr8~u%N%!%JatCxI6cL^Ord5`9+8If__3QeDkg|^k410o)w|{FA4Z-3YoU%!x!nfj zU@-04JS?S~wgc{3ZMAr=s0)!cJ1>0jeP--hre^ZVw=j`Qe?Ed5$1Tekn7r;>i$6r2 zO(sDi^H^UT08&+bfu(gef0(2(VEH=M12to z9f|F{BU0&doN#ZAXG+}-TLG>lY_uv&V@eCQAx0G78iCm=^?qegb9s4h?Jvitd7o1? z+lMv4uFf`LF@;pK_*IZsWwnZOy{l(&-4)6_B$TSh6#3=Ob`?pvRB%lc9>qdR=#Xgg zg$j0ZfLQG3c*=O!@`DSWt4~xK<DFZ5ww9z@p_Amyb>Ei}Giu)2(|#ZDTsv>8aQkwS!i_qed$% zKIYv8frC&j>~OxJ5{dLxcU~a$0IX*V1d?Y6U=XJ3K><$Xj47W5w7PYFArbTA*0)86 zUg>=WGV!SvF6Ht0_{Z-#z+Y$z$o&=YevEn3Nyrsi?VFy5K2eD7D#(;us8j+)olhIb zL?ax@KIJ6Lt0&Cie^bF(6e_hwh&YC?q!0W$CQiXXln`1ti9oEEZX*=6-4%`*tV-_) zxHoNFSn9%|!cmeUbeEzHHmF2bE+eVV05g$bd{5UAnpudBbJXq6=}4K>pH6vd74CR$WS&_F>jcH6PUnogb-Mr!=SyDDPmQLR3Y`qw+`H${e~h`Ic08ix0q<-9=f*zc@bz{oZP^Kj*u7dN9(#XX zqcIOZ!gJ)j+v;>j;|eJQPTceKCfP1FmBE+`4h7-k69Nl8&9!%XEKpm-A?cFV4D?7jTD8?b^9Rvxpx6eZY&9$D#Y0 zUhzu(K6<$?#kfc}#lv7V<~zEgdp}FpsJ41=C zkSbPR-Ve1TnmeVzpbOXhs^8gs(5P%>Q zeKO_?hmp$6p-yI7L@G!Kg$YNOc>y$>gA)K+#4xx>0DDIR&Ql*_r;BjSQ;bDGOj~DX zqwZPDtC=B8Y{U(!de2*omo$G1_NfYD>yYIYvXy#8$wm)OPi5bT4X}e{c?<}*%Mm!}dQ=G4G;B@IEhK+Wfo_kEMSYMdA`m^u}ndhlmF{MOZYGPq=VsMkLoaB!wpr!yYuZ^`5(TixWQhpq_d z^)>`O7z6Or-i7Sfyaez!*@C8pgO5F8ICx1?^RYYXo%Y&>afm8$CnuXBKC+>7KCrD| z!{;u?^ec!?mo=m}0YHCq`Kq-8_o+8I?~GcX0N^+ESg-@h<^%x5CIu%S>NWrfwOhCZ zUBF0Yclhd!X>^&c*%xfH%vS`;Y(SsP<^5{2d4J-3mwX9FQSZT%;4kz81L7RJov&`P z+dXv`O|G06&_$YlV>=1&TA~M1u@C9F@ZbiopBJbCt{z0-ttWpv(%{IPRV)hZ6*}1% zXUpw^hc|)YUgS|$D5lHaF=n`pyrDY4tlW4gi_3hTh5{cTv~0x}$Lj~W*7$4s#DIO$ zp%eT8=SHI0=tmpPOsj9z@<4FS? zGU}X;YazaBBB+1i!qOBCWnHht5!$F#7NoXrm4&J|bCoEb%-==U6o>%#edIJ4ey*;N zMi97?5famC8lvJ^T|6w7p|WwA43!ECWT-@4X$+NrI#CRSK6?s@bf%X_gndKRVziN< zZ}&}Nz13iEsHo5D3P{WNABN}8%kJGcU5m*f{sN!gJ*9tsN@*#)Og6JS?#%{yNrO~5 z6JcoVe2cu8pU{UG;EnhYqvp@&s8k~#;z>Mk>SGSKn2g|~EuC;!9j}$2_;T7)d_iRf z(&}Lmo?jy3p>addDfJL^S94T6mEZLW^QG}(i_S0#lT;7I;O2%i062BjYkO#_t13rL zc2#Anr@MbD478r`N`)rBIG~a9rxWf_AX%y!b(%F##q|YnmL*dnrK*R}BgV}md=x_E zW5Tqt09W51j@uE;rYMZx-V z8^RJh_>B~c2ym!KK#W7+wS+lTI4IJlKt!->M84q-SZ zb3L6%Zaan~ovtn};~Y0E1MV>EiZ+w>O*)r1F1=Rc zWOD(5b+#|h5!S01#<40LnwTs%Aj*3P+<(cd33Wu}kUS1=(uqyjTt?ackN*_gFQc1e z*bO)=#U`^W0@KXHU?0*4ylvA#s#O5dIud`6NOqcnqJMz7nP{Mers?GB-g|Hp=?ZZZm@g5v)s>>?sg(OmsZ8jpdHc@}I zYl7rmWUDVFWK7pSRKxRN*1)`|{R{BAB=Xxvcg$dl2{uO;A^deE%Pw75+@PkqB; z--qAE)O2))!pB28(lIzimbduotu{Hv#8W6VCLQCb-R*Q!Au~qlGXMJI;znY{)dnUP zXA$+}f!4>{YlgdcXv5V|y@!#5?+|~}i%HR|wSBx)yVYs%W^r5}HDyMjs-juRUB4Ef0NXV)3ddznoZqNR(n&+6i7LR*G_ zhDG`{%eQ455Jwk%HdTp~q`pT$X-P^yTFt0EZtAGU#ezua>n- zLqM2HnC!I^tno;ZD`C3N6Suf8nI+DlcnFaAwBs{hOfioq?bIcbP&YvAxq~~mm~+?^ z*Q8gPG|$&gm+5#t=}>=007SP0tTef~V!IVf5YKbnX<3Z)&!*~rTV;)$pipeL&} zj0JX;Ouan^Ns=M<$FtP>R2gEu&HW@_E}9z3d_)F+*!usJhm^q`K3a8!CMsml2#K2) zjFz(g?ibXaF;xDo3aM+ldCs*KjaV;InJ%#P-Xt1mn zK29}s@=3~uo3+cGZu#R>BNg$g;X2!x@p?5Cuv(Tjqd}6rU9PpEt09%VC|*NU6Bw&a zhDR6E;?r(U@*;n@4^dg(J_KxPo_u!_@bHK@l~gk}#x@&v-qo^AJKQ{AgPLz*x09(9 z>rr0grq*f+8EaqH+td;`MRmP~*KJL>{SDgv-CI+)72neN z319L(rXf3#NLEZ!*-*y5E3a!yjJISW&y?e9h&WEF*64o%d3Q#UHvL`B80)OE_2?|2 z&Y%~kaRxPGZj`Uz`7oqu^;I-~zvGdnL{w4!=O36fC60cprq))2+{+QqqUYj;_}uWJf$J{}J2u164HFNoHFfo7b)CaahYucz+ zIU=>G10b~MPKKA#PX%-O*TgG# zZ?kls(pQbPvss$Xn_efP$gt!^&A<}GwE}+|n`)qeVRNk-MPInsht+|7ZVKCuh{X9DEcxM}C=3PZoORM$GP> zsGQeM&g6X59iI;_I^*fpu%qgH;{crxF1ypt;Ih^01E{wS)aga*3}X4s$?x?U1do4z z%E%c{dVTrZ!vDoYKtAhtM}P(IaE9f;dz@iW9civjMpxs>MX%ol=wA7m7UioWFVmuZ z^~%d^T}{phqu&3O`LDhFP8UPGiC~yc22;-C3Fq@guXWP9=uLVeK=7-#UOAWDuQb0! zv}@4b*T13++t$}dD8r(9ZQChBiNAl=4HKc)E$TPF!gP!I%@Irof~66UyMMdt_S@a* z$<^ghX6Y)tP~f1xx4(vgL<$-@Z;#^86387Z!967?62)P(m=v zpWl2z3RLS$RG?yCumV-Tg3NYphiu89%vkRz3tY-J@2xjj*u)R3 z0x;eY6$J+x@*Ea#XZ;mntTJjDCsxNM^nr|xR57S)e+p@Xh)&bAxmP)j&3zo{vu zNh9q0@x9fJy%-^`Psm4@dwdCAwRo@G&7n2<@?TnDn;@L-N6T?7-^JJt^v ziG_Pv-fS1fSez^tmXQ1Nq`XtlSLN&8YM$P!ug$&uq9T@T#N#xe0rr3S8r%Y#Zku4! zGWHub!sdupyxgy7hJ}~y(C=!ghB&WnsRo(2QxzlJblPpxiyGL+q%;YDUmmn(7_P<1 zqj`MjyDQ^UAe8R+2Pp5B1MnGy6Y)ds&@Ocf>`Vae?QV4v>TXC!pu_DsbQuAvkQ&$y zAvR9W$}}ulpv*uDmmq&d^{-As(ubrxvl%rP43U}@VePx5xJ~Dfpv!;c9@BG~IcAMZk88uD zU!8~^5X)cnr3JN1H1>=Qwy!DW12N8v z{5n~U)-zM!SRsE*MG5^rCHMMJ4GTPBnJ?x5#49D{9PVL5fZM!yQNQln^k{1<6pJFSnJ#6&gI};^BY6Iz-DYrhKyS5Bp}}!wWlT zI2Btnt^{SHlp*Ar{ZqD@=illLsOsimvek()tJ{%UmFN)Ytuq}Wk<5_*krqDNkl0}` zt3HO%wZs!_rzG-qk#F)@zWB(t(fm~;nOdq#P3m)I)u2YeL}Z>8pXWfaF4IlIqgV38 z%5J78w@ZI=(9!TFHCmJpG3AhnZWh^8fJ)!tf;dbX<_OfLl(KOZ%#6@j=bCp;Q*FA> zW>Ean^4UQEbEs}{rAKhT%eD#`;prKhWo40_#y451m=a}@GbgGWZcd@G0D)>=vy`|0LeUVLe! z&u`eMSk15V%~i3msq3UH*LQ4Vmw2Ivna)6>CKpIh!lDhU>o_AluOUyK{-`mt0Tyl<`-lAJ7xro+w7BG z#D+9%RBr-v5vBg3O3mHt!&1m!1Ui5?y$pZLC>xJ8Qi@63bhd+ur4TP^X+9BImZMe3 zBou*4W>`c@xT45741tO#V-qUSSSrzxS)i#pzG&jvm;#8%H9!q43EmKaR)ygB32VW6 z%b`ZgA2uGjZfLXyQe|BGjW)gmdK(RQZ^Nu+1%3$9Xzu;0w@&t~VY$pUQf=w9YaV}< zn73x^v?f{eTU_nDNbwe|@l;KaFIpC`1GxU2{P85F*;eUn1ELOzLzW`FTDvLZ2euii z8=c76(+jG2Kqe-NMUneESMREUI6yQX*=C_k{%Tg#Ide8hGqC>QPcbKA8%tFuZRWy= z&;HuYjrioPAdbR#3a{R=n;Ye>RAYZf5aM{k3A|#UhN+RKL6T}mO?3@jP<6!lPSg|R zz9k^6#6$WR96Z1W7=u8_G4t5GO+hHv#_dzh;H^YvH?tj1?(OevWq+GGomVfLT*w5DX0SpCCl(JF_kM*Dx%`1M$)8&F?O zpBvcpTWA@5=vebE`{QJASTFECORxpxuCtV2puM2`6|j{Ro&dOktat=Rpt}#5A7&;* z?eP;?2mRboH^2?iy&JcXr@-zBh@TWrdd6R4+)R?)MV_yriGXJ*KWZwQ?G*%hV-@sYB@;&;`YjANI4lL`1|ik)bwq`b zl6sd7z^S8qCRu-{-uPvM0E~CpfPw=Jd5+5l&Pd{y4fNS6ylUWZmtQq-N^0Nf8W#;* z1#w=aH$V=ua+0!kB%9h6JA_cy0K&O8y)TYmpcd#sLgSa?s%h~_?xgFy%$i2HAcEK; zdzQEbq1;yWC))96DYP91ryz%dY{8lVuU)EWphRjPmeQUEAY$9IXaEX-22xNa6z zsTQMyUGiLV^d~mHS{&=g>LmnE8IklX=3A)w6eWTuFS3>U9D>M{?WM(;uJ9!a)q zo~X=41)hJjdjMJ^Z*?{hgcy&x`SSTyqpGJ$F&dy<1&e5n-)J_T=jpRGd8{6ZQ|*LV zC;3)AQt4(kY$(wJznP!1&E336zCFugL|&N*_a$8nPJNG6{=w#jIFWw)4q_f@ACU8T z$`<>N5Olghe#2{Sp}couaLO)oUB$jC3hHmK!r*^?lb>a?Mb1m%3*NN$6`L;-Q={wN zm&uB8;AHzCB9S#5-J*;a1^&kjy;jjszzklXAtRn5T2!QRK%5C0_fOwd;g%|Ppv!u< z|GJ84m8ZiS&Mv{pMmCV@ehTBXRo34%ap=qBi(Iib*cB1gnA)FW7epX3_#5qdYBDsu zp`m}{U*gGE?ILL2lS&}+4Evz$?uXM9DUE{v)$FEl zU_X&vZ?Pmn4Fl>fj{F+DMG+0+Ertj%+l1#>{9qa8i=JH6g;qSc8~I8H7n5b`EN{%* zQCkm14#gZd!S4{9F+e_QSy&;+%MHLy&7OZwAv;wtf@7F_VWp98i&;uXS%z6u*dHGt z9>39$=NnSR7N{Yc-S#xynK9&~ddyEU&2wLIE;&brfa_u^tI!UKZXkqY~;H6HgqIxspg*tk2H4u*d` z!s8f(ifo^^`V6axQ3oSLUR(@5nN%HEPKVID?2gX5APl65BMBlvDsp)&IvDiktQ<6S6mtge?)ijuLaThoOIw1w!1> zI>~eY@+h))syml96g@{Qm#22wS*m%6pcb=C=|SW9f?VY7y zLldRBqq=e|dn1^tJ+cJlwJCp7rMJTT>Sx5ogsNN8q`bHR9>R%K#wx6;!bRmDj8%Aa zHVZ$8=zkUw_nH*ZihlFCg!>p8X~ki(xqIe^xg$-I8r8hT-JJBNc(l~Vqp>UR#3y*; zN^Low>Gdo0G`ILBU%}36y`5#L_3f>UCkKjS_1~6P8;=-6U5<|#aom5}Ar7X(?2n=} z?ZEchMuf)cps5}+(sm~qJgA4w$~(|aQeueq85I#+`ZaWrNxX>;a>{d^ zDA#b}cq+B-tH?iDo!5WS5-}BAq~@vDQU%0ht)9D%o1V)L)3G?MLR2g3@u=aBL?kUl z3=i%g-stJqs+u@8Bi}WqhDgNgb#a1vo@J~#^+kr8OBo+=Zs|sY&TOWvHq(yzrRIvf znF8-^j*?A!ku9^0_(yc8k~otyN|#BtQYS-1uIdhbaqfJxS+{=|8E@vX{LAV!Y5KAd zxL9N155@Du{HN|H(U9|GHD9C!+tcyCI`u&R;I}r4n`8#~WiK}s{Dwy<=C+snJ~^@@ zkZiCnO-*p-r@g*ii5=3whD@?#;rq9XxuIaAz)T4o%dSR3%`=ukZC zqf)oUFs`aAUM_#-*rv|W@-7Y%A@-IIe0_?6NL?bTjmGpcs3wA)sdOAvXP5kCvfZAj z^g}s0uO@l`yg8x5@P#!Pk;$G2!zoOAuZFN$0IgNw`ZBv+rA4Ty%vCoy>rao>OqIN6 zVAKcJa`WBO)OJBq4@q2&dUH~nOrMqC7*30CS1a-bp55M&87ATj*l4>)>G?A499bZZVL8-?j*HK@A&m9*{kBAa;;NUX#vfo;4lA)99j zj5`QEiZ@|DL75NMN=@&+N(7EgQKD<6C~FZy)BJ?5C40@;f$|32{toXYKShhe4vZ2s z-i&`oiEgaw<(99J8ZHt;@jRV9?J

Eiy_S%a<+R8f={1CtRGJtU!vkwz>f4lnD&x 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

-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 H0GIv`B2zJk diff --git a/skills/stellar-php-sdk/SKILL.md b/skills/stellar-php-sdk/SKILL.md index 45bdd80a..e063f587 100644 --- a/skills/stellar-php-sdk/SKILL.md +++ b/skills/stellar-php-sdk/SKILL.md @@ -351,7 +351,9 @@ $result = $client->invokeMethod('expensive_operation', [XdrSCVal::forSymbol('dat methodOptions: new MethodOptions(fee: 10000, timeoutInSeconds: 60)); ``` -For contract authorization, multi-auth workflows, and remote signing: +Protocol 27 (CAP-71) adds opt-in `ADDRESS_V2` and `ADDRESS_WITH_DELEGATES` credential arms (legacy `ADDRESS` stays default); request V2 from simulation via `MethodOptions(authV2: true)`, build delegate trees with `SorobanAuthorizationEntry::withDelegates(...)`. + +For contract authorization, multi-auth workflows, delegated auth, and remote signing: [Smart Contracts Guide](./references/soroban_contracts.md) ## 7. XDR Encoding & Decoding diff --git a/skills/stellar-php-sdk/references/api_reference.md b/skills/stellar-php-sdk/references/api_reference.md index 6e680fc9..a6407d7c 100644 --- a/skills/stellar-php-sdk/references/api_reference.md +++ b/skills/stellar-php-sdk/references/api_reference.md @@ -3,7 +3,7 @@ Compact method signature reference for `soneso/stellar-php-sdk`. Generated by `generate_api_reference.php`. Do not edit manually. -**Stats:** 572 classes, 2921 methods +**Stats:** 577 classes, 2962 methods --- ## Core Classes @@ -78,7 +78,7 @@ build(): AllowTrustOperation const TYPE_NATIVE = 'native' const TYPE_CREDIT_ALPHANUM_4 = 'credit_alphanum4' const TYPE_CREDIT_ALPHANUM_12 = 'credit_alphanum12' -const TYPE_POOL_SHARE = 'liquidty_pool_shares' +const TYPE_POOL_SHARE = 'liquidity_pool_shares' getType(): string static create(string $type, ?string $code = null, ?string $issuer = null): Asset static createNonNativeAsset(string $code, string $issuer): AssetTypeCreditAlphanum @@ -89,6 +89,8 @@ static fromJson(array $json): Asset toXdr(): XdrAsset toXdrChangeTrustAsset(): XdrChangeTrustAsset static fromXdr(XdrAsset $xdrAsset): Asset +static fromXdrChangeTrustAsset(XdrChangeTrustAsset $xdrAsset): Asset +static fromXdrTrustlineAsset(XdrTrustlineAsset $xdrAsset): Asset toXdrTrustlineAsset(): XdrTrustlineAsset ## abstract AssetTypeCreditAlphanum extends Asset @@ -118,6 +120,7 @@ getAssetA(): Asset getAssetB(): Asset getType(): string toXdr(): XdrAsset +toXdrChangeTrustAsset(): XdrChangeTrustAsset ## BeginSponsoringFutureReservesOperation extends AbstractOperation __construct(string $sponsoredId) @@ -477,9 +480,10 @@ const MEMO_TYPE_RETURN = 4 __construct(int $type, $value = null) getType(): int getValue(): mixed +getIdAsString(): ?string static none(): Memo static text(string $text): Memo -static id(int $id): Memo +static id(string|int $id): Memo static hash(string $hash): Memo static return(string $hash): Memo validate() @@ -812,6 +816,9 @@ setContractCodeBytes(?string $contractCodeBytes): void ## Crypto --- +## CryptoException +__construct(string $message, int $code = 0, ?Throwable $previous = null) + ## CryptoKeyType const KEY_TYPE_ED25519 = 0 const KEY_TYPE_PRE_AUTH_TX = 1 @@ -1191,7 +1198,8 @@ execute(): Response Transaction $transaction ?ResourceConfig $resourceConfig ?string $authMode -__construct(Transaction $transaction, ?ResourceConfig $resourceConfig = null, ?string $authMode = null) +bool $authV2 +__construct(Transaction $transaction, ?ResourceConfig $resourceConfig = null, ?string $authMode = null, bool $authV2 = false) getRequestParams(): array getTransaction(): Transaction setTransaction(Transaction $transaction): void @@ -1199,6 +1207,8 @@ getResourceConfig(): ?ResourceConfig setResourceConfig(?ResourceConfig $resourceConfig): void getAuthMode(): ?string setAuthMode(?string $authMode): void +getAuthV2(): bool +setAuthV2(bool $authV2): void ## StrictReceivePathsRequestBuilder extends RequestBuilder __construct(Client $httpClient) @@ -3246,7 +3256,8 @@ string $contractId Network $network string $rpcUrl ?LoggerInterface $logger -__construct(KeyPair $sourceAccountKeyPair, string $contractId, Network $network, string $rpcUrl, ?LoggerInterface $logger = null) +?SorobanServer $server +__construct(KeyPair $sourceAccountKeyPair, string $contractId, Network $network, string $rpcUrl, ?LoggerInterface $logger = null, ?SorobanServer $server = null) ## ContractSpec array $entries @@ -3271,7 +3282,8 @@ string $wasmHash ?string $salt MethodOptions $methodOptions ?LoggerInterface $logger -__construct(string $rpcUrl, Network $network, KeyPair $sourceAccountKeyPair, string $wasmHash, ?array $constructorArgs = null, ?string $salt = null, ?MethodOptions $methodOptions = null, ?LoggerInterface $logger = null) +?SorobanServer $server +__construct(string $rpcUrl, Network $network, KeyPair $sourceAccountKeyPair, string $wasmHash, ?array $constructorArgs = null, ?string $salt = null, ?MethodOptions $methodOptions = null, ?LoggerInterface $logger = null, ?SorobanServer $server = null) ## Footprint XdrLedgerFootprint $xdrFootprint @@ -3290,14 +3302,16 @@ string $rpcUrl Network $network KeyPair $sourceAccountKeyPair ?LoggerInterface $logger -__construct(string $wasmBytes, string $rpcUrl, Network $network, KeyPair $sourceAccountKeyPair, ?LoggerInterface $logger = null) +?SorobanServer $server +__construct(string $wasmBytes, string $rpcUrl, Network $network, KeyPair $sourceAccountKeyPair, ?LoggerInterface $logger = null, ?SorobanServer $server = null) ## MethodOptions int $fee int $timeoutInSeconds bool $simulate bool $restore -__construct(int $fee = 100, int $timeoutInSeconds = 300, bool $simulate = true, bool $restore = true) +bool $authV2 +__construct(int $fee = 100, int $timeoutInSeconds = 300, bool $simulate = true, bool $restore = true, bool $authV2 = false) ## NativeUnionVal string $tag @@ -3327,6 +3341,17 @@ setSignatureExpirationLedger(int $signatureExpirationLedger): void getSignature(): XdrSCVal setSignature(XdrSCVal $signature): void +## SorobanAddressCredentialsWithDelegates +SorobanAddressCredentials $addressCredentials +array $delegates +__construct(SorobanAddressCredentials $addressCredentials, array $delegates = []) +static fromXdr(XdrSorobanAddressCredentialsWithDelegates $xdr): SorobanAddressCredentialsWithDelegates +toXdr(): XdrSorobanAddressCredentialsWithDelegates +getAddressCredentials(): SorobanAddressCredentials +setAddressCredentials(SorobanAddressCredentials $addressCredentials): void +getDelegates(): array +setDelegates(array $delegates): void + ## SorobanAuthorizationEntry SorobanCredentials $credentials SorobanAuthorizedInvocation $rootInvocation @@ -3335,7 +3360,9 @@ static fromXdr(XdrSorobanAuthorizationEntry $xdr): SorobanAuthorizationEntry toXdr(): XdrSorobanAuthorizationEntry static fromBase64Xdr(string $base64Xdr): SorobanAuthorizationEntry toBase64Xdr(): string -sign(KeyPair $signer, Network $network): void +buildPreimage(Network $network): XdrHashIDPreimage +sign(KeyPair $signer, Network $network, ?int $signatureExpirationLedger = null, ?string $forAddress = null): void +static withDelegates(SorobanAuthorizationEntry $source, int $signatureExpirationLedger, array $delegates = []): SorobanAuthorizationEntry getCredentials(): SorobanCredentials setCredentials(SorobanCredentials $credentials): void getRootInvocation(): SorobanAuthorizedInvocation @@ -3382,7 +3409,8 @@ buildInvokeMethodTx(string $name, ?array $args = null, ?MethodOptions $methodOpt getMethodNames(): array ## SorobanContractInfo -int $envInterfaceVersion +int $envMetaProtocol +int $envMetaPreRelease array $specEntries array $metaEntries array $supportedSeps @@ -3392,7 +3420,7 @@ array $udtUnions array $udtEnums array $udtErrorEnums array $events -__construct(int $envInterfaceVersion, array $specEntries, array $metaEntries) +__construct(int $envMetaProtocol, int $envMetaPreRelease, array $specEntries, array $metaEntries) ## SorobanContractParser static parseContractByteCode(string $byteCode): SorobanContractInfo @@ -3400,15 +3428,46 @@ static parseContractByteCode(string $byteCode): SorobanContractInfo ## SorobanContractParserException extends ErrorException ## SorobanCredentials +int $credentialType ?SorobanAddressCredentials $addressCredentials -__construct(?SorobanAddressCredentials $addressCredentials = null) +?SorobanAddressCredentialsWithDelegates $addressWithDelegates +__construct(SorobanAddressCredentials|int $credentialType = 0, ?SorobanAddressCredentials $addressCredentials = null, ?SorobanAddressCredentialsWithDelegates $addressWithDelegates = null) static forSourceAccount(): SorobanCredentials static forAddress(Address $address, int $nonce, int $signatureExpirationLedger, XdrSCVal $signature): SorobanCredentials static forAddressCredentials(SorobanAddressCredentials $addressCredentials): SorobanCredentials +static forAddressCredentialsV2(SorobanAddressCredentials $addressCredentials): SorobanCredentials +static forAddressWithDelegates(SorobanAddressCredentialsWithDelegates $addressWithDelegates): SorobanCredentials static fromXdr(XdrSorobanCredentials $xdr): SorobanCredentials toXdr(): XdrSorobanCredentials +isSourceAccount(): bool +isAddressBased(): bool getAddressCredentials(): ?SorobanAddressCredentials +writeBackAddressCredentials(SorobanAddressCredentials $addressCredentials): void setAddressCredentials(?SorobanAddressCredentials $addressCredentials): void +getAddressWithDelegates(): ?SorobanAddressCredentialsWithDelegates +setAddressWithDelegates(?SorobanAddressCredentialsWithDelegates $addressWithDelegates): void +getCredentialType(): int +setCredentialType(int $credentialType): void + +## SorobanDelegateDescriptor +string $address +?XdrSCVal $signature +array $nestedDelegates +__construct(string $address, ?XdrSCVal $signature = null, array $nestedDelegates = []) + +## SorobanDelegateSignature +XdrSCAddress $address +XdrSCVal $signature +array $nestedDelegates +__construct(XdrSCAddress $address, ?XdrSCVal $signature = null, array $nestedDelegates = []) +static fromXdr(XdrSorobanDelegateSignature $xdr): SorobanDelegateSignature +toXdr(): XdrSorobanDelegateSignature +getAddress(): XdrSCAddress +setAddress(XdrSCAddress $address): void +getSignature(): XdrSCVal +setSignature(XdrSCVal $signature): void +getNestedDelegates(): array +setNestedDelegates(array $nestedDelegates): void ## SorobanServer __construct(string $endpoint) @@ -3563,10 +3622,10 @@ static Entropy(string $entropy): Mnemonic static Generate(int $wordCount = 12): Mnemonic static Words($words, ?WordList $wordList = null, bool $verifyChecksum = true): Mnemonic __construct(int $wordCount = 12) -useEntropy(string $entropy): BIP39 -generateSecureEntropy(): BIP39 +useEntropy(string $entropy): self +generateSecureEntropy(): self mnemonic(): Mnemonic -wordList(WordList $wordList): BIP39 +wordList(WordList $wordList): self reverse(array $words, bool $verifyChecksum = true): Mnemonic ## CardKYCFields @@ -5109,7 +5168,7 @@ execute(): SEP24InteractiveResponse ?Validators $validators __construct(string $toml) static fromDomain(string $domain, ?Client $httpClient = null): StellarToml -static currencyFromUrl(string $toml): Currency +static currencyFromUrl(string $toml, ?Client $httpClient = null): Currency static currencyFromItem(array $item): Currency getGeneralInformation(): ?GeneralInformation getDocumentation(): ?Documentation @@ -5239,8 +5298,8 @@ toArray(): array ## WebAuth __construct(string $authEndpoint, string $serverSigningKey, string $serverHomeDomain, Network $network, ?Client $httpClient = null) -static fromDomain(string $domain, Network $network, ?Client $httpClient = null): WebAuth setGracePeriod(int $seconds): void +static fromDomain(string $domain, Network $network, ?Client $httpClient = null): WebAuth jwtToken(string $clientAccountId, array $signers, ?int $memo = null, ?string $homeDomain = null, ?string $clientDomain = null, ?KeyPair $clientDomainKeyPair = null, ?callable $clientDomainSigningCallback = null): string setMockHandler(MockHandler $handler): void @@ -5362,7 +5421,7 @@ const LANGUAGE_JAPANESE = 'japanese' const LANGUAGE_KOREAN = 'korean' const LANGUAGE_SPANISH = 'spanish' const LANGUAGE_MALAY = 'malay' -static getLanguage(string $lang = 'english'): WordList +static getLanguage(string $lang = 'english'): self __construct(string $language = 'english') which(): string getWord(int $index): ?string @@ -5375,15 +5434,15 @@ findIndex(string $search): ?int ## CustomFriendBot string $friendBotUrl __construct(string $friendBotUrl) -fundAccount(string $accountId): bool +fundAccount(string $accountId, ?Client $httpClient = null): bool getFriendBotUrl(): string setFriendBotUrl(string $friendBotUrl): void ## FriendBot -static fundTestAccount(string $accountId): bool +static fundTestAccount(string $accountId, ?Client $httpClient = null): bool ## FuturenetFriendBot -static fundTestAccount(string $accountId): bool +static fundTestAccount(string $accountId, ?Client $httpClient = null): bool ## Hash static generate(string $data): string @@ -5399,6 +5458,11 @@ getDecimalValueAsString(): string getStroopsAsString(): string getStroops(): BigInteger +## UrlValidator +static validateHttpsRequired(string $url): void +static validateDomain(string $domain): void +static validatePathSegment(string $value, string $paramName): void + --- ## Exceptions --- diff --git a/skills/stellar-php-sdk/references/rpc.md b/skills/stellar-php-sdk/references/rpc.md index d85a20c2..eb2cf63c 100644 --- a/skills/stellar-php-sdk/references/rpc.md +++ b/skills/stellar-php-sdk/references/rpc.md @@ -217,6 +217,8 @@ if ($simResponse->error === null) { } ``` +Protocol 27 (CAP-71): pass `authV2: true` to request `ADDRESS_V2` credential entries (`new SimulateTransactionRequest($tx, authV2: true)`). The `authV2` key is omitted from the JSON-RPC params when false (the default). RPCs without protocol 27 support silently ignore it and return legacy `ADDRESS` entries — detect support by inspecting the returned credential arm, not by expecting an error. + ### sendTransaction Submit a signed transaction to the network. This method returns immediately after validation -- it does not wait for ledger inclusion. Poll with `getTransaction()` to check the result. diff --git a/skills/stellar-php-sdk/references/sep-45.md b/skills/stellar-php-sdk/references/sep-45.md index 3d95a883..03b4fe60 100644 --- a/skills/stellar-php-sdk/references/sep-45.md +++ b/skills/stellar-php-sdk/references/sep-45.md @@ -180,6 +180,8 @@ $jwtToken = $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 (CAP-71):** `jwtToken()` signs whichever credential arm the server returns — `ADDRESS`, `ADDRESS_V2`, or `ADDRESS_WITH_DELEGATES` — and preserves the arm on write-back, so no flow change is needed. For `ADDRESS_WITH_DELEGATES` entries, supply every signer the contract's `__check_auth` rules require, including delegate signers. + --- ## Contracts Without Signature Requirements diff --git a/skills/stellar-php-sdk/references/soroban_contracts.md b/skills/stellar-php-sdk/references/soroban_contracts.md index 9e7fcf32..2cd6beca 100644 --- a/skills/stellar-php-sdk/references/soroban_contracts.md +++ b/skills/stellar-php-sdk/references/soroban_contracts.md @@ -386,6 +386,31 @@ $response = $tx->signAndSend(); // returns GetTransactionResponse echo $response->getStatus(); // e.g. "SUCCESS" ``` +## Protocol 27 Credentials (CAP-71) + +`SorobanCredentials` has four arms: source-account, legacy `ADDRESS` (default, valid on all protocols), and the opt-in `ADDRESS_V2` and `ADDRESS_WITH_DELEGATES` (protocol 27+; invalid below 27). All signing APIs handle every arm; `getAddressCredentials()` returns the inner `SorobanAddressCredentials` for any address arm (null only for source-account); `getCredentialType()` / `isSourceAccount()` inspect the arm. + +Request V2 entries from simulation with the `authV2` flag (`MethodOptions(authV2: true)` or `new SimulateTransactionRequest($tx, authV2: true)`). RPCs without protocol 27 support silently ignore it and return legacy `ADDRESS` entries — detect support by checking the returned credential arm, not by expecting an error. + +`ADDRESS_WITH_DELEGATES` lets delegate addresses co-sign one entry. Simulation never returns this arm; build it from an `ADDRESS`/`ADDRESS_V2` entry via `SorobanAuthorizationEntry::withDelegates($source, $expirationLedger, $delegates)`, passing `SorobanDelegateDescriptor` objects. The builder sorts the delegate array and rejects duplicates. All nodes (top-level + delegates at any depth) sign the same payload bound to the top-level address; delegates carry no nonce/expiration. `sign($kp, $network, forAddress: $strkey)` routes a signature to matching nodes depth-first; `null` signs top-level. A void top-level with all delegates signed is valid (delegates-only). After attaching a delegated entry, re-simulate and apply the returned `transactionData` / `minResourceFee` before submitting, since the first simulation excluded the delegate authorization. For multiple classical signatures on one node, call `sign()` in ascending public-key order (the SDK appends in call order and does not sort). + +```php +getLatestLedger()->getSequence() +$delegated = SorobanAuthorizationEntry::withDelegates( + $sourceEntry, + $expirationLedger, + [new SorobanDelegateDescriptor($delegateKeyPair->getAccountId())], +); +$delegated->sign($delegateKeyPair, Network::testnet(), forAddress: $delegateKeyPair->getAccountId()); +``` + +Source compatibility: the `SorobanCredentials` constructor's first parameter is now `int|SorobanAddressCredentials` and renamed; positional `SorobanAddressCredentials` callers are unaffected, but named-argument `new SorobanCredentials(addressCredentials: ...)` must switch to positional or `forAddressCredentials(...)`. New XDR enum/union cases mean exhaustive `match`/`switch` over them needs a `default` arm. + ## TTL Extension and Restore ### Extend Footprint TTL diff --git a/skills/stellar-php-sdk/references/xdr.md b/skills/stellar-php-sdk/references/xdr.md index ec152ed6..f180f83c 100644 --- a/skills/stellar-php-sdk/references/xdr.md +++ b/skills/stellar-php-sdk/references/xdr.md @@ -349,6 +349,8 @@ $decoded = SorobanAuthorizationEntry::fromBase64Xdr($base64); $signedBase64 = $decoded->toBase64Xdr(); ``` +Roundtrip preserves all four `SorobanCredentials` arms, including the protocol 27 (CAP-71) `ADDRESS_V2` and `ADDRESS_WITH_DELEGATES` arms. + ## XDR Ledger Key Base64 Encode ledger keys for use with the `getLedgerEntries` RPC method. diff --git a/tools/skill-generator/README.md b/tools/skill-generator/README.md new file mode 100644 index 00000000..ce79f868 --- /dev/null +++ b/tools/skill-generator/README.md @@ -0,0 +1,93 @@ +# Skill Generator + +PHP script that generates the agent-skill API reference file +(`skills/stellar-php-sdk/references/api_reference.md`) from the SDK source via +reflection. + +## What it does + +Extracts the public API of `soneso/stellar-php-sdk` into a compact, +signature-only Markdown reference. The output is consumed by the +`stellar-php-sdk` agent skill so AI coding agents can look up class, method, +constant, and property signatures without reading the raw source. + +For each public class, interface, trait, and enum it emits the declaration +header (with `extends` / `implements`), public constants, public properties, +and public methods declared on the type itself (inherited members are not +repeated). Types are grouped into buckets driven by their namespace: core, +crypto, requests, responses, soroban, sep, util, exceptions. + +## Requirements + +- PHP 8.1+ +- Composer dependencies installed (`composer install` from the repository + root). The script loads `vendor/autoload.php` and reflects the autoloaded + classes. + +## Usage + +Run from the repository root: + +```bash +php tools/skill-generator/generate_api_reference.php +``` + +Output is written to +`skills/stellar-php-sdk/references/api_reference.md` (overwriting the previous +generation). Paths are derived from the script location, so it can be invoked +from anywhere. + +After regenerating, rebuild the skill archive so the bundled zip matches the +new reference content: + +```bash +cd skills +rm -f stellar-php-sdk.zip +cd stellar-php-sdk && zip -r ../stellar-php-sdk.zip . -x "*.DS_Store" +``` + +## When to regenerate + +Regenerate whenever the SDK's public API surface changes: + +- New SEP implementation, public class, method, constant, or property in any + non-XDR namespace +- A type moved between namespaces +- A signature changed (parameter, type, default, return type) or a member + renamed, deprecated, or removed + +Stale generation does not break the SDK, but the agent skill will offer +out-of-date guidance. + +## What gets scanned + +- **Scanned source**: a recursive walk of `Soneso/StellarSDK/`. A file is + included when it declares a `class`, `interface`, `trait`, or `enum` whose + namespace is autoloadable. +- **Excluded namespaces**: `Soneso\StellarSDK\Xdr\` (the generated XDR layer is + documented separately in the skill's `xdr.md`). +- **Excluded members**: non-public constants, properties, and methods; magic + methods other than `__construct`; members inherited from a parent class or + interface (only members declared on the type itself are emitted). +- **Type simplification**: parameter, property, and return types are rendered + with short class names; nullable types use the `?` prefix; common base + exceptions and PHP interfaces (`Throwable`, `Stringable`, `JsonSerializable`, + etc.) are omitted from the header to reduce noise. + +## Output format + +Each type produces a section like: + +``` +## class SorobanAuthorizationEntry +SorobanCredentials $credentials +SorobanAuthorizedInvocation $rootInvocation +__construct(SorobanCredentials $credentials, SorobanAuthorizedInvocation $rootInvocation) +buildPreimage(Network $network): XdrHashIDPreimage +sign(KeyPair $signer, Network $network, ?int $signatureExpirationLedger = null, ?string $forAddress = null): void +static withDelegates(SorobanAuthorizationEntry $source, int $signatureExpirationLedger, array $delegates = []): SorobanAuthorizationEntry +``` + +The script prints class/method counts to stderr on completion. It is +reflection-based, so a class that fails to autoload is reported as a warning on +stderr and skipped rather than aborting the run. diff --git a/tools/skill-generator/generate_api_reference.php b/tools/skill-generator/generate_api_reference.php new file mode 100644 index 00000000..d2054170 --- /dev/null +++ b/tools/skill-generator/generate_api_reference.php @@ -0,0 +1,488 @@ + 0, + 'methods' => 0, + 'skipped' => 0, + 'errors' => 0, +]; + +// Store classes by group +$groups = [ + 'core' => [], + 'crypto' => [], + 'requests' => [], + 'responses' => [], + 'soroban' => [], + 'sep' => [], + 'util' => [], + 'exceptions' => [], +]; + +/** + * Recursively find all PHP files in a directory + */ +function findPhpFiles(string $directory): array { + $files = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $files[] = $file->getPathname(); + } + } + + return $files; +} + +/** + * Extract class name from file path + */ +function extractClassName(string $filePath): ?string { + $content = file_get_contents($filePath); + + // Extract namespace + if (!preg_match('/namespace\s+([\w\\\\]+);/', $content, $nsMatch)) { + return null; + } + + // Extract class/interface/trait name (anchored to line start to skip PHPDoc comments) + if (!preg_match('/^(?:abstract\s+|final\s+|readonly\s+)*(class|interface|trait|enum)\s+(\w+)/m', $content, $classMatch)) { + return null; + } + + return $nsMatch[1] . '\\' . $classMatch[2]; +} + +/** + * Check if namespace should be skipped + */ +function shouldSkipNamespace(string $className): bool { + foreach (SKIP_NAMESPACES as $skipNs) { + if (str_starts_with($className, $skipNs)) { + return true; + } + } + return false; +} + +/** + * Determine which group a class belongs to + */ +function determineGroup(string $className): string { + if (str_contains($className, '\\SEP\\')) { + return 'sep'; + } + if (str_contains($className, '\\Crypto\\')) { + return 'crypto'; + } + if (str_contains($className, '\\Requests\\')) { + return 'requests'; + } + if (str_contains($className, '\\Responses\\')) { + return 'responses'; + } + if (str_contains($className, '\\Soroban\\')) { + return 'soroban'; + } + if (str_contains($className, '\\Util\\')) { + return 'util'; + } + if (str_contains($className, '\\Exceptions\\')) { + return 'exceptions'; + } + return 'core'; +} + +/** + * Get short class name from fully qualified name + */ +function getShortName(string $className): string { + $parts = explode('\\', $className); + return end($parts); +} + +/** + * Format a type hint to use short names + */ +function formatType(?ReflectionType $type): string { + if ($type === null) { + return ''; + } + + if ($type instanceof ReflectionUnionType) { + $types = array_map(function($t) { + return formatSingleType($t); + }, $type->getTypes()); + return implode('|', $types); + } + + return formatSingleType($type); +} + +/** + * Format a single type (handling nullable) + */ +function formatSingleType(ReflectionNamedType|ReflectionIntersectionType $type): string { + if ($type instanceof ReflectionIntersectionType) { + $types = array_map(fn($t) => getShortName($t->getName()), $type->getTypes()); + return implode('&', $types); + } + + $name = $type->getName(); + + // Use short name for classes + if (!$type->isBuiltin()) { + $name = getShortName($name); + } + + // Handle nullable + if ($type->allowsNull() && $name !== 'mixed' && $name !== 'null') { + return '?' . $name; + } + + return $name; +} + +/** + * Format parameter with type and default value + */ +function formatParameter(ReflectionParameter $param): string { + $parts = []; + + // Type hint + if ($param->hasType()) { + $parts[] = formatType($param->getType()); + } + + // Parameter name + $name = '$' . $param->getName(); + if ($param->isVariadic()) { + $name = '...' . $name; + } + $parts[] = $name; + + // Default value + if ($param->isOptional() && !$param->isVariadic()) { + try { + if ($param->isDefaultValueAvailable()) { + $default = $param->getDefaultValue(); + if ($default === null) { + $parts[count($parts) - 1] .= ' = null'; + } elseif (is_bool($default)) { + $parts[count($parts) - 1] .= ' = ' . ($default ? 'true' : 'false'); + } elseif (is_string($default)) { + $parts[count($parts) - 1] .= ' = \'' . addslashes($default) . '\''; + } elseif (is_array($default)) { + $parts[count($parts) - 1] .= ' = []'; + } else { + $parts[count($parts) - 1] .= ' = ' . var_export($default, true); + } + } + } catch (ReflectionException $e) { + // Skip default value if it can't be determined + } + } + + return implode(' ', $parts); +} + +/** + * Extract class info using reflection + */ +function extractClassInfo(string $className): ?array { + try { + $reflection = new ReflectionClass($className); + + // Skip abstract classes and interfaces in some cases + $classType = ''; + if ($reflection->isInterface()) { + $classType = 'interface'; + } elseif ($reflection->isTrait()) { + $classType = 'trait'; + } elseif ($reflection->isAbstract()) { + $classType = 'abstract'; + } + + // Get parent and interfaces + $parent = $reflection->getParentClass(); + $parentName = null; + if ($parent && !in_array($parent->getName(), ['Exception', 'RuntimeException', 'InvalidArgumentException'])) { + $parentName = getShortName($parent->getName()); + } + + $interfaces = []; + foreach ($reflection->getInterfaces() as $interface) { + $interfaceName = $interface->getName(); + // Skip common PHP interfaces + if (!in_array($interfaceName, ['Throwable', 'Stringable', 'JsonSerializable', 'ArrayAccess', 'Iterator', 'IteratorAggregate', 'Countable'])) { + $interfaces[] = getShortName($interfaceName); + } + } + + // Extract public constants (defined in this class only) + $constants = []; + foreach ($reflection->getReflectionConstants() as $constant) { + if (!$constant->isPublic()) { + continue; + } + if ($constant->getDeclaringClass()->getName() !== $className) { + continue; + } + $value = $constant->getValue(); + if (is_string($value)) { + $constants[] = "const {$constant->getName()} = '{$value}'"; + } elseif (is_bool($value)) { + $constants[] = "const {$constant->getName()} = " . ($value ? 'true' : 'false'); + } elseif (is_int($value) || is_float($value)) { + $constants[] = "const {$constant->getName()} = {$value}"; + } elseif (is_null($value)) { + $constants[] = "const {$constant->getName()} = null"; + } else { + $constants[] = "const {$constant->getName()}"; + } + } + + // Extract public properties (defined in this class only) + $properties = []; + foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->getDeclaringClass()->getName() !== $className) { + continue; + } + $prop = ''; + if ($property->isStatic()) { + $prop .= 'static '; + } + if ($property->hasType()) { + $prop .= formatType($property->getType()) . ' '; + } + $prop .= '$' . $property->getName(); + $properties[] = $prop; + } + + // Extract methods + $methods = []; + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + // Skip magic methods except __construct + if (str_starts_with($method->getName(), '__') && $method->getName() !== '__construct') { + continue; + } + + // Only include methods defined in this class (not inherited) + if ($method->getDeclaringClass()->getName() !== $className) { + continue; + } + + $signature = ''; + + // Static + if ($method->isStatic()) { + $signature .= 'static '; + } + + // Method name + $signature .= $method->getName() . '('; + + // Parameters + $params = array_map('formatParameter', $method->getParameters()); + $signature .= implode(', ', $params); + + $signature .= ')'; + + // Return type + if ($method->hasReturnType()) { + $signature .= ': ' . formatType($method->getReturnType()); + } + + $methods[] = $signature; + } + + return [ + 'short_name' => getShortName($className), + 'type' => $classType, + 'parent' => $parentName, + 'interfaces' => $interfaces, + 'constants' => $constants, + 'properties' => $properties, + 'methods' => $methods, + ]; + + } catch (ReflectionException $e) { + fwrite(STDERR, "Warning: Could not reflect class {$className}: {$e->getMessage()}\n"); + return null; + } +} + +/** + * Format class section for markdown + */ +function formatClassSection(array $classInfo): string { + $output = ''; + + // Class header + $header = '## '; + if ($classInfo['type']) { + $header .= $classInfo['type'] . ' '; + } + $header .= $classInfo['short_name']; + + // Add parent/interfaces + if ($classInfo['parent']) { + $header .= ' extends ' . $classInfo['parent']; + } + if (!empty($classInfo['interfaces'])) { + $header .= ' implements ' . implode(', ', $classInfo['interfaces']); + } + + $output .= $header . "\n"; + + // Constants + foreach ($classInfo['constants'] as $constant) { + $output .= $constant . "\n"; + } + + // Public properties + foreach ($classInfo['properties'] as $property) { + $output .= $property . "\n"; + } + + // Methods + foreach ($classInfo['methods'] as $method) { + $output .= $method . "\n"; + } + + $output .= "\n"; + + return $output; +} + +// Main execution +fwrite(STDERR, "Scanning PHP files in " . SDK_PATH . "...\n"); + +$files = findPhpFiles(SDK_PATH); +fwrite(STDERR, "Found " . count($files) . " PHP files\n"); + +fwrite(STDERR, "Extracting class information...\n"); + +foreach ($files as $file) { + $className = extractClassName($file); + + if ($className === null) { + continue; + } + + // Skip certain namespaces + if (shouldSkipNamespace($className)) { + $stats['skipped']++; + continue; + } + + fwrite(STDERR, "Processing: {$className}\n"); + + $classInfo = extractClassInfo($className); + + if ($classInfo === null) { + $stats['errors']++; + continue; + } + + // Add to appropriate group + $group = determineGroup($className); + $groups[$group][] = $classInfo; + + $stats['classes']++; + $stats['methods'] += count($classInfo['methods']); +} + +// Sort classes within each group +foreach ($groups as &$group) { + usort($group, fn($a, $b) => strcmp($a['short_name'], $b['short_name'])); +} + +fwrite(STDERR, "Generating markdown output...\n"); + +// Generate markdown +$markdown = "# PHP SDK API Reference (Signatures)\n\n"; +$markdown .= "Compact method signature reference for `soneso/stellar-php-sdk`.\n"; +$markdown .= "Generated by `generate_api_reference.php`. Do not edit manually.\n\n"; +$markdown .= "**Stats:** {$stats['classes']} classes, {$stats['methods']} methods\n\n"; + +// Generate each group +$groupTitles = [ + 'core' => 'Core Classes', + 'crypto' => 'Crypto', + 'requests' => 'Requests (Query Builders)', + 'responses' => 'Responses', + 'soroban' => 'Soroban', + 'sep' => 'SEP (Stellar Ecosystem Proposals)', + 'util' => 'Util', + 'exceptions' => 'Exceptions', +]; + +foreach ($groupTitles as $key => $title) { + if (empty($groups[$key])) { + continue; + } + + $markdown .= "---\n"; + $markdown .= "## {$title}\n"; + $markdown .= "---\n\n"; + + foreach ($groups[$key] as $classInfo) { + $markdown .= formatClassSection($classInfo); + } +} + +// Ensure output directory exists +$outputDir = dirname(OUTPUT_PATH); +if (!is_dir($outputDir)) { + mkdir($outputDir, 0755, true); +} + +// Write output +file_put_contents(OUTPUT_PATH, $markdown); + +// Print stats +fwrite(STDERR, "\n=== Generation Complete ===\n"); +fwrite(STDERR, "Classes processed: {$stats['classes']}\n"); +fwrite(STDERR, "Methods extracted: {$stats['methods']}\n"); +fwrite(STDERR, "Files skipped: {$stats['skipped']}\n"); +fwrite(STDERR, "Errors: {$stats['errors']}\n"); +fwrite(STDERR, "Output written to: " . OUTPUT_PATH . "\n"); +fwrite(STDERR, "File size: " . number_format(filesize(OUTPUT_PATH)) . " bytes\n"); + +echo "API reference generated successfully!\n"; diff --git a/tools/xdr-generator/generator/generator.rb b/tools/xdr-generator/generator/generator.rb index 3e18f03e..4f8e3f45 100644 --- a/tools/xdr-generator/generator/generator.rb +++ b/tools/xdr-generator/generator/generator.rb @@ -422,12 +422,27 @@ def render_struct_encode(out, struct_name, class_name, fields) def render_struct_decode(out, struct_name, class_name, fields, is_base) _, _, return_type, _ = resolve_class_info(struct_name) decode_class = is_base ? "static" : struct_name + is_recursive = defined?(RECURSIVE_STRUCT_TYPES) && RECURSIVE_STRUCT_TYPES.include?(struct_name) out.puts " public static function decode(XdrBuffer $xdr): #{return_type} {" + if is_recursive + out.puts " $xdr->enterRecursion();" + out.puts " try {" + indent = " " + else + indent = "" + end fields.each do |f| if f[:is_ext_point] - out.puts " $xdr->readInteger32(); // extension point" + out.puts " #{indent}$xdr->readInteger32(); // extension point" else - render_decode_field_php(out, f[:name], f) + if is_recursive + # Temporarily redirect output to a buffer to add indentation + buf = StringIO.new + render_decode_field_php(buf, f[:name], f) + buf.string.each_line { |line| out.puts " #{line.rstrip}" } + else + render_decode_field_php(out, f[:name], f) + end end end # Build constructor call using non-ext fields, reordered to match constructor @@ -437,7 +452,14 @@ def render_struct_decode(out, struct_name, class_name, fields, is_base) optional_fields = constructor_fields.select { |f| f[:is_optional] } ordered_fields = required_fields + optional_fields args = ordered_fields.map { |f| "$#{f[:name]}" }.join(", ") - out.puts " return new #{decode_class}(#{args});" + if is_recursive + out.puts " return new #{decode_class}(#{args});" + out.puts " } finally {" + out.puts " $xdr->leaveRecursion();" + out.puts " }" + else + out.puts " return new #{decode_class}(#{args});" + end out.puts " }" end diff --git a/tools/xdr-generator/generator/type_overrides.rb b/tools/xdr-generator/generator/type_overrides.rb index 6ba63e7c..dce7bf8c 100644 --- a/tools/xdr-generator/generator/type_overrides.rb +++ b/tools/xdr-generator/generator/type_overrides.rb @@ -130,6 +130,21 @@ XdrDataValue ].freeze +# --------------------------------------------------------------------------- +# RECURSIVE_STRUCT_TYPES +# Struct types whose XDR decode method recurses into itself (directly or via a +# field of the same type). The generator wraps the decode body with +# XdrBuffer::enterRecursion / leaveRecursion so that hostile deeply-nested data +# received from the network cannot overflow the PHP call stack. +# +# Each entry triggers an enterRecursion() at the start of decode() and a +# leaveRecursion() in a finally block before the return. The cap is +# XdrBuffer::RECURSION_LIMIT (128). +# --------------------------------------------------------------------------- +RECURSIVE_STRUCT_TYPES = %w[ + XdrSorobanDelegateSignature +].freeze + # --------------------------------------------------------------------------- # EXTENSION_POINT_FIELDS # Maps struct names to field names that are void-only extension unions. From 5010fb1bf707b698a74abb68d00231fe1e9898d2 Mon Sep 17 00:00:00 2001 From: Christian Rogobete Date: Sun, 14 Jun 2026 17:50:27 +0200 Subject: [PATCH 2/6] Fix signature expiration stamping on the auth callback paths signAuthEntries (AssembledTransaction) and signAuthorizationEntries (WebAuthForContracts) stamped signatureExpirationLedger only on the direct signing paths, not before invoking the signing callback, so a callback-signed entry kept the wrong expiration and was rejected on submission. Stamp the expiration before the callback, as the direct paths already do. Also strengthen the Protocol 27 unit tests: assert the stamped expiration on the callback paths, verify produced signatures against the rebuilt preimage, and tighten weak assertions. --- .../WebAuthForContracts.php | 12 ++- .../Soroban/Contract/AssembledTransaction.php | 17 +++-- .../P27WebAuthForContractsTest.php | 76 +++++++++++++++++++ .../Unit/Soroban/P27AuthorizationTest.php | 9 +++ .../Unit/Soroban/P27CoverageClosureTest.php | 32 ++++++-- 5 files changed, 133 insertions(+), 13 deletions(-) diff --git a/Soneso/StellarSDK/SEP/WebAuthForContracts/WebAuthForContracts.php b/Soneso/StellarSDK/SEP/WebAuthForContracts/WebAuthForContracts.php index 52eb9eb5..9a4c1654 100644 --- a/Soneso/StellarSDK/SEP/WebAuthForContracts/WebAuthForContracts.php +++ b/Soneso/StellarSDK/SEP/WebAuthForContracts/WebAuthForContracts.php @@ -654,8 +654,18 @@ public function signAuthorizationEntries( ); } - // Send only the client domain entry to the callback + // Send only the client domain entry to the callback. Stamp the + // expiration first — as the client and client-domain-keypair + // branches do through sign() — so the remote signer signs over the + // intended expiration ledger rather than the challenge default. $clientDomainEntry = $signedEntries[$clientDomainEntryIndex]; + if ($signatureExpirationLedger !== null) { + $cdCreds = $clientDomainEntry->credentials->getAddressCredentials(); + if ($cdCreds !== null) { + $cdCreds->signatureExpirationLedger = $signatureExpirationLedger; + $clientDomainEntry->credentials->writeBackAddressCredentials($cdCreds); + } + } $signedEntry = $clientDomainSigningCallback($clientDomainEntry); // Validate callback return value diff --git a/Soneso/StellarSDK/Soroban/Contract/AssembledTransaction.php b/Soneso/StellarSDK/Soroban/Contract/AssembledTransaction.php index 822b21f0..b5c4eff9 100644 --- a/Soneso/StellarSDK/Soroban/Contract/AssembledTransaction.php +++ b/Soneso/StellarSDK/Soroban/Contract/AssembledTransaction.php @@ -596,16 +596,19 @@ public function signAuthEntries(KeyPair $signerKeyPair, ?callable $authorizeEntr continue; } + // Stamp expiration on the top-level credentials before signing — the + // preimage is built from the current expiration value. This applies to + // the callback path too, otherwise a delegated signer would sign over + // the simulation default and the host would reject it as expired. + $topCreds = $entry->credentials->getAddressCredentials(); + if ($topCreds !== null) { + $topCreds->signatureExpirationLedger = $expirationLedger; + $entry->credentials->writeBackAddressCredentials($topCreds); + } + if ($authorizeEntryCallback !== null) { $authorized = $authorizeEntryCallback($entry, $this->options->clientOptions->network); } else { - // Set expiration on the top-level credentials via the arm-preserving helper. - $topCreds = $entry->credentials->getAddressCredentials(); - if ($topCreds !== null) { - $topCreds->signatureExpirationLedger = $expirationLedger; - $entry->credentials->writeBackAddressCredentials($topCreds); - } - if ($entryMatchesDelegate) { // Route the signature to the matching delegate node(s) via forAddress. $entry->sign( diff --git a/Soneso/StellarSDKTests/Unit/SEP/WebAuthForContracts/P27WebAuthForContractsTest.php b/Soneso/StellarSDKTests/Unit/SEP/WebAuthForContracts/P27WebAuthForContractsTest.php index 3cb15df5..2c1bbe23 100644 --- a/Soneso/StellarSDKTests/Unit/SEP/WebAuthForContracts/P27WebAuthForContractsTest.php +++ b/Soneso/StellarSDKTests/Unit/SEP/WebAuthForContracts/P27WebAuthForContractsTest.php @@ -457,6 +457,82 @@ public function testSignAuthorizationEntriesPreservesV2Arm(): void ); $this->assertNotNull($innerCreds->signature->vec); $this->assertCount(1, $innerCreds->signature->vec); + + // The signature must verify against the preimage rebuilt from the signed + // entry (which now carries expiration 999999) — proving the signature was + // made over the stamped expiration, i.e. the expiration was applied + // before hashing, not after. + $payload = Hash::generate($clientSigned->buildPreimage(Network::testnet())->encode()); + $sigBytes = null; + foreach ($innerCreds->signature->vec[0]->map ?? [] as $mapEntry) { + if ($mapEntry->key->sym === 'signature') { + $sigBytes = $mapEntry->val->bytes?->getValue(); + break; + } + } + $this->assertNotNull($sigBytes); + $this->assertTrue($clientKeyPair->verifySignature($sigBytes, $payload), + 'client signature must verify against the preimage built with the stamped expiration'); + } + + /** + * The client-domain signing callback receives the entry with the expiration + * already stamped, so the remote signer signs over the intended expiration + * ledger rather than the challenge default. + */ + public function testSignAuthorizationEntriesStampsExpirationBeforeClientDomainCallback(): void + { + $clientKeyPair = KeyPair::random(); + $clientDomainKeyPair = KeyPair::random(); + $clientDomainAccountId = $clientDomainKeyPair->getAccountId(); + + $argsMap = $this->buildArgsMap( + $this->clientContractId, + $this->domain, + 'auth.example.stellar.org', + $this->serverAccountId, + 'nonce_cd_callback', + ); + + // The client-domain entry starts with expiration 111; the call passes 888888. + $clientDomainEntry = $this->buildAuthEntry( + $clientDomainAccountId, + $this->webAuthContractId, + 'web_auth_verify', + $argsMap, + 88001, + 111, + ); + + $webAuth = $this->makeWebAuth(); + + $expirationSeenByCallback = null; + $signed = $webAuth->signAuthorizationEntries( + [$clientDomainEntry], + $clientKeyPair->getAccountId(), // clientAccountId — does not match the entry + [], + 888888, + null, // no clientDomainKeyPair: exercise the callback branch + // The callback signs WITHOUT setting an expiration itself, so it + // signs over whatever the SDK stamped beforehand. + function (SorobanAuthorizationEntry $entry) use (&$expirationSeenByCallback, $clientDomainKeyPair): SorobanAuthorizationEntry { + $expirationSeenByCallback = + $entry->credentials->getAddressCredentials()?->signatureExpirationLedger; + $entry->sign($clientDomainKeyPair, Network::testnet()); + return $entry; + }, + $clientDomainAccountId, + ); + + // If the SDK skipped stamping before the callback, the callback would see + // the original 111 and this would fail. + $this->assertSame(888888, $expirationSeenByCallback, + 'signAuthorizationEntries must stamp the expiration before invoking the client-domain callback'); + + $innerCreds = $signed[0]->credentials->getAddressCredentials(); + $this->assertNotNull($innerCreds); + $this->assertSame(888888, $innerCreds->signatureExpirationLedger, + 'the client-domain entry must carry the stamped expiration'); } /** diff --git a/Soneso/StellarSDKTests/Unit/Soroban/P27AuthorizationTest.php b/Soneso/StellarSDKTests/Unit/Soroban/P27AuthorizationTest.php index 72295a1d..b3b49eb3 100644 --- a/Soneso/StellarSDKTests/Unit/Soroban/P27AuthorizationTest.php +++ b/Soneso/StellarSDKTests/Unit/Soroban/P27AuthorizationTest.php @@ -384,6 +384,15 @@ public function testV2SignWorks(): void $this->assertNotNull($sig); $this->assertNotNull($sig->vec); $this->assertCount(1, $sig->vec); + + // The signature must verify against the golden V2 (WITH_ADDRESS) payload + // hash, proving sign() used the address-bound preimage, not the legacy + // one (which hashes to a different payload and would fail verification). + $sigBytes = hex2bin($this->extractSignatureHex($entry)); + $this->assertTrue( + $signer->verifySignature($sigBytes, hex2bin(self::GOLDEN_V2_PAYLOAD_HEX)), + 'V2 signature must verify against the WITH_ADDRESS preimage payload', + ); } /** diff --git a/Soneso/StellarSDKTests/Unit/Soroban/P27CoverageClosureTest.php b/Soneso/StellarSDKTests/Unit/Soroban/P27CoverageClosureTest.php index 32d4e602..117c0920 100644 --- a/Soneso/StellarSDKTests/Unit/Soroban/P27CoverageClosureTest.php +++ b/Soneso/StellarSDKTests/Unit/Soroban/P27CoverageClosureTest.php @@ -811,18 +811,23 @@ public function testSignAuthEntriesSignsBothTopLevelAndDelegateWhenAddressRepeat // Delegate must also be signed. $this->assertNotNull($result->delegates[0]->signature->vec, 'Delegate signature must be written when address matches both top-level and delegate'); + + // The requested expiration must have been stamped before signing. + $this->assertSame(9999, $result->addressCredentials->signatureExpirationLedger, + 'signAuthEntries must stamp the requested expiration before signing'); } /** - * Callback-based signing in signAuthEntries passes the entry and network to the callback. - * - * Exercises the `$authorizeEntryCallback !== null` branch (line 600). + * Callback-based signing in signAuthEntries stamps the expiration on the + * entry BEFORE handing it to the callback, so the callback signs over the + * intended expiration ledger rather than the entry's original value. */ - public function testSignAuthEntriesCallbackBranchIsInvoked(): void + public function testSignAuthEntriesStampsExpirationBeforeCallback(): void { $signerKp = KeyPair::fromSeed(self::TEST_SECRET); $invokerKp = KeyPair::fromSeed(self::TEST_SECRET); + // Entry starts with expiration 100; signAuthEntries is called with 9999. $address = Address::fromAccountId($signerKp->getAccountId()); $addressCreds = new SorobanAddressCredentials($address, 1, 100, XdrSCVal::forVoid()); $validEntry = new SorobanAuthorizationEntry( @@ -834,10 +839,15 @@ public function testSignAuthEntriesCallbackBranchIsInvoked(): void $this->injectMockedServerResponses($tx, [$this->makeLatestLedgerResponse(1000)]); $callbackInvoked = false; + $expirationSeenByCallback = null; $tx->signAuthEntries( signerKeyPair: $signerKp, - authorizeEntryCallback: static function (SorobanAuthorizationEntry $e, Network $n) use (&$callbackInvoked, $signerKp): SorobanAuthorizationEntry { + // The callback signs WITHOUT setting an expiration itself, so the + // signature is made over whatever the SDK stamped beforehand. + authorizeEntryCallback: static function (SorobanAuthorizationEntry $e, Network $n) use (&$callbackInvoked, &$expirationSeenByCallback, $signerKp): SorobanAuthorizationEntry { $callbackInvoked = true; + $expirationSeenByCallback = + $e->credentials->getAddressCredentials()?->signatureExpirationLedger; $e->sign($signerKp, $n); return $e; }, @@ -845,6 +855,18 @@ public function testSignAuthEntriesCallbackBranchIsInvoked(): void ); $this->assertTrue($callbackInvoked, 'Authorize entry callback must be invoked for matching entries'); + // If the stamp were skipped on the callback path, the callback would see + // (and sign over) the original 100 and this would fail. + $this->assertEquals(9999, $expirationSeenByCallback, + 'signAuthEntries must stamp the expiration before invoking the callback'); + + $signedEntry = $tx->tx?->getOperations()[0]->auth[0]; + $this->assertEquals(9999, + $signedEntry->credentials->getAddressCredentials()->signatureExpirationLedger, + 'the signed entry must carry the stamped expiration'); + $this->assertNotNull( + $signedEntry->credentials->getAddressCredentials()->signature->vec, + 'the callback-signed entry must carry a signature'); } // ========================================================================= From 330c17958320f609a8c3b4326ef7f41c160189b3 Mon Sep 17 00:00:00 2001 From: Christian Rogobete Date: Thu, 18 Jun 2026 18:01:22 +0200 Subject: [PATCH 3/6] fix(p27): remove the unconfirmed authV2 simulation flag --- .../Soroban/Contract/AssembledTransaction.php | 3 - .../Soroban/Contract/MethodOptions.php | 6 - .../Requests/SimulateTransactionRequest.php | 43 ---- .../Integration/P27AddressV2RoundTripTest.php | 52 ++-- .../Soroban/P27AssembledTransactionTest.php | 236 +----------------- .../Unit/Soroban/P27CoverageClosureTest.php | 22 +- docs/soroban.md | 32 --- skills/stellar-php-sdk.zip | Bin 222268 -> 219046 bytes skills/stellar-php-sdk/SKILL.md | 2 +- .../references/api_reference.md | 10 +- skills/stellar-php-sdk/references/rpc.md | 2 - .../references/soroban_contracts.md | 2 - 12 files changed, 51 insertions(+), 359 deletions(-) diff --git a/Soneso/StellarSDK/Soroban/Contract/AssembledTransaction.php b/Soneso/StellarSDK/Soroban/Contract/AssembledTransaction.php index b5c4eff9..10b91795 100644 --- a/Soneso/StellarSDK/Soroban/Contract/AssembledTransaction.php +++ b/Soneso/StellarSDK/Soroban/Contract/AssembledTransaction.php @@ -359,11 +359,8 @@ public function simulate(?bool $restore = null) : void { $shouldRestore = $restore ?? $this->options->methodOptions->restore; $this->simulationResult = null; - // Thread authV2 from MethodOptions into the request; "authV2" appears in RPC params - // only when true. RPCs without Protocol 27 support silently ignore the flag. $this->simulationResponse = $this->server->simulateTransaction(new SimulateTransactionRequest( transaction: $this->tx, - authV2: $this->options->methodOptions->authV2, )); if ($shouldRestore && $this->simulationResponse->restorePreamble !== null) { if ($this->options->clientOptions->sourceAccountKeyPair->getPrivateKey() === null) { diff --git a/Soneso/StellarSDK/Soroban/Contract/MethodOptions.php b/Soneso/StellarSDK/Soroban/Contract/MethodOptions.php index 043fb534..6fadf261 100644 --- a/Soneso/StellarSDK/Soroban/Contract/MethodOptions.php +++ b/Soneso/StellarSDK/Soroban/Contract/MethodOptions.php @@ -36,18 +36,12 @@ class MethodOptions * builder before simulation. Default true. * @param bool $restore If true, will automatically attempt to restore archived ledger entries * that need renewal. Requires source account with private key. Default true. - * @param bool $authV2 When true, the simulate call requests ADDRESS_V2 credential entries - * (Protocol 27, CAP-71). The flag is forwarded to SimulateTransactionRequest - * and passed as "authV2": true in the RPC params only when enabled. RPCs - * without Protocol 27 support silently ignore it and return legacy ADDRESS - * entries. Do not enable on pre-27 networks. Default false. */ public function __construct( public int $fee = StellarConstants::MIN_BASE_FEE_STROOPS, public int $timeoutInSeconds = NetworkConstants::DEFAULT_SOROBAN_TIMEOUT_SECONDS, public bool $simulate = true, public bool $restore = true, - public bool $authV2 = false, ) { } diff --git a/Soneso/StellarSDK/Soroban/Requests/SimulateTransactionRequest.php b/Soneso/StellarSDK/Soroban/Requests/SimulateTransactionRequest.php index 11d5e88d..4ee3e65b 100644 --- a/Soneso/StellarSDK/Soroban/Requests/SimulateTransactionRequest.php +++ b/Soneso/StellarSDK/Soroban/Requests/SimulateTransactionRequest.php @@ -11,16 +11,6 @@ /** * Soroban Simulate Transaction Request. * - * The authV2 flag requests that the RPC node return ADDRESS_V2 credential entries - * (Protocol 27, CAP-71) instead of legacy ADDRESS entries during recording-mode simulation. - * The flag is effective only when authMode is "record" or "record_allow_nonroot"; it is - * ignored under "enforce". RPCs without Protocol 27 support silently ignore the flag and - * return legacy ADDRESS entries — support is detected by inspecting the credential arm of - * returned entries, not by any error signal. - * - * The key "authV2" is omitted from the request when the flag is false (the default), so - * existing call sites require no changes and pre-27 RPCs never see the key. - * * @see https://developers.stellar.org/network/soroban-rpc/api-reference/methods/simulateTransaction * @package Soneso\StellarSDK\Soroban\Requests */ @@ -36,24 +26,17 @@ class SimulateTransactionRequest * transactions. * @param string|null $authMode Support for non-root authorization. Only available for protocol >= 23. * Possible values: "enforce" | "record" | "record_allow_nonroot" - * @param bool $authV2 When true, requests ADDRESS_V2 credential entries (Protocol 27, CAP-71). - * The key is omitted when false; RPCs without support silently ignore it and return legacy entries. - * Invalid on pre-27 networks: emitting ADDRESS_V2 entries on a pre-27 network invalidates the transaction. */ public function __construct( public Transaction $transaction, public ?ResourceConfig $resourceConfig = null, public ?string $authMode = null, - public bool $authV2 = false, ) { } /** * Builds and returns the request parameters array for the RPC API call. * - * The "authV2" key is included only when $authV2 is true. Omitting the key (the default) - * preserves compatibility with pre-27 RPCs that do not recognize it. - * * @return array The request parameters formatted for Soroban RPC */ public function getRequestParams() : array { @@ -67,9 +50,6 @@ public function getRequestParams() : array { if ($this->authMode !== null) { $params['authMode'] = $this->authMode; } - if ($this->authV2) { - $params['authV2'] = true; - } return $params; } @@ -134,27 +114,4 @@ public function setAuthMode(?string $authMode): void $this->authMode = $authMode; } - /** - * Returns whether ADDRESS_V2 credential entries are requested during simulation. - * - * @return bool true when the authV2 flag is set - */ - public function getAuthV2(): bool - { - return $this->authV2; - } - - /** - * Sets the authV2 flag. - * - * When true, "authV2": true is included in the request params. RPCs without Protocol 27 support - * silently ignore the flag. Do not enable on pre-27 networks. - * - * @param bool $authV2 whether to request ADDRESS_V2 credential entries - */ - public function setAuthV2(bool $authV2): void - { - $this->authV2 = $authV2; - } - } \ No newline at end of file diff --git a/Soneso/StellarSDKTests/Integration/P27AddressV2RoundTripTest.php b/Soneso/StellarSDKTests/Integration/P27AddressV2RoundTripTest.php index 184d0356..bb9eaf6b 100644 --- a/Soneso/StellarSDKTests/Integration/P27AddressV2RoundTripTest.php +++ b/Soneso/StellarSDKTests/Integration/P27AddressV2RoundTripTest.php @@ -28,13 +28,10 @@ /** * Protocol 27 (CAP-71) ADDRESS_V2 round-trip integration test. * - * Tests the full simulate -> inspect-credential-arm -> sign -> submit flow when the - * RPC returns ADDRESS_V2 credentials (after Protocol 27 upgrade). Until the testnet - * upgrades to Protocol 27 (2026-06-18) and an RPC server supporting the authV2 flag - * is released (stellar-rpc #783), the RPC returns legacy ADDRESS credentials even when - * authV2=true is sent. This test tolerates that: it inspects the credential arm of the - * returned entries and adapts its assertions accordingly so that it exercises the - * correct P27 code paths once available without blocking CI before then. + * Tests the full simulate -> assemble ADDRESS_V2 -> sign -> submit flow. Simulation + * returns legacy ADDRESS credentials; the ADDRESS_V2 credential arm is assembled + * client-side so the round-trip exercises the address-bound V2 signing and submission + * path. Submission succeeds only once the network runs Protocol 27. * * The test requires network access to the testnet RPC and is gated by $testOn. * It will only run when explicitly invoked via the integration suite. @@ -65,12 +62,12 @@ public function setUp(): void } /** - * ADDRESS_V2 round-trip: simulate with authV2=true, inspect the returned credential arm, - * sign using the correct preimage for the detected arm, and submit. + * ADDRESS_V2 round-trip: simulate, assemble the ADDRESS_V2 arm client-side, sign using + * the address-bound preimage, and submit. * - * Detection: the credential arm of the returned entry reveals whether the RPC honored the - * authV2 flag. We assert the correct arm-specific preimage was used for signing without - * hard-requiring V2 — no released RPC supports authV2 yet (stellar-rpc #783). + * Simulation returns a legacy ADDRESS entry for the invoker (the invoker differs from the + * transaction source). That entry is converted to the ADDRESS_V2 arm before signing, and a + * hard assertion guarantees a V2 entry is present so the V2 path is genuinely exercised. * * @throws Exception * @throws GuzzleException @@ -81,8 +78,7 @@ public function testAddressV2SimulateSignRoundTrip(): void $this->markTestSkipped( 'P27 ADDRESS_V2 integration test requires testnet access. ' . 'Set $testOn = "testnet" to enable. ' - . 'Note: authV2 RPC support is gated on stellar-rpc #783 (unreleased). ' - . 'This test tolerates legacy ADDRESS responses until V2 RPC support is released.' + . 'Submission succeeds only once the network runs Protocol 27.' ); } @@ -110,9 +106,7 @@ public function testAddressV2SimulateSignRoundTrip(): void $this->assertNotNull($submitterAccount); $transaction = (new TransactionBuilder($submitterAccount))->addOperation($op)->build(); - // Simulate with authV2=true to request V2 credential entries. - // RPCs without authV2 support silently ignore the flag and return legacy ADDRESS entries. - $request = new SimulateTransactionRequest(transaction: $transaction, authV2: true); + $request = new SimulateTransactionRequest(transaction: $transaction); $simulateResponse = $this->server->simulateTransaction($request); $this->assertNull($simulateResponse->error); @@ -127,12 +121,30 @@ public function testAddressV2SimulateSignRoundTrip(): void $auth = $simulateResponse->getSorobanAuth(); $this->assertNotNull($auth); + // Simulation returns a legacy ADDRESS entry for the invoker. Assemble the ADDRESS_V2 + // arm client-side so the round-trip exercises the address-bound V2 signing path. + foreach ($auth as $entry) { + if ($entry->credentials->credentialType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS) { + $inner = $entry->credentials->getAddressCredentials(); + if ($inner !== null && $inner->address->accountId === $invokerId) { + $entry->credentials = SorobanCredentials::forAddressCredentialsV2($inner); + } + } + } + + $hasV2 = false; + foreach ($auth as $entry) { + if ($entry->credentials->credentialType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2) { + $hasV2 = true; + } + } + $this->assertTrue($hasV2, 'Expected an ADDRESS_V2 auth entry after the client-side rewrite'); + $latestLedgerResponse = $this->server->getLatestLedger(); $this->assertNotNull($latestLedgerResponse->sequence); - // Inspect the credential arm and sign correctly for each arm. - // We do NOT assert that the RPC returned V2 — it may still return legacy ADDRESS - // until stellar-rpc #783 is released and deployed. + // Sign each entry; sign() selects the correct preimage based on the credential arm, + // so the V2 entry uses the address-bound preimage. foreach ($auth as $entry) { $this->assertInstanceOf(SorobanAuthorizationEntry::class, $entry); diff --git a/Soneso/StellarSDKTests/Unit/Soroban/P27AssembledTransactionTest.php b/Soneso/StellarSDKTests/Unit/Soroban/P27AssembledTransactionTest.php index 4167effa..4cecaff1 100644 --- a/Soneso/StellarSDKTests/Unit/Soroban/P27AssembledTransactionTest.php +++ b/Soneso/StellarSDKTests/Unit/Soroban/P27AssembledTransactionTest.php @@ -6,7 +6,6 @@ namespace Soneso\StellarSDKTests\Unit\Soroban; -use DateTime; use Exception; use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; @@ -15,7 +14,6 @@ use phpseclib3\Math\BigInteger; use PHPUnit\Framework\TestCase; use Soneso\StellarSDK\Account; -use Soneso\StellarSDK\Constants\NetworkConstants; use Soneso\StellarSDK\Crypto\KeyPair; use Soneso\StellarSDK\Crypto\StrKey; use Soneso\StellarSDK\InvokeContractHostFunction; @@ -36,18 +34,13 @@ use Soneso\StellarSDK\Soroban\SorobanAuthorizedFunction; use Soneso\StellarSDK\Soroban\SorobanAuthorizedInvocation; use Soneso\StellarSDK\Soroban\SorobanCredentials; -use Soneso\StellarSDK\Soroban\SorobanDelegateDescriptor; use Soneso\StellarSDK\Soroban\SorobanDelegateSignature; use Soneso\StellarSDK\Soroban\SorobanServer; -use Soneso\StellarSDK\TimeBounds; use Soneso\StellarSDK\TransactionBuilder; -use Soneso\StellarSDK\Xdr\XdrExtensionPoint; -use Soneso\StellarSDK\Xdr\XdrLedgerEntryType; use Soneso\StellarSDK\Xdr\XdrLedgerFootprint; use Soneso\StellarSDK\Xdr\XdrLedgerKey; use Soneso\StellarSDK\Xdr\XdrSCAddress; use Soneso\StellarSDK\Xdr\XdrSCVal; -use Soneso\StellarSDK\Xdr\XdrSCValType; use Soneso\StellarSDK\Xdr\XdrSorobanResources; use Soneso\StellarSDK\Xdr\XdrSorobanTransactionData; use Soneso\StellarSDK\Xdr\XdrSorobanTransactionDataExt; @@ -56,7 +49,7 @@ /** * Protocol 27 (CAP-71) simulation and AssembledTransaction tests. * - * Covers SimulateTransactionRequest.authV2 wire flag, MethodOptions.authV2 thread-through, + * Covers SimulateTransactionRequest / MethodOptions serialization, * signAuthEntries and needsNonInvokerSigningBy across all three address arms, the * delegates-only send-precheck reconciliation, and arm preservation. */ @@ -84,54 +77,13 @@ public function setUp(): void } // ========================================================================= - // TASK 1 — SimulateTransactionRequest.authV2 wire flag + // SimulateTransactionRequest param serialization // ========================================================================= /** - * The "authV2" key must be ABSENT from request params when $authV2 is false (default). + * Request params serialize transaction, resourceConfig, and authMode. */ - public function testAuthV2KeyAbsentByDefault(): void - { - $tx = $this->buildMockTx(); - $request = new SimulateTransactionRequest(transaction: $tx); - - $params = $request->getRequestParams(); - - $this->assertArrayNotHasKey('authV2', $params, '"authV2" key must not appear when flag is false (default)'); - $this->assertArrayHasKey('transaction', $params); - } - - /** - * The "authV2" key must be ABSENT when explicitly set to false. - */ - public function testAuthV2KeyAbsentWhenExplicitFalse(): void - { - $tx = $this->buildMockTx(); - $request = new SimulateTransactionRequest(transaction: $tx, authV2: false); - - $params = $request->getRequestParams(); - - $this->assertArrayNotHasKey('authV2', $params, '"authV2" key must not appear when explicitly false'); - } - - /** - * The "authV2" key must be present and equal to boolean true when opted in. - */ - public function testAuthV2KeyPresentAsBooleanTrueWhenOptedIn(): void - { - $tx = $this->buildMockTx(); - $request = new SimulateTransactionRequest(transaction: $tx, authV2: true); - - $params = $request->getRequestParams(); - - $this->assertArrayHasKey('authV2', $params, '"authV2" key must appear when flag is true'); - $this->assertSame(true, $params['authV2'], '"authV2" must be boolean true (not a string or int)'); - } - - /** - * Existing params (transaction, resourceConfig, authMode) must be unaffected by authV2. - */ - public function testExistingParamsUnaffectedByAuthV2(): void + public function testRequestParamsSerializeExistingFields(): void { $tx = $this->buildMockTx(); $resourceConfig = new \Soneso\StellarSDK\Soroban\Requests\ResourceConfig(5000000); @@ -139,7 +91,6 @@ public function testExistingParamsUnaffectedByAuthV2(): void transaction: $tx, resourceConfig: $resourceConfig, authMode: 'record', - authV2: true, ); $params = $request->getRequestParams(); @@ -147,84 +98,22 @@ public function testExistingParamsUnaffectedByAuthV2(): void $this->assertArrayHasKey('transaction', $params); $this->assertArrayHasKey('resourceConfig', $params); $this->assertEquals('record', $params['authMode']); - $this->assertSame(true, $params['authV2']); - } - - /** - * Setter/getter round-trip for authV2. - */ - public function testAuthV2SetterGetterRoundTrip(): void - { - $tx = $this->buildMockTx(); - $request = new SimulateTransactionRequest(transaction: $tx); - - $this->assertFalse($request->getAuthV2()); - - $request->setAuthV2(true); - $this->assertTrue($request->getAuthV2()); - $this->assertArrayHasKey('authV2', $request->getRequestParams()); - - $request->setAuthV2(false); - $this->assertFalse($request->getAuthV2()); - $this->assertArrayNotHasKey('authV2', $request->getRequestParams()); } // ========================================================================= - // TASK 2 — MethodOptions.authV2 threads into the simulate() request + // MethodOptions fields // ========================================================================= /** - * When MethodOptions.authV2 = true, the simulate() call must send "authV2": true in the - * RPC request body. Verified by intercepting the mock HTTP request body. - */ - public function testMethodOptionsAuthV2TrueThreadsIntoSimulateRequest(): void - { - $capturedBodies = []; - $tx = $this->buildAssembledTransactionWithMock( - methodOptions: new MethodOptions(simulate: false, restore: false, authV2: true), - mockResponses: [$this->createSimulateResponse()], - capturedBodies: $capturedBodies, - ); - - $tx->simulate(); - - $this->assertCount(1, $capturedBodies, 'Expected exactly one RPC request'); - $body = json_decode($capturedBodies[0], true); - $this->assertIsArray($body); - // The SorobanServer prepareRequest() places getRequestParams() directly under 'params'. - $params = $body['params'] ?? []; - $this->assertArrayHasKey('authV2', $params, '"authV2" must appear in RPC params when MethodOptions.authV2 = true'); - $this->assertSame(true, $params['authV2']); - } - - /** - * Mirror: default MethodOptions must NOT include "authV2" in the RPC request body. + * MethodOptions stores the values passed to the constructor. */ - public function testMethodOptionsDefaultOmitsAuthV2FromSimulateRequest(): void + public function testMethodOptionsFieldsAreSet(): void { - $capturedBodies = []; - $tx = $this->buildAssembledTransactionWithMock( - methodOptions: new MethodOptions(simulate: false, restore: false), - mockResponses: [$this->createSimulateResponse()], - capturedBodies: $capturedBodies, - ); - - $tx->simulate(); - - $this->assertCount(1, $capturedBodies, 'Expected exactly one RPC request'); - $body = json_decode($capturedBodies[0], true); - $this->assertIsArray($body); - $params = $body['params'] ?? []; - $this->assertArrayNotHasKey('authV2', $params, '"authV2" must NOT appear in RPC params by default'); - } - - /** - * MethodOptions default values include authV2 = false. - */ - public function testMethodOptionsAuthV2DefaultIsFalse(): void - { - $options = new MethodOptions(); - $this->assertFalse($options->authV2); + $options = new MethodOptions(fee: 500, timeoutInSeconds: 120, simulate: false, restore: false); + $this->assertSame(500, $options->fee); + $this->assertSame(120, $options->timeoutInSeconds); + $this->assertFalse($options->simulate); + $this->assertFalse($options->restore); } // ========================================================================= @@ -621,80 +510,6 @@ private function buildAssembledTransactionWithAuthEntries( return $tx; } - /** - * Builds an AssembledTransaction with a mocked HTTP server and request-body capture. - * - * @param MethodOptions $methodOptions - * @param array $mockResponses - * @param array $capturedBodies output: bodies of HTTP POST requests - */ - private function buildAssembledTransactionWithMock( - MethodOptions $methodOptions, - array $mockResponses, - array &$capturedBodies, - ): AssembledTransaction { - $capturedBodiesRef = &$capturedBodies; - $mock = new MockHandler($mockResponses); - $stack = HandlerStack::create($mock); - // Middleware to capture request bodies. - $stack->push(static function (callable $handler) use (&$capturedBodiesRef): callable { - return static function ($request, $options) use ($handler, &$capturedBodiesRef) { - $capturedBodiesRef[] = (string) $request->getBody(); - return $handler($request, $options); - }; - }); - $client = new Client(['handler' => $stack]); - - $invokerKp = KeyPair::fromSeed(self::TEST_SECRET_KEY); - $clientOptions = new ClientOptions( - sourceAccountKeyPair: $invokerKp, - contractId: self::TEST_CONTRACT_ID, - network: $this->network, - rpcUrl: self::TEST_RPC_URL, - ); - $txOptions = new AssembledTransactionOptions( - clientOptions: $clientOptions, - methodOptions: $methodOptions, - method: 'test', - arguments: [], - ); - - $reflection = new \ReflectionClass(AssembledTransaction::class); - $tx = $reflection->newInstanceWithoutConstructor(); - - $optionsProp = $reflection->getProperty('options'); - $optionsProp->setAccessible(true); - $optionsProp->setValue($tx, $txOptions); - - $server = new SorobanServer($txOptions->clientOptions->rpcUrl); - $serverReflection = new \ReflectionClass($server); - $httpClientProp = $serverReflection->getProperty('httpClient'); - $httpClientProp->setAccessible(true); - $httpClientProp->setValue($server, $client); - - $serverProp = $reflection->getProperty('server'); - $serverProp->setAccessible(true); - $serverProp->setValue($tx, $server); - - // Build the raw transaction builder (no network). - $account = new Account($invokerKp->getAccountId(), new BigInteger(123456789)); - $hostFn = new InvokeContractHostFunction(self::TEST_CONTRACT_ID, 'test', []); - $op = (new InvokeHostFunctionOperationBuilder($hostFn))->build(); - $txBuilder = new TransactionBuilder(sourceAccount: $account); - $txBuilder->setTimeBounds(new TimeBounds( - (new DateTime())->modify('- ' . NetworkConstants::DEFAULT_TIME_BOUNDS_OFFSET_SECONDS . ' seconds'), - (new DateTime())->modify('+ ' . $methodOptions->timeoutInSeconds . ' seconds') - )); - $txBuilder->addOperation($op); - $txBuilder->setMaxOperationFee($methodOptions->fee); - - $rawProp = $reflection->getProperty('raw'); - $rawProp->setAccessible(true); - $rawProp->setValue($tx, $txBuilder); - - return $tx; - } - /** * Injects mock responses into the SorobanServer inside an AssembledTransaction. * @@ -717,33 +532,6 @@ private function injectMockedServerResponses(AssembledTransaction $tx, array $re $httpClientProp->setValue($server, $client); } - /** - * Creates a mock simulateTransaction response (success, no auth). - */ - private function createSimulateResponse(): Response - { - $footprint = new XdrLedgerFootprint([], []); - $resources = new XdrSorobanResources($footprint, 0, 0, 0); - $ext = new XdrSorobanTransactionDataExt(0); - $txData = new XdrSorobanTransactionData($ext, $resources, 0); - - return new Response(200, [], json_encode([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => [ - 'minResourceFee' => '100', - 'latestLedger' => 1000, - 'transactionData' => $txData->toBase64Xdr(), - 'results' => [ - [ - 'auth' => [], - 'xdr' => XdrSCVal::forVoid()->toBase64Xdr(), - ], - ], - ], - ])); - } - /** * Creates a mock getLatestLedger response. */ diff --git a/Soneso/StellarSDKTests/Unit/Soroban/P27CoverageClosureTest.php b/Soneso/StellarSDKTests/Unit/Soroban/P27CoverageClosureTest.php index 117c0920..d909ea87 100644 --- a/Soneso/StellarSDKTests/Unit/Soroban/P27CoverageClosureTest.php +++ b/Soneso/StellarSDKTests/Unit/Soroban/P27CoverageClosureTest.php @@ -53,10 +53,10 @@ /** * Protocol 27 (CAP-71) coverage closure tests. * - * Targets executable lines missed by Batches B, C, and D unit tests: + * Targets executable lines missed by the other P27 unit tests: * error-guard paths in SorobanCredentials / SorobanAuthorizationEntry, getters/setters - * on the new wrapper types, depth-limit signing, deep delegate-tree XDR round-trip, - * getBlockingNonInvokerSigners, and the authV2 MethodOptions constructor. + * on the new wrapper types, depth-limit signing, deep delegate-tree XDR round-trip, and + * getBlockingNonInvokerSigners. * * No test here duplicates an assertion already present in P27AuthorizationTest, * P27AssembledTransactionTest, or P27WebAuthForContractsTest. @@ -588,22 +588,6 @@ public function testXdrBufferLeaveRecursionNoOpAtZero(): void $this->assertSame(0, $buf->getRecursionDepth()); } - // ========================================================================= - // MethodOptions — authV2 constructor (covers lines 39-50) - // ========================================================================= - - /** - * MethodOptions authV2 property defaults to false and can be set via constructor. - */ - public function testMethodOptionsAuthV2PropertyDefault(): void - { - $default = new MethodOptions(); - $this->assertFalse($default->authV2); - - $enabled = new MethodOptions(authV2: true); - $this->assertTrue($enabled->authV2); - } - // ========================================================================= // AssembledTransaction — getBlockingNonInvokerSigners and helpers // ========================================================================= diff --git a/docs/soroban.md b/docs/soroban.md index b666a604..e623a4f8 100644 --- a/docs/soroban.md +++ b/docs/soroban.md @@ -510,38 +510,6 @@ The legacy `ADDRESS` arm remains the default everywhere and stays fully valid. T 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->getAddressCredentials()` to read the inner `SorobanAddressCredentials` of any address arm (it returns `null` only for source-account credentials), and `$credentials->getCredentialType()` / `$credentials->isSourceAccount()` to inspect the arm. Factories `SorobanCredentials::forAddressCredentialsV2()` and `SorobanCredentials::forAddressWithDelegates()` build the new arms directly. -#### Requesting V2 Entries from Simulation - -Set `authV2` to request `ADDRESS_V2` credential arms in the simulation response. RPC servers without support silently ignore the flag and return legacy `ADDRESS` entries — detect support by inspecting the credential arm of the returned entries, never by expecting an error. When `authV2` is `false` (the default), the key is omitted from the JSON-RPC params entirely. - -```php -buildInvokeMethodTx( - name: 'swap', - args: $args, - methodOptions: new MethodOptions(authV2: true), -); - -// Detect whether the RPC honored the flag -$entries = $tx->getSimulationData()->auth ?? []; -$gotV2 = false; -foreach ($entries as $entry) { - if ($entry->credentials->getCredentialType() - === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2) { - $gotV2 = true; - } -} - -// Low-level: opt in on the simulate request -$request = new SimulateTransactionRequest($transaction, authV2: true); -$response = $server->simulateTransaction($request); -``` - #### 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`. diff --git a/skills/stellar-php-sdk.zip b/skills/stellar-php-sdk.zip index 2d37e63bf069fcf430d6fc6cbba0324433150e19..f8c65f93176455c05c0044e26ae231f88e50a1f6 100644 GIT binary patch delta 42430 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

    XcL0Qn5);E9E<$d+$KlA6M4aUFpoRk*vjL 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
    pQ1_kxje?6gAPOLLUDMyYSm_J#JtI9~ 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 diff --git a/skills/stellar-php-sdk/SKILL.md b/skills/stellar-php-sdk/SKILL.md index e063f587..67f2fb90 100644 --- a/skills/stellar-php-sdk/SKILL.md +++ b/skills/stellar-php-sdk/SKILL.md @@ -351,7 +351,7 @@ $result = $client->invokeMethod('expensive_operation', [XdrSCVal::forSymbol('dat methodOptions: new MethodOptions(fee: 10000, timeoutInSeconds: 60)); ``` -Protocol 27 (CAP-71) adds opt-in `ADDRESS_V2` and `ADDRESS_WITH_DELEGATES` credential arms (legacy `ADDRESS` stays default); request V2 from simulation via `MethodOptions(authV2: true)`, build delegate trees with `SorobanAuthorizationEntry::withDelegates(...)`. +Protocol 27 (CAP-71) adds opt-in `ADDRESS_V2` and `ADDRESS_WITH_DELEGATES` credential arms (legacy `ADDRESS` stays default); build delegate trees with `SorobanAuthorizationEntry::withDelegates(...)`. For contract authorization, multi-auth workflows, delegated auth, and remote signing: [Smart Contracts Guide](./references/soroban_contracts.md) diff --git a/skills/stellar-php-sdk/references/api_reference.md b/skills/stellar-php-sdk/references/api_reference.md index a6407d7c..24c360b1 100644 --- a/skills/stellar-php-sdk/references/api_reference.md +++ b/skills/stellar-php-sdk/references/api_reference.md @@ -3,7 +3,7 @@ Compact method signature reference for `soneso/stellar-php-sdk`. Generated by `generate_api_reference.php`. Do not edit manually. -**Stats:** 577 classes, 2962 methods +**Stats:** 577 classes, 2960 methods --- ## Core Classes @@ -1198,8 +1198,7 @@ execute(): Response Transaction $transaction ?ResourceConfig $resourceConfig ?string $authMode -bool $authV2 -__construct(Transaction $transaction, ?ResourceConfig $resourceConfig = null, ?string $authMode = null, bool $authV2 = false) +__construct(Transaction $transaction, ?ResourceConfig $resourceConfig = null, ?string $authMode = null) getRequestParams(): array getTransaction(): Transaction setTransaction(Transaction $transaction): void @@ -1207,8 +1206,6 @@ getResourceConfig(): ?ResourceConfig setResourceConfig(?ResourceConfig $resourceConfig): void getAuthMode(): ?string setAuthMode(?string $authMode): void -getAuthV2(): bool -setAuthV2(bool $authV2): void ## StrictReceivePathsRequestBuilder extends RequestBuilder __construct(Client $httpClient) @@ -3310,8 +3307,7 @@ int $fee int $timeoutInSeconds bool $simulate bool $restore -bool $authV2 -__construct(int $fee = 100, int $timeoutInSeconds = 300, bool $simulate = true, bool $restore = true, bool $authV2 = false) +__construct(int $fee = 100, int $timeoutInSeconds = 300, bool $simulate = true, bool $restore = true) ## NativeUnionVal string $tag diff --git a/skills/stellar-php-sdk/references/rpc.md b/skills/stellar-php-sdk/references/rpc.md index eb2cf63c..d85a20c2 100644 --- a/skills/stellar-php-sdk/references/rpc.md +++ b/skills/stellar-php-sdk/references/rpc.md @@ -217,8 +217,6 @@ if ($simResponse->error === null) { } ``` -Protocol 27 (CAP-71): pass `authV2: true` to request `ADDRESS_V2` credential entries (`new SimulateTransactionRequest($tx, authV2: true)`). The `authV2` key is omitted from the JSON-RPC params when false (the default). RPCs without protocol 27 support silently ignore it and return legacy `ADDRESS` entries — detect support by inspecting the returned credential arm, not by expecting an error. - ### sendTransaction Submit a signed transaction to the network. This method returns immediately after validation -- it does not wait for ledger inclusion. Poll with `getTransaction()` to check the result. diff --git a/skills/stellar-php-sdk/references/soroban_contracts.md b/skills/stellar-php-sdk/references/soroban_contracts.md index 2cd6beca..20955096 100644 --- a/skills/stellar-php-sdk/references/soroban_contracts.md +++ b/skills/stellar-php-sdk/references/soroban_contracts.md @@ -390,8 +390,6 @@ echo $response->getStatus(); // e.g. "SUCCESS" `SorobanCredentials` has four arms: source-account, legacy `ADDRESS` (default, valid on all protocols), and the opt-in `ADDRESS_V2` and `ADDRESS_WITH_DELEGATES` (protocol 27+; invalid below 27). All signing APIs handle every arm; `getAddressCredentials()` returns the inner `SorobanAddressCredentials` for any address arm (null only for source-account); `getCredentialType()` / `isSourceAccount()` inspect the arm. -Request V2 entries from simulation with the `authV2` flag (`MethodOptions(authV2: true)` or `new SimulateTransactionRequest($tx, authV2: true)`). RPCs without protocol 27 support silently ignore it and return legacy `ADDRESS` entries — detect support by checking the returned credential arm, not by expecting an error. - `ADDRESS_WITH_DELEGATES` lets delegate addresses co-sign one entry. Simulation never returns this arm; build it from an `ADDRESS`/`ADDRESS_V2` entry via `SorobanAuthorizationEntry::withDelegates($source, $expirationLedger, $delegates)`, passing `SorobanDelegateDescriptor` objects. The builder sorts the delegate array and rejects duplicates. All nodes (top-level + delegates at any depth) sign the same payload bound to the top-level address; delegates carry no nonce/expiration. `sign($kp, $network, forAddress: $strkey)` routes a signature to matching nodes depth-first; `null` signs top-level. A void top-level with all delegates signed is valid (delegates-only). After attaching a delegated entry, re-simulate and apply the returned `transactionData` / `minResourceFee` before submitting, since the first simulation excluded the delegate authorization. For multiple classical signatures on one node, call `sign()` in ascending public-key order (the SDK appends in call order and does not sort). ```php From 082ad98ee665dadf061081b9e04a7abde26f4d31 Mon Sep 17 00:00:00 2001 From: Christian Rogobete Date: Thu, 18 Jun 2026 23:35:21 +0200 Subject: [PATCH 4/6] test(p27): add an ADDRESS_WITH_DELEGATES end-to-end integration test --- .../P27WithDelegatesRoundTripTest.php | 241 ++++++++++++++++++ .../soroban_modular_account_contract.wasm | Bin 0 -> 1423 bytes 2 files changed, 241 insertions(+) create mode 100644 Soneso/StellarSDKTests/Integration/P27WithDelegatesRoundTripTest.php create mode 100644 Soneso/StellarSDKTests/wasm/soroban_modular_account_contract.wasm diff --git a/Soneso/StellarSDKTests/Integration/P27WithDelegatesRoundTripTest.php b/Soneso/StellarSDKTests/Integration/P27WithDelegatesRoundTripTest.php new file mode 100644 index 00000000..2d9eda45 --- /dev/null +++ b/Soneso/StellarSDKTests/Integration/P27WithDelegatesRoundTripTest.php @@ -0,0 +1,241 @@ +testOn === 'testnet') { + $this->network = Network::testnet(); + $this->server = new SorobanServer(self::TESTNET_SERVER_URL); + $this->server->setLogger(new PrintLogger()); + } + } + + /** + * ADDRESS_WITH_DELEGATES round-trip: deploy a modular custom account that authorizes through a + * delegate, attach and sign the delegate tree, re-simulate in enforcing mode, and submit. + * + * @throws Exception + * @throws GuzzleException + */ + public function testWithDelegatesSimulateSignRoundTrip(): void + { + if ($this->testOn !== 'testnet') { + $this->markTestSkipped( + 'P27 ADDRESS_WITH_DELEGATES integration test requires testnet access. ' + . 'Set $testOn = "testnet" to enable. ' + . 'Submission succeeds only once the network runs Protocol 27.' + ); + } + + // Fund the submitter (transaction source) and a distinct delegate (a G-account that + // authorizes on behalf of the modular account). + $submitterKeyPair = KeyPair::random(); + $delegateKeyPair = KeyPair::random(); + $submitterId = $submitterKeyPair->getAccountId(); + $delegateId = $delegateKeyPair->getAccountId(); + + FriendBot::fundTestAccount($submitterId); + FriendBot::fundTestAccount($delegateId); + sleep(5); + + // Deploy the modular custom account (registering the delegate as an allowed signer) and the + // auth (increment) business contract, both via the high-level client. + $modularWasmHash = SorobanClient::install(new InstallRequest( + wasmBytes: file_get_contents(self::MODULAR_ACCOUNT_PATH), + rpcUrl: self::TESTNET_SERVER_URL, + network: $this->network, + sourceAccountKeyPair: $submitterKeyPair, + )); + $signersArg = XdrSCVal::forVec([Address::fromAccountId($delegateId)->toXdrSCVal()]); + $modularClient = SorobanClient::deploy(new DeployRequest( + rpcUrl: self::TESTNET_SERVER_URL, + network: $this->network, + sourceAccountKeyPair: $submitterKeyPair, + wasmHash: $modularWasmHash, + constructorArgs: [$signersArg], + )); + $modularAccountId = $modularClient->getContractId(); + + $authWasmHash = SorobanClient::install(new InstallRequest( + wasmBytes: file_get_contents(self::AUTH_CONTRACT_PATH), + rpcUrl: self::TESTNET_SERVER_URL, + network: $this->network, + sourceAccountKeyPair: $submitterKeyPair, + )); + $authClient = SorobanClient::deploy(new DeployRequest( + rpcUrl: self::TESTNET_SERVER_URL, + network: $this->network, + sourceAccountKeyPair: $submitterKeyPair, + wasmHash: $authWasmHash, + )); + $authContractId = $authClient->getContractId(); + sleep(5); + + // 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. + $args = [Address::fromContractId($modularAccountId)->toXdrSCVal(), XdrSCVal::forU32(1)]; + $invokeHostFunction = new InvokeContractHostFunction($authContractId, 'increment', $args); + $op = (new InvokeHostFunctionOperationBuilder($invokeHostFunction))->build(); + + $submitterAccount = $this->server->getAccount($submitterId); + $this->assertNotNull($submitterAccount); + $transaction = (new TransactionBuilder($submitterAccount))->addOperation($op)->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. + $request = new SimulateTransactionRequest(transaction: $transaction); + $simulateResponse = $this->server->simulateTransaction($request); + + $this->assertNull($simulateResponse->error); + $this->assertNotNull($simulateResponse->results); + $auth = $simulateResponse->getSorobanAuth(); + $this->assertNotNull($auth); + $this->assertCount(1, $auth, 'increment should require exactly one authorization (the modular account)'); + + $latestLedgerResponse = $this->server->getLatestLedger(); + $this->assertNotNull($latestLedgerResponse->sequence); + $signatureExpirationLedger = $latestLedgerResponse->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. + $signedAuth = []; + foreach ($auth as $entry) { + $credType = $entry->credentials->credentialType; + if ($credType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS + || $credType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_V2) { + $withDelegates = SorobanAuthorizationEntry::withDelegates( + $entry, + $signatureExpirationLedger, + [new SorobanDelegateDescriptor($delegateId)], + ); + $withDelegates->sign( + $delegateKeyPair, + $this->network, + signatureExpirationLedger: $signatureExpirationLedger, + forAddress: $delegateId, + ); + $signedAuth[] = $withDelegates; + } else { + $signedAuth[] = $entry; + } + } + + // A WITH_DELEGATES entry must be present with a void top-level signature and a signed delegate node. + $withDelegatesEntry = null; + foreach ($signedAuth as $entry) { + if ($entry->credentials->credentialType === XdrSorobanCredentialsType::SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + $withDelegatesEntry = $entry; + } + } + $this->assertNotNull($withDelegatesEntry, 'Expected an ADDRESS_WITH_DELEGATES auth entry'); + $delegatesWrapper = $withDelegatesEntry->credentials->addressWithDelegates; + $this->assertNotNull($delegatesWrapper); + $this->assertNull( + $delegatesWrapper->addressCredentials->signature->vec, + 'The top-level signature must remain void (the modular account signs nothing itself)', + ); + $this->assertCount(1, $delegatesWrapper->delegates, 'Exactly one delegate node should be attached'); + $this->assertNotNull( + $delegatesWrapper->delegates[0]->signature->vec, + '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. + $transaction->setSorobanAuth($signedAuth); + $enforceRequest = new SimulateTransactionRequest(transaction: $transaction, authMode: 'enforce'); + $reSimulateResponse = $this->server->simulateTransaction($enforceRequest); + $this->assertNull($reSimulateResponse->error, 'Enforcing re-simulation should not error'); + $this->assertNotNull($reSimulateResponse->getTransactionData()); + $this->assertNotNull($reSimulateResponse->minResourceFee); + + // Apply the enforcing simulation's footprint and resource fee; the already-signed auth is kept. + $transaction->setSorobanTransactionData($reSimulateResponse->getTransactionData()); + $transaction->addResourceFee($reSimulateResponse->minResourceFee); + $transaction->sign($submitterKeyPair, $this->network); + + $sendResponse = $this->server->sendTransaction($transaction); + $this->assertNull($sendResponse->error); + + $statusResponse = $this->pollStatus($this->server, $sendResponse->hash); + $resVal = $statusResponse->getResultValue(); + $this->assertNotNull($resVal); + // 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. + $this->assertEquals(1, $resVal->u32); + } + + private function pollStatus(SorobanServer $server, string $transactionId): GetTransactionResponse + { + $statusResponse = $server->getTransaction($transactionId); + $count = 15; + while ($count-- > 0 && $statusResponse->status === GetTransactionResponse::STATUS_NOT_FOUND) { + sleep(3); + $statusResponse = $server->getTransaction($transactionId); + } + return $statusResponse; + } +} diff --git a/Soneso/StellarSDKTests/wasm/soroban_modular_account_contract.wasm b/Soneso/StellarSDKTests/wasm/soroban_modular_account_contract.wasm new file mode 100644 index 0000000000000000000000000000000000000000..89e3804e1a021f43491e3d73201cca1800d78c3c GIT binary patch literal 1423 zcma)6L2nyX5T2R0Yr9@=vQaN3mC&=BLvyHQyR98xP>I`N$tc=cQ+|T z!j2Tn;@BHMfm8ki9FUMAB*ZTOaiRzQLYaAXBDDv^v;1b>%)FWTzVS0e*)akD)(U$# z#pxbRWmi)pK-AfE3VSr00d;O+ABTW=h6iSE^4`ZCz8+y06x$!NeuNFSM;IAB1X2at z0-L~A2pff}HADaW{6}zR;4j{jujO3)v`{!sjwk6Wf=U?1he?fI+bzT??aD`EKwr6_@dqDAYXpQ2 zI2tr{R4Kr!5?yr^IDa8=pVNEbd!rg|f&WdFB!Sx1^u$G0Xa4V~$sia$0H`UyG4kKy z3%~r-g-!noweo+0rD;=4eExHF{@`;L{h!gPT3Tsqcp1FK3vCC5#k^{BP8?RLf{vp( zhw%laMSyeeE0~{~RWiyS;6c^qNDj0mW^G=xJ7mcj7?GIylE%wGF9K_BE@=-n`rdF0 zoX-duBSXgfRH~7P%2Uf2w)q;Zu6a~rP9&SO)~1qH(s1xA^e*jy9N}w0h%;uZF{-I8 zpj{0nyI+{cSV^;sL z&_)F)-^66bLMyu~?_1t>o_w7zUFr=$@i+DtKVF(8l>1PBvy^++b;bVPAA^`~dM--M zB$cd^6K;zWKHRt_CK;a{CwXMJ5zk%OAiE*%9jBdUXUwZvg=W3mt_!8hRZ6pLFj~4F zwL0}iz40K8>&<)Z_TzTc>I`<8`$>D}VXNEh#*e!Dtya6=-H$u%erFIRkK^XQQQQo= literal 0 HcmV?d00001 From dc8ce3000c7c1486018480d5bbd6a07aa699c431 Mon Sep 17 00:00:00 2001 From: Christian Rogobete Date: Thu, 18 Jun 2026 23:35:21 +0200 Subject: [PATCH 5/6] test(p27): deploy via SorobanClient in the P27 integration tests --- .../Integration/P27AddressV2RoundTripTest.php | 94 ++++--------------- 1 file changed, 18 insertions(+), 76 deletions(-) diff --git a/Soneso/StellarSDKTests/Integration/P27AddressV2RoundTripTest.php b/Soneso/StellarSDKTests/Integration/P27AddressV2RoundTripTest.php index bb9eaf6b..1e0c1ab2 100644 --- a/Soneso/StellarSDKTests/Integration/P27AddressV2RoundTripTest.php +++ b/Soneso/StellarSDKTests/Integration/P27AddressV2RoundTripTest.php @@ -14,6 +14,9 @@ use Soneso\StellarSDK\InvokeHostFunctionOperationBuilder; use Soneso\StellarSDK\Network; use Soneso\StellarSDK\Soroban\Address; +use Soneso\StellarSDK\Soroban\Contract\DeployRequest; +use Soneso\StellarSDK\Soroban\Contract\InstallRequest; +use Soneso\StellarSDK\Soroban\Contract\SorobanClient; use Soneso\StellarSDK\Soroban\Requests\SimulateTransactionRequest; use Soneso\StellarSDK\Soroban\SorobanAuthorizationEntry; use Soneso\StellarSDK\Soroban\SorobanCredentials; @@ -46,7 +49,7 @@ class P27AddressV2RoundTripTest extends TestCase * * This gate follows the same convention used throughout the integration suite. */ - private string $testOn = 'skip'; // Change to 'testnet' to enable + private string $testOn = 'testnet'; private Network $network; private SorobanServer $server; @@ -92,8 +95,20 @@ public function testAddressV2SimulateSignRoundTrip(): void FriendBot::fundTestAccount($invokerId); sleep(5); - // Deploy the auth contract. - $contractId = $this->deployContract($this->server, self::AUTH_CONTRACT_PATH, $submitterKeyPair); + // Deploy the auth contract via the high-level client. + $wasmHash = SorobanClient::install(new InstallRequest( + wasmBytes: file_get_contents(self::AUTH_CONTRACT_PATH), + rpcUrl: self::TESTNET_SERVER_URL, + network: $this->network, + sourceAccountKeyPair: $submitterKeyPair, + )); + $contractClient = SorobanClient::deploy(new DeployRequest( + rpcUrl: self::TESTNET_SERVER_URL, + network: $this->network, + sourceAccountKeyPair: $submitterKeyPair, + wasmHash: $wasmHash, + )); + $contractId = $contractClient->getContractId(); // Build the invoke transaction. $invokerAddress = Address::fromAccountId($invokerId); @@ -213,77 +228,4 @@ private function pollStatus(SorobanServer $server, string $transactionId): GetTr return $statusResponse; } - private function deployContract(SorobanServer $server, string $wasmPath, KeyPair $accountKeyPair): string - { - // Load WASM. - $wasm = file_get_contents($wasmPath); - if ($wasm === false) { - throw new Exception("Could not load WASM from $wasmPath"); - } - - $accountId = $accountKeyPair->getAccountId(); - $account = $server->getAccount($accountId); - $this->assertNotNull($account); - - // Upload WASM. - $uploadHostFunction = new \Soneso\StellarSDK\UploadContractWasmHostFunction($wasm); - $op = (new \Soneso\StellarSDK\InvokeHostFunctionOperationBuilder($uploadHostFunction))->build(); - - $transaction = (new TransactionBuilder($account))->addOperation($op)->build(); - - $uploadRequest = new SimulateTransactionRequest($transaction); - $simulateResponse = $server->simulateTransaction($uploadRequest); - - $this->assertNull($simulateResponse->error); - $transactionData = $simulateResponse->getTransactionData(); - $transaction->setSorobanTransactionData($transactionData); - $transaction->addResourceFee($simulateResponse->minResourceFee); - $transaction->sign($accountKeyPair, $this->network); - - $sendResponse = $server->sendTransaction($transaction); - $this->assertNull($sendResponse->error); - - $statusResponse = $this->pollStatus($server, $sendResponse->hash); - $this->assertNotNull($statusResponse); - - $wasmId = $statusResponse->getResultValue()?->bytes?->getValue(); - $this->assertNotNull($wasmId); - - // Re-fetch account (sequence updated). - $account = $server->getAccount($accountId); - $this->assertNotNull($account); - - // Create contract. - $createHostFunction = new \Soneso\StellarSDK\CreateContractHostFunction( - new \Soneso\StellarSDK\Soroban\Address( - \Soneso\StellarSDK\Soroban\Address::TYPE_ACCOUNT, - accountId: $accountId, - ), - bin2hex($wasmId), - ); - $op2 = (new \Soneso\StellarSDK\InvokeHostFunctionOperationBuilder($createHostFunction))->build(); - - $transaction2 = (new TransactionBuilder($account))->addOperation($op2)->build(); - - $createRequest = new SimulateTransactionRequest($transaction2); - $simulateResponse2 = $server->simulateTransaction($createRequest); - - $this->assertNull($simulateResponse2->error); - $transactionData2 = $simulateResponse2->getTransactionData(); - $transaction2->setSorobanTransactionData($transactionData2); - $transaction2->addResourceFee($simulateResponse2->minResourceFee); - $transaction2->setSorobanAuth($simulateResponse2->getSorobanAuth()); - $transaction2->sign($accountKeyPair, $this->network); - - $sendResponse2 = $server->sendTransaction($transaction2); - $this->assertNull($sendResponse2->error); - - $statusResponse2 = $this->pollStatus($server, $sendResponse2->hash); - $this->assertNotNull($statusResponse2); - - $contractIdBytes = $statusResponse2->getResultValue()?->address?->contractId; - $this->assertNotNull($contractIdBytes); - - return \Soneso\StellarSDK\Crypto\StrKey::encodeContractIdHex($contractIdBytes); - } } From 376e420cbbe2f955d1b1985119628d3425d9527f Mon Sep 17 00:00:00 2001 From: Christian Rogobete Date: Thu, 18 Jun 2026 23:35:21 +0200 Subject: [PATCH 6/6] docs(p27): document client-side V2 and delegated auth construction --- docs/soroban.md | 6 +++++- skills/stellar-php-sdk.zip | Bin 219046 -> 220803 bytes .../references/soroban_contracts.md | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/soroban.md b/docs/soroban.md index e623a4f8..94d3273a 100644 --- a/docs/soroban.md +++ b/docs/soroban.md @@ -588,7 +588,11 @@ $delegated->sign( `SorobanDelegateDescriptor` supports nesting via `nestedDelegates` and accepts a pre-built `signature` (default void) for nodes signed externally, such as contract addresses. -After attaching a `WITH_DELEGATES` entry to the transaction with `$transaction->setSorobanAuth(...)`, re-simulate before submitting: the first simulation did not include the delegate authorization, so its resource fees are understated. Call `$server->simulateTransaction(...)` again with the delegated entry attached, then apply the returned data before signing — assign `$response->getTransactionData()` via `$transaction->setSorobanTransactionData(...)` and add `$response->getMinResourceFee()` via `$transaction->addResourceFee(...)`. +`SorobanCredentials::forAddressCredentialsV2` and the delegated arms are built client-side: simulation and the high-level client 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` (`new SimulateTransactionRequest(transaction: $transaction, authMode: 'enforce')`), then apply the returned data before signing: `$transaction->setSorobanTransactionData($response->getTransactionData())` and `$transaction->addResourceFee($response->getMinResourceFee())`. The already-signed auth is preserved. + +When converting a simulated `ADDRESS` entry to `ADDRESS_V2` in place, reuse its nonce — `SorobanCredentials::forAddressCredentialsV2($credentials->getAddressCredentials())` carries the nonce over; a fresh nonce will not match the recorded footprint and then relies on the enforcing re-simulation above. #### Source Compatibility diff --git a/skills/stellar-php-sdk.zip b/skills/stellar-php-sdk.zip index f8c65f93176455c05c0044e26ae231f88e50a1f6..5691c1acb3080893cb2efa1937fad24516b40c15 100644 GIT binary patch delta 10176 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 VGRpaYK!fX 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 diff --git a/skills/stellar-php-sdk/references/soroban_contracts.md b/skills/stellar-php-sdk/references/soroban_contracts.md index 20955096..cf9555aa 100644 --- a/skills/stellar-php-sdk/references/soroban_contracts.md +++ b/skills/stellar-php-sdk/references/soroban_contracts.md @@ -390,7 +390,7 @@ echo $response->getStatus(); // e.g. "SUCCESS" `SorobanCredentials` has four arms: source-account, legacy `ADDRESS` (default, valid on all protocols), and the opt-in `ADDRESS_V2` and `ADDRESS_WITH_DELEGATES` (protocol 27+; invalid below 27). All signing APIs handle every arm; `getAddressCredentials()` returns the inner `SorobanAddressCredentials` for any address arm (null only for source-account); `getCredentialType()` / `isSourceAccount()` inspect the arm. -`ADDRESS_WITH_DELEGATES` lets delegate addresses co-sign one entry. Simulation never returns this arm; build it from an `ADDRESS`/`ADDRESS_V2` entry via `SorobanAuthorizationEntry::withDelegates($source, $expirationLedger, $delegates)`, passing `SorobanDelegateDescriptor` objects. The builder sorts the delegate array and rejects duplicates. All nodes (top-level + delegates at any depth) sign the same payload bound to the top-level address; delegates carry no nonce/expiration. `sign($kp, $network, forAddress: $strkey)` routes a signature to matching nodes depth-first; `null` signs top-level. A void top-level with all delegates signed is valid (delegates-only). After attaching a delegated entry, re-simulate and apply the returned `transactionData` / `minResourceFee` before submitting, since the first simulation excluded the delegate authorization. For multiple classical signatures on one node, call `sign()` in ascending public-key order (the SDK appends in call order and does not sort). +`ADDRESS_WITH_DELEGATES` lets delegate addresses co-sign one entry. Simulation never returns this arm; build it from an `ADDRESS`/`ADDRESS_V2` entry via `SorobanAuthorizationEntry::withDelegates($source, $expirationLedger, $delegates)`, passing `SorobanDelegateDescriptor` objects. The builder sorts the delegate array and rejects duplicates. All nodes (top-level + delegates at any depth) sign the same payload bound to the top-level address; delegates carry no nonce/expiration. `sign($kp, $network, forAddress: $strkey)` routes a signature to matching nodes depth-first; `null` signs top-level. A void top-level with all delegates signed is valid (delegates-only). After attaching the signed entries, re-simulate in enforcing mode (`new SimulateTransactionRequest($tx, authMode: 'enforce')`) and apply the returned `transactionData` / `minResourceFee` 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). For multiple classical signatures on one node, call `sign()` in ascending public-key order (the SDK appends in call order and does not sort). ```php