From 61a223c22544d78231783b52277e377b78d33ab3 Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:28:48 +0200 Subject: [PATCH 1/3] feat: let a subclass replace the client's collaborators JsonRpcClient exposes getAutofiller(), getSubmitter(), getAccountReader(),getOrderbookReader(), getFeeCalculator() and getFaucet(). Overriding one in a subclass substitutes it everywhere the client works with it. Submitter and Autofiller no longer construct collaborators of their own but ask the client for them, so an override reaches the indirect paths too - notably submitAndWait(), which autofills through the Submitter rather than through the client. This is needed for a Xahau package built on the upcoming xrpl-php. 3.0.0 made the definitions injectable, which covers the encoding: Xahau assigns different ordinals to most of the types both networks share. It does not cover behaviour, and Xahau differs there as well - it prices a transaction individually because hooks may fire, so its fee has to be queried per transaction with the tx_blob rather than derived from the network's base fee. Submitting a Remit to the Xahau testnet with the XRP Ledger formula is rejected with telINSUF_FEE_P: 12 drops against the 24 the server asks for. Without this the Xahau package could write its own autofiller but not get it into the normal call path, and $client->submitAndWait($tx, autofill: true) would keep setting the wrong fee. Additive: every getter has the previous behaviour as its default. --- CHANGELOG.md | 10 ++ README.md | 27 ++++ src/Client/Autofiller.php | 2 +- src/Client/Faucet.php | 4 +- src/Client/JsonRpcClient.php | 83 ++++++++-- src/Client/Submitter.php | 2 +- tests/Client/ReplaceableCollaboratorsTest.php | 142 ++++++++++++++++++ 7 files changed, 257 insertions(+), 13 deletions(-) create mode 100644 tests/Client/ReplaceableCollaboratorsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6af3e..e198b85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.3.0] - unreleased + +### 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] - unreleased ### Added diff --git a/README.md b/README.md index de2900b..6c3d3ae 100644 --- a/README.md +++ b/README.md @@ -245,5 +245,32 @@ 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 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']); + } +} From 6b7ad433a720c356baf762dfd498835fd67d73d9 Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:19:54 +0200 Subject: [PATCH 2/3] task: fix CHANGELOG.md --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e198b85..f257f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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.3.0] - unreleased +## [2.3.0] - 2026-08-26 ### Added - `JsonRpcClient` exposes its collaborators through `getAutofiller()`, @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 including the indirect ones, where `Submitter` and `Autofiller` used to construct collaborators of their own. -## [2.2.0] - unreleased +## [2.2.0] - 2026-08-25 ### Added - Object form of the Sugar functions: `Autofiller`, `Submitter`, `AccountReader`, From 3269e1685966cb7575298565ab1253044522c9cd Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:53:38 +0200 Subject: [PATCH 3/3] docs: restructure the README and fix its snippets - Docker and the test instructions move out of "Examples", where they were subsections, into a "Development" block at the end - New section "The objects behind the client" for the classes added in 2.2.0, which the README did not mention although the Sugar functions are deprecated in their favour - The five examples that were missing from the list are added Fixes three defects in the code samples: - The account_objects sample called getBody() on the return of syncRequest(), which is a BaseResponse and has no such method, so it died with a fatal error. It also carried an "Account Info" heading over an AccountObjectsRequest. - The payment sample derived the operational wallet from the standby seed. - It sent 100 XRP, exactly what the faucet hands out, so following it with a faucet wallet yields tecUNFUNDED_PAYMENT because reserve and fee are left uncovered. The payment sample now leads with submitAndWait($tx, autofill: true, wallet: $wallet), which only started working with the getSignedTx() fix in 2.2.0, and keeps the sign-then-submit form as the alternative the files in examples/ use. All samples were run against the Testnet. --- README.md | 170 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 101 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 6c3d3ae..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 @@ -273,4 +262,47 @@ 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 +```