From f19919e20c7ba6dd84c5858d961b80d5efa76553 Mon Sep 17 00:00:00 2001 From: Bence Nagy Date: Wed, 11 Nov 2026 12:09:56 +0200 Subject: [PATCH 1/2] Document the Stellar Asset Contract client Add a chapter on SACClient to the Soroban guide and a SACClient passage with three common mistakes to the skill's contract reference. --- docs/soroban.md | 271 ++++++++++++++++++ .../references/soroban_contracts.md | 135 +++++++++ 2 files changed, 406 insertions(+) diff --git a/docs/soroban.md b/docs/soroban.md index bbf7ea6e..27ca2c73 100644 --- a/docs/soroban.md +++ b/docs/soroban.md @@ -331,6 +331,277 @@ $methodOptions = new MethodOptions( $result = $client->invokeMethod('expensive_op', [], methodOptions: $methodOptions); ``` +## Stellar Asset Contracts (SAC) + +Every classic asset, the native asset included, has a Stellar Asset Contract: the built-in token contract that lets Soroban contracts and accounts move the asset. Its contract id derives from the asset and the network, so you know it without a lookup. `SACClient` wraps the contract with typed calls. Amounts are i128 base units: a SAC has 7 decimals, so 1 unit of the asset is 10,000,000 base units. + +### Opening a SAC Client + +`Asset::getContractId()` returns the contract id ("C...") of an asset's SAC on a network. `SACClient::forAsset()` opens the client from the asset, and `SACClient::forContractId()` from an id you already have. Neither sends a request, because the client carries the contract spec. + +```php +getContractId(Network::testnet()) . PHP_EOL; +// CBEFNXB7GMEO2PKBCOSKRUQBDWAH4PVA2RIREKPV335KM7MBK2HRLRGB + +$sac = SACClient::forAsset($asset, $keyPair, Network::testnet(), $rpcUrl); + +// The same contract, opened by its id +$sac = SACClient::forContractId( + 'CBEFNXB7GMEO2PKBCOSKRUQBDWAH4PVA2RIREKPV335KM7MBK2HRLRGB', + $keyPair, + Network::testnet(), + $rpcUrl +); +``` + +The id differs per network: on the public network, call `getContractId(Network::public())`. A liquidity pool share has no SAC, so `getContractId()` throws a `RuntimeException` for one. `SorobanClient::forClientOptions()` also opens a SAC by its id. For the functions without a typed method, such as `allowance` or `approve`, use `$sac->getSorobanClient()`. + +### Read-Only Client + +Reads need no keypair. `SACClient::readOnlyForAsset()` and `SACClient::readOnlyForContractId()` build their transactions for the placeholder source account `SACClient::READ_ONLY_SOURCE_ACCOUNT_ID`, and simulation needs no account entry for it. + +```php +getContractId() . PHP_EOL; // CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +echo $xlm->decimals() . PHP_EOL; // 7 +echo $xlm->name() . PHP_EOL; // native +echo $xlm->symbol() . PHP_EOL; // native + +$balance = $xlm->balance(Address::fromAccountId('GB3ARMCOZUG5BFMVS7WWR5AAV42FVQDLRCUOJRN5MDGSXMKUTSFF3VMX')); +echo gmp_strval($balance) . PHP_EOL; +``` + +For an issued asset, `name()` returns `CODE:ISSUER` and `symbol()` the asset code. + +A read-only client can build a transfer, but it can't sign one: `sign()` and `signAndSend()` throw an `Exception` that names the placeholder account, and `transferAndSend()` throws before any request. Reads never restore archived state, on any client. When ledger entries a read needs are archived, the read throws a `RuntimeException` naming the contract and the function. To restore them, invoke the function through `getSorobanClient()` of a client opened with a funded account's keypair; `MethodOptions` restores archived state by default. + +### Reading a Balance + +`balance()` takes an account (G...) or contract (C...) address and returns the balance in base units as `GMP`. A contract without a balance reads 0. A missing account or trustline is not a zero balance: the simulation fails with the contract error, `Error(Contract, #6)` for a missing account and `Error(Contract, #13)` for a missing trustline. + +```php +balance(Address::fromAccountId($holderId)); + echo gmp_strval($balance) . PHP_EOL; // 125000000 is 12.5 units +} catch (\InvalidArgumentException $e) { + echo "Not a balance holder: {$e->getMessage()}\n"; +} catch (\Exception $e) { + if (str_contains($e->getMessage(), 'Error(Contract, #6)')) { + echo "The account does not exist\n"; + } elseif (str_contains($e->getMessage(), 'Error(Contract, #13)')) { + echo "The account has no trustline for the asset\n"; + } else { + echo "Balance read failed: {$e->getMessage()}\n"; + } +} + +// A contract holder +$poolBalance = $sac->balance(Address::fromContractId('CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE')); +``` + +Balances belong to the base account, so a muxed holder is rejected before any request. `balance()` throws an `InvalidArgumentException` for an M... or W... address, and the message names the base account or contract to query. `MuxedAccount::fromAccountId($muxedId)->getEd25519AccountId()` returns the base account of an M... address. + +### Sending a Transfer + +`SACClient::toBaseUnits()` converts a decimal amount such as `'12.5'` to base units. It accepts digits with at most 7 fractional digits and throws an `InvalidArgumentException` for anything else. `transfer()` and `transferAndSend()` take the amount in base units as `GMP`, `int`, or a string of digits. + +`transferAndSend()` sends from the client's source account, signs, submits, and waits for the result: + +```php +transferAndSend( + Address::fromAccountId('GB3ARMCOZUG5BFMVS7WWR5AAV42FVQDLRCUOJRN5MDGSXMKUTSFF3VMX'), + $amount +); +echo $response->txHash . PHP_EOL; +``` + +A failed transaction throws an `Exception` with the result XDR. + +The recipient can also be a muxed account (M...) or, on protocol 30 and later, a muxed contract (W...). The contract credits the base address, and the transfer event carries the muxed id as `to_muxed_id`. `transfer()` returns the simulated `AssembledTransaction`; with the source account as sender, `signAndSend()` submits it: + +```php +getAccountId(); + +$tx = $sac->transfer( + Address::fromAccountId($keyPair->getAccountId()), + Address::fromMuxedAccountId($muxedId), + SACClient::toBaseUnits('25') +); +$response = $tx->signAndSend(); + +// Events of the first (and only) operation +$eventsXdr = $response->getEvents()?->contractEventsXdr[0] ?? []; +foreach ($eventsXdr as $eventXdr) { + $body = XdrContractEvent::fromBase64Xdr($eventXdr)->body->v0; + if ($body !== null && ($body->topics[0]->sym ?? null) === 'transfer') { + $data = $body->data->toNative(); // ['amount' => GMP, 'to_muxed_id' => int] + echo $data['to_muxed_id'] . PHP_EOL; // 4242 + } +} +``` + +`transfer()` and `transferAndSend()` check the amount and the addresses before any request: a decimal string, a negative amount, or one above 2^127 - 1 throws an `InvalidArgumentException`. + +### Transfers Authorized by Another Account + +A transfer needs the authorization of its sender (`from`). When the sender is an account (G...) other than the source account, the sender signs its authorization entry and the source account submits. `needsNonInvokerSigningBy()` lists the accounts that still have to sign. See [Authorization](#authorization) for remote signing. + +```php +transfer( + Address::fromAccountId($bob->getAccountId()), + Address::fromAccountId('GB3ARMCOZUG5BFMVS7WWR5AAV42FVQDLRCUOJRN5MDGSXMKUTSFF3VMX'), + SACClient::toBaseUnits('10.5') +); + +print_r($tx->needsNonInvokerSigningBy()); // [Bob's account id] + +$tx->signAuthEntries(signerKeyPair: $bob); +$response = $tx->signAndSend(); +``` + +A contract sender (C...) is authorized by the contract's own `__check_auth`. `sign()` does not block on its unsigned entry, and `signAuthEntries()` signs for accounts only. When the contract expects a signature value, write it into the contract's entry and store the entries with `setSorobanAuth()` before `signAndSend()`. Simulation leaves the entry's signature expiration ledger at 0, so set it first: the value the contract verifies covers the entry's authorization preimage, `$entry->buildPreimage(Network::testnet())`, and that preimage includes the expiration ledger. + +```php +transfer( + Address::fromContractId($walletId), + Address::fromAccountId('GB3ARMCOZUG5BFMVS7WWR5AAV42FVQDLRCUOJRN5MDGSXMKUTSFF3VMX'), + SACClient::toBaseUnits('5') +); + +// The authorization stays valid for about 10 ledgers +$expirationLedger = (new SorobanServer($rpcUrl))->getLatestLedger()->sequence + 10; + +$entries = $tx->tx->getOperations()[0]->getAuth(); +foreach ($entries as $entry) { + $credentials = $entry->credentials->getAddressCredentials(); + if ($credentials !== null && $credentials->address->toStrKey() === $walletId) { + // Set the expiration before computing the signature: the preimage includes it + $credentials->signatureExpirationLedger = $expirationLedger; + $payload = hash('sha256', $entry->buildPreimage(Network::testnet())->encode(), true); + + // Placeholder: replace with the value your wallet's __check_auth verifies for $payload; + // its form depends on the wallet contract + $credentials->signature = XdrSCVal::forVoid(); + } +} +$tx->tx->setSorobanAuth($entries); + +$response = $tx->signAndSend(); +``` + +Simulation does not run `__check_auth`, so the simulated resources and fee leave out its cost. When the wallet's check reads storage or does heavy work, the transaction may need higher resources than the simulation returned. + +An issued asset's SAC is deployed once per network before its first use; see [Deploy Stellar Asset Contract (SAC)](#deploy-stellar-asset-contract-sac). The native asset's SAC exists on every network. + ## Installing and Deploying Put your contract on the network. Install uploads the WASM bytecode once; deploy creates contract instances from that code. diff --git a/skills/stellar-php-sdk/references/soroban_contracts.md b/skills/stellar-php-sdk/references/soroban_contracts.md index af4d9185..19f23014 100644 --- a/skills/stellar-php-sdk/references/soroban_contracts.md +++ b/skills/stellar-php-sdk/references/soroban_contracts.md @@ -220,6 +220,141 @@ $result = $client->invokeMethod('transfer', $args); `Address` parameters accept G... and C... strings or account and contract `Address` objects. `MuxedAddress` parameters also accept M... and W... strings or their `Address` objects. `u64`, `timepoint`, and `duration` parameters accept a non-negative `int` or a decimal string up to `18446744073709551615`. A prebuilt `XdrSCVal` passes through unchanged, and other inputs throw `InvalidArgumentException` naming the parameter type and the value or its type. +### Stellar Asset Contracts: SACClient + +Every classic asset, the native asset included, has a Stellar Asset Contract (SAC) whose id derives from the asset and the network: `Asset::getContractId(Network $network): string` returns the C... strkey without a request (a liquidity pool share throws `RuntimeException`). `SACClient` opens a SAC without a request and offers typed `balance()`, `decimals()`, `name()`, `symbol()`, `transfer()`, and `transferAndSend()`. Amounts are i128 base units of 7 decimals (1 unit = 10,000,000 base units); `SACClient::toBaseUnits(string): GMP` converts a decimal string. `getSorobanClient()` reaches the other SAC functions (`allowance`, `approve`, `mint`, ...), and `SorobanClient::forClientOptions()` also opens a SAC. + +```php +getContractId() . PHP_EOL; // CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +echo $xlm->decimals() . PHP_EOL; // 7 +echo $xlm->name() . PHP_EOL; // native (issued asset: "CODE:ISSUER") +echo $xlm->symbol() . PHP_EOL; // native (issued asset: the code) +$balance = $xlm->balance(Address::fromAccountId($recipientId)); // GMP, base units +echo gmp_strval($balance) . PHP_EOL; + +// Keyed client: the keypair's account is the source and the sender of transferAndSend() +$keyPair = KeyPair::fromSeed('SXXX...'); +$asset = Asset::createNonNativeAsset('TESTASSET', 'GDUKMGUGDZQK6YHYA5Z6AY2G4XDSZPSZ3SW5UN3ARVMO6QSRDWP5YLEX'); +$sac = SACClient::forAsset($asset, $keyPair, $network, $rpcUrl); +// Same contract by id: SACClient::forContractId('CBEFNXB7GMEO2PKBCOSKRUQBDWAH4PVA2RIREKPV335KM7MBK2HRLRGB', $keyPair, $network, $rpcUrl) + +// Simulates, signs, sends, and polls; throws Exception unless the transaction succeeds +$amount = SACClient::toBaseUnits('12.5'); // GMP 125000000 +$response = $sac->transferAndSend(Address::fromAccountId($recipientId), $amount); +echo $response->txHash . PHP_EOL; + +// Transfer to a muxed account (M...): the base account is credited, the transfer +// event data is a map with 'amount' (i128) and 'to_muxed_id' (u64). +// transfer() returns the simulated AssembledTransaction, unsigned and unsent. +$muxedId = (new MuxedAccount($recipientId, 4242))->getAccountId(); +$tx = $sac->transfer( + Address::fromAccountId($keyPair->getAccountId()), // from: G or C + Address::fromMuxedAccountId($muxedId), // to: G, M, C, or W + SACClient::toBaseUnits('25'), +); +$response = $tx->signAndSend(); +``` + +`transfer()` and `transferAndSend()` take `GMP|int|string` base units (string: digits only) from 0 to 2^127 - 1 and validate amount and addresses before any request (`InvalidArgumentException`). A `from` account other than the source appears in `$tx->needsNonInvokerSigningBy()`; call `$tx->signAuthEntries($fromKeyPair)` before `$tx->signAndSend()` (see Multi-Auth Workflows). Reads never sign, send, or restore archived state: an archived entry makes a read throw `RuntimeException`. + +**Trap: passing a decimal amount to `transfer()`.** + +```php +getAccountId()); +$to = Address::fromAccountId('GB3ARMCOZUG5BFMVS7WWR5AAV42FVQDLRCUOJRN5MDGSXMKUTSFF3VMX'); + +// WRONG: amounts are base units, not asset units +$tx = $sac->transfer($from, $to, '12.5'); // InvalidArgumentException: whole number of base units required +$tx = $sac->transfer($from, $to, 12); // no error: builds a transfer of 12 base units, which is 0.0000012 XLM + +// CORRECT: convert the decimal amount first +$tx = $sac->transfer($from, $to, SACClient::toBaseUnits('12.5')); // 125000000 base units +``` + +**Trap: passing a muxed (M...) holder to `balance()`.** + +```php +balance(Address::fromMuxedAccountId($muxedId)); + +// CORRECT: balances belong to the base account (a W... holder: query its C... base contract) +$baseId = MuxedAccount::fromAccountId($muxedId)->getEd25519AccountId(); // GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ +$balance = $sac->balance(Address::fromAccountId($baseId)); +``` + +**Trap: treating a missing trustline as a zero balance.** + +```php +balance($holder)); + +// CORRECT: catch the contract errors: #13 = no trustline, #6 = no account +try { + $balance = gmp_strval($sac->balance($holder)); +} catch (\Exception $e) { + if (str_contains($e->getMessage(), 'Error(Contract, #13)')) { + $balance = null; // the holder has no trustline for the asset + } elseif (str_contains($e->getMessage(), 'Error(Contract, #6)')) { + $balance = null; // the holder account does not exist + } else { + throw $e; + } +} +``` + ### Low-Level: SorobanServer Full control over simulation, signing, and submission. From f10e51bb9c6c5576f8dd2f511d3185bfc745cb04 Mon Sep 17 00:00:00 2001 From: Christian Rogobete Date: Wed, 11 Nov 2026 18:18:31 +0100 Subject: [PATCH 2/2] Describe contract senders without a placeholder example Before this change, the Stellar Asset Contract chapter showed a contract sender example that submitted with a placeholder signature, and advised higher resources for the wallet's check. This change describes the steps in prose: complete the authorization entry, re-simulate in enforcing mode, apply the returned transaction data and set the fee. It also states the largest amount toBaseUnits() accepts. --- docs/soroban.md | 54 +++---------------------------------------------- 1 file changed, 3 insertions(+), 51 deletions(-) diff --git a/docs/soroban.md b/docs/soroban.md index 27ca2c73..596bdf52 100644 --- a/docs/soroban.md +++ b/docs/soroban.md @@ -438,7 +438,7 @@ Balances belong to the base account, so a muxed holder is rejected before any re ### Sending a Transfer -`SACClient::toBaseUnits()` converts a decimal amount such as `'12.5'` to base units. It accepts digits with at most 7 fractional digits and throws an `InvalidArgumentException` for anything else. `transfer()` and `transferAndSend()` take the amount in base units as `GMP`, `int`, or a string of digits. +`SACClient::toBaseUnits()` converts a decimal amount such as `'12.5'` to base units. It accepts digits with at most 7 fractional digits up to `17014118346046923173168730371588.4105727`, the largest i128 amount, and throws an `InvalidArgumentException` for anything else, including a larger value. `transfer()` and `transferAndSend()` take the amount in base units as `GMP`, `int`, or a string of digits. `transferAndSend()` sends from the client's source account, signs, submits, and waits for the result: @@ -548,57 +548,9 @@ $tx->signAuthEntries(signerKeyPair: $bob); $response = $tx->signAndSend(); ``` -A contract sender (C...) is authorized by the contract's own `__check_auth`. `sign()` does not block on its unsigned entry, and `signAuthEntries()` signs for accounts only. When the contract expects a signature value, write it into the contract's entry and store the entries with `setSorobanAuth()` before `signAndSend()`. Simulation leaves the entry's signature expiration ledger at 0, so set it first: the value the contract verifies covers the entry's authorization preimage, `$entry->buildPreimage(Network::testnet())`, and that preimage includes the expiration ledger. +A contract sender (C...) is authorized by the contract's own `__check_auth`. `sign()` does not block on its unsigned entry, and `signAuthEntries()` signs for accounts only. When the contract expects a signature value, complete its entry before `signAndSend()`. The entries are in `$tx->tx->getOperations()[0]->getAuth()`, and the contract's entry is the one whose `$entry->credentials->getAddressCredentials()` holds the contract's address. Simulation leaves the entry's signature expiration ledger at 0. Set `signatureExpirationLedger` on those credentials before you compute the signature payload, the SHA-256 hash of `$entry->buildPreimage(Network::testnet())->encode()`, because the preimage includes the expiration ledger. Obtain the value the wallet's `__check_auth` verifies for that payload, in the form the wallet contract defines, and write it to the credentials' `signature`. Then attach the completed entries with `$tx->tx->setSorobanAuth($entries)`. -```php -transfer( - Address::fromContractId($walletId), - Address::fromAccountId('GB3ARMCOZUG5BFMVS7WWR5AAV42FVQDLRCUOJRN5MDGSXMKUTSFF3VMX'), - SACClient::toBaseUnits('5') -); - -// The authorization stays valid for about 10 ledgers -$expirationLedger = (new SorobanServer($rpcUrl))->getLatestLedger()->sequence + 10; - -$entries = $tx->tx->getOperations()[0]->getAuth(); -foreach ($entries as $entry) { - $credentials = $entry->credentials->getAddressCredentials(); - if ($credentials !== null && $credentials->address->toStrKey() === $walletId) { - // Set the expiration before computing the signature: the preimage includes it - $credentials->signatureExpirationLedger = $expirationLedger; - $payload = hash('sha256', $entry->buildPreimage(Network::testnet())->encode(), true); - - // Placeholder: replace with the value your wallet's __check_auth verifies for $payload; - // its form depends on the wallet contract - $credentials->signature = XdrSCVal::forVoid(); - } -} -$tx->tx->setSorobanAuth($entries); - -$response = $tx->signAndSend(); -``` - -Simulation does not run `__check_auth`, so the simulated resources and fee leave out its cost. When the wallet's check reads storage or does heavy work, the transaction may need higher resources than the simulation returned. +The first simulation runs in recording mode and does not run `__check_auth`, so its resources and fee leave out the wallet's check and the ledger entries it reads. After attaching the completed entries, re-simulate in enforcing mode: pass `new SimulateTransactionRequest(transaction: $tx->tx, authMode: 'enforce')` to `SorobanServer::simulateTransaction()` and check that the response carries no `error` and no `resultError`. Apply its transaction data with `$tx->tx->setSorobanTransactionData($response->getTransactionData())` and replace the total fee with `$tx->tx->setFee($tx->options->methodOptions->fee + $response->getMinResourceFee())`; the signed authorization entries stay attached. Then sign and submit with `$tx->signAndSend()`. [Protocol 27 Credentials (CAP-71)](#protocol-27-credentials-cap-71) describes the enforcing re-simulation. An issued asset's SAC is deployed once per network before its first use; see [Deploy Stellar Asset Contract (SAC)](#deploy-stellar-asset-contract-sac). The native asset's SAC exists on every network.