diff --git a/Soneso/StellarSDK/SEP/WebAuthForContracts/WebAuthForContracts.php b/Soneso/StellarSDK/SEP/WebAuthForContracts/WebAuthForContracts.php index e85c41fd..9a4c1654 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; } @@ -658,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 @@ -864,30 +870,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..10b91795 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,9 @@ 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)); + $this->simulationResponse = $this->server->simulateTransaction(new SimulateTransactionRequest( + transaction: $this->tx, + )); 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 +473,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 +550,331 @@ 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; + + // 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 { - $entry->sign(signer: $signerKeyPair, network: $this->options->clientOptions->network); + 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/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..1e0c1ab2 --- /dev/null +++ b/Soneso/StellarSDKTests/Integration/P27AddressV2RoundTripTest.php @@ -0,0 +1,231 @@ + 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. + */ +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 = 'testnet'; + + 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, assemble the ADDRESS_V2 arm client-side, sign using + * the address-bound preimage, and submit. + * + * 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 + */ + public function testAddressV2SimulateSignRoundTrip(): void + { + if ($this->testOn !== 'testnet') { + $this->markTestSkipped( + 'P27 ADDRESS_V2 integration test requires testnet access. ' + . 'Set $testOn = "testnet" to enable. ' + . 'Submission succeeds only once the network runs Protocol 27.' + ); + } + + // 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 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); + $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(); + + $request = new SimulateTransactionRequest(transaction: $transaction); + $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); + + // 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); + + // 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); + + $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; + } + +} 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/Unit/SEP/WebAuthForContracts/P27WebAuthForContractsTest.php b/Soneso/StellarSDKTests/Unit/SEP/WebAuthForContracts/P27WebAuthForContractsTest.php new file mode 100644 index 00000000..2c1bbe23 --- /dev/null +++ b/Soneso/StellarSDKTests/Unit/SEP/WebAuthForContracts/P27WebAuthForContractsTest.php @@ -0,0 +1,974 @@ +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); + + // 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'); + } + + /** + * 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..4cecaff1 --- /dev/null +++ b/Soneso/StellarSDKTests/Unit/Soroban/P27AssembledTransactionTest.php @@ -0,0 +1,560 @@ +invokerKp = KeyPair::fromSeed(self::TEST_SECRET_KEY); + $this->network = Network::testnet(); + } + + // ========================================================================= + // SimulateTransactionRequest param serialization + // ========================================================================= + + /** + * Request params serialize transaction, resourceConfig, and authMode. + */ + public function testRequestParamsSerializeExistingFields(): void + { + $tx = $this->buildMockTx(); + $resourceConfig = new \Soneso\StellarSDK\Soroban\Requests\ResourceConfig(5000000); + $request = new SimulateTransactionRequest( + transaction: $tx, + resourceConfig: $resourceConfig, + authMode: 'record', + ); + + $params = $request->getRequestParams(); + + $this->assertArrayHasKey('transaction', $params); + $this->assertArrayHasKey('resourceConfig', $params); + $this->assertEquals('record', $params['authMode']); + } + + // ========================================================================= + // MethodOptions fields + // ========================================================================= + + /** + * MethodOptions stores the values passed to the constructor. + */ + public function testMethodOptionsFieldsAreSet(): void + { + $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); + } + + // ========================================================================= + // 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; + } + + /** + * 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 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..b3b49eb3 --- /dev/null +++ b/Soneso/StellarSDKTests/Unit/Soroban/P27AuthorizationTest.php @@ -0,0 +1,899 @@ +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); + + // 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', + ); + } + + /** + * 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..d909ea87 --- /dev/null +++ b/Soneso/StellarSDKTests/Unit/Soroban/P27CoverageClosureTest.php @@ -0,0 +1,1399 @@ +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()); + } + + // ========================================================================= + // 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'); + + // 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 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 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( + SorobanCredentials::forAddressCredentials($addressCreds), + $this->makeInvocation(), + ); + + $tx = $this->buildAssembledTransactionWithAuthEntries([$validEntry], $invokerKp); + $this->injectMockedServerResponses($tx, [$this->makeLatestLedgerResponse(1000)]); + + $callbackInvoked = false; + $expirationSeenByCallback = null; + $tx->signAuthEntries( + signerKeyPair: $signerKp, + // 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; + }, + validUntilLedgerSeq: 9999, + ); + + $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'); + } + + // ========================================================================= + // 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/Soneso/StellarSDKTests/wasm/soroban_modular_account_contract.wasm b/Soneso/StellarSDKTests/wasm/soroban_modular_account_contract.wasm new file mode 100644 index 00000000..89e3804e Binary files /dev/null and b/Soneso/StellarSDKTests/wasm/soroban_modular_account_contract.wasm differ 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..94d3273a 100644 --- a/docs/soroban.md +++ b/docs/soroban.md @@ -499,6 +499,105 @@ $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. + +#### 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. + +`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 + +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 82923b44..5691c1ac 100644 Binary files a/skills/stellar-php-sdk.zip and b/skills/stellar-php-sdk.zip differ diff --git a/skills/stellar-php-sdk/SKILL.md b/skills/stellar-php-sdk/SKILL.md index 45bdd80a..67f2fb90 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); 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..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:** 572 classes, 2921 methods +**Stats:** 577 classes, 2960 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 @@ -3246,7 +3253,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 +3279,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,7 +3299,8 @@ 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 @@ -3327,6 +3337,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 +3356,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 +3405,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 +3416,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 +3424,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 +3618,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 +5164,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 +5294,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 +5417,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 +5430,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 +5454,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/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..cf9555aa 100644 --- a/skills/stellar-php-sdk/references/soroban_contracts.md +++ b/skills/stellar-php-sdk/references/soroban_contracts.md @@ -386,6 +386,29 @@ $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. + +`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 +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.