diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6af3e..f257f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).. -## [2.2.0] - unreleased +## [2.3.0] - 2026-08-26 + +### Added +- `JsonRpcClient` exposes its collaborators through `getAutofiller()`, + `getSubmitter()`, `getAccountReader()`, `getOrderbookReader()`, + `getFeeCalculator()` and `getFaucet()`. A subclass overrides one of them to + substitute its own, and the replacement reaches every path that uses it - + including the indirect ones, where `Submitter` and `Autofiller` used to + construct collaborators of their own. + +## [2.2.0] - 2026-08-25 ### Added - Object form of the Sugar functions: `Autofiller`, `Submitter`, `AccountReader`, diff --git a/README.md b/README.md index de2900b..b68b9b5 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ This library requires PHP 8.2 or later and two PHP extensions: `simplito/elliptic-php`, which does the secp256k1 signing. Composer will refuse to install without it. -## Examples +## Examples ### The "Quickstart" Examples @@ -59,7 +59,11 @@ php examples/mptoken.php // Multi-Purpose Token: issue, authorize, send, claw ba php examples/permissioned-domain.php // Credentials + PermissionedDomain + PermissionedDEX php examples/amm-clawback.php // Claw a token back out of an AMM pool php examples/nftoken-modify.php // Mint a mutable NFT and change its URI -etc... +php examples/rlusd.php // Trust line and payment in Ripple USD +php examples/custom-currency-codes.php // Currency codes beyond the three character form +php examples/payment-with-destination-tag.php +php examples/xrp-balance.php +php examples/provoke-error.php // What an error response looks like ``` All of these run against the Testnet and fund their own wallets from the faucet. @@ -74,50 +78,9 @@ php examples/internal/binary-codec.php etc... ``` -### Run the project via Docker - -1. Tell the container which user to run as, so the files it writes belong to - you. The values differ between Linux and macOS, so they come from `.env`: - -```console -printf 'DOCKER_UID=%s\nDOCKER_GID=%s\n' "$(id -u)" "$(id -g)" > .env -``` - -2. Start the project and open a shell: - -```console -docker compose up -d -docker compose exec php bash -``` - -3. In the container shell, install the composer dependencies: - -```console -composer install -``` - -The image is built from `docker/`. Xdebug is preconfigured to reach the host on -port 9090 via `host.docker.internal`, which works on Linux as well because the -compose file maps it to the host gateway. For anything else that is specific to -your machine, add a `docker-compose.override.yml`; it is gitignored. - -### Run Tests - -You can run the tests with the following command: - -```console -./vendor/bin/phpunit tests -``` - -You can perform static code analysis with psalm with the following command: - -```console -./vendor/bin/psalm --config=psalm.xml -``` - ## Try it yourself -### Issuing an [Account Info request](https://xrpl.org/account_info.html): +### Issuing an [account_objects request](https://xrpl.org/account_objects.html) ```php require __DIR__.'/../vendor/autoload.php'; @@ -137,50 +100,76 @@ $request = new AccountObjectsRequest( deletionBlockersOnly: true ); -// Using synchronous request +// Synchronous $response = $client->syncRequest($request); -$json = json_decode($response->getBody()); -print_r($json); +print_r($response->getResult()); -// Using asynchronous request +// Asynchronous - the promise resolves to the same response object // $response = $client->request($request)->wait(); -// $json = json_decode($response->getBody()); -// print_r($json); +// print_r($response->getResult()); ``` -### Making a payment: +### Making a payment ```php // Use your own credentials here: -$testnetStandbyAccountSeed = 'sEdTcvQ9k4UUEHD9y947QiXEs93Fp2k'; -$testnetStandbyAccountAddress = 'raJNboPDvjLrYZropPFrxvz2Qm7A9guEVd'; -$standbyWallet = Wallet::fromSeed($testnetStandbyAccountSeed); - -// Use your own credentials here: -$testnetOperationalAccountSeed = 'sEdVHf8rNEaRveJw4NdVKxm3iYWFuRb'; -$testnetOperationalAccountAddress = 'rEQ3ik2kmAvajqpFweKgDghJFZQGpXxuRN'; -$operationalWallet = Wallet::fromSeed($testnetStandbyAccountSeed); +$senderWallet = Wallet::fromSeed('sEdTcvQ9k4UUEHD9y947QiXEs93Fp2k'); +$destination = 'rEQ3ik2kmAvajqpFweKgDghJFZQGpXxuRN'; $client = new JsonRpcClient("https://s.altnet.rippletest.net:51234"); $tx = [ "TransactionType" => "Payment", - "Account" => $testnetStandbyAccountAddress, - "Amount" => xrpToDrops("100"), - "Destination" => $testnetOperationalAccountAddress + "Account" => $senderWallet->getAddress(), + "Amount" => xrpToDrops("10"), + "Destination" => $destination ]; -$autofilledTx = $client->autofill($tx); -$signedTx = $standbyWallet->sign($autofilledTx); -$txResponse = $client->submitAndWait($signedTx['tx_blob']); +// Fills in Sequence, Fee and LastLedgerSequence, signs, submits, and waits +// until the transaction is in a validated ledger. +$txResponse = $client->submitAndWait($tx, autofill: true, wallet: $senderWallet); $result = $txResponse->getResult(); -if ($result['meta']['TransactionResult'] === 'tecUNFUNDED_PAYMENT') { - print_r("Error: The sending account is unfunded! TxHash: {$result['hash']}" . PHP_EOL); + +// A tec result still reaches the ledger, so the code has to be checked - +// submitAndWait() returning is not by itself a success. +if ($result['meta']['TransactionResult'] !== 'tesSUCCESS') { + print_r("Payment failed with {$result['meta']['TransactionResult']}! TxHash: {$result['hash']}" . PHP_EOL); } else { - print_r("Token payment done! TxHash: {$result['hash']}" . PHP_EOL); + print_r("Payment done! TxHash: {$result['hash']}" . PHP_EOL); } ``` +Signing yourself and submitting the blob works just as well, and is what the +files in `examples/` do: + +```php +$signedTx = $senderWallet->sign($client->autofill($tx)); +$txResponse = $client->submitAndWait($signedTx['tx_blob']); +``` + +### The objects behind the client + +`JsonRpcClient` is a facade. Behind each of its operations sits a class that can +also be used on its own: + +| Class | What it does | +|---|---| +| `Autofiller` | fills in Sequence, Fee and LastLedgerSequence | +| `Submitter` | submits transactions and polls for their outcome | +| `AccountReader` | balances and transaction history | +| `OrderbookReader` | the offers in one order book | +| `FeeCalculator` | the current network fee | +| `Faucet` | funds a wallet on a test network | + +```php +$balances = (new AccountReader($client))->getBalances($address); +// same as +$balances = $client->getBalances($address); +``` + +The functions in the `Hardcastle\XRPL_PHP\Sugar` namespace do the same and keep +working, but they are deprecated as of 2.2.0 and delegate to these classes. + ## Xahau support The library ships the Xahau transaction types alongside the XRP Ledger ones, but @@ -245,5 +234,75 @@ The definitions travel through the whole encode and decode, including nested objects and arrays. They do not touch the shared default instance, so a process can talk to both networks at once. +### Replacing what the client works with + +The definitions decide how a transaction is encoded, but not everything a network +does differently. Xahau prices a transaction individually, because hooks may +fire, so the XRP Ledger's fee formula produces too low a fee there. + +Since 2.3.0 the client exposes the objects it works with, and a subclass can +substitute one: + +```php +use Hardcastle\XRPL_PHP\Client\Autofiller; +use Hardcastle\XRPL_PHP\Client\JsonRpcClient; + +class XahauClient extends JsonRpcClient +{ + public function getAutofiller(): Autofiller + { + return new XahauAutofiller($this); + } +} +``` + +The replacement is used wherever that object is reached, including +`submitAndWait()`, which autofills through the `Submitter` rather than through +the client. The same works for `getSubmitter()`, `getAccountReader()`, +`getOrderbookReader()`, `getFeeCalculator()` and `getFaucet()`. + A dedicated Xahau package building on this is planned; the Xahau types will then -move out of this library. \ No newline at end of file +move out of this library. + +## Development + +### Running the project via Docker + +1. Tell the container which user to run as, so the files it writes belong to + you. The values differ between Linux and macOS, so they come from `.env`: + +```console +printf 'DOCKER_UID=%s\nDOCKER_GID=%s\n' "$(id -u)" "$(id -g)" > .env +``` + +2. Start the project and open a shell: + +```console +docker compose up -d +docker compose exec php bash +``` + +3. In the container shell, install the composer dependencies: + +```console +composer install +``` + +The image is built from `docker/`. Xdebug is preconfigured to reach the host on +port 9090 via `host.docker.internal`, which works on Linux as well because the +compose file maps it to the host gateway. For anything else that is specific to +your machine, add a `docker-compose.override.yml`; it is gitignored. + +### Running the tests + +You can run the tests with the following command: + +```console +./vendor/bin/phpunit tests +``` + +You can perform static code analysis with psalm with the following command: + +```console +./vendor/bin/psalm --config=psalm.xml +``` diff --git a/src/Client/Autofiller.php b/src/Client/Autofiller.php index fbd0e14..70325be 100644 --- a/src/Client/Autofiller.php +++ b/src/Client/Autofiller.php @@ -265,7 +265,7 @@ public function fetchOwnerReserveFee(): BigDecimal */ public function calculateFeePerTransactionType(array &$tx, ?int $signersCount = 0): void { - $netFeeXrp = (new FeeCalculator($this->client))->getFeeXrp(); + $netFeeXrp = $this->client->getFeeCalculator()->getFeeXrp(); $netFeeDrops = xrpToDrops($netFeeXrp); $baseFee = BigDecimal::of($netFeeDrops); diff --git a/src/Client/Faucet.php b/src/Client/Faucet.php index 13b4001..20d9a1d 100644 --- a/src/Client/Faucet.php +++ b/src/Client/Faucet.php @@ -53,7 +53,7 @@ public function fundWallet( ? $wallet : Wallet::generate(); - $accountReader = new AccountReader($this->client); + $accountReader = $this->client->getAccountReader(); $startingBalance = 0.0; try { @@ -96,7 +96,7 @@ public function fundWallet( */ private function waitForFunding(string $address, float $startingBalance): float { - $accountReader = new AccountReader($this->client); + $accountReader = $this->client->getAccountReader(); $balance = $startingBalance; for ($attempt = 0; $attempt < self::POLL_ATTEMPTS; $attempt++) { diff --git a/src/Client/JsonRpcClient.php b/src/Client/JsonRpcClient.php index 607ffd9..ceef4b5 100644 --- a/src/Client/JsonRpcClient.php +++ b/src/Client/JsonRpcClient.php @@ -282,6 +282,71 @@ public function getMaxFeeXrp(): string /** * @return string */ + /** + * The collaborator that fills in Sequence, Fee and LastLedgerSequence. + * + * Override this in a subclass to supply a different one. A network whose + * fee model differs from the XRP Ledger's - Xahau prices per transaction, + * because hooks may fire - needs its own, and overriding here puts it into + * every path that autofills, including submitAndWait(). + * + * @return Autofiller + */ + public function getAutofiller(): Autofiller + { + return new Autofiller($this); + } + + /** + * The collaborator that submits transactions and polls for their outcome. + * + * @return Submitter + */ + public function getSubmitter(): Submitter + { + return new Submitter($this); + } + + /** + * The collaborator that reads balances and transaction history. + * + * @return AccountReader + */ + public function getAccountReader(): AccountReader + { + return new AccountReader($this); + } + + /** + * The collaborator that reads the order book. + * + * @return OrderbookReader + */ + public function getOrderbookReader(): OrderbookReader + { + return new OrderbookReader($this); + } + + /** + * The collaborator that looks up the network fee. + * + * @return FeeCalculator + */ + public function getFeeCalculator(): FeeCalculator + { + return new FeeCalculator($this); + } + + /** + * The collaborator that funds wallets from a faucet. + * + * @return Faucet + */ + public function getFaucet(): Faucet + { + return new Faucet($this); + } + /** * The definitions transactions of this connection are encoded against. * @@ -323,7 +388,7 @@ private function getCollectKeyFromCommand(string $command): string|null */ public function getXrpBalance(string $address): string { - return (new AccountReader($this))->getXrpBalance($address); + return $this->getAccountReader()->getXrpBalance($address); } /** * Every balance an account holds: XRP and all its trust lines. @@ -346,7 +411,7 @@ public function getBalances( ?int $limit = null ): array { - return (new AccountReader($this))->getBalances($address, $ledgerHash, $ledgerIndex, $peer, $limit); + return $this->getAccountReader()->getBalances($address, $ledgerHash, $ledgerIndex, $peer, $limit); } /** * The transaction history of an account, newest first unless $forward is set. @@ -376,7 +441,7 @@ public function getTransactions( mixed $marker = null ): array { - return (new AccountReader($this))->getTransactions($address, $ledgerIndexMin, $ledgerIndexMax, $ledgerHash, $ledgerIndex, $binary, $forward, $limit, $marker); + return $this->getAccountReader()->getTransactions($address, $ledgerIndexMin, $ledgerIndexMax, $ledgerHash, $ledgerIndex, $binary, $forward, $limit, $marker); } /** * The offers standing in one order book of the decentralized exchange. @@ -399,7 +464,7 @@ public function getOrderbook( ?string $taker = null ): array { - return (new OrderbookReader($this))->getOrderbook($takerGets, $takerPays, $ledgerHash, $ledgerIndex, $limit, $taker); + return $this->getOrderbookReader()->getOrderbook($takerGets, $takerPays, $ledgerHash, $ledgerIndex, $limit, $taker); } /** @@ -410,7 +475,7 @@ public function getOrderbook( */ public function getFeeXrp(?int $cushion = null): string { - return (new FeeCalculator($this))->getFeeXrp($cushion === null ? null : (float)$cushion); + return $this->getFeeCalculator()->getFeeXrp($cushion === null ? null : (float)$cushion); } /** * Ask a test network faucet for a funded wallet. @@ -422,7 +487,7 @@ public function getFeeXrp(?int $cushion = null): string */ public function fundWallet(?Wallet $wallet = null, ?string $faucetHost = null): Wallet { - return (new Faucet($this))->fundWallet($wallet, $faucetHost)['wallet']; + return $this->getFaucet()->fundWallet($wallet, $faucetHost)['wallet']; } /** @@ -440,7 +505,7 @@ public function fundWallet(?Wallet $wallet = null, ?string $faucetHost = null): */ public function autofill(Transaction|array $transaction, ?int $signersCount = null): array { - return (new Autofiller($this))->autofill($transaction, $signersCount); + return $this->getAutofiller()->autofill($transaction, $signersCount); } /** * Submit a transaction and return the server's preliminary opinion. @@ -460,7 +525,7 @@ public function submit( ?Wallet $wallet = null ): SubmitResponse { - return (new Submitter($this))->submit($transaction, $autofill, $failHard, $wallet); + return $this->getSubmitter()->submit($transaction, $autofill, $failHard, $wallet); } /** * Submit a transaction and wait until its outcome is final. @@ -481,7 +546,7 @@ public function submitAndWait( ?Wallet $wallet = null ): TxResponse { - return (new Submitter($this))->submitAndWait($transaction, $autofill, $failHard, $wallet); + return $this->getSubmitter()->submitAndWait($transaction, $autofill, $failHard, $wallet); } /** diff --git a/src/Client/Submitter.php b/src/Client/Submitter.php index 8a7fc5f..53a82c6 100644 --- a/src/Client/Submitter.php +++ b/src/Client/Submitter.php @@ -203,7 +203,7 @@ public function getSignedTx( } if ($autofill) { - $tx = (new Autofiller($this->client))->autofill($tx); + $tx = $this->client->getAutofiller()->autofill($tx); } // Wallet::sign() returns a tx_blob/hash envelope, while every caller diff --git a/tests/Client/ReplaceableCollaboratorsTest.php b/tests/Client/ReplaceableCollaboratorsTest.php new file mode 100644 index 0000000..93da8b7 --- /dev/null +++ b/tests/Client/ReplaceableCollaboratorsTest.php @@ -0,0 +1,142 @@ +start(); + } + + public static function tearDownAfterClass(): void + { + self::$server->stop(); + } + + protected function setUp(): void + { + self::$server->setDefaultResponse(new RpcMethodResponse([ + 'server_info' => [ + 'info' => [ + 'validated_ledger' => ['base_fee_xrp' => 0.00001], + 'load_factor' => 1, + ], + ], + 'account_info' => ['account_data' => ['Sequence' => 1432]], + 'ledger' => ['ledger_index' => 2908714], + 'submit' => ['engine_result' => 'tesSUCCESS'], + ])); + } + + private function client(): JsonRpcClient + { + return new JsonRpcClient(self::$server->getServerRoot()); + } + + private function customClient(): JsonRpcClient + { + return new class(self::$server->getServerRoot()) extends JsonRpcClient { + public function getAutofiller(): Autofiller + { + return new class($this) extends Autofiller { + public function calculateFeePerTransactionType(array &$tx, ?int $signersCount = 0): void + { + $tx['Fee'] = ReplaceableCollaboratorsTest::CUSTOM_FEE; + } + }; + } + }; + } + + private function payment(): array + { + return [ + 'TransactionType' => 'Payment', + 'Account' => 'rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf', + 'Destination' => self::DESTINATION, + 'Amount' => '1000', + ]; + } + + public function testDefaultClientUsesTheOwnCollaborators(): void + { + $this->assertInstanceOf(Autofiller::class, $this->client()->getAutofiller()); + $this->assertInstanceOf(Submitter::class, $this->client()->getSubmitter()); + } + + public function testOverriddenAutofillerIsUsedByTheClient(): void + { + $this->assertEquals('12', $this->client()->autofill($this->payment())['Fee']); + $this->assertEquals(self::CUSTOM_FEE, $this->customClient()->autofill($this->payment())['Fee']); + } + + /** + * The path that matters: submitAndWait() autofills through the Submitter, + * which used to construct an Autofiller of its own, so an override never + * reached it. + */ + public function testOverriddenAutofillerReachesTheSubmitter(): void + { + $client = $this->customClient(); + $wallet = Wallet::fromSeed(self::SEED); + + $tx = $this->payment(); + $tx['Account'] = $wallet->getAddress(); + + $signed = $client->getSubmitter()->getSignedTx($tx, true, $wallet); + + $this->assertEquals(self::CUSTOM_FEE, $signed['Fee']); + } + + /** + * The fee lookup is reached from inside the Autofiller, so it has to come + * from the client as well. + */ + public function testOverriddenFeeCalculatorReachesTheAutofiller(): void + { + $client = new class(self::$server->getServerRoot()) extends JsonRpcClient { + public function getFeeCalculator(): \Hardcastle\XRPL_PHP\Client\FeeCalculator + { + return new class($this) extends \Hardcastle\XRPL_PHP\Client\FeeCalculator { + public function getFeeXrp(?float $cushion = null): string + { + return '0.000500'; + } + }; + } + }; + + // 0.0005 XRP = 500 drops, and the cushion is already included + $this->assertEquals('500', $client->autofill($this->payment())['Fee']); + } +}