From ce73ebf01523bee9647398e272aa37613c4147ec Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:13:55 +0200 Subject: [PATCH 01/16] chore: set the 2.1.0 release date Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dbc31b..1ac0324 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.1.0] - unreleased +## [2.1.0] - 2026-08-24 ### Added - The binary codec works against definitions handed in from outside, so a package From 2eb235e01b01ae436456310c2b7b07ef3cb0e36e Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:36:17 +0200 Subject: [PATCH 02/16] test: cover the transaction cost calculation - RpcMethodResponse serves canned rippled responses keyed by JSON-RPC method. MockRippledResponse routes by URL path, which the client never varies, so anything reaching rippled through the normal code path could not be mocked. - FeeCalculationTest covers the network fee, the owner reserve that AccountDelete and AMMCreate pay instead, that maxFeeXrp does not cap the reserve but does cap the network fee, and that the EscrowFinish surcharge scales with the fulfillment size. Both special cases were fixed in 2.0.0 without a test. Reverting either fix now breaks two of these. Co-Authored-By: Claude Opus 5 --- tests/MockRippled/RpcMethodResponse.php | 88 +++++++++ tests/Sugar/FeeCalculationTest.php | 225 ++++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 tests/MockRippled/RpcMethodResponse.php create mode 100644 tests/Sugar/FeeCalculationTest.php diff --git a/tests/MockRippled/RpcMethodResponse.php b/tests/MockRippled/RpcMethodResponse.php new file mode 100644 index 0000000..9175989 --- /dev/null +++ b/tests/MockRippled/RpcMethodResponse.php @@ -0,0 +1,88 @@ +setDefaultResponse(new RpcMethodResponse([ + * 'fee' => ['drops' => ['open_ledger_fee' => '10']], + * 'account_info' => ['account_data' => ['Sequence' => 23]], + * ])); + * + * The payloads are the contents of rippled's `result` object; the envelope is + * added here. + */ +class RpcMethodResponse implements ResponseInterface +{ + /** + * @param array $resultsByMethod + * @param array $notFoundResult Returned for unmapped methods + */ + public function __construct( + private readonly array $resultsByMethod, + private readonly array $notFoundResult = ['error' => 'unknownCmd'] + ) { + } + + /** + * Which methods this response knows about, for assertions in tests. + * + * @return string[] + */ + public function getKnownMethods(): array + { + return array_keys($this->resultsByMethod); + } + + public function getRef(): string + { + return md5(json_encode($this->resultsByMethod) ?: ''); + } + + public function getBody(RequestInfo $request): string + { + $method = self::readMethod($request); + $result = $this->resultsByMethod[$method] ?? $this->notFoundResult; + + // rippled echoes the request's method back and wraps everything in + // a result object; several code paths read `status` and `validated`. + return json_encode([ + 'result' => $result + ['status' => 'success'], + ]) ?: ''; + } + + public function getHeaders(RequestInfo $request): array + { + return ['Content-Type' => 'application/json']; + } + + public function getStatus(RequestInfo $request): int + { + return 200; + } + + /** + * The JSON-RPC method of a request, or an empty string if the body is not + * a JSON-RPC call. + */ + public static function readMethod(RequestInfo $request): string + { + $body = json_decode($request->getInput(), true); + + return is_array($body) && isset($body['method']) && is_string($body['method']) + ? $body['method'] + : ''; + } +} diff --git a/tests/Sugar/FeeCalculationTest.php b/tests/Sugar/FeeCalculationTest.php new file mode 100644 index 0000000..a6fa719 --- /dev/null +++ b/tests/Sugar/FeeCalculationTest.php @@ -0,0 +1,225 @@ +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' => self::BASE_FEE_XRP], + 'load_factor' => 1, + ], + ], + 'server_state' => [ + 'state' => ['validated_ledger' => ['reserve_inc' => self::RESERVE_INC]], + ], + 'account_info' => [ + 'account_data' => ['Sequence' => self::SEQUENCE], + ], + 'ledger' => ['ledger_index' => self::LEDGER_INDEX], + 'account_objects' => ['account_objects' => []], + ])); + + $this->client = new JsonRpcClient(self::$server->getServerRoot()); + } + + /** + * base_fee_xrp 0.00001 XRP = 10 drops, times the default cushion of 1.2, + * rounded up. + */ + public function testOrdinaryTransactionPaysTheNetworkFee(): void + { + $request = [ + 'TransactionType' => 'Payment', + 'Account' => self::ACCOUNT, + 'Destination' => self::DESTINATION, + 'Amount' => '1000', + ]; + $tx = $this->client->autofill($request); + + $this->assertEquals('12', $tx['Fee']); + } + + /** + * AMMCreate burns one owner reserve. Before 2.0.0 it got the network fee + * and every AMMCreate was rejected with telINSUF_FEE_P. + */ + public function testAmmCreatePaysTheOwnerReserve(): void + { + $request = [ + 'TransactionType' => 'AMMCreate', + 'Account' => self::ACCOUNT, + 'Amount' => '1000', + 'Amount2' => ['currency' => 'USD', 'issuer' => self::DESTINATION, 'value' => '10'], + 'TradingFee' => 10, + ]; + $tx = $this->client->autofill($request); + + $this->assertEquals(self::RESERVE_INC, $tx['Fee']); + } + + public function testAccountDeletePaysTheOwnerReserve(): void + { + $request = [ + 'TransactionType' => 'AccountDelete', + 'Account' => self::ACCOUNT, + 'Destination' => self::DESTINATION, + ]; + $tx = $this->client->autofill($request); + + $this->assertEquals(self::RESERVE_INC, $tx['Fee']); + } + + /** + * The owner reserve is a protocol requirement, so maxFeeXrp must not cap + * it - the default cap of 2 XRP is lower than many networks' reserve. + */ + public function testOwnerReserveIsNotCappedByMaxFeeXrp(): void + { + $client = new JsonRpcClient(self::$server->getServerRoot(), null, '0.1'); + + $request = [ + 'TransactionType' => 'AMMCreate', + 'Account' => self::ACCOUNT, + 'Amount' => '1000', + 'Amount2' => ['currency' => 'USD', 'issuer' => self::DESTINATION, 'value' => '10'], + 'TradingFee' => 10, + ]; + $tx = $client->autofill($request); + + $this->assertEquals(self::RESERVE_INC, $tx['Fee']); + } + + /** + * The ordinary fee, in contrast, is capped. + */ + public function testNetworkFeeIsCappedByMaxFeeXrp(): void + { + $client = new JsonRpcClient(self::$server->getServerRoot(), null, '0.000005'); + + $request = [ + 'TransactionType' => 'Payment', + 'Account' => self::ACCOUNT, + 'Destination' => self::DESTINATION, + 'Amount' => '1000', + ]; + $tx = $client->autofill($request); + + $this->assertEquals('5', $tx['Fee']); + } + + /** + * net fee x (33 + fulfillment bytes / 16), where the net fee is the + * cushioned one, as in xrpl.js. The size used to be computed as + * strlen($fulfillment / 2) instead of strlen($fulfillment) / 2, so the hex + * string was cast to a number and the size was always 1 byte. + */ + public function testEscrowFinishFeeScalesWithFulfillmentSize(): void + { + // 32 hex characters = 16 bytes, net fee 10 x 1.2 = 12 drops + // -> 12 x (33 + 16/16) = 408 + $fulfillment = str_repeat('A0', 16); + + $request = [ + 'TransactionType' => 'EscrowFinish', + 'Account' => self::ACCOUNT, + 'Owner' => self::DESTINATION, + 'OfferSequence' => 7, + 'Fulfillment' => $fulfillment, + ]; + $tx = $this->client->autofill($request); + + $this->assertEquals('408', $tx['Fee']); + } + + /** + * A larger fulfillment has to cost more - with the old bug every size gave + * the same fee. + */ + public function testLargerFulfillmentCostsMore(): void + { + $request = [ + 'TransactionType' => 'EscrowFinish', + 'Account' => self::ACCOUNT, + 'Owner' => self::DESTINATION, + 'OfferSequence' => 7, + 'Fulfillment' => str_repeat('A0', 16), + ]; + $small = $this->client->autofill($request); + + $request = [ + 'TransactionType' => 'EscrowFinish', + 'Account' => self::ACCOUNT, + 'Owner' => self::DESTINATION, + 'OfferSequence' => 7, + 'Fulfillment' => str_repeat('A0', 256), + ]; + $large = $this->client->autofill($request); + + $this->assertGreaterThan((int)$small['Fee'], (int)$large['Fee']); + } + + /** + * autofill() fills in what the transaction does not carry already. + */ + public function testAutofillSetsSequenceAndLastLedgerSequence(): void + { + $request = [ + 'TransactionType' => 'Payment', + 'Account' => self::ACCOUNT, + 'Destination' => self::DESTINATION, + 'Amount' => '1000', + ]; + $tx = $this->client->autofill($request); + + $this->assertEquals(self::SEQUENCE, $tx['Sequence']); + $this->assertEquals(self::LEDGER_INDEX + 20, $tx['LastLedgerSequence']); + } +} From f6fafbbe0b4eec9ea6a3073003f10a4e6ef5c939 Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:40:47 +0200 Subject: [PATCH 03/16] refactor: stop using deprecated brick/math APIs The test suite ran with four deprecation notices, all from brick/math 0.14 announcing removals in 0.15: - MathUtilities used BigDecimal::getIntegralPart() and getFractionalPart(), which 0.15 removes and 0.16 reintroduces with a different meaning. They are replaced by splitting the decimal's string form, which reproduces both parts including the sign. - DROPS_PER_XRP was the float 1000000.0 although drops are a whole number, so every conversion passed a float into brick/math. - The fee cushion, the load factor and the EscrowFinish multiplier reached brick/math as floats and are converted explicitly. - exactlyDividedBy() is renamed to dividedByExact(). MathUtilitiesTest characterises the three decimal helpers, which had no tests of their own. The expectations were captured from the old implementation before the change, quirks included: the precision of a value below one counts the leading zero, and zero itself has precision 0. The same 13 cases were also run against a real brick/math 0.15 in an isolated project and produce identical results. The constraint stays at <0.15 because hardcastle/buffer caps brick/math at ^0.14, so the full suite cannot be run against it yet. Co-Authored-By: Claude Opus 5 --- src/Core/MathUtilities.php | 57 ++++++++++++++------- src/Sugar/autofill.php | 4 +- src/Sugar/getFeeXrp.php | 4 +- src/Sugar/xrpConversion.php | 4 +- tests/Core/MathUtilitiesTest.php | 88 ++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 22 deletions(-) create mode 100644 tests/Core/MathUtilitiesTest.php diff --git a/src/Core/MathUtilities.php b/src/Core/MathUtilities.php index ec802f4..3d2daf7 100644 --- a/src/Core/MathUtilities.php +++ b/src/Core/MathUtilities.php @@ -56,44 +56,65 @@ public static function sha512Half(Buffer|string $input): Buffer */ public static function getBigDecimalPrecision(BigDecimal $number, bool $include_zeros = false): int { - $absNumber = $number->abs(); // Get the absolute value - $integralPart = $absNumber->getIntegralPart(); - $fractionalPart = $absNumber->getFractionalPart(); - - if ($include_zeros) { - $combined = $integralPart . $fractionalPart; - } else { - $combined = rtrim($integralPart . $fractionalPart, '0'); + [$integralPart, $fractionalPart] = self::splitDecimal($number->abs()); + + $combined = $integralPart . $fractionalPart; + if (!$include_zeros) { + $combined = rtrim($combined, '0'); } return strlen($combined); - } /** + * The power of ten of the most significant digit. + * * @param BigDecimal $number * @return int */ - public static function getBigDecimalExponent(BigDecimal $number):int + public static function getBigDecimalExponent(BigDecimal $number): int { - if (str_starts_with('0', $number->abs()->getIntegralPart())) { - $fractional = $number->abs()->getFractionalPart(); + [$integralPart, $fractionalPart] = self::splitDecimal($number->abs()); - return -1 * (strlen($number->abs()->getFractionalPart()) - strlen(ltrim($fractional, '0')) + 1); + // Below one the exponent is negative and counts the leading zeros of + // the fractional part. + if ($integralPart === '0') { + return -1 * (strlen($fractionalPart) - strlen(ltrim($fractionalPart, '0')) + 1); } - return strlen($number->abs()->getIntegralPart()) - 1; + return strlen($integralPart) - 1; } public static function trimAmountZeros(BigDecimal $amount): string { - $ip = $amount->getIntegralPart(); - $fp = $amount->getFractionalPart(); + [$integralPart, $fractionalPart] = self::splitDecimal($amount); - $trimmed = rtrim($fp, '0'); + $trimmed = rtrim($fractionalPart, '0'); // A whole number is rendered without a fractional part, matching how // rippled and the reference SDKs serialize token amounts. - return (strlen($trimmed) > 0) ? $ip . '.' . $trimmed : $ip; + return (strlen($trimmed) > 0) ? $integralPart . '.' . $trimmed : $integralPart; + } + + /** + * Split a decimal into its integral and fractional digits. + * + * This replaces BigDecimal::getIntegralPart() and getFractionalPart(), + * which brick/math 0.15 removes and 0.16 reintroduces with a different + * meaning. The string form of a BigDecimal is exactly those two parts + * joined by a dot, so the split reproduces them including the sign on the + * integral part. + * + * @param BigDecimal $number + * @return array{0: string, 1: string} + */ + private static function splitDecimal(BigDecimal $number): array + { + $decimal = (string)$number; + $separator = strpos($decimal, '.'); + + return ($separator === false) + ? [$decimal, ''] + : [substr($decimal, 0, $separator), substr($decimal, $separator + 1)]; } } diff --git a/src/Sugar/autofill.php b/src/Sugar/autofill.php index 20a1ba1..592135b 100644 --- a/src/Sugar/autofill.php +++ b/src/Sugar/autofill.php @@ -218,7 +218,9 @@ function calculateFeePerTransactionType (JsonRpcClient $client, array &$tx, ?int */ function scaleValue (string $value, int|float $multiplier): BigDecimal { - return BigDecimal::of($value)->multipliedBy($multiplier); + // brick/math deprecates passing floats; the multipliers here are exact + // binary fractions, so the string form is lossless. + return BigDecimal::of($value)->multipliedBy((string)$multiplier); } /** diff --git a/src/Sugar/getFeeXrp.php b/src/Sugar/getFeeXrp.php index 5bd5817..36d4e29 100644 --- a/src/Sugar/getFeeXrp.php +++ b/src/Sugar/getFeeXrp.php @@ -44,7 +44,9 @@ function getFeeXrp( $serverInfo['load_factor'] = 1; } - $fee = $baseFeeXrp->multipliedBy($serverInfo['load_factor'])->multipliedBy($feeCushion); + $fee = $baseFeeXrp + ->multipliedBy((string)$serverInfo['load_factor']) + ->multipliedBy((string)$feeCushion); $fee = BigDecimal::min($fee, $client->getMaxFeeXrp()); diff --git a/src/Sugar/xrpConversion.php b/src/Sugar/xrpConversion.php index ad65e17..04ff3f9 100644 --- a/src/Sugar/xrpConversion.php +++ b/src/Sugar/xrpConversion.php @@ -6,7 +6,7 @@ use Brick\Math\BigInteger; use Exception; -const DROPS_PER_XRP = 1000000.0; +const DROPS_PER_XRP = 1000000; const MAX_FRACTION_LENGTH = 6; const SANITY_CHECK = "/^-?[0-9.]+$/u"; @@ -30,7 +30,7 @@ function dropsToXrp(mixed $dropsToConvert): string throw new Exception("dropsToXrp: failed sanity check - value \"{$drops}\" does not match (^-?[0-9]+$)."); } - return (string) BigDecimal::of($drops)->exactlyDividedBy(DROPS_PER_XRP); + return (string) BigDecimal::of($drops)->dividedByExact(DROPS_PER_XRP); } } diff --git a/tests/Core/MathUtilitiesTest.php b/tests/Core/MathUtilitiesTest.php new file mode 100644 index 0000000..ec81692 --- /dev/null +++ b/tests/Core/MathUtilitiesTest.php @@ -0,0 +1,88 @@ + + */ + public static function decimalProvider(): array + { + // value, precision, precision incl. zeros, exponent, trimmed + return [ + 'zero' => ['0', 0, 1, -1, '0'], + 'one' => ['1', 1, 1, 0, '1'], + 'ten' => ['10', 1, 2, 1, '10'], + 'thousand' => ['1000', 1, 4, 3, '1000'], + 'tenth' => ['0.1', 2, 2, -1, '0.1'], + 'thousandth' => ['0.001', 4, 4, -3, '0.001'], + 'mixed' => ['123.456', 6, 6, 2, '123.456'], + 'sixteen digits' => ['1111111111111111', 16, 16, 15, '1111111111111111'], + 'negative integer' => ['-2', 1, 1, 0, '-2'], + 'negative decimal' => ['-12.34567', 7, 7, 1, '-12.34567'], + 'tiny' => ['1e-20', 21, 21, -20, '0.00000000000000000001'], + 'small fraction' => ['0.0000123', 8, 8, -5, '0.0000123'], + 'quadrillion' => ['1000000000000000', 1, 16, 15, '1000000000000000'], + ]; + } + + #[DataProvider('decimalProvider')] + public function testPrecision(string $value, int $precision): void + { + $this->assertEquals($precision, MathUtilities::getBigDecimalPrecision(BigDecimal::of($value))); + } + + #[DataProvider('decimalProvider')] + public function testPrecisionIncludingZeros(string $value, int $precision, int $withZeros): void + { + $this->assertEquals( + $withZeros, + MathUtilities::getBigDecimalPrecision(BigDecimal::of($value), true) + ); + } + + #[DataProvider('decimalProvider')] + public function testExponent(string $value, int $precision, int $withZeros, int $exponent): void + { + $this->assertEquals($exponent, MathUtilities::getBigDecimalExponent(BigDecimal::of($value))); + } + + #[DataProvider('decimalProvider')] + public function testTrimAmountZeros( + string $value, + int $precision, + int $withZeros, + int $exponent, + string $trimmed + ): void { + $this->assertEquals($trimmed, MathUtilities::trimAmountZeros(BigDecimal::of($value))); + } + + /** + * A whole number is rendered without a fractional part - a trailing ".0" + * is not what rippled or the reference SDKs produce. + */ + public function testTrimAmountZerosDropsAnEmptyFraction(): void + { + $this->assertEquals('10000', MathUtilities::trimAmountZeros(BigDecimal::of('10000.000'))); + $this->assertEquals('10000.5', MathUtilities::trimAmountZeros(BigDecimal::of('10000.500'))); + } +} From bcec11c0ec6762ee4ab6665a77efc258ccb8d9bc Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:44:26 +0200 Subject: [PATCH 04/16] refactor: move autofill into an Autofiller class The eleven free functions of src/Sugar/autofill.php become methods of Hardcastle\XRPL_PHP\Client\Autofiller, which holds the client instead of taking it as a first argument. The free functions remain as @deprecated wrappers that delegate, so existing code keeps working. Fixes two bugs in the AccountDelete blocker check, which was dead twice over: - It was guarded by `!isset($tx['TransactionType'])` rather than a comparison against AccountDelete, so it ran only for transactions without a type and never for the one it exists for. - It counted blockers as `$result['account_objects']['length']`, a JavaScript idiom. In PHP that is an undefined key, so the comparison was `null > 0` and could never be true. JsonRpcClient::autofill() no longer takes the transaction by reference. It was never modified through it - the value was passed on by value internally - and the reference only forced callers to assign the array to a variable first. The method also accepts $signersCount now, which the multi-signing fee path needed but could not be reached from the client. Co-Authored-By: Claude Opus 5 --- src/Client/Autofiller.php | 312 +++++++++++++++++++++++++++++ src/Client/JsonRpcClient.php | 12 +- src/Sugar/autofill.php | 277 ++++--------------------- tests/Sugar/FeeCalculationTest.php | 111 +++++++--- 4 files changed, 440 insertions(+), 272 deletions(-) create mode 100644 src/Client/Autofiller.php diff --git a/src/Client/Autofiller.php b/src/Client/Autofiller.php new file mode 100644 index 0000000..413de0d --- /dev/null +++ b/src/Client/Autofiller.php @@ -0,0 +1,312 @@ +client->getDefinitions()))->decode($transaction); + } else if ($transaction instanceof Transaction) { + $tx = $transaction->toArray(); + } else { + $tx = $transaction; + } + + $this->setValidAddresses($tx); + + if (!isset($tx['Sequence'])) { + $this->setNextValidSequenceNumber($tx); + } + + if (!isset($tx['Fee'])) { + $this->calculateFeePerTransactionType($tx, $signersCount); + } + + if (!isset($tx['LastLedgerSequence'])) { + $this->setLatestValidatedLedgerSequence($tx); + } + + if (($tx['TransactionType'] ?? null) === 'AccountDelete') { + $this->checkAccountDeleteBlockers($tx); + } + + if (empty($tx['SourceTag'])) { + unset($tx['SourceTag']); + } + if (empty($tx['DestinationTag'])) { + unset($tx['DestinationTag']); + } + + return $tx; + } + + /** + * Resolve X-addresses into classic addresses and their tags. + * + * @param array $tx + * @return void + * @throws Exception + */ + public function setValidAddresses(array &$tx): void + { + $this->validateAccountAddress($tx, 'Account', 'SourceTag'); + + if (isset($tx['Destination'])) { + $this->validateAccountAddress($tx, 'Destination', 'DestinationTag'); + } + + // DepositPreauth: + $this->convertToClassicAddress($tx, 'Authorize'); + $this->convertToClassicAddress($tx, 'Unauthorize'); + + // EscrowCancel, EscrowFinish: + $this->convertToClassicAddress($tx, 'Owner'); + + // SetRegularKey: + $this->convertToClassicAddress($tx, 'RegularKey'); + } + + /** + * @param array $tx + * @param string $accountField + * @param string $tagField + * @return void + * @throws Exception + */ + public function validateAccountAddress(array &$tx, string $accountField, string $tagField): void + { + ['classicAccount' => $classicAccount, 'tag' => $tag] = self::getClassicAccountAndTag($tx[$accountField]); + + $tx[$accountField] = $classicAccount; + + if (isset($tag) && $tag !== false) { + if (isset($tx[$tagField]) && $tx[$tagField] !== $tag) { + throw new Exception("The {$tagField}, if present, must match the tag of the {$accountField} X-address"); + } + + $tx[$tagField] = $tag; + } + } + + /** + * @param string $account + * @param int|null $expectedTag + * @return array + * @throws Exception + */ + public static function getClassicAccountAndTag(string $account, ?int $expectedTag = null): array + { + if (CoreUtilities::isValidXAddress($account)) { + $classicAddress = CoreUtilities::xAddressToClassicAddress($account); + if (!is_null($expectedTag) && $expectedTag !== $classicAddress['tag']) { + throw new Exception('Address includes a tag that does not match the tag specified in the transaction'); + } + + return [ + 'classicAccount' => $classicAddress['classicAddress'], + 'tag' => $classicAddress['tag'] + ]; + } + + return [ + 'classicAccount' => $account, + 'tag' => $expectedTag + ]; + } + + /** + * @param array $tx + * @param string $fieldName + * @return void + * @throws Exception + */ + public function convertToClassicAddress(array &$tx, string $fieldName): void + { + $account = $tx[$fieldName] ?? null; + + if (is_string($account)) { + ['classicAccount' => $classicAccount] = self::getClassicAccountAndTag($account); + $tx[$fieldName] = $classicAccount; + } + } + + /** + * @param array $tx + * @return void + * @throws Exception + */ + public function setNextValidSequenceNumber(array &$tx): void + { + $accountInfoRequest = new AccountInfoRequest( + account: $tx['Account'], + ledgerIndex: 'current' + ); + + $accountInfoResponse = $this->client->syncRequest($accountInfoRequest); + if ($accountInfoResponse instanceof ErrorResponse) { + throw new Exception($accountInfoResponse->getError()); + } + + $tx['Sequence'] = $accountInfoResponse->getResult()['account_data']['Sequence']; + } + + /** + * The owner reserve, which AccountDelete and AMMCreate pay as their + * transaction cost instead of the ordinary network fee. + * + * @return BigDecimal + * @throws MathException + */ + public function fetchOwnerReserveFee(): BigDecimal + { + $serverStateResponse = $this->client->request(new ServerStateRequest())->wait(); + + $fee = $serverStateResponse->getResult()['state']['validated_ledger']['reserve_inc'] ?? null; + + if (is_null($fee)) { + throw new Exception('Could not read the owner reserve from server_state'); + } + + return BigDecimal::of($fee); + } + + /** + * @param array $tx + * @param int|null $signersCount + * @return void + * @throws MathException + */ + public function calculateFeePerTransactionType(array &$tx, ?int $signersCount = 0): void + { + $netFeeXrp = getFeeXrp($this->client); + $netFeeDrops = xrpToDrops($netFeeXrp); + $baseFee = BigDecimal::of($netFeeDrops); + + if ($tx['TransactionType'] === 'EscrowFinish' && isset($tx['Fulfillment']) && !is_null($tx['Fulfillment'])) { + // net fee x (33 + fulfillment size in bytes / 16) + $fulfillmentBytesSize = ceil(strlen($tx['Fulfillment']) / 2); + $product = self::scaleValue($netFeeDrops, 33 + $fulfillmentBytesSize / 16); + $baseFee = $product->toScale(0, RoundingMode::CEILING); + } + + // Both burn one owner reserve instead of paying the ordinary network fee + $paysOwnerReserve = in_array($tx['TransactionType'], self::OWNER_RESERVE_FEE_TYPES, true); + if ($paysOwnerReserve) { + $baseFee = $this->fetchOwnerReserveFee(); + } + + /* + * Multi-signed Transaction + * 10 drops x (1 + Number of Signatures Provided) + */ + if ($signersCount > 0) { + $baseFee = BigDecimal::sum($baseFee, self::scaleValue($netFeeDrops, 1 + $signersCount)); + } + + // The owner reserve is a protocol requirement, so maxFeeXrp must not cap it + $maxFeeDrops = xrpToDrops($this->client->getMaxFeeXrp()); + $totalFee = $paysOwnerReserve ? $baseFee : BigDecimal::min($baseFee, $maxFeeDrops); + + $tx['Fee'] = (string)$totalFee->toScale(0, RoundingMode::CEILING); + } + + /** + * @param string $value + * @param int|float $multiplier + * @return BigDecimal + * @throws MathException + */ + public static function scaleValue(string $value, int|float $multiplier): BigDecimal + { + // brick/math deprecates passing floats; the multipliers here are exact + // binary fractions, so the string form is lossless. + return BigDecimal::of($value)->multipliedBy((string)$multiplier); + } + + /** + * @param array $tx + * @return void + */ + public function setLatestValidatedLedgerSequence(array &$tx): void + { + $tx['LastLedgerSequence'] = $this->client->getLedgerIndex() + self::LEDGER_OFFSET; + } + + /** + * An account holding Escrows, PayChannels, RippleStates or Checks cannot + * be deleted. + * + * @param array $tx + * @return void + * @throws Exception + */ + public function checkAccountDeleteBlockers(array &$tx): void + { + $accountObjectsRequest = new AccountObjectsRequest( + account: $tx['Account'], + ledgerIndex: 'validated', + deletionBlockersOnly: true + ); + + $accountObjectsResponse = $this->client->request($accountObjectsRequest)->wait(); + + $blockers = $accountObjectsResponse->getResult()['account_objects'] ?? []; + + if (count($blockers) > 0) { + throw new Exception("Account {$tx['Account']} cannot be deleted; there are Escrows, PayChannels, RippleStates, or Checks associated with the account."); + } + } +} diff --git a/src/Client/JsonRpcClient.php b/src/Client/JsonRpcClient.php index 39550f7..f9f2573 100644 --- a/src/Client/JsonRpcClient.php +++ b/src/Client/JsonRpcClient.php @@ -29,7 +29,6 @@ use Hardcastle\XRPL_PHP\Models\Transaction\TransactionTypes\BaseTransaction as Transaction; use Hardcastle\XRPL_PHP\Models\Transaction\TxResponse; use Hardcastle\XRPL_PHP\Wallet\Wallet; -use function Hardcastle\XRPL_PHP\Sugar\autofill; use function Hardcastle\XRPL_PHP\Sugar\fundWallet; use function Hardcastle\XRPL_PHP\Sugar\getXrpBalance; use function Hardcastle\XRPL_PHP\Sugar\getBalances; @@ -413,14 +412,21 @@ public function fundWallet(?Wallet $wallet = null, ?string $faucetHost = null): } /** + * Fill in Sequence, Fee and LastLedgerSequence where the transaction does + * not carry them already. * + * The transaction used to be taken by reference although it was never + * modified, which forced callers to pass a variable. It is passed by value + * now; existing calls keep working. * * @param Transaction|array $transaction + * @param int|null $signersCount Number of signatures a multi-signed transaction will carry * @return array + * @throws Exception */ - public function autofill(Transaction|array &$transaction): array + public function autofill(Transaction|array $transaction, ?int $signersCount = null): array { - return autofill($this, $transaction); + return (new Autofiller($this))->autofill($transaction, $signersCount); } /** diff --git a/src/Sugar/autofill.php b/src/Sugar/autofill.php index 592135b..35813ed 100644 --- a/src/Sugar/autofill.php +++ b/src/Sugar/autofill.php @@ -3,266 +3,96 @@ namespace Hardcastle\XRPL_PHP\Sugar; use Brick\Math\BigDecimal; -use Brick\Math\Exception\MathException; -use Brick\Math\Exception\RoundingNecessaryException; -use Brick\Math\RoundingMode; use Exception; +use Hardcastle\XRPL_PHP\Client\Autofiller; use Hardcastle\XRPL_PHP\Client\JsonRpcClient; -use Hardcastle\XRPL_PHP\Core\CoreUtilities; -use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\BinaryCodec; -use Hardcastle\XRPL_PHP\Models\Account\AccountInfoRequest; -use Hardcastle\XRPL_PHP\Models\Account\AccountObjectsRequest; -use Hardcastle\XRPL_PHP\Models\ErrorResponse; -use Hardcastle\XRPL_PHP\Models\ServerInfo\ServerStateRequest; use Hardcastle\XRPL_PHP\Models\Transaction\TransactionTypes\BaseTransaction as Transaction; /** - * Transaction types whose cost is one owner reserve rather than the network fee. - */ -const OWNER_RESERVE_FEE_TYPES = ['AccountDelete', 'AMMCreate']; - -/** - * @param array $tx - * @return void - * @throws Exception - */ -function setValidAddresses (array &$tx): void -{ - validateAccountAddress($tx, 'Account', 'SourceTag'); - - if (isset($tx['Destination'])) { - validateAccountAddress($tx, 'Destination', 'DestinationTag'); - } - - // DepositPreauth: - convertToClassicAddress($tx, 'Authorize'); - convertToClassicAddress($tx, 'Unauthorize'); - - // EscrowCancel, EscrowFinish: - convertToClassicAddress($tx, 'Owner'); - - // SetRegularKey: - convertToClassicAddress($tx, 'RegularKey'); -} - -/** - * Covert tx if X-address is given. If tag is provided in tx and X-address, check if the match. + * Thin wrappers around Hardcastle\XRPL_PHP\Client\Autofiller. * - * @param array $tx - * @param string $accountField - * @param string $tagField - * @return void - * @throws Exception + * The logic moved into that class; these functions remain so that existing + * code keeps working. They will be removed in a future major version. */ -function validateAccountAddress (array &$tx, string $accountField, string $tagField): void -{ - ['classicAccount' => $classicAccount, 'tag' => $tag] = getClassicAccountAndTag($tx[$accountField]); - - $tx[$accountField] = $classicAccount; - - if (isset($tag) && $tag !== false) { - if(isset($tx[$tagField]) && $tx[$tagField] !== $tag) { - throw new Exception("The {$tagField}, if present, must match the tag of the {$accountField} X-address"); - } - - $tx[$tagField] = $tag; - } -} /** - * @param string $account - * @param int|null $expectedTag - * @return array - * @throws Exception + * @deprecated Use Autofiller::getClassicAccountAndTag() */ -function getClassicAccountAndTag (string $account, ?int $expectedTag = null): array +function getClassicAccountAndTag(string $account, ?int $expectedTag = null): array { - if (CoreUtilities::isValidXAddress($account)) { - $classicAddress = CoreUtilities::xAddressToClassicAddress($account); - if (!is_null($expectedTag) && $expectedTag !== $classicAddress['tag']) { - throw new Exception('Address includes a tag that does not match the tag specified in the transaction'); - } - - return [ - 'classicAccount' => $classicAddress['classicAddress'], - 'tag' => $classicAddress['tag'] - ]; - } - - return [ - 'classicAccount' => $account, - 'tag' => $expectedTag - ]; + return Autofiller::getClassicAccountAndTag($account, $expectedTag); } /** - * @param array $tx - * @param string $fieldName - * @return void - * @throws Exception + * @deprecated Use Autofiller::scaleValue() */ -function convertToClassicAddress (array &$tx, string $fieldName): void +function scaleValue(string $value, int|float $multiplier): BigDecimal { - $account = $tx[$fieldName] ?? null; - - if(is_string($account)) { - ['classicAccount' => $classicAccount] = getClassicAccountAndTag($account); - $tx[$fieldName] = $classicAccount; - } + return Autofiller::scaleValue($value, $multiplier); } /** - * @param JsonRpcClient $client - * @param array $tx - * @return void - * @throws Exception + * @deprecated Use Autofiller::setValidAddresses() */ -function setNextValidSequenceNumber (JsonRpcClient $client, array &$tx): void +function setValidAddresses(array &$tx): void { - $accountInfoRequest = new AccountInfoRequest( - account: $tx['Account'], - ledgerIndex: 'current' - ); - - $accountInfoResponse = $client->syncRequest($accountInfoRequest); - if($accountInfoResponse instanceof ErrorResponse) { - throw new Exception($accountInfoResponse->getError()); - } - - $tx['Sequence'] = $accountInfoResponse->getResult()['account_data']['Sequence']; + (new Autofiller(new JsonRpcClient('https://xrplcluster.com')))->setValidAddresses($tx); } /** - * The owner reserve, which AccountDelete and AMMCreate have to pay as their - * transaction cost instead of the ordinary network fee. - * - * @param JsonRpcClient $client - * @return BigDecimal - * @throws MathException + * @deprecated Use Autofiller::setNextValidSequenceNumber() */ -function fetchOwnerReserveFee (JsonRpcClient $client): BigDecimal +function setNextValidSequenceNumber(JsonRpcClient $client, array &$tx): void { - $serverStateRequest = new ServerStateRequest(); - - $serverStateResponse = $client->request($serverStateRequest)->wait(); - - $fee = $serverStateResponse->getResult()['state']['validated_ledger']['reserve_inc'] ?? null; - - if (is_null($fee)) { - throw new Exception('Could not read the owner reserve from server_state'); - } - - return BigDecimal::of($fee); + (new Autofiller($client))->setNextValidSequenceNumber($tx); } /** - * @param JsonRpcClient $client - * @return BigDecimal - * @throws MathException - * @deprecated Use fetchOwnerReserveFee(), the fee is not specific to AccountDelete + * @deprecated Use Autofiller::fetchOwnerReserveFee() */ -function fetchAccountDeleteFee (JsonRpcClient $client): BigDecimal +function fetchOwnerReserveFee(JsonRpcClient $client): BigDecimal { - return fetchOwnerReserveFee($client); + return (new Autofiller($client))->fetchOwnerReserveFee(); } /** - * @param JsonRpcClient $client - * @param array $tx - * @param int|null $signersCount - * @return void - * @throws MathException - * @throws RoundingNecessaryException + * @deprecated Use Autofiller::fetchOwnerReserveFee(); the fee is not specific + * to AccountDelete */ -function calculateFeePerTransactionType (JsonRpcClient $client, array &$tx, ?int $signersCount = 0): void +function fetchAccountDeleteFee(JsonRpcClient $client): BigDecimal { - $netFeeXrp = getFeeXrp($client); - $netFeeDrops = xrpToDrops($netFeeXrp); - $baseFee = BigDecimal::of($netFeeDrops); - - if ($tx['TransactionType'] === 'EscrowFinish' && isset($tx['Fulfillment']) && !is_null($tx['Fulfillment'])) { - // 10 drops × (33 + (Fulfillment size in bytes / 16)) - $fulfillmentBytesSize = ceil(strlen($tx['Fulfillment']) / 2); - $product = BigDecimal::of(scaleValue($netFeeDrops, 33 + $fulfillmentBytesSize / 16)); - $baseFee = $product->toScale(0, RoundingMode::CEILING); - } - - // Both burn one owner reserve instead of paying the ordinary network fee - if (in_array($tx['TransactionType'], OWNER_RESERVE_FEE_TYPES, true)) { - $baseFee = fetchOwnerReserveFee($client); - } - - /* - * Multi-signed Transaction - * 10 drops × (1 + Number of Signatures Provided) - */ - if ($signersCount > 0) { - $baseFee = BigDecimal::sum($baseFee, scaleValue($netFeeDrops, 1 + $signersCount)); - } - - // The owner reserve is a protocol requirement, so maxFeeXrp must not cap it - $maxFeeDrops = xrpToDrops($client->getMaxFeeXrp()); - $totalFee = in_array($tx['TransactionType'], OWNER_RESERVE_FEE_TYPES, true) - ? $baseFee - : BigDecimal::min($baseFee, $maxFeeDrops); - - // Round up baseFee and return it as a string - $tx['Fee'] = (string) $totalFee->toScale(0, RoundingMode::CEILING); + return (new Autofiller($client))->fetchOwnerReserveFee(); } /** - * @param string $value - * @param int|float $multiplier - * @return BigDecimal - * @throws MathException + * @deprecated Use Autofiller::calculateFeePerTransactionType() */ -function scaleValue (string $value, int|float $multiplier): BigDecimal +function calculateFeePerTransactionType(JsonRpcClient $client, array &$tx, ?int $signersCount = 0): void { - // brick/math deprecates passing floats; the multipliers here are exact - // binary fractions, so the string form is lossless. - return BigDecimal::of($value)->multipliedBy((string)$multiplier); + (new Autofiller($client))->calculateFeePerTransactionType($tx, $signersCount); } /** - * @param JsonRpcClient $client - * @param array $tx - * @return void + * @deprecated Use Autofiller::setLatestValidatedLedgerSequence() */ -function setLatestValidatedLedgerSequence (JsonRpcClient $client, array &$tx): void +function setLatestValidatedLedgerSequence(JsonRpcClient $client, array &$tx): void { - $ledgerSequence = $client->getLedgerIndex(); - $ledgerOffset = 20; - $tx['LastLedgerSequence'] = $ledgerSequence + $ledgerOffset; + (new Autofiller($client))->setLatestValidatedLedgerSequence($tx); } /** - * @param JsonRpcClient $client - * @param array $tx - * @return void - * @throws Exception + * @deprecated Use Autofiller::checkAccountDeleteBlockers() */ -function checkAccountDeleteBlockers (JsonRpcClient $client, array &$tx): void +function checkAccountDeleteBlockers(JsonRpcClient $client, array &$tx): void { - $accountObjectsRequest = new AccountObjectsRequest( - account: $tx['Account'], - ledgerIndex: 'validated', - deletionBlockersOnly: true - ); - - $accountObjectsResponse = $client->request($accountObjectsRequest)->wait(); - - if ($accountObjectsResponse->getResult()['account_objects']['length'] > 0) { - throw new Exception("Account {$tx['Account']} cannot be deleted; there are Escrows, PayChannels, RippleStates, or Checks associated with the account."); - } + (new Autofiller($client))->checkAccountDeleteBlockers($tx); } if (! function_exists('Hardcastle\XRPL_PHP\Sugar\autofill')) { /** - * @param JsonRpcClient $client - * @param Transaction|string|array $transaction - * @param int|null $signersCount - * @return array + * @deprecated Use JsonRpcClient::autofill() or Autofiller::autofill() + * * @throws Exception */ function autofill( @@ -271,43 +101,6 @@ function autofill( ?int $signersCount = null ): array { - if (is_string($transaction)) { - $binaryCodec = new BinaryCodec($client->getDefinitions()); - $tx = $binaryCodec->decode($transaction); - } else if ($transaction instanceof Transaction) { - $tx = $transaction->toArray(); - } else { - $tx = $transaction; - } - - setValidAddresses($tx); - - //TODO: check function - //setTransactionFlagsToNumber($tx); - - if (!isset($tx['Sequence'])) { - setNextValidSequenceNumber($client, $tx); - } - - if (!isset($tx['Fee'])) { - calculateFeePerTransactionType($client, $tx, $signersCount); - } - - if (!isset($tx['LastLedgerSequence'])) { - setLatestValidatedLedgerSequence($client, $tx); - } - - if (!isset($tx['TransactionType'])) { - checkAccountDeleteBlockers($client, $tx); - } - - if (empty($tx['SourceTag'])) { - unset($tx['SourceTag']); - } - if (empty($tx['DestinationTag'])) { - unset($tx['DestinationTag']); - } - - return $tx; + return (new Autofiller($client))->autofill($transaction, $signersCount); } } diff --git a/tests/Sugar/FeeCalculationTest.php b/tests/Sugar/FeeCalculationTest.php index a6fa719..b99074f 100644 --- a/tests/Sugar/FeeCalculationTest.php +++ b/tests/Sugar/FeeCalculationTest.php @@ -76,13 +76,12 @@ protected function setUp(): void */ public function testOrdinaryTransactionPaysTheNetworkFee(): void { - $request = [ + $tx = $this->client->autofill([ 'TransactionType' => 'Payment', 'Account' => self::ACCOUNT, 'Destination' => self::DESTINATION, 'Amount' => '1000', - ]; - $tx = $this->client->autofill($request); + ]); $this->assertEquals('12', $tx['Fee']); } @@ -93,26 +92,24 @@ public function testOrdinaryTransactionPaysTheNetworkFee(): void */ public function testAmmCreatePaysTheOwnerReserve(): void { - $request = [ + $tx = $this->client->autofill([ 'TransactionType' => 'AMMCreate', 'Account' => self::ACCOUNT, 'Amount' => '1000', 'Amount2' => ['currency' => 'USD', 'issuer' => self::DESTINATION, 'value' => '10'], 'TradingFee' => 10, - ]; - $tx = $this->client->autofill($request); + ]); $this->assertEquals(self::RESERVE_INC, $tx['Fee']); } public function testAccountDeletePaysTheOwnerReserve(): void { - $request = [ + $tx = $this->client->autofill([ 'TransactionType' => 'AccountDelete', 'Account' => self::ACCOUNT, 'Destination' => self::DESTINATION, - ]; - $tx = $this->client->autofill($request); + ]); $this->assertEquals(self::RESERVE_INC, $tx['Fee']); } @@ -125,14 +122,13 @@ public function testOwnerReserveIsNotCappedByMaxFeeXrp(): void { $client = new JsonRpcClient(self::$server->getServerRoot(), null, '0.1'); - $request = [ + $tx = $client->autofill([ 'TransactionType' => 'AMMCreate', 'Account' => self::ACCOUNT, 'Amount' => '1000', 'Amount2' => ['currency' => 'USD', 'issuer' => self::DESTINATION, 'value' => '10'], 'TradingFee' => 10, - ]; - $tx = $client->autofill($request); + ]); $this->assertEquals(self::RESERVE_INC, $tx['Fee']); } @@ -144,13 +140,12 @@ public function testNetworkFeeIsCappedByMaxFeeXrp(): void { $client = new JsonRpcClient(self::$server->getServerRoot(), null, '0.000005'); - $request = [ + $tx = $client->autofill([ 'TransactionType' => 'Payment', 'Account' => self::ACCOUNT, 'Destination' => self::DESTINATION, 'Amount' => '1000', - ]; - $tx = $client->autofill($request); + ]); $this->assertEquals('5', $tx['Fee']); } @@ -167,14 +162,13 @@ public function testEscrowFinishFeeScalesWithFulfillmentSize(): void // -> 12 x (33 + 16/16) = 408 $fulfillment = str_repeat('A0', 16); - $request = [ + $tx = $this->client->autofill([ 'TransactionType' => 'EscrowFinish', 'Account' => self::ACCOUNT, 'Owner' => self::DESTINATION, 'OfferSequence' => 7, 'Fulfillment' => $fulfillment, - ]; - $tx = $this->client->autofill($request); + ]); $this->assertEquals('408', $tx['Fee']); } @@ -185,23 +179,21 @@ public function testEscrowFinishFeeScalesWithFulfillmentSize(): void */ public function testLargerFulfillmentCostsMore(): void { - $request = [ + $small = $this->client->autofill([ 'TransactionType' => 'EscrowFinish', 'Account' => self::ACCOUNT, 'Owner' => self::DESTINATION, 'OfferSequence' => 7, 'Fulfillment' => str_repeat('A0', 16), - ]; - $small = $this->client->autofill($request); + ]); - $request = [ + $large = $this->client->autofill([ 'TransactionType' => 'EscrowFinish', 'Account' => self::ACCOUNT, 'Owner' => self::DESTINATION, 'OfferSequence' => 7, 'Fulfillment' => str_repeat('A0', 256), - ]; - $large = $this->client->autofill($request); + ]); $this->assertGreaterThan((int)$small['Fee'], (int)$large['Fee']); } @@ -211,15 +203,80 @@ public function testLargerFulfillmentCostsMore(): void */ public function testAutofillSetsSequenceAndLastLedgerSequence(): void { - $request = [ + $tx = $this->client->autofill([ 'TransactionType' => 'Payment', 'Account' => self::ACCOUNT, 'Destination' => self::DESTINATION, 'Amount' => '1000', - ]; - $tx = $this->client->autofill($request); + ]); $this->assertEquals(self::SEQUENCE, $tx['Sequence']); $this->assertEquals(self::LEDGER_INDEX + 20, $tx['LastLedgerSequence']); } + + /** + * An account holding Escrows, PayChannels, RippleStates or Checks cannot be + * deleted. The check was unreachable: it was guarded by + * `!isset($tx['TransactionType'])` instead of a comparison against + * AccountDelete, and it counted blockers with the JavaScript idiom + * `$objects['length']`, which is an undefined key in PHP. + */ + public function testAccountDeleteIsRejectedWhenBlockersExist(): void + { + self::$server->setDefaultResponse(new RpcMethodResponse([ + 'server_info' => [ + 'info' => [ + 'validated_ledger' => ['base_fee_xrp' => self::BASE_FEE_XRP], + 'load_factor' => 1, + ], + ], + 'server_state' => [ + 'state' => ['validated_ledger' => ['reserve_inc' => self::RESERVE_INC]], + ], + 'account_info' => ['account_data' => ['Sequence' => self::SEQUENCE]], + 'ledger' => ['ledger_index' => self::LEDGER_INDEX], + 'account_objects' => [ + 'account_objects' => [ + ['LedgerEntryType' => 'Escrow'], + ], + ], + ])); + + $this->expectExceptionMessage('cannot be deleted'); + + $this->client->autofill([ + 'TransactionType' => 'AccountDelete', + 'Account' => self::ACCOUNT, + 'Destination' => self::DESTINATION, + ]); + } + + /** + * The blocker lookup must not run for other transaction types. + */ + public function testOtherTypesAreNotCheckedForBlockers(): void + { + self::$server->setDefaultResponse(new RpcMethodResponse([ + 'server_info' => [ + 'info' => [ + 'validated_ledger' => ['base_fee_xrp' => self::BASE_FEE_XRP], + 'load_factor' => 1, + ], + ], + 'account_info' => ['account_data' => ['Sequence' => self::SEQUENCE]], + 'ledger' => ['ledger_index' => self::LEDGER_INDEX], + 'account_objects' => [ + 'account_objects' => [['LedgerEntryType' => 'Escrow']], + ], + ])); + + $tx = $this->client->autofill([ + 'TransactionType' => 'Payment', + 'Account' => self::ACCOUNT, + 'Destination' => self::DESTINATION, + 'Amount' => '1000', + ]); + + $this->assertEquals('12', $tx['Fee']); + } } From 9a58f7b4dc713cd07d607e12fc154fdf6cc2eb8c Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:58:44 +0200 Subject: [PATCH 05/16] refactor: move submission into a Submitter class The eight free functions of src/Sugar/submit.php become methods of Hardcastle\XRPL_PHP\Client\Submitter, which holds the client instead of taking it as a first argument. The free functions remain as @deprecated wrappers that delegate. Fixes getSignedTx(): it returned the tx_blob/hash envelope of Wallet::sign() while every caller expects a transaction array, so submitRequest() found no SigningPubKey and threw "Transaction must be signed". Submitting an unsigned transaction together with a wallet - the documented convenience path and the reason submit() and submitAndWait() take a $wallet at all - therefore always failed. Every example works around it by signing first and passing the blob. The signed blob is now decoded back into an array, the shape the already-signed branch returns. SubmitTest covers isSigned(), getLastLedgerSequence() and isAccountDelete() for both arrays and blobs, the three getSignedTx() paths, and that submit() reaches the ledger with an unsigned transaction and a wallet. The polling loop of submitAndWait() sleeps three seconds per attempt and is left to the integration tests. Co-Authored-By: Claude Opus 5 --- src/Client/JsonRpcClient.php | 6 +- src/Client/Submitter.php | 250 +++++++++++++++++++++++++++++++++++ src/Sugar/submit.php | 189 +++++--------------------- tests/Sugar/SubmitTest.php | 208 +++++++++++++++++++++++++++++ 4 files changed, 491 insertions(+), 162 deletions(-) create mode 100644 src/Client/Submitter.php create mode 100644 tests/Sugar/SubmitTest.php diff --git a/src/Client/JsonRpcClient.php b/src/Client/JsonRpcClient.php index f9f2573..3e9494c 100644 --- a/src/Client/JsonRpcClient.php +++ b/src/Client/JsonRpcClient.php @@ -35,8 +35,6 @@ use function Hardcastle\XRPL_PHP\Sugar\getFeeXrp; use function Hardcastle\XRPL_PHP\Sugar\getOrderbook; use function Hardcastle\XRPL_PHP\Sugar\getTransactions; -use function Hardcastle\XRPL_PHP\Sugar\submit; -use function Hardcastle\XRPL_PHP\Sugar\submitAndWait; class JsonRpcClient { @@ -447,7 +445,7 @@ public function submit( ?Wallet $wallet = null ): SubmitResponse { - return submit($this, $transaction, $autofill, $failHard, $wallet); + return (new Submitter($this))->submit($transaction, $autofill, $failHard, $wallet); } /** @@ -468,7 +466,7 @@ public function submitAndWait( ?Wallet $wallet = null ): TxResponse { - return submitAndWait($this, $transaction, $autofill, $failHard, $wallet); + return (new Submitter($this))->submitAndWait($transaction, $autofill, $failHard, $wallet); } /** diff --git a/src/Client/Submitter.php b/src/Client/Submitter.php new file mode 100644 index 0000000..3c0bab9 --- /dev/null +++ b/src/Client/Submitter.php @@ -0,0 +1,250 @@ +client->getDefinitions(); + + $submitRequest = new SubmitRequest( + txBlob: (new BinaryCodec($definitions))->encode($signedTransaction), + failHard: self::isAccountDelete($signedTransaction, $definitions) || $failHard + ); + + return $this->client->request($submitRequest); + } + + /** + * @param Transaction|array|string $transaction + * @param bool|null $autofill + * @param bool|null $failHard + * @param Wallet|null $wallet + * @return SubmitResponse + * @throws Exception + */ + public function submit( + Transaction|array|string $transaction, + ?bool $autofill = false, + ?bool $failHard = false, + ?Wallet $wallet = null + ): SubmitResponse { + $signedTx = $this->getSignedTx($transaction, $autofill, $wallet); + + return $this->submitRequest($signedTx, $failHard)->wait(); + } + + /** + * Submit and wait until the outcome is final. + * + * @param Transaction|array|string $transaction + * @param bool|null $autofill + * @param bool|null $failHard + * @param Wallet|null $wallet + * @return TxResponse + * @throws Exception + */ + public function submitAndWait( + Transaction|array|string $transaction, + ?bool $autofill = false, + ?bool $failHard = false, + ?Wallet $wallet = null + ): TxResponse { + $definitions = $this->client->getDefinitions(); + $signedTx = $this->getSignedTx($transaction, $autofill, $wallet); + + $lastLedger = self::getLastLedgerSequence($signedTx, $definitions); + if (is_null($lastLedger)) { + throw new Exception('Transaction must contain a LastLedgerSequence value for reliable submission.'); + } + + $response = $this->submitRequest($signedTx, $failHard)->wait(); + + return $this->waitForFinalTransactionOutcome( + HashLedger::hashSignedTx($signedTx, $definitions), + $lastLedger, + $response->getResult()['engine_result'] + ); + } + + /** + * Poll until the transaction is in a validated ledger, or until its + * LastLedgerSequence has been passed and it never can be. + * + * @param string $txHash + * @param int $lastLedger + * @param string $submissionResult + * @return TxResponse + * @throws Exception + */ + public function waitForFinalTransactionOutcome( + string $txHash, + int $lastLedger, + string $submissionResult + ): TxResponse { + sleep(self::LEDGER_CLOSE_TIME); + + $latestLedger = $this->client->getLedgerIndex(); + + if ($lastLedger < $latestLedger) { + throw new Exception( + "The latest ledger sequence {$latestLedger} is greater than the transaction's LastLedgerSequence ({$lastLedger})." + . PHP_EOL . "Preliminary result: {$submissionResult}" + ); + } + + $txResponse = $this->client->request(new TxRequest($txHash))->wait(); + + if ($txResponse instanceof ErrorResponse) { + if ($txResponse->getError() === 'txnNotFound') { + return $this->waitForFinalTransactionOutcome($txHash, $lastLedger, $submissionResult); + } + + throw new Exception( + "{$txResponse->getError()}" + . PHP_EOL . "Preliminary result: {$submissionResult}" + . PHP_EOL . "Full error details: " . print_r($txResponse, true) + ); + } + + if ($txResponse->getResult()['validated']) { + return $txResponse; + } + + return $this->waitForFinalTransactionOutcome($txHash, $lastLedger, $submissionResult); + } + + /** + * Turn whatever was handed in into a signed transaction array. + * + * @param Transaction|string|array $transaction + * @param bool|null $autofill + * @param Wallet|null $wallet + * @return array + * @throws Exception + */ + public function getSignedTx( + Transaction|string|array $transaction, + ?bool $autofill = false, + ?Wallet $wallet = null + ): array { + $definitions = $this->client->getDefinitions(); + + if (is_string($transaction)) { + $tx = (new BinaryCodec($definitions))->decode($transaction); + } else if ($transaction instanceof Transaction) { + $tx = $transaction->toArray(); + } else { + $tx = $transaction; + } + + if (self::isSigned($tx)) { + return $tx; + } + + if (is_null($wallet)) { + throw new Exception('Wallet must be provided when submitting an unsigned transaction'); + } + + if ($autofill) { + $tx = (new Autofiller($this->client))->autofill($tx); + } + + // Wallet::sign() returns a tx_blob/hash envelope, while every caller + // here expects a transaction array - the same shape the already-signed + // branch above returns. + return (new BinaryCodec($definitions))->decode($wallet->sign($tx)['tx_blob']); + } + + /** + * @param array $tx + * @return bool + */ + public static function isSigned(array $tx): bool + { + return (!empty($tx['SigningPubKey']) || !empty($tx['TxnSignature'])); + } + + /** + * @param array|string $tx A transaction array or a tx_blob + * @param Definitions|null $definitions + * @return int|null + * @throws Exception + */ + public static function getLastLedgerSequence(array|string $tx, ?Definitions $definitions = null): int|null + { + if (is_string($tx)) { + // Decoding resolves every field in the blob, so a transaction from + // another network needs that network's definitions even though + // LastLedgerSequence itself carries the same ordinal everywhere. + $tx = (new BinaryCodec($definitions))->decode($tx); + } + + return isset($tx['LastLedgerSequence']) ? (int)$tx['LastLedgerSequence'] : null; + } + + /** + * @param array|string $tx A transaction array or a tx_blob + * @param Definitions|null $definitions + * @return bool + * @throws Exception + */ + public static function isAccountDelete(array|string $tx, ?Definitions $definitions = null): bool + { + if (is_string($tx)) { + $tx = (new BinaryCodec($definitions))->decode($tx); + } + + return ($tx['TransactionType'] ?? null) === 'AccountDelete'; + } +} diff --git a/src/Sugar/submit.php b/src/Sugar/submit.php index 6c221d1..92ec49e 100644 --- a/src/Sugar/submit.php +++ b/src/Sugar/submit.php @@ -5,51 +5,37 @@ use Exception; use GuzzleHttp\Promise\PromiseInterface; use Hardcastle\XRPL_PHP\Client\JsonRpcClient; -use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\BinaryCodec; +use Hardcastle\XRPL_PHP\Client\Submitter; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Definitions\Definitions; -use Hardcastle\XRPL_PHP\Models\ErrorResponse; -use Hardcastle\XRPL_PHP\Models\Transaction\SubmitRequest; use Hardcastle\XRPL_PHP\Models\Transaction\SubmitResponse; use Hardcastle\XRPL_PHP\Models\Transaction\TransactionTypes\BaseTransaction as Transaction; -use Hardcastle\XRPL_PHP\Models\Transaction\TxRequest; use Hardcastle\XRPL_PHP\Models\Transaction\TxResponse; -use Hardcastle\XRPL_PHP\Utils\Hashes\HashLedger; use Hardcastle\XRPL_PHP\Wallet\Wallet; -const LEDGER_CLOSE_TIME = 3; //Seconds +/** + * Thin wrappers around Hardcastle\XRPL_PHP\Client\Submitter. + * + * The logic moved into that class; these functions remain so that existing + * code keeps working. They will be removed in a future major version. + */ +const LEDGER_CLOSE_TIME = Submitter::LEDGER_CLOSE_TIME; + +/** + * @deprecated Use Submitter::submitRequest() + * @throws Exception + */ function submitRequest( JsonRpcClient $client, array $signedTransaction, ?bool $failHard = false ): PromiseInterface { - if (!isSigned($signedTransaction)) { - throw new Exception('Transaction must be signed'); - } - - $binaryCodec = new BinaryCodec($client->getDefinitions()); - $signedTxEncoded = $binaryCodec->encode($signedTransaction); - - $submitRequest = new SubmitRequest( - txBlob: $signedTxEncoded, - failHard: isAccountDelete($signedTransaction, $client->getDefinitions()) || $failHard - ); - - return $client->request($submitRequest); + return (new Submitter($client))->submitRequest($signedTransaction, $failHard); } /** - * The core logic of reliable submission. This polls the ledger until the result of the - * transaction can be considered final, meaning it has either been included in a - * validated ledger, or the transaction's lastLedgerSequence has been surpassed by the - * latest ledger sequence (meaning it will never be included in a validated ledger). - * - * @param JsonRpcClient $client - * @param string $txHash - * @param int $lastLedger - * @param string $submissionResult - * @return TxResponse + * @deprecated Use Submitter::waitForFinalTransactionOutcome() * @throws Exception */ function waitForFinalTransactionOutcome( @@ -59,66 +45,19 @@ function waitForFinalTransactionOutcome( string $submissionResult ): TxResponse { - sleep(LEDGER_CLOSE_TIME); - - $latestLedger = $client->getLedgerIndex(); - - if ($lastLedger < $latestLedger) { - throw new Exception("The latest ledger sequence {$latestLedger} is greater than the transaction's LastLedgerSequence ({$lastLedger})." - . PHP_EOL ."Preliminary result: {$submissionResult}"); - } - - $txRequest = new TxRequest($txHash); - $txResponse = $client->request($txRequest)->wait(); - - if ($txResponse instanceof ErrorResponse) { - if ($txResponse->getError() === 'txnNotFound') { - return waitForFinalTransactionOutcome( - $client, - $txHash, - $lastLedger, - $submissionResult - ); - } - - throw new Exception ("{$txResponse->getError()}" - . PHP_EOL . "Preliminary result: {$submissionResult}" - . PHP_EOL . "Full error details: " .print_r($txResponse, true) - ); - } - - if ($txResponse->getResult()['validated']) { - return $txResponse; - } - - //print_r($txResponse); - - return waitForFinalTransactionOutcome( - $client, - $txHash,$lastLedger, - $submissionResult - ); + return (new Submitter($client))->waitForFinalTransactionOutcome($txHash, $lastLedger, $submissionResult); } /** - * Checks if the transaction has been signed - * - * @param array $tx - * @return bool + * @deprecated Use Submitter::isSigned() */ function isSigned(array $tx): bool { - return (!empty($tx['SigningPubKey']) || !empty($tx['TxnSignature'])); + return Submitter::isSigned($tx); } /** - * Initializes a transaction for a submit request - * - * @param JsonRpcClient $client - * @param Transaction|string|array $transaction - * @param bool|null $autofill - * @param Wallet|null $wallet - * @return array + * @deprecated Use Submitter::getSignedTx() * @throws Exception */ function getSignedTx( @@ -128,74 +67,31 @@ function getSignedTx( ?Wallet $wallet = null ): array { - if (is_string($transaction)) { - $binaryCodec = new BinaryCodec($client->getDefinitions()); - $tx = $binaryCodec->decode($transaction); - } else if ($transaction instanceof Transaction) { - $tx = $transaction->toArray(); - } else { - $tx = $transaction; - } - - if (isSigned($tx)) { - return $tx; - } - - if(is_null($wallet)) { - throw new Exception('Wallet must be provided when submitting an unsigned transaction'); - } - - if ($autofill) { - $tx = autofill($client, $tx); - } - - return $wallet->sign($tx); + return (new Submitter($client))->getSignedTx($transaction, $autofill, $wallet); } /** - * Checks if there is a LastLedgerSequence as a part of the transaction - * - * @param array|string $tx - * @return int|null + * @deprecated Use Submitter::getLastLedgerSequence() + * @throws Exception */ function getLastLedgerSequence(array|string $tx, ?Definitions $definitions = null): int|null { - if (is_string($tx)) { - // Decoding resolves every field in the blob, so a transaction from - // another network needs that network's definitions even though - // LastLedgerSequence itself carries the same ordinal everywhere. - $binaryCodec = new BinaryCodec($definitions); - $tx = $binaryCodec->decode($tx); - } - - return (isset($tx['LastLedgerSequence'])) ? (int)$tx['LastLedgerSequence'] : null; + return Submitter::getLastLedgerSequence($tx, $definitions); } /** - * Checks if the transaction is an AccountDelete transaction - * - * @param array|string $tx - * @return bool + * @deprecated Use Submitter::isAccountDelete() + * @throws Exception */ function isAccountDelete(array|string $tx, ?Definitions $definitions = null): bool { - if (is_string($tx)) { - $binaryCodec = new BinaryCodec($definitions); - $tx = $binaryCodec->decode($tx); - } - - return($tx['TransactionType'] === 'AccountDelete'); + return Submitter::isAccountDelete($tx, $definitions); } if (! function_exists('Hardcastle\XRPL_PHP\Sugar\submit')) { /** - * @param JsonRpcClient $client - * @param Transaction|array|string $transaction - * @param bool|null $autofill - * @param bool|null $failHard - * @param Wallet|null $wallet - * @return SubmitResponse + * @deprecated Use JsonRpcClient::submit() or Submitter::submit() * @throws Exception */ function submit( @@ -206,21 +102,14 @@ function submit( ?Wallet $wallet ): SubmitResponse { - $signedTx = getSignedTx($client, $transaction, $autofill, $wallet); - - return submitRequest($client, $signedTx, $failHard)->wait(); + return (new Submitter($client))->submit($transaction, $autofill, $failHard, $wallet); } } if (! function_exists('Hardcastle\XRPL_PHP\Sugar\submitAndWait')) { /** - * @param JsonRpcClient $client - * @param Transaction|array|string $transaction - * @param bool|null $autofill - * @param bool|null $failHard - * @param Wallet|null $wallet - * @return TxResponse + * @deprecated Use JsonRpcClient::submitAndWait() or Submitter::submitAndWait() * @throws Exception */ function submitAndWait( @@ -231,22 +120,6 @@ function submitAndWait( ?Wallet $wallet = null ): TxResponse { - $signedTx = getSignedTx($client, $transaction, $autofill, $wallet); - - $lastLedger = getLastLedgerSequence($signedTx, $client->getDefinitions()); - if(is_null($lastLedger)) { - throw new Exception('Transaction must contain a LastLedgerSequence value for reliable submission.'); - } - - $response = submitRequest($client, $signedTx, $failHard)->wait(); - - $txHash = HashLedger::hashSignedTx($signedTx, $client->getDefinitions()); - - return waitForFinalTransactionOutcome( - $client, - $txHash, - $lastLedger, - $response->getResult()['engine_result'] - ); + return (new Submitter($client))->submitAndWait($transaction, $autofill, $failHard, $wallet); } -} \ No newline at end of file +} diff --git a/tests/Sugar/SubmitTest.php b/tests/Sugar/SubmitTest.php new file mode 100644 index 0000000..03cf134 --- /dev/null +++ b/tests/Sugar/SubmitTest.php @@ -0,0 +1,208 @@ +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, + ], + ], + 'server_state' => [ + 'state' => ['validated_ledger' => ['reserve_inc' => '2000000']], + ], + 'account_info' => ['account_data' => ['Sequence' => self::SEQUENCE]], + 'ledger' => ['ledger_index' => self::LEDGER_INDEX], + 'account_objects' => ['account_objects' => []], + 'submit' => ['engine_result' => 'tesSUCCESS', 'tx_json' => []], + ])); + + $this->client = new JsonRpcClient(self::$server->getServerRoot()); + $this->wallet = Wallet::fromSeed(self::SEED); + } + + private function unsignedPayment(): array + { + return [ + 'TransactionType' => 'Payment', + 'Account' => $this->wallet->getAddress(), + 'Destination' => self::DESTINATION, + 'Amount' => '1000', + 'Fee' => '12', + 'Sequence' => 1, + 'LastLedgerSequence' => self::LEDGER_INDEX + 20, + ]; + } + + public function testIsSigned(): void + { + $this->assertFalse(isSigned($this->unsignedPayment())); + $this->assertTrue(isSigned($this->signedPayment())); + } + + /** + * The signed transaction as an array, which is the shape the submission + * path passes around. + */ + private function signedPayment(): array + { + return (new BinaryCodec())->decode($this->wallet->sign($this->unsignedPayment())['tx_blob']); + } + + public function testGetLastLedgerSequenceFromArray(): void + { + $this->assertEquals(self::LEDGER_INDEX + 20, getLastLedgerSequence($this->unsignedPayment())); + $tx = $this->unsignedPayment(); + unset($tx['LastLedgerSequence']); + $this->assertNull(getLastLedgerSequence($tx)); + } + + public function testGetLastLedgerSequenceFromBlob(): void + { + $blob = $this->wallet->sign($this->unsignedPayment())['tx_blob']; + + $this->assertEquals(self::LEDGER_INDEX + 20, getLastLedgerSequence($blob)); + } + + public function testIsAccountDelete(): void + { + $this->assertFalse(isAccountDelete($this->unsignedPayment())); + + $delete = $this->unsignedPayment(); + $delete['TransactionType'] = 'AccountDelete'; + unset($delete['Amount']); + + $this->assertTrue(isAccountDelete($delete)); + $this->assertTrue(isAccountDelete($this->wallet->sign($delete)['tx_blob'])); + } + + public function testGetSignedTxReturnsAnAlreadySignedTransaction(): void + { + $signed = $this->signedPayment(); + + $this->assertEquals($signed, getSignedTx($this->client, $signed)); + } + + public function testGetSignedTxRequiresAWalletForUnsignedTransactions(): void + { + $this->expectExceptionMessage('Wallet must be provided when submitting an unsigned transaction'); + + getSignedTx($this->client, $this->unsignedPayment()); + } + + /** + * getSignedTx() used to return the tx_blob/hash envelope of Wallet::sign() + * while every caller expected a transaction array, so submitting an + * unsigned transaction together with a wallet always failed with + * "Transaction must be signed". + */ + public function testGetSignedTxSignsWithTheWallet(): void + { + $result = getSignedTx($this->client, $this->unsignedPayment(), false, $this->wallet); + + $this->assertEquals('Payment', $result['TransactionType']); + $this->assertArrayHasKey('TxnSignature', $result); + $this->assertTrue(isSigned($result)); + } + + /** + * With autofill the fields the transaction does not carry are filled in + * before signing. + */ + public function testGetSignedTxAutofills(): void + { + $tx = [ + 'TransactionType' => 'Payment', + 'Account' => $this->wallet->getAddress(), + 'Destination' => self::DESTINATION, + 'Amount' => '1000', + ]; + + $result = getSignedTx($this->client, $tx, true, $this->wallet); + + $this->assertEquals(self::SEQUENCE, $result['Sequence']); + $this->assertEquals(self::LEDGER_INDEX + 20, $result['LastLedgerSequence']); + $this->assertEquals('12', $result['Fee']); + } + + public function testSubmitRequestRejectsUnsignedTransactions(): void + { + $this->expectExceptionMessage('Transaction must be signed'); + + submitRequest($this->client, $this->unsignedPayment()); + } + + public function testSubmitReturnsTheEngineResult(): void + { + $response = $this->client->submit($this->unsignedPayment(), true, false, $this->wallet); + + $this->assertEquals('tesSUCCESS', $response->getResult()['engine_result']); + } + + /** + * submitAndWait() needs a LastLedgerSequence to know when to stop polling. + */ + public function testSubmitAndWaitRequiresLastLedgerSequence(): void + { + $tx = $this->unsignedPayment(); + unset($tx['LastLedgerSequence']); + $signed = (new BinaryCodec())->decode($this->wallet->sign($tx)['tx_blob']); + + $this->expectExceptionMessage('LastLedgerSequence'); + + $this->client->submitAndWait($signed); + } +} From 6c1daaff52a5bf0366020f7262ed05474c7f52ce Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:00:37 +0200 Subject: [PATCH 06/16] refactor: move the read paths into AccountReader and OrderbookReader getXrpBalance(), getBalances() and getTransactions() become methods of AccountReader; getOrderbook() becomes a method of OrderbookReader. Both hold the client instead of taking it as a first argument. The free functions remain as @deprecated wrappers that delegate. Drops formatBalances(), which was declared to return an array but had its entire body commented out, so it raised a TypeError on every call. Nothing referenced it. Co-Authored-By: Claude Opus 5 --- src/Client/AccountReader.php | 170 +++++++++++++++++++++++++++++++++ src/Client/JsonRpcClient.php | 12 +-- src/Client/OrderbookReader.php | 57 +++++++++++ src/Sugar/balances.php | 106 +++----------------- src/Sugar/getOrderbook.php | 43 ++------- src/Sugar/getTransactions.php | 63 ++---------- 6 files changed, 262 insertions(+), 189 deletions(-) create mode 100644 src/Client/AccountReader.php create mode 100644 src/Client/OrderbookReader.php diff --git a/src/Client/AccountReader.php b/src/Client/AccountReader.php new file mode 100644 index 0000000..608311a --- /dev/null +++ b/src/Client/AccountReader.php @@ -0,0 +1,170 @@ +client->request(new AccountInfoRequest( + account: $address, + ledgerHash: $ledgerHash, + ledgerIndex: $ledgerIndex + ))->wait(); + + if ($response instanceof ErrorResponse) { + throw new Exception($response->getError()); + } + + return dropsToXrp($response->getResult()['account_data']['Balance']); + } + + /** + * XRP and trust line balances, following the result marker until the + * ledger has no more pages. + * + * @return array + * @throws Exception + */ + public function getBalances( + string $address, + ?string $ledgerHash = null, + ?string $ledgerIndex = 'validated', + ?string $peer = null, + ?int $limit = null + ): array { + $balances = []; + + // A peer filter asks about one counterparty, where XRP has no meaning + if (!$peer) { + try { + $balances[] = [ + 'currency' => 'XRP', + 'value' => $this->getXrpBalance($address, $ledgerHash, $ledgerIndex), + ]; + } catch (Exception) { + // An account can be gone and still be referenced by trust lines + } + } + + $marker = null; + while (true) { + $response = $this->client->request(new AccountLinesRequest( + account: $address, + ledgerHash: $ledgerHash, + ledgerIndex: $ledgerIndex, + peer: $peer, + limit: $limit, + marker: $marker + ))->wait(); + + if ($response instanceof ErrorResponse) { + if ($response->getError() === 'actNotFound' && !empty($balances)) { + break; + } + throw new Exception($response->getError()); + } + + $result = $response->getResult(); + foreach ($result['lines'] as $line) { + $balances[] = [ + 'value' => $line['balance'], + 'currency' => $line['currency'], + 'issuer' => $line['account'], + ]; + } + + $marker = $result['marker'] ?? null; + if (!$marker || ($limit && count($balances) >= $limit)) { + break; + } + } + + return ($limit && count($balances) > $limit) + ? array_slice($balances, 0, $limit) + : $balances; + } + + /** + * The transaction history of an account, following the result marker. + * + * @throws Exception + */ + public function getTransactions( + string $address, + ?int $ledgerIndexMin = null, + ?int $ledgerIndexMax = null, + ?string $ledgerHash = null, + ?string $ledgerIndex = 'validated', + ?bool $binary = null, + ?bool $forward = null, + ?int $limit = null, + mixed $marker = null + ): array { + $transactions = []; + + while (true) { + $response = $this->client->request(new AccountTxRequest( + account: $address, + ledgerIndexMin: $ledgerIndexMin, + ledgerIndexMax: $ledgerIndexMax, + ledgerHash: $ledgerHash, + ledgerIndex: $ledgerIndex, + binary: $binary, + forward: $forward, + limit: $limit, + marker: $marker + ))->wait(); + + if ($response instanceof ErrorResponse) { + throw new Exception($response->getError()); + } + + $result = $response->getResult(); + $transactions = array_merge($transactions, $result['transactions']); + + $marker = $result['marker'] ?? null; + if (!$marker || ($limit && count($transactions) >= $limit)) { + break; + } + } + + return ($limit && count($transactions) > $limit) + ? array_slice($transactions, 0, $limit) + : $transactions; + } +} diff --git a/src/Client/JsonRpcClient.php b/src/Client/JsonRpcClient.php index 3e9494c..fed9a0c 100644 --- a/src/Client/JsonRpcClient.php +++ b/src/Client/JsonRpcClient.php @@ -30,11 +30,7 @@ use Hardcastle\XRPL_PHP\Models\Transaction\TxResponse; use Hardcastle\XRPL_PHP\Wallet\Wallet; use function Hardcastle\XRPL_PHP\Sugar\fundWallet; -use function Hardcastle\XRPL_PHP\Sugar\getXrpBalance; -use function Hardcastle\XRPL_PHP\Sugar\getBalances; use function Hardcastle\XRPL_PHP\Sugar\getFeeXrp; -use function Hardcastle\XRPL_PHP\Sugar\getOrderbook; -use function Hardcastle\XRPL_PHP\Sugar\getTransactions; class JsonRpcClient { @@ -313,7 +309,7 @@ private function getCollectKeyFromCommand(string $command): string|null */ public function getXrpBalance(string $address): string { - return getXrpBalance($this, $address); + return (new AccountReader($this))->getXrpBalance($address); } /** @@ -333,7 +329,7 @@ public function getBalances( ?int $limit = null ): array { - return getBalances($this, $address, $ledgerHash, $ledgerIndex, $peer, $limit); + return (new AccountReader($this))->getBalances($address, $ledgerHash, $ledgerIndex, $peer, $limit); } /** @@ -361,7 +357,7 @@ public function getTransactions( mixed $marker = null ): array { - return getTransactions($this, $address, $ledgerIndexMin, $ledgerIndexMax, $ledgerHash, $ledgerIndex, $binary, $forward, $limit, $marker); + return (new AccountReader($this))->getTransactions($address, $ledgerIndexMin, $ledgerIndexMax, $ledgerHash, $ledgerIndex, $binary, $forward, $limit, $marker); } /** @@ -383,7 +379,7 @@ public function getOrderbook( ?string $taker = null ): array { - return getOrderbook($this, $takerGets, $takerPays, $ledgerHash, $ledgerIndex, $limit, $taker); + return (new OrderbookReader($this))->getOrderbook($takerGets, $takerPays, $ledgerHash, $ledgerIndex, $limit, $taker); } /** diff --git a/src/Client/OrderbookReader.php b/src/Client/OrderbookReader.php new file mode 100644 index 0000000..e922fef --- /dev/null +++ b/src/Client/OrderbookReader.php @@ -0,0 +1,57 @@ +client->request(new BookOffersRequest( + takerGets: $takerGets, + takerPays: $takerPays, + ledgerHash: $ledgerHash, + ledgerIndex: $ledgerIndex, + number: $limit, + taker: $taker + ))->wait(); + + if ($response instanceof ErrorResponse) { + throw new Exception($response->getError()); + } + + return $response->getResult()['offers']; + } +} diff --git a/src/Sugar/balances.php b/src/Sugar/balances.php index 3cd44c2..7eb0de6 100644 --- a/src/Sugar/balances.php +++ b/src/Sugar/balances.php @@ -3,27 +3,20 @@ namespace Hardcastle\XRPL_PHP\Sugar; use Exception; +use Hardcastle\XRPL_PHP\Client\AccountReader; use Hardcastle\XRPL_PHP\Client\JsonRpcClient; -use GuzzleHttp\Promise\Promise; -use Hardcastle\XRPL_PHP\Models\Account\AccountInfoRequest; -use Hardcastle\XRPL_PHP\Models\Account\AccountLinesRequest; -use Hardcastle\XRPL_PHP\Models\ErrorResponse; -function formatBalances(array $trustlines): array -{ - /* - $fn = function (Trustline $trustline) { - return [ - - ]; - }; - return array_map($fn, $trustlines); - */ -} +/** + * Thin wrappers around Hardcastle\XRPL_PHP\Client\AccountReader. + * + * The logic moved into that class; these functions remain so that existing + * code keeps working. They will be removed in a future major version. + */ if (! function_exists('Hardcastle\XRPL_PHP\Sugar\getXrpBalance')) { /** + * @deprecated Use JsonRpcClient::getXrpBalance() or AccountReader::getXrpBalance() * @throws Exception */ function getXrpBalance( @@ -33,32 +26,14 @@ function getXrpBalance( ?string $ledgerIndex = 'validated', ): string { - $accountInfoRequest = new AccountInfoRequest( - account: $address, - ledgerHash: $ledgerHash, - ledgerIndex: $ledgerIndex - ); - - $xrpResponse = $client->request($accountInfoRequest)->wait(); - - if($xrpResponse::class === ErrorResponse::class) { - throw new Exception($xrpResponse->getError()); - } - - return dropsToXrp($xrpResponse->getResult()['account_data']['Balance']); + return (new AccountReader($client))->getXrpBalance($address, $ledgerHash, $ledgerIndex); } } if (! function_exists('Hardcastle\XRPL_PHP\Sugar\getBalances')) { /** - * @param JsonRpcClient $client - * @param string $address - * @param string|null $ledgerHash - * @param string|null $ledgerIndex - * @param string|null $peer - * @param int|null $limit - * @return array + * @deprecated Use JsonRpcClient::getBalances() or AccountReader::getBalances() * @throws Exception */ function getBalances( @@ -70,63 +45,6 @@ function getBalances( ?int $limit = null ): array { - $balances = []; - - // 1. Get XRP Balance (if no peer filter) - if (!$peer) { - try { - $xrpBalance = getXrpBalance($client, $address, $ledgerHash, $ledgerIndex); - $balances[] = [ - 'currency' => 'XRP', - 'value' => $xrpBalance - ]; - } catch (Exception $e) { - // If account not found, it might still have trustlines (rare but possible if deleted) - // or we just ignore and continue to trustlines - } - } - - // 2. Get Trustline Balances - $marker = null; - while (true) { - $linesRequest = new AccountLinesRequest( - account: $address, - ledgerHash: $ledgerHash, - ledgerIndex: $ledgerIndex, - peer: $peer, - limit: $limit, - marker: $marker - ); - - $response = $client->request($linesRequest)->wait(); - - if ($response::class === ErrorResponse::class) { - if ($response->getError() === 'actNotFound' && !empty($balances)) { - // We already have XRP balance, so we can return it even if actNotFound for lines - break; - } - throw new Exception($response->getError()); - } - - $result = $response->getResult(); - foreach ($result['lines'] as $line) { - $balances[] = [ - 'value' => $line['balance'], - 'currency' => $line['currency'], - 'issuer' => $line['account'] - ]; - } - - $marker = $result['marker'] ?? null; - if (!$marker || ($limit && count($balances) >= $limit)) { - break; - } - } - - if ($limit && count($balances) > $limit) { - return array_slice($balances, 0, $limit); - } - - return $balances; + return (new AccountReader($client))->getBalances($address, $ledgerHash, $ledgerIndex, $peer, $limit); } -} \ No newline at end of file +} diff --git a/src/Sugar/getOrderbook.php b/src/Sugar/getOrderbook.php index 9981453..ab805bc 100644 --- a/src/Sugar/getOrderbook.php +++ b/src/Sugar/getOrderbook.php @@ -1,31 +1,19 @@ -getOrderbook( + $takerGets, $takerPays, $ledgerHash, $ledgerIndex, $limit, $taker ); - - $response = $client->request($request)->wait(); - - if ($response::class === ErrorResponse::class) { - throw new Exception($response->getError()); - } - - return $response->getResult()['offers']; } } diff --git a/src/Sugar/getTransactions.php b/src/Sugar/getTransactions.php index c809b33..d197695 100644 --- a/src/Sugar/getTransactions.php +++ b/src/Sugar/getTransactions.php @@ -3,29 +3,17 @@ namespace Hardcastle\XRPL_PHP\Sugar; use Exception; +use Hardcastle\XRPL_PHP\Client\AccountReader; use Hardcastle\XRPL_PHP\Client\JsonRpcClient; -use GuzzleHttp\Promise\Promise; -use Hardcastle\XRPL_PHP\Models\Account\AccountInfoRequest; -use Hardcastle\XRPL_PHP\Models\Account\AccountLinesRequest; - -use Hardcastle\XRPL_PHP\Models\Account\AccountTxRequest; -use Hardcastle\XRPL_PHP\Models\ErrorResponse; +/** + * Thin wrapper around Hardcastle\XRPL_PHP\Client\AccountReader. + */ if (! function_exists('Hardcastle\XRPL_PHP\Sugar\getTransactions')) { /** - * @param JsonRpcClient $client - * @param string $address - * @param int|null $ledgerIndexMin - * @param int|null $ledgerIndexMax - * @param string|null $ledgerHash - * @param string|null $ledgerIndex - * @param bool|null $binary - * @param bool|null $forward - * @param int|null $limit - * @param mixed|null $marker - * @return array + * @deprecated Use JsonRpcClient::getTransactions() or AccountReader::getTransactions() * @throws Exception */ function getTransactions( @@ -41,40 +29,9 @@ function getTransactions( mixed $marker = null ): array { - $transactions = []; - - while (true) { - $request = new AccountTxRequest( - account: $address, - ledgerIndexMin: $ledgerIndexMin, - ledgerIndexMax: $ledgerIndexMax, - ledgerHash: $ledgerHash, - ledgerIndex: $ledgerIndex, - binary: $binary, - forward: $forward, - limit: $limit, - marker: $marker - ); - - $response = $client->request($request)->wait(); - - if ($response::class === ErrorResponse::class) { - throw new Exception($response->getError()); - } - - $result = $response->getResult(); - $transactions = array_merge($transactions, $result['transactions']); - - $marker = $result['marker'] ?? null; - if (!$marker || ($limit && count($transactions) >= $limit)) { - break; - } - } - - if ($limit && count($transactions) > $limit) { - return array_slice($transactions, 0, $limit); - } - - return $transactions; + return (new AccountReader($client))->getTransactions( + $address, $ledgerIndexMin, $ledgerIndexMax, $ledgerHash, + $ledgerIndex, $binary, $forward, $limit, $marker + ); } -} \ No newline at end of file +} From 2b57587762f361777b70d043acd6136bfd8bc839 Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:02:00 +0200 Subject: [PATCH 07/16] refactor: move the fee lookup and the faucet into classes getFeeXrp() becomes FeeCalculator::getFeeXrp() and fundWallet() becomes Faucet::fundWallet(). The free functions remain as @deprecated wrappers, and JsonRpcClient no longer imports a single function from Sugar - every method now delegates to a class. Drops two dead stubs from fundWallet.php: getHttpOptions() was never called, and getUpdatedBalance() computed a balance, had an empty if body and returned 0 unconditionally. Neither was referenced anywhere. Faucet::waitForFunding() replaces the inline polling loop and now returns as soon as the balance rises, rather than falling through the remaining attempts. An empty faucet response raises instead of silently continuing with an undefined address. Co-Authored-By: Claude Opus 5 --- src/Client/Autofiller.php | 3 +- src/Client/Faucet.php | 117 +++++++++++++++++++++++++++++++++++ src/Client/FeeCalculator.php | 59 ++++++++++++++++++ src/Client/JsonRpcClient.php | 6 +- src/Sugar/fundWallet.php | 107 +++----------------------------- src/Sugar/getFeeXrp.php | 53 +++------------- 6 files changed, 199 insertions(+), 146 deletions(-) create mode 100644 src/Client/Faucet.php create mode 100644 src/Client/FeeCalculator.php diff --git a/src/Client/Autofiller.php b/src/Client/Autofiller.php index 413de0d..d8caba2 100644 --- a/src/Client/Autofiller.php +++ b/src/Client/Autofiller.php @@ -22,7 +22,6 @@ use Hardcastle\XRPL_PHP\Models\ServerInfo\ServerStateRequest; use Hardcastle\XRPL_PHP\Models\Transaction\TransactionTypes\BaseTransaction as Transaction; -use function Hardcastle\XRPL_PHP\Sugar\getFeeXrp; use function Hardcastle\XRPL_PHP\Sugar\xrpToDrops; /** @@ -231,7 +230,7 @@ public function fetchOwnerReserveFee(): BigDecimal */ public function calculateFeePerTransactionType(array &$tx, ?int $signersCount = 0): void { - $netFeeXrp = getFeeXrp($this->client); + $netFeeXrp = (new FeeCalculator($this->client))->getFeeXrp(); $netFeeDrops = xrpToDrops($netFeeXrp); $baseFee = BigDecimal::of($netFeeDrops); diff --git a/src/Client/Faucet.php b/src/Client/Faucet.php new file mode 100644 index 0000000..13b4001 --- /dev/null +++ b/src/Client/Faucet.php @@ -0,0 +1,117 @@ +getClassicAddress())) + ? $wallet + : Wallet::generate(); + + $accountReader = new AccountReader($this->client); + + $startingBalance = 0.0; + try { + $startingBalance = (float)$accountReader->getXrpBalance($walletToFund->getClassicAddress()); + } catch (Exception) { + // An unfunded account does not exist yet, so its balance is zero + } + + $hostname = $faucetHost ?? DefaultFaucets::getFaucetHost($this->client); + $pathname = $faucetPath ?? DefaultFaucets::getDefaultFaucetPath($hostname); + + $response = (new JsonRpcClient($hostname))->rawRequest( + method: 'POST', + resource: $pathname, + body: json_encode([ + 'destination' => $walletToFund->getClassicAddress(), + 'xrpAmount' => $amount ?? '100', + ]) + )->wait(); + + $faucetResponse = json_decode((string)$response->getBody(), true); + + if (!isset($faucetResponse['account']['address'])) { + throw new Exception('The faucet did not return an account address.'); + } + + return [ + 'wallet' => $walletToFund, + 'balance' => $this->waitForFunding($faucetResponse['account']['address'], $startingBalance), + 'fundWalletResponse' => $faucetResponse, + ]; + } + + /** + * Poll the balance until the faucet payment shows up. + * + * @param string $address + * @param float $startingBalance + * @return float The balance last seen, funded or not + */ + private function waitForFunding(string $address, float $startingBalance): float + { + $accountReader = new AccountReader($this->client); + $balance = $startingBalance; + + for ($attempt = 0; $attempt < self::POLL_ATTEMPTS; $attempt++) { + try { + $balance = (float)$accountReader->getXrpBalance($address); + if ($balance > $startingBalance) { + return $balance; + } + } catch (Exception) { + // The account may not exist yet + } + + sleep(self::POLL_INTERVAL); + } + + return $balance; + } +} diff --git a/src/Client/FeeCalculator.php b/src/Client/FeeCalculator.php new file mode 100644 index 0000000..e6663cb --- /dev/null +++ b/src/Client/FeeCalculator.php @@ -0,0 +1,59 @@ +client->getFeeCushion(); + + $serverInfo = $this->client->request(new ServerInfoRequest())->wait()->getResult()['info']; + + $baseFee = $serverInfo['validated_ledger']['base_fee_xrp'] ?? null; + if (is_null($baseFee)) { + throw new Exception('getFeeXrp: Could not get base_fee_xrp from server_info'); + } + + $loadFactor = $serverInfo['load_factor'] ?? 1; + + $fee = BigDecimal::of($baseFee) + ->multipliedBy((string)$loadFactor) + ->multipliedBy((string)$feeCushion); + + $fee = BigDecimal::min($fee, $this->client->getMaxFeeXrp()); + + return (string)$fee->toScale(6, RoundingMode::UP); + } +} diff --git a/src/Client/JsonRpcClient.php b/src/Client/JsonRpcClient.php index fed9a0c..f9e8aa2 100644 --- a/src/Client/JsonRpcClient.php +++ b/src/Client/JsonRpcClient.php @@ -29,8 +29,6 @@ use Hardcastle\XRPL_PHP\Models\Transaction\TransactionTypes\BaseTransaction as Transaction; use Hardcastle\XRPL_PHP\Models\Transaction\TxResponse; use Hardcastle\XRPL_PHP\Wallet\Wallet; -use function Hardcastle\XRPL_PHP\Sugar\fundWallet; -use function Hardcastle\XRPL_PHP\Sugar\getFeeXrp; class JsonRpcClient { @@ -390,7 +388,7 @@ public function getOrderbook( */ public function getFeeXrp(?int $cushion = null): string { - return getFeeXrp($this, $cushion); + return (new FeeCalculator($this))->getFeeXrp($cushion === null ? null : (float)$cushion); } /** @@ -402,7 +400,7 @@ public function getFeeXrp(?int $cushion = null): string */ public function fundWallet(?Wallet $wallet = null, ?string $faucetHost = null): Wallet { - return fundWallet($this, $wallet, $faucetHost)['wallet']; + return (new Faucet($this))->fundWallet($wallet, $faucetHost)['wallet']; } /** diff --git a/src/Sugar/fundWallet.php b/src/Sugar/fundWallet.php index 94967fc..2b5ed05 100644 --- a/src/Sugar/fundWallet.php +++ b/src/Sugar/fundWallet.php @@ -3,47 +3,20 @@ namespace Hardcastle\XRPL_PHP\Sugar; use Exception; +use Hardcastle\XRPL_PHP\Client\Faucet; use Hardcastle\XRPL_PHP\Client\JsonRpcClient; -use Hardcastle\Buffer\Buffer; -use Hardcastle\XRPL_PHP\Core\CoreUtilities; -use Hardcastle\XRPL_PHP\Wallet\DefaultFaucets; use Hardcastle\XRPL_PHP\Wallet\Wallet; -function getHttpOptions(JsonRpcClient $client, Buffer $postBody, ?string $faucetHost): array -{ - return [ - 'hostname' => $faucetHost ?? DefaultFaucets::getFaucetHost($client), - 'port' => 443, - 'path' => '/accounts', - 'method' => 'POST', - 'headers' => [ - 'Content-Type' => 'application/json', - 'Content-Length' => $postBody->getLength() - ] - ]; -} - -function getUpdatedBalance(JsonRpcClient $client, string $address, float $originalBalance): float -{ - $newBalance = null; - try { - $newBalance = (float)$client->getXrpBalance($address); - } catch (Exception) { - //new Balance remains undefined - } - - if ($newBalance > $originalBalance) { - - } - - //resolve: (response: { wallet: Wallet; balance: number }) => void, - //reject: (err: ErrorConstructor | Error | unknown) => void, - - return 0; -} +/** + * Thin wrapper around Hardcastle\XRPL_PHP\Client\Faucet. + */ if (!function_exists('Hardcastle\XRPL_PHP\Sugar\fundWallet')) { + /** + * @deprecated Use JsonRpcClient::fundWallet() or Faucet::fundWallet() + * @throws Exception + */ function fundWallet( JsonRpcClient $client, ?Wallet $wallet = null, @@ -52,66 +25,6 @@ function fundWallet( ?string $amount = null ): array { - // Generate a new Wallet if no existing Wallet is provided or its address is invalid to fund - if ($wallet && CoreUtilities::isValidClassicAddress($wallet->getClassicAddress())) { - $walletToFund = $wallet; - } else { - $walletToFund = Wallet::generate(); - } - - // Create the POST request body - $jsonData = json_encode([ - 'destination' => $walletToFund->getClassicAddress(), - 'xrpAmount' => '100', // Default to 1000 XRP in drops - ]); - - $startingBalance = 0; - try { - $startingBalance = getXrpBalance($client, $walletToFund->getClassicAddress()); - } catch (Exception) { - // startingBalance remains '0' - } - - // This would be getHTTPOptions in xrpl.js - $hostname = $faucetHost ?? DefaultFaucets::getFaucetHost($client); - $pathname = $faucetPath ?? DefaultFaucets::getDefaultFaucetPath($hostname); - $faucetClient = new JsonRpcClient($hostname); - - $response = $faucetClient->rawRequest( - method: 'POST', - resource: $pathname, - body: $jsonData - )->wait(); - - $faucetWallet = json_decode((string) $response->getBody(), true); - - if (!isset($faucetWallet['account']['address'])) { - // error: 'The faucet account is undefined' - } - - $classicAddress = $faucetWallet['account']['address']; - - $updatedBalance = $startingBalance; - - $intervalSeconds = 1; - $attempts = 20; - while ($attempts > 0) { - try { - $updatedBalance = (float) getXrpBalance($client, $classicAddress); - if ($updatedBalance > $startingBalance) { - break; - } - } catch (Exception) { - - } - sleep($intervalSeconds); - $attempts--; - } - - return [ - 'wallet' => $walletToFund, - 'balance' => $updatedBalance, - 'fundWalletResponse' => json_decode((string) $response->getBody(), true) - ]; + return (new Faucet($client))->fundWallet($wallet, $faucetHost, $faucetPath, $amount); } -} \ No newline at end of file +} diff --git a/src/Sugar/getFeeXrp.php b/src/Sugar/getFeeXrp.php index 36d4e29..5304a65 100644 --- a/src/Sugar/getFeeXrp.php +++ b/src/Sugar/getFeeXrp.php @@ -2,55 +2,22 @@ namespace Hardcastle\XRPL_PHP\Sugar; -use Brick\Math\BigDecimal; -use Brick\Math\RoundingMode; use Exception; +use Hardcastle\XRPL_PHP\Client\FeeCalculator; use Hardcastle\XRPL_PHP\Client\JsonRpcClient; -use Hardcastle\XRPL_PHP\Models\ServerInfo\ServerInfoRequest; + +/** + * Thin wrapper around Hardcastle\XRPL_PHP\Client\FeeCalculator. + */ if (! function_exists('Hardcastle\XRPL_PHP\Sugar\getFeeXrp')) { /** - * Calculates the current transaction fee for the ledger. - * Note: This is a public API that can be called directly. - * - * @param JsonRpcClient $client - * @param int|null $cushion - * @return string - * @throws \Brick\Math\Exception\MathException - * @throws \Brick\Math\Exception\RoundingNecessaryException + * @deprecated Use JsonRpcClient::getFeeXrp() or FeeCalculator::getFeeXrp() + * @throws Exception */ - function getFeeXrp( - JsonRpcClient $client, - ?int $cushion = null - ): string + function getFeeXrp(JsonRpcClient $client, ?int $cushion = null): string { - $feeCushion = $cushion ?? $client->getFeeCushion(); - - $serverInfoRequest = new ServerInfoRequest(); - - $serverInfoResponse = $client->request($serverInfoRequest)->wait(); - - $serverInfo = $serverInfoResponse->getResult()['info']; - - $baseFee = $serverInfo['validated_ledger']['base_fee_xrp'] ?? null; - - if(is_null($baseFee)) { - throw new Exception('getFeeXrp: Could not get base_fee_xrp from server_info'); - } - - $baseFeeXrp = BigDecimal::of($baseFee); - if(is_null($serverInfo['load_factor'])) { - $serverInfo['load_factor'] = 1; - } - - $fee = $baseFeeXrp - ->multipliedBy((string)$serverInfo['load_factor']) - ->multipliedBy((string)$feeCushion); - - $fee = BigDecimal::min($fee, $client->getMaxFeeXrp()); - - //Round fee to 6 decimal places - return $fee->toScale(6, RoundingMode::UP); + return (new FeeCalculator($client))->getFeeXrp($cushion === null ? null : (float)$cushion); } -} \ No newline at end of file +} From c0cbcf32427cc4278f9ce31788ecd5b174670ae5 Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:03:55 +0200 Subject: [PATCH 08/16] build: configure Psalm for a library and add a baseline Psalm reported 812 findings, so nobody read them. Roughly 60 per cent were issue types that make sense for an application and not for a library: - UnusedClass and PossiblyUnused* flag the public surface, whose callers are consumers Psalm cannot see - 158 classes, 95 methods and properties. - ClassMustBeFinal wants 227 classes closed, including BaseTransaction, which every transaction model extends and which a separate Xahau package extends from outside. - MissingOverrideAttribute wants #[\Override], which only exists from PHP 8.3 while this library supports ^8.2 and CI builds 8.2. Suppressing those leaves 179 real findings. They are frozen in psalm-baseline.xml so the run is green and any new finding turns it red - a ratchet rather than a permanent wall of noise. The error level stays at 2, so type errors in new code are still reported in full. Psalm now runs in CI, without which the baseline would not hold. Co-Authored-By: Claude Opus 5 --- .github/workflows/unit_test.yml | 5 +- psalm-baseline.xml | 508 ++++++++++++++++++++++++++++++++ psalm.xml | 31 ++ 3 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 psalm-baseline.xml diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index ece30f0..930cec4 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -24,4 +24,7 @@ jobs: version: latest - name: Run PHPUnit - run: vendor/bin/phpunit ./tests \ No newline at end of file + run: vendor/bin/phpunit ./tests + + - name: Run Psalm + run: vendor/bin/psalm --config=psalm.xml --no-progress \ No newline at end of file diff --git a/psalm-baseline.xml b/psalm-baseline.xml new file mode 100644 index 0000000..868072e --- /dev/null +++ b/psalm-baseline.xml @@ -0,0 +1,508 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $walletToFund->getClassicAddress(), + 'xrpAmount' => $amount ?? '100', + ])]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + inverseFactor]]> + factor) + 1]]> + factor]]> + + + + base * $b256[$it3])]]> + + + + + + + + + + + + + + + + + + + readJson($parser)]]> + + + + + + + + + + + + + + + + + + + + + + ledgerEntryTypes]]> + transactionResults]]> + typeOrdinals[$fieldInfo->getType()]]]> + + + typeOrdinals[$fieldInfo->getType()]]]> + + + definitions['FIELDS']]]> + transactionTypes]]> + + + definitions['FIELDS']]]> + definitions['LEDGER_ENTRY_TYPES']]]> + definitions['TRANSACTION_RESULTS']]]> + definitions['TRANSACTION_TYPES']]]> + definitions['TYPES']]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + data)]]> + + + + + + + + + + getBody())]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $type, + 'Account' => self::ACCOUNT, + 'Fee' => '10', + 'Sequence' => 1, + ])]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self::MPT_ISSUANCE_ID, + 'value' => '-1', + ])]]> + self::MPT_ISSUANCE_ID, + 'value' => '10.5', + ])]]> + self::MPT_ISSUANCE_ID, + 'value' => '9223372036854775808', + ])]]> + '10', 'nonsense' => 'x'])]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + json)]]> + + + + + + json)]]> + + + + + + + + + + + + + + + + + + json)]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + resultsByMethod)]]> + $result + ['status' => 'success'], + ])]]> + + + + + + + + + + + toArray())]]> + 'AmmCreate', + 'Account' => self::ACCOUNT, + 'Fee' => '10', + 'Sequence' => 1, + ])]]> + + + + + + + + + + + + + unsignedPayment())]]> + + client, $signed)]]> + client, $this->unsignedPayment())]]> + client, $this->unsignedPayment(), false, $this->wallet)]]> + client, $tx, true, $this->wallet)]]> + + unsignedPayment())]]> + wallet->sign($delete)['tx_blob'])]]> + + signedPayment())]]> + unsignedPayment())]]> + client, $this->unsignedPayment())]]> + + + diff --git a/psalm.xml b/psalm.xml index 8e6147f..253bb5e 100644 --- a/psalm.xml +++ b/psalm.xml @@ -2,6 +2,10 @@ + + + + + + + + + + + + + + + From b5683f12875a108bdab282b49a7cbf6463a777f8 Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:04:13 +0200 Subject: [PATCH 09/16] build: add the missing trailing newline to the workflow Co-Authored-By: Claude Opus 5 --- .github/workflows/unit_test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 930cec4..1a540be 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -27,4 +27,4 @@ jobs: run: vendor/bin/phpunit ./tests - name: Run Psalm - run: vendor/bin/psalm --config=psalm.xml --no-progress \ No newline at end of file + run: vendor/bin/psalm --config=psalm.xml --no-progress From 265514cb5a5456429898c440eb5b04a91fc047c5 Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:04:41 +0200 Subject: [PATCH 10/16] chore: rename the two examples that were not kebab-case custom_currency_codes.php was snake_case and xrpBalance.php camelCase, while every other file in examples/ and examples/internal/ uses hyphens. The numbered prefixes in examples/quickstart/ encode a reading order rather than a spelling and are left alone. Only variable names inside xrp-balance.php still contain xrpBalance; no documentation referenced either filename. Co-Authored-By: Claude Opus 5 --- examples/{custom_currency_codes.php => custom-currency-codes.php} | 0 examples/{xrpBalance.php => xrp-balance.php} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename examples/{custom_currency_codes.php => custom-currency-codes.php} (100%) rename examples/{xrpBalance.php => xrp-balance.php} (100%) diff --git a/examples/custom_currency_codes.php b/examples/custom-currency-codes.php similarity index 100% rename from examples/custom_currency_codes.php rename to examples/custom-currency-codes.php diff --git a/examples/xrpBalance.php b/examples/xrp-balance.php similarity index 100% rename from examples/xrpBalance.php rename to examples/xrp-balance.php From f26ac25c5c50e78c98a22fd0fbc044e3060b7866 Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:11:18 +0200 Subject: [PATCH 11/16] chore: move the dev image to docker/ and consolidate compose Follows the house conventions from the repository consistency document: - containers/php/ becomes docker/, matching docker/Dockerfile plus a docker-compose.yml in the root. The nested layout implied several services where there is one image. - The three compose files become one. The platform differences were the user the container runs as, a tmpfs mount and how the host is reached; the first now comes from DOCKER_UID/DOCKER_GID in .env, the second is harmless everywhere, and extra_hosts maps host.docker.internal to the host gateway so the same xdebug.ini works on Linux and macOS. docker-compose.override.yml stays as the local escape hatch and remains gitignored. - xdebug-mac.ini and xdebug-linux.ini are gone. Neither compose file ever mounted them - both referenced xdebug.ini - and the Linux one had xdebug commented out entirely. Fixes docker-compose.linux.yml along the way, which mounted php.ini twice and xdebug.ini not at all, so Linux users got no xdebug configuration. That file no longer exists, but the bug would have been carried into the merged one. The license is already MIT in the badge, composer.json and LICENSE.md, and the CI workflow triggers on push without a branch filter, so the master to main rename needs no in-code change here. Co-Authored-By: Claude Opus 5 --- .env.example | 8 ++++++++ README.md | 18 +++++++++++++++--- containers/php/xdebug-linux.ini | 5 ----- containers/php/xdebug-mac.ini | 5 ----- docker-compose.linux.yml | 11 ----------- docker-compose.mac.yml | 11 ----------- docker-compose.yml | 19 +++++++++++++++++-- {containers/php => docker}/Dockerfile | 0 {containers/php => docker}/php.ini | 0 {containers/php => docker}/xdebug.ini | 0 10 files changed, 40 insertions(+), 37 deletions(-) create mode 100644 .env.example delete mode 100644 containers/php/xdebug-linux.ini delete mode 100644 containers/php/xdebug-mac.ini delete mode 100644 docker-compose.linux.yml delete mode 100644 docker-compose.mac.yml rename {containers/php => docker}/Dockerfile (100%) rename {containers/php => docker}/php.ini (100%) rename {containers/php => docker}/xdebug.ini (100%) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5091b39 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# The user the php container runs as, so files it writes belong to you. +# Linux: DOCKER_UID=1000 DOCKER_GID=1000 +# macOS: DOCKER_UID=501 DOCKER_GID=20 +# +# Generate the right values with: +# printf 'DOCKER_UID=%s\nDOCKER_GID=%s\n' "$(id -u)" "$(id -g)" > .env +DOCKER_UID=1000 +DOCKER_GID=1000 diff --git a/README.md b/README.md index ccf7ace..18bc8c7 100644 --- a/README.md +++ b/README.md @@ -143,19 +143,31 @@ etc... ### Run the project via Docker -1. In the project directory, start the project and open a shell: +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 -u 0 php bash +docker compose exec php bash ``` -2. In the container shell, install the composer dependencies: +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: diff --git a/containers/php/xdebug-linux.ini b/containers/php/xdebug-linux.ini deleted file mode 100644 index 8f54eb9..0000000 --- a/containers/php/xdebug-linux.ini +++ /dev/null @@ -1,5 +0,0 @@ -;zend_extension=xdebug.so -xdebug.mode=debug -xdebug.start_with_request=yes -xdebug.client_host=172.18.0.1 -xdebug.client_port=9003 \ No newline at end of file diff --git a/containers/php/xdebug-mac.ini b/containers/php/xdebug-mac.ini deleted file mode 100644 index fae7d3c..0000000 --- a/containers/php/xdebug-mac.ini +++ /dev/null @@ -1,5 +0,0 @@ -zend_extension=xdebug.so -xdebug.mode=debug -xdebug.start_with_request=yes -xdebug.client_host=host.docker.internal -xdebug.client_port=9090 \ No newline at end of file diff --git a/docker-compose.linux.yml b/docker-compose.linux.yml deleted file mode 100644 index 87a867a..0000000 --- a/docker-compose.linux.yml +++ /dev/null @@ -1,11 +0,0 @@ -# Use this as docker-compose.override.yml if you use Linux -services: - php: - volumes: - - .:/app - - ~/.composer/cache:/.composer/cache - - ./containers/php/php.ini:/usr/local/etc/php/conf.d/docker-php.ini - - ./containers/php/php.ini:/usr/local/etc/php/conf.d/docker-php.ini - tmpfs: - - /tmp:mode=1777 - user: "1000:1000" diff --git a/docker-compose.mac.yml b/docker-compose.mac.yml deleted file mode 100644 index 2ec50b5..0000000 --- a/docker-compose.mac.yml +++ /dev/null @@ -1,11 +0,0 @@ -# Use this as docker-compose.override.yml if you use Mac -services: - php: - volumes: - - .:/app - - ~/.composer/cache:/.composer/cache - - ./containers/php/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini - - ./containers/php/php.ini:/usr/local/etc/php/conf.d/docker-php.ini - user: "501:20" - environment: - PHP_IDE_CONFIG: "serverName=docker" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index c00a239..fe86932 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,28 @@ services: php: build: - context: containers/php + context: docker volumes: - .:/app + - ~/.composer/cache:/.composer/cache + - ./docker/php.ini:/usr/local/etc/php/conf.d/docker-php.ini + - ./docker/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini + # Files the container writes should belong to the host user. Set DOCKER_UID + # and DOCKER_GID in .env; see the README. The defaults match a typical + # Linux desktop, macOS usually needs 501:20. + user: "${DOCKER_UID:-1000}:${DOCKER_GID:-1000}" + # Lets xdebug.client_host=host.docker.internal resolve on Linux too, where + # Docker does not provide that name by itself. + extra_hosts: + - "host.docker.internal:host-gateway" + tmpfs: + - /tmp:mode=1777 + environment: + PHP_IDE_CONFIG: "serverName=docker" rippled: container_name: rippled image: natenichols/rippled-standalone:latest ports: - "5005:5005" - - "6006:6006" \ No newline at end of file + - "6006:6006" diff --git a/containers/php/Dockerfile b/docker/Dockerfile similarity index 100% rename from containers/php/Dockerfile rename to docker/Dockerfile diff --git a/containers/php/php.ini b/docker/php.ini similarity index 100% rename from containers/php/php.ini rename to docker/php.ini diff --git a/containers/php/xdebug.ini b/docker/xdebug.ini similarity index 100% rename from containers/php/xdebug.ini rename to docker/xdebug.ini From 33ac901e6e4bae53dd65e0b789049f8ee108e0d0 Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:19:23 +0200 Subject: [PATCH 12/16] test: cover submitAndWait against the Testnet submitAndWait() polls the ledger with a three second sleep between attempts, so the unit suite reached everything around it but never the loop itself. The new test funds two accounts from the faucet and drives the whole path: autofill against a live server, signing, submission, and polling until the transaction sits in a validated ledger. It also covers the pre-signed blob form the examples use, and that a transaction without a LastLedgerSequence is refused. The test needs the faucet and takes about 45 seconds, so it carries the group integration-slow and CI excludes it. Run it with `vendor/bin/phpunit --group integration-slow`. It immediately found what the unit tests could not: rippled sends base_fee_xrp as a JSON number, so it arrives as a float and brick/math deprecates that. The mocks used a string and therefore never reproduced it. FeeCalculator now converts explicitly, and the mocks use a number, so the unit suite would catch a regression on its own. Co-Authored-By: Claude Opus 5 --- .github/workflows/unit_test.yml | 2 +- src/Client/FeeCalculator.php | 4 +- tests/Integration/SubmitAndWaitTest.php | 114 ++++++++++++++++++++++++ tests/Sugar/FeeCalculationTest.php | 3 +- tests/Sugar/SubmitTest.php | 2 +- 5 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 tests/Integration/SubmitAndWaitTest.php diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 1a540be..1d3b05d 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -24,7 +24,7 @@ jobs: version: latest - name: Run PHPUnit - run: vendor/bin/phpunit ./tests + run: vendor/bin/phpunit ./tests --exclude-group integration-slow - name: Run Psalm run: vendor/bin/psalm --config=psalm.xml --no-progress diff --git a/src/Client/FeeCalculator.php b/src/Client/FeeCalculator.php index e6663cb..369b7f5 100644 --- a/src/Client/FeeCalculator.php +++ b/src/Client/FeeCalculator.php @@ -48,7 +48,9 @@ public function getFeeXrp(?float $cushion = null): string $loadFactor = $serverInfo['load_factor'] ?? 1; - $fee = BigDecimal::of($baseFee) + // rippled sends base_fee_xrp as a JSON number, so it arrives as a + // float. brick/math wants it as a string. + $fee = BigDecimal::of((string)$baseFee) ->multipliedBy((string)$loadFactor) ->multipliedBy((string)$feeCushion); diff --git a/tests/Integration/SubmitAndWaitTest.php b/tests/Integration/SubmitAndWaitTest.php new file mode 100644 index 0000000..0642df5 --- /dev/null +++ b/tests/Integration/SubmitAndWaitTest.php @@ -0,0 +1,114 @@ +client = new JsonRpcClient(self::TESTNET_URL); + } + + /** + * The whole path in one go: an unsigned transaction plus a wallet, filled + * in, signed, submitted, and polled until it is in a validated ledger. + * + * Passing an unsigned transaction together with a wallet is the case that + * used to fail with "Transaction must be signed", because getSignedTx() + * returned the tx_blob envelope of Wallet::sign() rather than an array. + */ + public function testSubmitAndWaitReachesAValidatedLedger(): void + { + $sender = $this->client->fundWallet(); + $receiver = $this->client->fundWallet(); + + $response = $this->client->submitAndWait( + [ + 'TransactionType' => 'Payment', + 'Account' => $sender->getAddress(), + 'Destination' => $receiver->getAddress(), + 'Amount' => '1000000', + ], + autofill: true, + failHard: false, + wallet: $sender + ); + + $result = $response->getResult(); + + $this->assertTrue($result['validated'], 'the transaction has to be in a validated ledger'); + $this->assertEquals('tesSUCCESS', $result['meta']['TransactionResult']); + $this->assertEquals('Payment', $result['tx_json']['TransactionType'] ?? $result['TransactionType']); + } + + /** + * The same through the object form, and with the transaction signed by the + * caller beforehand - the way every example does it. + */ + public function testSubmitterAcceptsAPreSignedBlob(): void + { + $wallet = $this->client->fundWallet(); + $receiver = $this->client->fundWallet(); + + $signed = $wallet->sign($this->client->autofill([ + 'TransactionType' => 'Payment', + 'Account' => $wallet->getAddress(), + 'Destination' => $receiver->getAddress(), + 'Amount' => '1000000', + ])); + + $response = (new Submitter($this->client))->submitAndWait($signed['tx_blob']); + $result = $response->getResult(); + + $this->assertTrue($result['validated']); + $this->assertEquals('tesSUCCESS', $result['meta']['TransactionResult']); + $this->assertEquals($signed['hash'], $result['hash']); + } + + /** + * Without a LastLedgerSequence there is no point at which polling could + * stop, so submission is refused before it starts. + */ + public function testSubmitAndWaitRefusesATransactionWithoutLastLedgerSequence(): void + { + $wallet = $this->client->fundWallet(); + + $signed = $wallet->sign([ + 'TransactionType' => 'AccountSet', + 'Account' => $wallet->getAddress(), + 'Fee' => '12', + 'Sequence' => 1, + ]); + + $this->expectExceptionMessage('LastLedgerSequence'); + + (new Submitter($this->client))->submitAndWait($signed['tx_blob']); + } +} diff --git a/tests/Sugar/FeeCalculationTest.php b/tests/Sugar/FeeCalculationTest.php index b99074f..f732dc6 100644 --- a/tests/Sugar/FeeCalculationTest.php +++ b/tests/Sugar/FeeCalculationTest.php @@ -25,7 +25,8 @@ final class FeeCalculationTest extends TestCase /** The owner reserve rippled reports, in drops */ private const RESERVE_INC = '2000000'; - private const BASE_FEE_XRP = '0.00001'; + // rippled sends this as a JSON number, not a string + private const BASE_FEE_XRP = 0.00001; private const LEDGER_INDEX = 2908714; diff --git a/tests/Sugar/SubmitTest.php b/tests/Sugar/SubmitTest.php index 03cf134..0086d45 100644 --- a/tests/Sugar/SubmitTest.php +++ b/tests/Sugar/SubmitTest.php @@ -57,7 +57,7 @@ protected function setUp(): void self::$server->setDefaultResponse(new RpcMethodResponse([ 'server_info' => [ 'info' => [ - 'validated_ledger' => ['base_fee_xrp' => '0.00001'], + 'validated_ledger' => ['base_fee_xrp' => 0.00001], 'load_factor' => 1, ], ], From c6c67d4c6708404a56239e1b51f7128a61f77cc9 Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:27:29 +0200 Subject: [PATCH 13/16] fix: make AccountOffersResponse loadable The class extends BaseResponse but imported BaseRequest, so autoloading it raised "Class BaseResponse not found" and it could never be instantiated. Every other response class in the namespace imports BaseResponse. Found by scanning all 233 classes under src/ for loadability; it is the only one affected. Co-Authored-By: Claude Opus 5 --- src/Models/Account/AccountOffersResponse.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Models/Account/AccountOffersResponse.php b/src/Models/Account/AccountOffersResponse.php index 86417d2..273371c 100644 --- a/src/Models/Account/AccountOffersResponse.php +++ b/src/Models/Account/AccountOffersResponse.php @@ -10,7 +10,7 @@ namespace Hardcastle\XRPL_PHP\Models\Account; -use Hardcastle\XRPL_PHP\Models\BaseRequest; +use Hardcastle\XRPL_PHP\Models\BaseResponse; /** * public API Methods / Ledger Methods From e34878b5d9787a24af4537ba76766ad911c0e9a7 Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:35:33 +0200 Subject: [PATCH 14/16] docs: describe every class and the SerializedType contract Class docblock coverage goes from 70 to 100 per cent. The 69 that had none, or only an empty frame, now say what the thing is - a Hash160 is not simply "a Hash160" but the width the order book directory fields use, and an Issue is an asset without an amount, in three shapes. SerializedType now documents the contract its 29 subclasses share: what fromParser(), fromJson(), toJson() and toBytes() mean, that the round trip is what has to hold rather than symmetry with PHP types, and that only STObject and STArray resolve field names. That covers roughly 120 subclass methods which would otherwise each have repeated the same three sentences. Also fills the gaps in the classes added earlier on this branch: Autofiller and Submitter carried several docblocks over verbatim from the free functions, and those were tag frames without a description. Wording follows ripple-binary-codec where the type is a direct port. Co-Authored-By: Claude Opus 5 --- src/Client/Autofiller.php | 51 ++++++++++++++++-- src/Client/JsonRpcClient.php | 7 +++ src/Client/Submitter.php | 29 +++++++++-- src/Core/CoreUtilities.php | 4 ++ src/Core/HashPrefix.php | 3 +- src/Core/MathUtilities.php | 6 +++ src/Core/Networks.php | 4 ++ src/Core/RippleAddressCodec/AddressCodec.php | 4 ++ src/Core/RippleAddressCodec/BaseX.php | 3 ++ src/Core/RippleAddressCodec/Codec.php | 4 ++ .../CodecWithXrpAlphabet.php | 4 ++ src/Core/RippleAddressCodec/Utils.php | 3 ++ src/Core/RippleBinaryCodec/Binary.php | 3 ++ src/Core/RippleBinaryCodec/BinaryCodec.php | 7 +++ .../Definitions/Definitions.php | 8 +++ .../Definitions/FieldHeader.php | 6 +++ .../Serdes/BinarySerializer.php | 3 ++ .../RippleBinaryCodec/Types/AccountId.php | 6 +++ src/Core/RippleBinaryCodec/Types/Amount.php | 8 +++ src/Core/RippleBinaryCodec/Types/Blob.php | 5 ++ src/Core/RippleBinaryCodec/Types/Currency.php | 7 +++ src/Core/RippleBinaryCodec/Types/Hash.php | 6 +++ src/Core/RippleBinaryCodec/Types/Hash128.php | 3 ++ src/Core/RippleBinaryCodec/Types/Hash160.php | 3 ++ src/Core/RippleBinaryCodec/Types/Hash256.php | 3 ++ src/Core/RippleBinaryCodec/Types/Issue.php | 7 +++ src/Core/RippleBinaryCodec/Types/Path.php | 3 ++ src/Core/RippleBinaryCodec/Types/PathSet.php | 6 +++ src/Core/RippleBinaryCodec/Types/PathStep.php | 6 +++ .../Types/SerializedType.php | 52 ++++++++++++++++++- .../RippleBinaryCodec/Types/SignedInt.php | 3 ++ .../RippleBinaryCodec/Types/SignedInt32.php | 3 ++ .../RippleBinaryCodec/Types/SignedInt64.php | 3 ++ src/Core/RippleBinaryCodec/Types/StArray.php | 7 +++ src/Core/RippleBinaryCodec/Types/StObject.php | 7 +++ .../RippleBinaryCodec/Types/UnsignedInt.php | 3 ++ .../RippleBinaryCodec/Types/UnsignedInt16.php | 3 ++ .../Types/UnsignedInt192.php | 6 +++ .../RippleBinaryCodec/Types/UnsignedInt32.php | 3 ++ .../Types/UnsignedInt384.php | 3 ++ .../Types/UnsignedInt512.php | 3 ++ .../RippleBinaryCodec/Types/UnsignedInt64.php | 7 +++ .../RippleBinaryCodec/Types/UnsignedInt8.php | 3 ++ .../RippleBinaryCodec/Types/UnsignedInt96.php | 3 ++ .../RippleBinaryCodec/Types/Vector256.php | 3 ++ .../RippleBinaryCodec/Types/XchainBridge.php | 4 ++ .../RippleKeyPairs/AbstractKeyPairService.php | 4 ++ .../RippleKeyPairs/Ed25519KeyPairService.php | 4 ++ src/Core/RippleKeyPairs/KeyPair.php | 3 ++ .../KeyPairServiceInterface.php | 4 ++ .../Secp256k1KeyPairService.php | 4 ++ src/Core/Stablecoin/RLUSD.php | 3 ++ src/Core/Stablecoin/Stablecoin.php | 3 ++ src/Core/Stablecoin/USDC.php | 3 ++ src/Exceptions/ValidationException.php | 4 ++ src/Models/BaseRequest.php | 6 +++ src/Models/BaseResponse.php | 3 ++ src/Models/Common/Amount.php | 4 ++ src/Models/ErrorResponse.php | 3 ++ .../Transaction/SubmitMultisignedResponse.php | 3 ++ src/Models/Transaction/SubmitResponse.php | 4 ++ .../Transaction/TransactionEntryResponse.php | 3 ++ src/Models/Transaction/TxHistoryResponse.php | 3 ++ src/Models/Transaction/TxResponse.php | 3 ++ src/Models/Utility/JsonResponse.php | 3 ++ src/Models/Utility/PingResponse.php | 3 ++ src/Models/Utility/RandomResponse.php | 3 ++ src/Models/Warning.php | 3 ++ src/Utils/Hashes/HashLedger.php | 2 +- src/Utils/Utilities.php | 3 ++ src/Wallet/DefaultFaucets.php | 4 ++ src/Wallet/Wallet.php | 6 +++ 72 files changed, 407 insertions(+), 12 deletions(-) diff --git a/src/Client/Autofiller.php b/src/Client/Autofiller.php index d8caba2..fbd0e14 100644 --- a/src/Client/Autofiller.php +++ b/src/Client/Autofiller.php @@ -49,6 +49,13 @@ public function __construct(private readonly JsonRpcClient $client) } /** + * Fill in what a transaction needs before it can be signed. + * + * Only fields the transaction does not carry already are touched, so a + * caller can pin any of them by setting it beforehand. Empty SourceTag and + * DestinationTag entries are removed, because rippled treats a present tag + * of 0 differently from an absent one. + * * @param Transaction|string|array $transaction A model, a tx_blob or an array * @param int|null $signersCount Number of signatures a multi-signed transaction will carry * @return array @@ -119,6 +126,12 @@ public function setValidAddresses(array &$tx): void } /** + * Replace one X-address field with its classic address, and write the tag + * it carries into the matching tag field. + * + * An X-address encodes the destination tag, so a tag given separately has + * to agree with it. + * * @param array $tx * @param string $accountField * @param string $tagField @@ -141,9 +154,14 @@ public function validateAccountAddress(array &$tx, string $accountField, string } /** - * @param string $account - * @param int|null $expectedTag - * @return array + * Split an address into its classic form and its tag. + * + * A classic address is returned unchanged together with the tag that was + * passed in, so callers do not have to know which form they hold. + * + * @param string $account A classic address or an X-address + * @param int|null $expectedTag Has to match the tag inside an X-address + * @return array{classicAccount: string, tag: int|false|null} * @throws Exception */ public static function getClassicAccountAndTag(string $account, ?int $expectedTag = null): array @@ -167,6 +185,11 @@ public static function getClassicAccountAndTag(string $account, ?int $expectedTa } /** + * Rewrite one address field to its classic form, ignoring any tag. + * + * Used for the fields that name an account without addressing a payment to + * it, such as Authorize, Owner and RegularKey. + * * @param array $tx * @param string $fieldName * @return void @@ -183,6 +206,11 @@ public function convertToClassicAddress(array &$tx, string $fieldName): void } /** + * Set Sequence to the account's next one. + * + * Read from the current ledger rather than the validated one, so that + * transactions submitted back to back do not reuse a sequence. + * * @param array $tx * @return void * @throws Exception @@ -223,8 +251,15 @@ public function fetchOwnerReserveFee(): BigDecimal } /** + * Set Fee to what this transaction type costs. + * + * Usually the network fee, but AccountDelete and AMMCreate burn one owner + * reserve instead, and EscrowFinish pays a surcharge that grows with the + * size of its fulfillment. Multi-signing adds one network fee per + * signature. + * * @param array $tx - * @param int|null $signersCount + * @param int|null $signersCount Number of signatures the transaction will carry * @return void * @throws MathException */ @@ -263,6 +298,8 @@ public function calculateFeePerTransactionType(array &$tx, ?int $signersCount = } /** + * Multiply a decimal string by a factor. + * * @param string $value * @param int|float $multiplier * @return BigDecimal @@ -276,6 +313,12 @@ public static function scaleValue(string $value, int|float $multiplier): BigDeci } /** + * Set LastLedgerSequence, the ledger after which the transaction can no + * longer be included. + * + * Without it a transaction could sit in the queue indefinitely, and + * reliable submission would have no point at which to stop waiting. + * * @param array $tx * @return void */ diff --git a/src/Client/JsonRpcClient.php b/src/Client/JsonRpcClient.php index f9e8aa2..c4a6a99 100644 --- a/src/Client/JsonRpcClient.php +++ b/src/Client/JsonRpcClient.php @@ -30,6 +30,13 @@ use Hardcastle\XRPL_PHP\Models\Transaction\TxResponse; use Hardcastle\XRPL_PHP\Wallet\Wallet; +/** + * A connection to a rippled server over JSON-RPC. + * + * Beyond issuing requests it offers the operations most callers need - + * autofill, submit, balances, transaction history - each delegating to a class + * of its own. + */ class JsonRpcClient { private const DEFAULT_FEE_CUSHION = 1.2; diff --git a/src/Client/Submitter.php b/src/Client/Submitter.php index 3c0bab9..8a7fc5f 100644 --- a/src/Client/Submitter.php +++ b/src/Client/Submitter.php @@ -66,10 +66,16 @@ public function submitRequest(array $signedTransaction, ?bool $failHard = false) } /** + * Submit a transaction and return as soon as the server has taken it. + * + * The result is rippled's preliminary opinion, not an outcome: a + * tesSUCCESS here can still fail once the transaction is applied. Use + * submitAndWait() when the outcome matters. + * * @param Transaction|array|string $transaction - * @param bool|null $autofill - * @param bool|null $failHard - * @param Wallet|null $wallet + * @param bool|null $autofill Fill in Sequence, Fee and LastLedgerSequence first + * @param bool|null $failHard Refuse to retry the transaction in later ledgers + * @param Wallet|null $wallet Required unless the transaction is already signed * @return SubmitResponse * @throws Exception */ @@ -207,6 +213,11 @@ public function getSignedTx( } /** + * Whether a transaction carries a signature. + * + * A single-signed transaction has a SigningPubKey, a multi-signed one has + * Signers and an empty SigningPubKey, so either field is enough. + * * @param array $tx * @return bool */ @@ -216,8 +227,11 @@ public static function isSigned(array $tx): bool } /** + * The ledger after which the transaction can no longer be included, or + * null if it carries no such limit. + * * @param array|string $tx A transaction array or a tx_blob - * @param Definitions|null $definitions + * @param Definitions|null $definitions Needed to decode a blob of another network * @return int|null * @throws Exception */ @@ -234,8 +248,13 @@ public static function getLastLedgerSequence(array|string $tx, ?Definitions $def } /** + * Whether this is an AccountDelete. + * + * Those are submitted with failHard, because a deletion that is retried in + * a later ledger would burn the owner reserve again. + * * @param array|string $tx A transaction array or a tx_blob - * @param Definitions|null $definitions + * @param Definitions|null $definitions Needed to decode a blob of another network * @return bool * @throws Exception */ diff --git a/src/Core/CoreUtilities.php b/src/Core/CoreUtilities.php index c9a6f1c..9df25f9 100644 --- a/src/Core/CoreUtilities.php +++ b/src/Core/CoreUtilities.php @@ -14,6 +14,10 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleAddressCodec\AddressCodec; +/** + * Address helpers used across the library: validation, and conversion between + * classic addresses and X-addresses. + */ class CoreUtilities { private static ?CoreUtilities $instance = null; diff --git a/src/Core/HashPrefix.php b/src/Core/HashPrefix.php index fc5c29e..9f6c9f8 100644 --- a/src/Core/HashPrefix.php +++ b/src/Core/HashPrefix.php @@ -11,7 +11,8 @@ namespace Hardcastle\XRPL_PHP\Core; /** - * + * The four byte prefixes rippled puts in front of data before hashing it, so + * that a transaction hash can never collide with a ledger hash. */ class HashPrefix { diff --git a/src/Core/MathUtilities.php b/src/Core/MathUtilities.php index 3d2daf7..803493b 100644 --- a/src/Core/MathUtilities.php +++ b/src/Core/MathUtilities.php @@ -13,6 +13,12 @@ use Brick\Math\BigDecimal; use Hardcastle\Buffer\Buffer; +/** + * Decimal and hashing helpers. + * + * The decimal functions exist because token amounts carry more precision than + * a PHP float can hold, so everything goes through brick/math. + */ class MathUtilities { public static function unsignedRightShift(int $value, int $steps): int diff --git a/src/Core/Networks.php b/src/Core/Networks.php index ad54f7d..60a6f24 100644 --- a/src/Core/Networks.php +++ b/src/Core/Networks.php @@ -12,6 +12,10 @@ use Exception; +/** + * The known networks and their endpoints, so that 'testnet' can be written + * instead of a URL. + */ class Networks { private const NETWORKS = [ diff --git a/src/Core/RippleAddressCodec/AddressCodec.php b/src/Core/RippleAddressCodec/AddressCodec.php index 78495e1..c6b0f1f 100644 --- a/src/Core/RippleAddressCodec/AddressCodec.php +++ b/src/Core/RippleAddressCodec/AddressCodec.php @@ -12,6 +12,10 @@ use Hardcastle\Buffer\Buffer; +/** + * Encodes and decodes the address forms of the XRP Ledger: classic addresses, + * X-addresses, seeds and public keys. + */ class AddressCodec extends CodecWithXrpAlphabet { public const PREFIX_BYTES = [ diff --git a/src/Core/RippleAddressCodec/BaseX.php b/src/Core/RippleAddressCodec/BaseX.php index a6e8ccf..26d5ff9 100644 --- a/src/Core/RippleAddressCodec/BaseX.php +++ b/src/Core/RippleAddressCodec/BaseX.php @@ -13,6 +13,9 @@ use Hardcastle\Buffer\Buffer; use SplFixedArray; +/** + * Base conversion for arbitrary alphabets, the machinery behind base58. + */ class BaseX { private const SIZE = 256; diff --git a/src/Core/RippleAddressCodec/Codec.php b/src/Core/RippleAddressCodec/Codec.php index 9362dcb..690116c 100644 --- a/src/Core/RippleAddressCodec/Codec.php +++ b/src/Core/RippleAddressCodec/Codec.php @@ -13,6 +13,10 @@ use Exception; use Hardcastle\Buffer\Buffer; +/** + * Base58 encoding with a four byte checksum and a version prefix, which is what + * makes a mistyped address detectable rather than merely wrong. + */ class Codec { private readonly BaseX $baseCodec; diff --git a/src/Core/RippleAddressCodec/CodecWithXrpAlphabet.php b/src/Core/RippleAddressCodec/CodecWithXrpAlphabet.php index 4f7b598..e6d5287 100644 --- a/src/Core/RippleAddressCodec/CodecWithXrpAlphabet.php +++ b/src/Core/RippleAddressCodec/CodecWithXrpAlphabet.php @@ -12,6 +12,10 @@ use Hardcastle\Buffer\Buffer; +/** + * The base58 codec bound to the XRP Ledger's own alphabet, which differs from + * the Bitcoin one. + */ class CodecWithXrpAlphabet extends Codec { public const ACCOUNT_ID = 0; // Account address (20 bytes) diff --git a/src/Core/RippleAddressCodec/Utils.php b/src/Core/RippleAddressCodec/Utils.php index 85d1ed2..4cbdf92 100644 --- a/src/Core/RippleAddressCodec/Utils.php +++ b/src/Core/RippleAddressCodec/Utils.php @@ -10,6 +10,9 @@ namespace Hardcastle\XRPL_PHP\Core\RippleAddressCodec; +/** + * Byte and array helpers shared by the address codec. + */ class Utils { const XRPL_ALPHABET = "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"; diff --git a/src/Core/RippleBinaryCodec/Binary.php b/src/Core/RippleBinaryCodec/Binary.php index 5064281..fd3f67e 100644 --- a/src/Core/RippleBinaryCodec/Binary.php +++ b/src/Core/RippleBinaryCodec/Binary.php @@ -14,6 +14,9 @@ use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Types\StObject; +/** + * Turns bytes into JSON, and holds the definitions the codec works against. + */ class Binary { protected ?Definitions $definitions = null; diff --git a/src/Core/RippleBinaryCodec/BinaryCodec.php b/src/Core/RippleBinaryCodec/BinaryCodec.php index 4e27ce6..0818bf7 100644 --- a/src/Core/RippleBinaryCodec/BinaryCodec.php +++ b/src/Core/RippleBinaryCodec/BinaryCodec.php @@ -16,6 +16,13 @@ use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Types\AccountId; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Types\StObject; +/** + * Encodes transactions to their binary form and back. + * + * encode() produces what gets submitted, encodeForSigning() the reduced form + * that a signature covers - fields marked as not being signing fields, such as + * TxnSignature and BatchSigners, are left out of it. + */ class BinaryCodec extends Binary { const TRANSACTION_SIGN = '53545800'; diff --git a/src/Core/RippleBinaryCodec/Definitions/Definitions.php b/src/Core/RippleBinaryCodec/Definitions/Definitions.php index 983f59c..93a5f7b 100644 --- a/src/Core/RippleBinaryCodec/Definitions/Definitions.php +++ b/src/Core/RippleBinaryCodec/Definitions/Definitions.php @@ -4,6 +4,14 @@ use Exception; +/** + * The field, type and enum tables the codec works against, read from + * definitions.json. + * + * Maps field names to their ordinal and back, which is what makes a transaction + * serializable. Other networks supply their own set; see fromArray() and + * fromFile(). + */ class Definitions { public static ?Definitions $instance = null; diff --git a/src/Core/RippleBinaryCodec/Definitions/FieldHeader.php b/src/Core/RippleBinaryCodec/Definitions/FieldHeader.php index 8facdd0..e352141 100644 --- a/src/Core/RippleBinaryCodec/Definitions/FieldHeader.php +++ b/src/Core/RippleBinaryCodec/Definitions/FieldHeader.php @@ -5,6 +5,12 @@ use Ds\Hashable; use Hardcastle\Buffer\Buffer; +/** + * The type and field ordinal that identify a field on the wire. + * + * Together these two numbers form the header byte or bytes that precede every + * field value. + */ class FieldHeader implements Hashable { public function __construct(private int $typeCode, private int $fieldCode) diff --git a/src/Core/RippleBinaryCodec/Serdes/BinarySerializer.php b/src/Core/RippleBinaryCodec/Serdes/BinarySerializer.php index 40ec496..196a734 100644 --- a/src/Core/RippleBinaryCodec/Serdes/BinarySerializer.php +++ b/src/Core/RippleBinaryCodec/Serdes/BinarySerializer.php @@ -15,6 +15,9 @@ use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Definitions\FieldInstance; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Types\SerializedType; +/** + * Assembles a byte stream, writing field headers, lengths and values in order. + */ class BinarySerializer { public function __construct(private readonly Buffer $bytes) diff --git a/src/Core/RippleBinaryCodec/Types/AccountId.php b/src/Core/RippleBinaryCodec/Types/AccountId.php index 1dcb682..4edbfdd 100644 --- a/src/Core/RippleBinaryCodec/Types/AccountId.php +++ b/src/Core/RippleBinaryCodec/Types/AccountId.php @@ -14,6 +14,12 @@ use Hardcastle\XRPL_PHP\Core\RippleAddressCodec\AddressCodec; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * An account, 20 bytes on the wire. + * + * The JSON form is the familiar base58 classic address; the bytes are the + * RIPEMD160 of the SHA256 of the public key, which is what the address encodes. + */ class AccountId extends Hash160 { private const HEX_REGEX = "/^[A-F0-9]{40}$/"; diff --git a/src/Core/RippleBinaryCodec/Types/Amount.php b/src/Core/RippleBinaryCodec/Types/Amount.php index 6b76e3b..e0a3629 100644 --- a/src/Core/RippleBinaryCodec/Types/Amount.php +++ b/src/Core/RippleBinaryCodec/Types/Amount.php @@ -20,6 +20,14 @@ define('MAX_DROPS', BigDecimal::of("1e17")); define('MIN_XRP', BigDecimal::of("1e-6")); +/** + * An amount of XRP, of an issued token or of an MPT. + * + * The three are told apart by the top bits of the first byte and differ in + * length: 8 bytes for XRP, 48 for a token (mantissa, currency, issuer) and 33 + * for an MPT (value plus the 24 byte issuance id). XRP is a plain integer + * count of drops, a token value is a signed decimal with its own exponent. + */ class Amount extends SerializedType { public const DEFAULT_AMOUNT_HEX = "4000000000000000"; diff --git a/src/Core/RippleBinaryCodec/Types/Blob.php b/src/Core/RippleBinaryCodec/Types/Blob.php index 2ad0eaf..8d4e37a 100644 --- a/src/Core/RippleBinaryCodec/Types/Blob.php +++ b/src/Core/RippleBinaryCodec/Types/Blob.php @@ -17,6 +17,11 @@ use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BytesList; use function MongoDB\BSON\fromJSON; +/** + * Arbitrary bytes of variable length, such as a Memo, a URI or a signature. + * + * The length is not part of the value; it is written by the field header. + */ class Blob extends SerializedType { public function __construct(?Buffer $bytes = null) diff --git a/src/Core/RippleBinaryCodec/Types/Currency.php b/src/Core/RippleBinaryCodec/Types/Currency.php index c97ef91..d38f0fd 100644 --- a/src/Core/RippleBinaryCodec/Types/Currency.php +++ b/src/Core/RippleBinaryCodec/Types/Currency.php @@ -15,6 +15,13 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * A currency code, 20 bytes on the wire. + * + * Renders as "XRP" for the native asset, as a three character code where the + * bytes allow it, and otherwise as the full 40 character hex - which is how + * non-standard codes are carried. + */ class Currency extends Hash160 { private const XRP_HEX_REGEX = '/^0{40}$/'; diff --git a/src/Core/RippleBinaryCodec/Types/Hash.php b/src/Core/RippleBinaryCodec/Types/Hash.php index 2705e66..a439ba1 100644 --- a/src/Core/RippleBinaryCodec/Types/Hash.php +++ b/src/Core/RippleBinaryCodec/Types/Hash.php @@ -14,6 +14,12 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * Base class of the fixed width hash types. + * + * The width is fixed per subclass and enforced on construction: a value of the + * wrong length would shift every field that follows it. + */ abstract class Hash extends SerializedType { protected static int $width; diff --git a/src/Core/RippleBinaryCodec/Types/Hash128.php b/src/Core/RippleBinaryCodec/Types/Hash128.php index 4cac8a4..596362a 100644 --- a/src/Core/RippleBinaryCodec/Types/Hash128.php +++ b/src/Core/RippleBinaryCodec/Types/Hash128.php @@ -15,6 +15,9 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * A 128 bit (16 byte) hash. Used by EmailHash. + */ class Hash128 extends Hash { protected static int $width = 16; diff --git a/src/Core/RippleBinaryCodec/Types/Hash160.php b/src/Core/RippleBinaryCodec/Types/Hash160.php index 6537a2c..3f60a4f 100644 --- a/src/Core/RippleBinaryCodec/Types/Hash160.php +++ b/src/Core/RippleBinaryCodec/Types/Hash160.php @@ -15,6 +15,9 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * A 160 bit (20 byte) hash. Used by the currency and issuer fields of an order book directory. + */ class Hash160 extends Hash { public static int $width = 20; diff --git a/src/Core/RippleBinaryCodec/Types/Hash256.php b/src/Core/RippleBinaryCodec/Types/Hash256.php index 0235f6f..9413a52 100644 --- a/src/Core/RippleBinaryCodec/Types/Hash256.php +++ b/src/Core/RippleBinaryCodec/Types/Hash256.php @@ -15,6 +15,9 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * A 256 bit (32 byte) hash - transaction ids, ledger indexes and most object references. + */ class Hash256 extends Hash { protected static int $width = 32; diff --git a/src/Core/RippleBinaryCodec/Types/Issue.php b/src/Core/RippleBinaryCodec/Types/Issue.php index e6d30f4..61d689c 100644 --- a/src/Core/RippleBinaryCodec/Types/Issue.php +++ b/src/Core/RippleBinaryCodec/Types/Issue.php @@ -14,6 +14,13 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * An asset, without an amount: XRP, an issued token or an MPT. + * + * XRP is 20 bytes of currency code, a token adds the 20 byte issuer, and an + * MPT is 44 bytes carrying the issuer, a reserved placeholder and the issuance + * sequence in little endian. + */ class Issue extends SerializedType { protected static int $bytesLength = 20; diff --git a/src/Core/RippleBinaryCodec/Types/Path.php b/src/Core/RippleBinaryCodec/Types/Path.php index 8310875..bcc6804 100644 --- a/src/Core/RippleBinaryCodec/Types/Path.php +++ b/src/Core/RippleBinaryCodec/Types/Path.php @@ -15,6 +15,9 @@ use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BytesList; +/** + * One path a payment can take, a sequence of steps. + */ class Path extends SerializedType { public function __construct(?Buffer $bytes = null) diff --git a/src/Core/RippleBinaryCodec/Types/PathSet.php b/src/Core/RippleBinaryCodec/Types/PathSet.php index e510e12..15547c1 100644 --- a/src/Core/RippleBinaryCodec/Types/PathSet.php +++ b/src/Core/RippleBinaryCodec/Types/PathSet.php @@ -14,6 +14,12 @@ use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BytesList; +/** + * The alternative paths offered for a cross-currency payment. + * + * At most six paths, each a sequence of steps, terminated on the wire by a + * marker byte. + */ class PathSet extends SerializedType { public const PATHSET_END_BYTE = 0x00; diff --git a/src/Core/RippleBinaryCodec/Types/PathStep.php b/src/Core/RippleBinaryCodec/Types/PathStep.php index 530b9e2..5c61205 100644 --- a/src/Core/RippleBinaryCodec/Types/PathStep.php +++ b/src/Core/RippleBinaryCodec/Types/PathStep.php @@ -14,6 +14,12 @@ use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BytesList; +/** + * One hop of a payment path. + * + * A step names an account to ride through, a currency to convert into, or an + * issuer - which of the three is present is encoded in a leading type byte. + */ class PathStep extends SerializedType { const TYPE_ACCOUNT = 0x01; diff --git a/src/Core/RippleBinaryCodec/Types/SerializedType.php b/src/Core/RippleBinaryCodec/Types/SerializedType.php index 3aa4d51..5743de6 100644 --- a/src/Core/RippleBinaryCodec/Types/SerializedType.php +++ b/src/Core/RippleBinaryCodec/Types/SerializedType.php @@ -16,6 +16,29 @@ use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BytesList; /** + * Base class of every type the binary codec knows. + * + * A serialized type is a wrapper around the bytes of one field as they appear + * on the wire. Every subclass implements the same four operations, so the rest + * of the codec can treat them interchangeably: + * + * fromParser() reads an instance out of a byte stream, advancing the parser + * past it. Variable length fields receive the length that the + * field header announced as $lengthHint. + * fromJson() builds an instance from the JSON form rippled uses for that + * field - usually a string, an array for the composite types. + * toJson() the reverse, and the shape rippled expects to receive. + * toBytes() the raw bytes, which is what actually gets signed. + * + * fromJson() and toJson() are not symmetric with the PHP type system: a + * Hash256 takes and returns a hex string, an Amount takes and returns either a + * string of drops or an array, and STObject nests. What they are symmetric in + * is the round trip - decode(encode($x)) has to equal $x. + * + * Only STObject and STArray resolve field names, so only those two need to + * know which network's definitions apply; the rest serialize their own bytes + * and are network agnostic. + * * JavaScript: * https://github.com/XRPLF/xrpl.js/blob/main/packages/ripple-binary-codec/src/types/serialized-type.ts * @@ -35,6 +58,7 @@ public function __construct(?Buffer $bytes = null) } /** + * Append these bytes to a list being assembled. * * @param BytesList $list * @return void @@ -45,6 +69,8 @@ public function toBytesSink(BytesList $list): void } /** + * The raw bytes of this value, as they appear on the wire. + * * @return Buffer */ public function toBytes(): Buffer @@ -53,6 +79,8 @@ public function toBytes(): Buffer } /** + * The bytes as an uppercase hex string. + * * @return string */ public function toHex(): string @@ -61,6 +89,11 @@ public function toHex(): string } /** + * The JSON form rippled uses for this field. + * + * Types that carry no structure of their own fall back to hex, which is + * how rippled renders them too. + * * @return array|string|int */ public function toJson(): array|string|int @@ -77,6 +110,7 @@ public function toString(): string } /** + * Read an instance out of a hex string. * * @param string $hex * @return SerializedType @@ -89,9 +123,12 @@ public static function fromHex(string $hex): SerializedType } /** + * The class that handles a type named in definitions.json. * + * Returns an empty instance, which the codec then uses as a factory - the + * static fromParser() and fromJson() are reached through it. * - * @param string $name + * @param string $name A key of the TYPES section of definitions.json * @return SerializedType * @throws Exception */ @@ -140,8 +177,21 @@ public static function getTypeByName(string $name): SerializedType return new $typeMap[$name](); } + /** + * Read an instance from a byte stream, advancing the parser past it. + * + * @param BinaryParser $parser + * @param int|null $lengthHint Byte count for variable length fields + * @return SerializedType + */ abstract static function fromParser(BinaryParser $parser, ?int $lengthHint = null): SerializedType; + /** + * Build an instance from the JSON form rippled uses for this field. + * + * @param string $serializedJson A scalar, or a JSON encoded array for the composite types + * @return SerializedType + */ abstract static function fromJson(string $serializedJson): SerializedType; } diff --git a/src/Core/RippleBinaryCodec/Types/SignedInt.php b/src/Core/RippleBinaryCodec/Types/SignedInt.php index 30cca46..1f5810e 100644 --- a/src/Core/RippleBinaryCodec/Types/SignedInt.php +++ b/src/Core/RippleBinaryCodec/Types/SignedInt.php @@ -5,6 +5,9 @@ use Brick\Math\BigInteger; use Hardcastle\Buffer\Buffer; +/** + * Base class of the signed integer types, stored in two's complement. + */ abstract class SignedInt extends SerializedType { protected BigInteger $value; diff --git a/src/Core/RippleBinaryCodec/Types/SignedInt32.php b/src/Core/RippleBinaryCodec/Types/SignedInt32.php index db04f26..da866d4 100644 --- a/src/Core/RippleBinaryCodec/Types/SignedInt32.php +++ b/src/Core/RippleBinaryCodec/Types/SignedInt32.php @@ -6,6 +6,9 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * A signed 32 bit integer. + */ class SignedInt32 extends SignedInt { public const WIDTH = 4; diff --git a/src/Core/RippleBinaryCodec/Types/SignedInt64.php b/src/Core/RippleBinaryCodec/Types/SignedInt64.php index fdacea1..bf0eb0b 100644 --- a/src/Core/RippleBinaryCodec/Types/SignedInt64.php +++ b/src/Core/RippleBinaryCodec/Types/SignedInt64.php @@ -6,6 +6,9 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * A signed 64 bit integer. + */ class SignedInt64 extends SignedInt { public const WIDTH = 8; diff --git a/src/Core/RippleBinaryCodec/Types/StArray.php b/src/Core/RippleBinaryCodec/Types/StArray.php index 907b30e..982a7f1 100644 --- a/src/Core/RippleBinaryCodec/Types/StArray.php +++ b/src/Core/RippleBinaryCodec/Types/StArray.php @@ -15,6 +15,13 @@ use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinarySerializer; +/** + * An array of objects, such as Memos, Signers or the AmountEntry list of a Remit. + * + * Each element is an STObject; the array is terminated by a marker byte. Along + * with STObject this is one of the two types that resolve field names, so it + * carries the definitions of the network it was parsed with. + */ class StArray extends SerializedType { public const ARRAY_END_MARKER = 0xf1; diff --git a/src/Core/RippleBinaryCodec/Types/StObject.php b/src/Core/RippleBinaryCodec/Types/StObject.php index 3ec9546..95ccf88 100644 --- a/src/Core/RippleBinaryCodec/Types/StObject.php +++ b/src/Core/RippleBinaryCodec/Types/StObject.php @@ -18,6 +18,13 @@ use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinarySerializer; use Hardcastle\XRPL_PHP\Core\CoreUtilities; +/** + * A nested object, and the outermost container of every transaction. + * + * Fields are written in the order of their definitions ordinal, not the order + * they were given in, because the signature covers the canonical ordering. + * Along with STArray this is one of the two types that resolve field names. + */ class StObject extends SerializedType { public const OBJECT_END_MARKER_HEX = "E1"; diff --git a/src/Core/RippleBinaryCodec/Types/UnsignedInt.php b/src/Core/RippleBinaryCodec/Types/UnsignedInt.php index 97f0f52..ffbcaef 100644 --- a/src/Core/RippleBinaryCodec/Types/UnsignedInt.php +++ b/src/Core/RippleBinaryCodec/Types/UnsignedInt.php @@ -13,6 +13,9 @@ use Brick\Math\BigInteger; use Hardcastle\Buffer\Buffer; +/** + * Base class of the unsigned integer types. + */ abstract class UnsignedInt extends SerializedType { protected BigInteger $value; diff --git a/src/Core/RippleBinaryCodec/Types/UnsignedInt16.php b/src/Core/RippleBinaryCodec/Types/UnsignedInt16.php index 5c27bfc..6c72bf4 100644 --- a/src/Core/RippleBinaryCodec/Types/UnsignedInt16.php +++ b/src/Core/RippleBinaryCodec/Types/UnsignedInt16.php @@ -14,6 +14,9 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * An unsigned 16 bit integer, such as TransactionType or TransferFee. + */ class UnsignedInt16 extends UnsignedInt { public const WIDTH = 2; diff --git a/src/Core/RippleBinaryCodec/Types/UnsignedInt192.php b/src/Core/RippleBinaryCodec/Types/UnsignedInt192.php index eaf6d07..e2918cd 100644 --- a/src/Core/RippleBinaryCodec/Types/UnsignedInt192.php +++ b/src/Core/RippleBinaryCodec/Types/UnsignedInt192.php @@ -13,6 +13,12 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * An unsigned 192 bit integer. + * + * Note that definitions.json calls the 24 byte type Hash192, which is handled + * by the Hash192 class; this one is kept for callers that ask for UInt192. + */ class UnsignedInt192 extends UnsignedInt { public static function fromParser(BinaryParser $parser, ?int $lengthHint = null): UnsignedInt192 diff --git a/src/Core/RippleBinaryCodec/Types/UnsignedInt32.php b/src/Core/RippleBinaryCodec/Types/UnsignedInt32.php index fca57b1..8c9de94 100644 --- a/src/Core/RippleBinaryCodec/Types/UnsignedInt32.php +++ b/src/Core/RippleBinaryCodec/Types/UnsignedInt32.php @@ -14,6 +14,9 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * An unsigned 32 bit integer - Sequence, Flags, Expiration and most counters. + */ class UnsignedInt32 extends UnsignedInt { public const WIDTH = 4; diff --git a/src/Core/RippleBinaryCodec/Types/UnsignedInt384.php b/src/Core/RippleBinaryCodec/Types/UnsignedInt384.php index e6ee8e7..176f13a 100644 --- a/src/Core/RippleBinaryCodec/Types/UnsignedInt384.php +++ b/src/Core/RippleBinaryCodec/Types/UnsignedInt384.php @@ -13,6 +13,9 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * An unsigned 384 bit integer. No field currently uses it. + */ class UnsignedInt384 extends UnsignedInt { public static function fromParser(BinaryParser $parser, ?int $lengthHint = null): UnsignedInt384 diff --git a/src/Core/RippleBinaryCodec/Types/UnsignedInt512.php b/src/Core/RippleBinaryCodec/Types/UnsignedInt512.php index 249f89c..9ac92cd 100644 --- a/src/Core/RippleBinaryCodec/Types/UnsignedInt512.php +++ b/src/Core/RippleBinaryCodec/Types/UnsignedInt512.php @@ -13,6 +13,9 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * An unsigned 512 bit integer. No field currently uses it. + */ class UnsignedInt512 extends UnsignedInt { public static function fromParser(BinaryParser $parser, ?int $lengthHint = null): UnsignedInt512 diff --git a/src/Core/RippleBinaryCodec/Types/UnsignedInt64.php b/src/Core/RippleBinaryCodec/Types/UnsignedInt64.php index cdf8ccb..f4ca0cb 100644 --- a/src/Core/RippleBinaryCodec/Types/UnsignedInt64.php +++ b/src/Core/RippleBinaryCodec/Types/UnsignedInt64.php @@ -14,6 +14,13 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * An unsigned 64 bit integer. + * + * Rendered as a 16 character hex string in JSON, unlike the smaller widths + * which are numbers. The MPToken amount fields are the exception and use base + * 10; see BASE_10_FIELDS. + */ class UnsignedInt64 extends UnsignedInt { public const WIDTH = 8; diff --git a/src/Core/RippleBinaryCodec/Types/UnsignedInt8.php b/src/Core/RippleBinaryCodec/Types/UnsignedInt8.php index 0d3a600..2d1edb2 100644 --- a/src/Core/RippleBinaryCodec/Types/UnsignedInt8.php +++ b/src/Core/RippleBinaryCodec/Types/UnsignedInt8.php @@ -14,6 +14,9 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * An unsigned 8 bit integer, such as TransactionResult or AssetScale. + */ class UnsignedInt8 extends UnsignedInt { public const WIDTH = 1; diff --git a/src/Core/RippleBinaryCodec/Types/UnsignedInt96.php b/src/Core/RippleBinaryCodec/Types/UnsignedInt96.php index e9d6082..2dc6119 100644 --- a/src/Core/RippleBinaryCodec/Types/UnsignedInt96.php +++ b/src/Core/RippleBinaryCodec/Types/UnsignedInt96.php @@ -13,6 +13,9 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * An unsigned 96 bit integer. + */ class UnsignedInt96 extends UnsignedInt { public static function fromParser(BinaryParser $parser, ?int $lengthHint = null): UnsignedInt96 diff --git a/src/Core/RippleBinaryCodec/Types/Vector256.php b/src/Core/RippleBinaryCodec/Types/Vector256.php index d116c0e..8e72fe5 100644 --- a/src/Core/RippleBinaryCodec/Types/Vector256.php +++ b/src/Core/RippleBinaryCodec/Types/Vector256.php @@ -16,6 +16,9 @@ use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BytesList; +/** + * A sequence of Hash256 values, such as Indexes, Amendments or CredentialIDs. + */ class Vector256 extends SerializedType { protected static int $width = 32; diff --git a/src/Core/RippleBinaryCodec/Types/XchainBridge.php b/src/Core/RippleBinaryCodec/Types/XchainBridge.php index c4799f5..cd01c12 100644 --- a/src/Core/RippleBinaryCodec/Types/XchainBridge.php +++ b/src/Core/RippleBinaryCodec/Types/XchainBridge.php @@ -14,6 +14,10 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\RippleBinaryCodec\Serdes\BinaryParser; +/** + * The four part description of a cross-chain bridge: the door account and the + * asset on each of the two chains. + */ class XchainBridge extends SerializedType { public const TYPE_ORDER = [ diff --git a/src/Core/RippleKeyPairs/AbstractKeyPairService.php b/src/Core/RippleKeyPairs/AbstractKeyPairService.php index 2c3acc4..568ac8a 100644 --- a/src/Core/RippleKeyPairs/AbstractKeyPairService.php +++ b/src/Core/RippleKeyPairs/AbstractKeyPairService.php @@ -7,6 +7,10 @@ use Hardcastle\XRPL_PHP\Core\RippleAddressCodec\AddressCodec; use Hardcastle\XRPL_PHP\Core\CoreUtilities; +/** + * Behaviour shared by the signing algorithms, such as deriving an address from a + * public key. + */ class AbstractKeyPairService { protected const PREFIX_ED25519 = 'ED'; diff --git a/src/Core/RippleKeyPairs/Ed25519KeyPairService.php b/src/Core/RippleKeyPairs/Ed25519KeyPairService.php index 152b28e..a7d5bc4 100644 --- a/src/Core/RippleKeyPairs/Ed25519KeyPairService.php +++ b/src/Core/RippleKeyPairs/Ed25519KeyPairService.php @@ -6,6 +6,10 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\MathUtilities; +/** + * Ed25519 signing. The default for new wallets; its public keys are marked by a + * leading ED byte. + */ class Ed25519KeyPairService extends AbstractKeyPairService implements KeyPairServiceInterface { private static ?Ed25519KeyPairService $instance = null; diff --git a/src/Core/RippleKeyPairs/KeyPair.php b/src/Core/RippleKeyPairs/KeyPair.php index e78ccb6..c37b3b0 100644 --- a/src/Core/RippleKeyPairs/KeyPair.php +++ b/src/Core/RippleKeyPairs/KeyPair.php @@ -4,6 +4,9 @@ use Exception; +/** + * A public and private key pair, and the entry point for choosing an algorithm. + */ class KeyPair { public const EDDSA = 'ed25519'; diff --git a/src/Core/RippleKeyPairs/KeyPairServiceInterface.php b/src/Core/RippleKeyPairs/KeyPairServiceInterface.php index bf0e130..f7e716d 100644 --- a/src/Core/RippleKeyPairs/KeyPairServiceInterface.php +++ b/src/Core/RippleKeyPairs/KeyPairServiceInterface.php @@ -6,6 +6,10 @@ use Hardcastle\XRPL_PHP\Core\MathUtilities; use Hardcastle\XRPL_PHP\Core\CoreUtilities; +/** + * What an algorithm has to provide: generate a seed, derive a key pair from it, + * sign and verify. + */ interface KeyPairServiceInterface { /** diff --git a/src/Core/RippleKeyPairs/Secp256k1KeyPairService.php b/src/Core/RippleKeyPairs/Secp256k1KeyPairService.php index 3e43085..4927df6 100644 --- a/src/Core/RippleKeyPairs/Secp256k1KeyPairService.php +++ b/src/Core/RippleKeyPairs/Secp256k1KeyPairService.php @@ -8,6 +8,10 @@ use Hardcastle\Buffer\Buffer; use Hardcastle\XRPL_PHP\Core\MathUtilities; +/** + * secp256k1 signing, the algorithm the ledger started with and still the one + * behind most existing accounts. + */ class Secp256k1KeyPairService extends AbstractKeyPairService implements KeyPairServiceInterface { private static ?Secp256k1KeyPairService $instance = null; diff --git a/src/Core/Stablecoin/RLUSD.php b/src/Core/Stablecoin/RLUSD.php index 4ea111c..094af23 100644 --- a/src/Core/Stablecoin/RLUSD.php +++ b/src/Core/Stablecoin/RLUSD.php @@ -12,6 +12,9 @@ use Exception; +/** + * Ripple USD, with its issuers on Mainnet and Testnet. + */ class RLUSD extends Stablecoin { private const SETTINGS = [ 'mainnet' => [ diff --git a/src/Core/Stablecoin/Stablecoin.php b/src/Core/Stablecoin/Stablecoin.php index 6cdd632..9690d42 100644 --- a/src/Core/Stablecoin/Stablecoin.php +++ b/src/Core/Stablecoin/Stablecoin.php @@ -12,6 +12,9 @@ use Exception; +/** + * Base class for the well known stablecoins, holding their issuer per network. + */ abstract class Stablecoin { diff --git a/src/Core/Stablecoin/USDC.php b/src/Core/Stablecoin/USDC.php index a848f32..4c44b8a 100644 --- a/src/Core/Stablecoin/USDC.php +++ b/src/Core/Stablecoin/USDC.php @@ -12,6 +12,9 @@ use Exception; +/** + * Circle USD, with its issuers on Mainnet and Testnet. + */ class USDC extends Stablecoin { private const SETTINGS = [ 'mainnet' => [ diff --git a/src/Exceptions/ValidationException.php b/src/Exceptions/ValidationException.php index f5ce3dd..3e52280 100644 --- a/src/Exceptions/ValidationException.php +++ b/src/Exceptions/ValidationException.php @@ -1,4 +1,8 @@ Date: Tue, 25 Aug 2026 09:39:06 +0200 Subject: [PATCH 15/16] docs: describe the methods whose behaviour the signature does not show Public method coverage goes from 18 to 47 per cent. What was added are the methods where the name alone leaves a question open: - JsonRpcClient and Wallet, the surface most callers touch. getBalances() and getTransactions() page through the ledger, which is worth knowing before calling them in a loop. - The address codec and CoreUtilities, where the distinction between a classic address and an X-address decides whether a destination tag survives. - BinaryParser and BinarySerializer, whose field headers and length prefixes are variable width - not obvious from readFieldHeader() alone. - The two key pair services, including why secp256k1 goes through a root key and a sequence while Ed25519 does not. Left undocumented on purpose: 78 trivial accessors, 37 request and response models whose class docblock already links the xrpl.org page, and the fromParser/fromJson/toJson triads of the codec types, whose shared contract is described once on SerializedType. The @param on the abstract SerializedType::fromJson() is gone again: several subclasses widen the parameter to string|int, and pinning it to string made Psalm call their is_string() checks redundant. Co-Authored-By: Claude Opus 5 --- src/Client/JsonRpcClient.php | 36 +++++++++----- src/Core/CoreUtilities.php | 29 +++++++++-- src/Core/Ctid.php | 7 ++- src/Core/MathUtilities.php | 13 +++++ src/Core/Networks.php | 6 ++- src/Core/RippleAddressCodec/AddressCodec.php | 15 +++++- src/Core/RippleAddressCodec/BaseX.php | 10 ++++ src/Core/RippleAddressCodec/Codec.php | 13 +++++ .../CodecWithXrpAlphabet.php | 27 ++++++---- src/Core/RippleBinaryCodec/Binary.php | 9 +++- .../Definitions/FieldHeader.php | 8 +++ .../Definitions/FieldInstance.php | 4 ++ .../RippleBinaryCodec/Serdes/BinaryParser.php | 49 ++++++++++--------- .../Serdes/BinarySerializer.php | 13 +++++ src/Core/RippleBinaryCodec/Types/PathSet.php | 3 ++ src/Core/RippleBinaryCodec/Types/PathStep.php | 3 ++ .../Types/SerializedType.php | 7 +-- .../RippleKeyPairs/AbstractKeyPairService.php | 3 ++ .../RippleKeyPairs/Ed25519KeyPairService.php | 17 +++++++ src/Core/RippleKeyPairs/KeyPair.php | 6 ++- .../Secp256k1KeyPairService.php | 17 +++++++ src/Utils/Hashes/HashLedger.php | 3 ++ src/Utils/Utilities.php | 10 ++++ src/Wallet/Wallet.php | 12 +++++ 24 files changed, 262 insertions(+), 58 deletions(-) diff --git a/src/Client/JsonRpcClient.php b/src/Client/JsonRpcClient.php index c4a6a99..607ffd9 100644 --- a/src/Client/JsonRpcClient.php +++ b/src/Client/JsonRpcClient.php @@ -55,6 +55,11 @@ class JsonRpcClient private readonly string $maxFeeXrp; + /** + * Open a connection to a rippled server. + * The URL may be a short network name such as 'testnet' instead of an address. + * Pass definitions to talk to a network other than the XRP Ledger. + */ public function __construct( string $connectionUrl, ?float $feeCushion = null, @@ -106,6 +111,10 @@ public function rawRequest(string $method, string $resource = '', ?string $body * @param bool|null $returnRawResponse * @return PromiseInterface */ + /** + * Issue a request and get a promise for the typed response. + * Use syncRequest() when the answer is needed right away. + */ public function request(BaseRequest $request, ?bool $returnRawResponse = false): PromiseInterface { $promise = $this->rawRequest( @@ -316,8 +325,11 @@ public function getXrpBalance(string $address): string { return (new AccountReader($this))->getXrpBalance($address); } - /** + * Every balance an account holds: XRP and all its trust lines. + * Pages through the ledger until there is nothing left, so a large account + * causes several round trips. + * * @param string $address * @param string|null $ledgerHash * @param string|null $ledgerIndex @@ -336,8 +348,10 @@ public function getBalances( { return (new AccountReader($this))->getBalances($address, $ledgerHash, $ledgerIndex, $peer, $limit); } - /** + * The transaction history of an account, newest first unless $forward is set. + * Pages through the ledger the same way getBalances() does. + * * @param string $address * @param int|null $ledgerIndexMin * @param int|null $ledgerIndexMax @@ -364,8 +378,9 @@ public function getTransactions( { return (new AccountReader($this))->getTransactions($address, $ledgerIndexMin, $ledgerIndexMax, $ledgerHash, $ledgerIndex, $binary, $forward, $limit, $marker); } - /** + * The offers standing in one order book of the decentralized exchange. + * * @param array $takerGets * @param array $takerPays * @param string|null $ledgerHash @@ -397,9 +412,9 @@ public function getFeeXrp(?int $cushion = null): string { return (new FeeCalculator($this))->getFeeXrp($cushion === null ? null : (float)$cushion); } - /** - * + * Ask a test network faucet for a funded wallet. + * Generates one if none is given, and waits until the funds have arrived. * * @param Wallet|null $wallet * @param string|null $faucetHost @@ -427,15 +442,14 @@ public function autofill(Transaction|array $transaction, ?int $signersCount = nu { return (new Autofiller($this))->autofill($transaction, $signersCount); } - /** - * + * Submit a transaction and return the server's preliminary opinion. + * That opinion is not an outcome; use submitAndWait() when it matters. * * @param Transaction|string|array $transaction * @param bool|null $autofill * @param bool|null $failHard * @param Wallet|null $wallet - * * @return SubmitResponse * @throws Exception */ @@ -448,15 +462,15 @@ public function submit( { return (new Submitter($this))->submit($transaction, $autofill, $failHard, $wallet); } - /** - * + * Submit a transaction and wait until its outcome is final. + * Polls until the transaction is in a validated ledger, or until its + * LastLedgerSequence has passed and it never can be. * * @param Transaction|string|array $transaction * @param bool|null $autofill * @param bool|null $failHard * @param Wallet|null $wallet - * * @return TxResponse * @throws Exception */ diff --git a/src/Core/CoreUtilities.php b/src/Core/CoreUtilities.php index 9df25f9..576b493 100644 --- a/src/Core/CoreUtilities.php +++ b/src/Core/CoreUtilities.php @@ -24,6 +24,9 @@ class CoreUtilities private readonly AddressCodec $addressCodec; + /** + * The shared instance. + */ public static function getInstance(): CoreUtilities { if (self::$instance === null) { @@ -33,6 +36,10 @@ public static function getInstance(): CoreUtilities return self::$instance; } + /** + * The classic address of an account, whichever form was given. + * An X-address carrying a tag is rejected, because the tag would be lost. + */ public static function ensureClassicAddress(string $account): string { $_this = self::getInstance(); @@ -56,8 +63,9 @@ public static function ensureClassicAddress(string $account): string return $account; } - /** + * Whether this is a well formed classic address, checksum included. + * * @param null|string $address */ public static function isValidClassicAddress(string|null $address): bool @@ -67,6 +75,9 @@ public static function isValidClassicAddress(string|null $address): bool return $_this->addressCodec->isValidClassicAddress($address); } + /** + * Whether this is a well formed X-address. + */ public static function isValidXAddress(string $address): bool { $_this = self::getInstance(); @@ -74,6 +85,10 @@ public static function isValidXAddress(string $address): bool return $_this->addressCodec->isValidXAddress($address); } + /** + * Combine a classic address and a destination tag into one X-address. + * The point of the format is that the tag can no longer be forgotten. + */ public static function classicAddressToXAddress(string $xAddress, mixed $tag, bool $isTestnet = false): string { $_this = self::getInstance(); @@ -81,14 +96,18 @@ public static function classicAddressToXAddress(string $xAddress, mixed $tag, bo return $_this->addressCodec->classicAddressToXAddress($xAddress, $tag, $isTestnet); } + /** + * Split an X-address back into its classic address and its tag. + */ public static function xAddressToClassicAddress(string $xAddress): array { $_this = self::getInstance(); return $_this->addressCodec->xAddressToClassicAddress($xAddress); } - /** + * The account address a public key belongs to. + * * @param Buffer|string $publicKey * @return string * @throws Exception Error @@ -105,8 +124,9 @@ public static function deriveAddress(Buffer|string $publicKey): string return $_this->addressCodec->encodeAccountId($publicKeyHash); } - /** + * Encode raw entropy as a seed string, marking which algorithm it is for. + * * @throws Exception Error */ public static function encodeSeed(Buffer $entropy, string $type): string @@ -114,8 +134,9 @@ public static function encodeSeed(Buffer $entropy, string $type): string $_this = self::getInstance(); return $_this->addressCodec->encodeSeed($entropy, $type); } - /** + * Read a seed string back into its entropy and its algorithm. + * * @throws Exception Error */ public static function decodeSeed(string $seed): array diff --git a/src/Core/Ctid.php b/src/Core/Ctid.php index bcdb71f..a7c9468 100644 --- a/src/Core/Ctid.php +++ b/src/Core/Ctid.php @@ -31,8 +31,10 @@ public function __construct(string $ctidAsHex) { $this->internal = Buffer::from($ctidAsHex, 'hex'); } - /** + * Build a CTID from a ledger sequence, the index of the transaction in it and + * the network id. + * * @param int $ledgerIndex * @param int $transactionIndex * @param int $networkId @@ -49,8 +51,9 @@ public static function fromRawValues(int $ledgerIndex, int $transactionIndex, in return new Ctid($ledgerIndexHex . $transactionIndexHex . $networkId); } - /** + * Read a CTID string back into its three parts. + * * @param string $ctidAsHex * @return Ctid * @throws Exception diff --git a/src/Core/MathUtilities.php b/src/Core/MathUtilities.php index 803493b..3cad0c8 100644 --- a/src/Core/MathUtilities.php +++ b/src/Core/MathUtilities.php @@ -21,6 +21,9 @@ */ class MathUtilities { + /** + * A right shift that does not carry the sign, the way JavaScript's >>> works. + */ public static function unsignedRightShift(int $value, int $steps): int { if ($steps === 0) { @@ -30,6 +33,9 @@ public static function unsignedRightShift(int $value, int $steps): int return ($value >> $steps) & ~(1 << (8 * PHP_INT_SIZE - 1) >> ($steps - 1)); } + /** + * The account id of a public key: RIPEMD160 over its SHA256. + */ public static function computePublicKeyHash(Buffer $bytes): Buffer { $hash256 = hash('sha256', $bytes->toUtf8(), true); @@ -38,6 +44,9 @@ public static function computePublicKeyHash(Buffer $bytes): Buffer return Buffer::from($hash160); } + /** + * The first half of a SHA512, which is how the ledger builds its hashes. + */ public static function sha512Half(Buffer|string $input): Buffer { if ($input instanceof Buffer) { @@ -91,6 +100,10 @@ public static function getBigDecimalExponent(BigDecimal $number): int return strlen($integralPart) - 1; } + /** + * Render a token amount without trailing zeros, and without a fractional part + * when there is none left. + */ public static function trimAmountZeros(BigDecimal $amount): string { [$integralPart, $fractionalPart] = self::splitDecimal($amount); diff --git a/src/Core/Networks.php b/src/Core/Networks.php index 60a6f24..1022d38 100644 --- a/src/Core/Networks.php +++ b/src/Core/Networks.php @@ -50,8 +50,9 @@ class Networks 'networkId' => 21338 ], ]; - /** + * Look up a network by its short name, such as 'testnet'. + * * @param string $identifier * @return array * @throws Exception @@ -64,8 +65,9 @@ public static function getNetwork(string $identifier): array throw new Exception('Network not found'); } - /** + * Look up a network by the NetworkID transactions on it carry. + * * @param int $networkId * @return array * @throws Exception diff --git a/src/Core/RippleAddressCodec/AddressCodec.php b/src/Core/RippleAddressCodec/AddressCodec.php index c6b0f1f..4ebecc5 100644 --- a/src/Core/RippleAddressCodec/AddressCodec.php +++ b/src/Core/RippleAddressCodec/AddressCodec.php @@ -29,8 +29,9 @@ public function __construct() { parent::__construct(Utils::XRPL_ALPHABET); } - /** + * Combine a classic address, a tag and the network into an X-address. + * * @psalm-param 4294967295 $tag */ public function classicAddressToXAddress(string $classicAddress, int $tag, bool $isTestnet = false): string @@ -39,6 +40,9 @@ public function classicAddressToXAddress(string $classicAddress, int $tag, bool return $this->encodeXAddress($accountBuffer, $tag, $isTestnet); } + /** + * Encode the parts of an X-address into its string form. + */ public function encodeXAddress(Buffer $accountId, $tag, bool $test = false): string { $flag = $tag === false ? 0 : ($tag <= self::MAX_32_BIT_UNSIGNED_INT ? 1 : 2); @@ -65,6 +69,9 @@ public function encodeXAddress(Buffer $accountId, $tag, bool $test = false): str return $this->encodeChecked(Buffer::from(join('', $hex), 'hex')); } + /** + * Split an X-address into its classic address, tag and network. + */ public function xAddressToClassicAddress(string $xAddress): array { [$accountId, $tag, $test] = array_values($this->decodeXAddress($xAddress)); @@ -76,6 +83,9 @@ public function xAddressToClassicAddress(string $xAddress): array ]; } + /** + * Decode an X-address into its raw parts. + */ public function decodeXAddress(string $xAddress): array { $decoded = $this->decodeChecked($xAddress); @@ -89,6 +99,9 @@ public function decodeXAddress(string $xAddress): array ]; } + /** + * Whether the string decodes as an X-address. + */ public function isValidXAddress(string $xAddress): bool { try { diff --git a/src/Core/RippleAddressCodec/BaseX.php b/src/Core/RippleAddressCodec/BaseX.php index 26d5ff9..17ef7ce 100644 --- a/src/Core/RippleAddressCodec/BaseX.php +++ b/src/Core/RippleAddressCodec/BaseX.php @@ -60,6 +60,10 @@ public function __construct(string $alphabet) $this->inverseFactor = log(256) / log($this->base); //1.365658237309761 } + /** + * Encode bytes in this alphabet, keeping leading zero bytes as leading zero + * characters. + */ public function encode(Buffer $bytes): string { $zeroes = 0; @@ -102,6 +106,9 @@ public function encode(Buffer $bytes): string return $str; } + /** + * Decode a string, raising if it holds a character outside the alphabet. + */ public function decode(string $string): Buffer { $buffer = $this->decodeUnsafe($string); @@ -112,6 +119,9 @@ public function decode(string $string): Buffer return $buffer; } + /** + * Decode a string, returning null instead of raising on invalid input. + */ public function decodeUnsafe(string $source): ?Buffer { if (strlen($source) === 0) { diff --git a/src/Core/RippleAddressCodec/Codec.php b/src/Core/RippleAddressCodec/Codec.php index 690116c..b68dd3b 100644 --- a/src/Core/RippleAddressCodec/Codec.php +++ b/src/Core/RippleAddressCodec/Codec.php @@ -26,11 +26,17 @@ public function __construct(private readonly string $alphabet) $this->baseCodec = new BaseX($this->alphabet); } + /** + * Encode bytes with a version prefix and a checksum. + */ public function encode(Buffer $bytes, array $options): string { return $this->encodeVersioned($bytes, $options['versions'], $options['expectedLength']); } + /** + * Decode a string, verifying its checksum and stripping the version prefix. + */ public function decode(string $base58String, array $options): array { $withoutSum = $this->decodeChecked($base58String); @@ -70,12 +76,19 @@ public function decode(string $base58String, array $options): array throw new Exception('Unknown version'); } + /** + * Append a four byte checksum and encode the result. + */ public function encodeChecked(Buffer $bytes): string { $check = $this->sha256($this->sha256($bytes))->slice(0,4); return $this->encodeRaw(Buffer::concat([$bytes, $check])); } + /** + * Decode and verify the four byte checksum, so that a mistyped character is + * caught rather than silently accepted. + */ public function decodeChecked(string $base58string): Buffer { $buffer = $this->decodeRaw($base58string); diff --git a/src/Core/RippleAddressCodec/CodecWithXrpAlphabet.php b/src/Core/RippleAddressCodec/CodecWithXrpAlphabet.php index e6d5287..8210970 100644 --- a/src/Core/RippleAddressCodec/CodecWithXrpAlphabet.php +++ b/src/Core/RippleAddressCodec/CodecWithXrpAlphabet.php @@ -35,8 +35,9 @@ public function __construct(string $alphabet) { parent::__construct(Utils::XRPL_ALPHABET); } - /** + * Encode entropy as a seed, marking the algorithm it belongs to. + * * @param Buffer $entropy * @param string $type * @return string @@ -56,8 +57,9 @@ public function encodeSeed(Buffer $entropy, string $type): string return $this->encode($entropy, $options); } - /** + * Decode a seed into its entropy and its algorithm. + * * @param string $seed * @param array $options * @return array @@ -73,8 +75,9 @@ public function decodeSeed(string $seed, array $options = []): array return $this->decode($seed, $options); } - /** + * Encode a 20 byte account id as a classic address. + * * @param Buffer $bytes * @return string */ @@ -86,8 +89,9 @@ public function encodeAccountId(Buffer $bytes): string ]; return $this->encode($bytes, $options); } - /** + * Decode a classic address into its 20 byte account id. + * * @param string $accountId * @return Buffer * @throws \Exception @@ -100,8 +104,9 @@ public function decodeAccountId(string $accountId): Buffer ]; return Buffer::from($this->decode($accountId, $options)['bytes']); } - /** + * Encode a validator's public key. + * * @param Buffer $bytes * @return string */ @@ -113,8 +118,9 @@ public function encodeNodePublic(Buffer $bytes): string ]; return $this->encode($bytes, $options); } - /** + * Decode a validator's public key. + * * @param string $base58string * @return Buffer * @throws \Exception @@ -127,8 +133,9 @@ public function decodeNodePublic(string $base58string): Buffer ]; return Buffer::from($this->decode($base58string, $options)['bytes']); } - /** + * Encode an account's public key. + * * @param Buffer $bytes * @return string */ @@ -140,8 +147,9 @@ public function encodeAccountPublic(Buffer $bytes): string ]; return $this->encode($bytes, $options); } - /** + * Decode an account's public key. + * * @param string $base58string * @return Buffer * @throws \Exception @@ -154,8 +162,9 @@ public function decodeAccountPublic(string $base58string): Buffer ]; return Buffer::from($this->decode($base58string, $options)['bytes']); } - /** + * Whether the string decodes as a classic address with a valid checksum. + * * @param string $address * @return bool */ diff --git a/src/Core/RippleBinaryCodec/Binary.php b/src/Core/RippleBinaryCodec/Binary.php index fd3f67e..a6ae580 100644 --- a/src/Core/RippleBinaryCodec/Binary.php +++ b/src/Core/RippleBinaryCodec/Binary.php @@ -44,6 +44,9 @@ public function getDefinitions(): Definitions return $this->definitions ??= Definitions::getInstance(); } + /** + * A parser over the given bytes, carrying this codec's definitions. + */ public function makeParser(string $bytes): BinaryParser { return new BinaryParser($bytes, $this->getDefinitions()); @@ -76,8 +79,9 @@ public function serializeObject(string $jsonObject, array $options = []) } */ - /** + * Read one object out of a parser and return its JSON form. + * * @param BinaryParser $parser */ public function readJson(BinaryParser $parser): array|int|string //xrpl.js: JsonObject, defined in serialized-type.js @@ -107,6 +111,9 @@ public function signingClaimData() } */ + /** + * Decode a hex string into the JSON form of the object it holds. + */ public function binaryToJson(string $bytes): array { $parser = $this->makeParser($bytes); diff --git a/src/Core/RippleBinaryCodec/Definitions/FieldHeader.php b/src/Core/RippleBinaryCodec/Definitions/FieldHeader.php index e352141..a3a2ed8 100644 --- a/src/Core/RippleBinaryCodec/Definitions/FieldHeader.php +++ b/src/Core/RippleBinaryCodec/Definitions/FieldHeader.php @@ -17,6 +17,11 @@ public function __construct(private int $typeCode, private int $fieldCode) { } + /** + * The header as it is written to the stream. + * Type and field ordinal share one byte where both are below 16, and spill + * into extra bytes otherwise. + */ public function toBytes(): Buffer { $header = []; @@ -80,6 +85,9 @@ public function hash() return $this->typeCode . ":" . $this->fieldCode; } + /** + * Whether two headers name the same field. + */ public function equals($obj): bool { return ($this->typeCode === $obj->getTypeCode() && $this->fieldCode === $obj->getFieldCode()); diff --git a/src/Core/RippleBinaryCodec/Definitions/FieldInstance.php b/src/Core/RippleBinaryCodec/Definitions/FieldInstance.php index 4db00cd..bded12b 100644 --- a/src/Core/RippleBinaryCodec/Definitions/FieldInstance.php +++ b/src/Core/RippleBinaryCodec/Definitions/FieldInstance.php @@ -72,6 +72,10 @@ public function getOrdinal(): int return $this->ordinal; } + /** + * Build the ordinal the codec sorts fields by, which is the type code followed + * by the field code. + */ public function buildField(string $name, FieldInfo $fieldInfo, int $typeOrdinal): FieldInstance { diff --git a/src/Core/RippleBinaryCodec/Serdes/BinaryParser.php b/src/Core/RippleBinaryCodec/Serdes/BinaryParser.php index a202358..98b2089 100644 --- a/src/Core/RippleBinaryCodec/Serdes/BinaryParser.php +++ b/src/Core/RippleBinaryCodec/Serdes/BinaryParser.php @@ -53,9 +53,8 @@ public function getDefinitions(): Definitions { return $this->definitions ??= Definitions::getInstance(); } - /** - * + * The next byte without consuming it. * * @return int * @throws Exception @@ -68,9 +67,8 @@ public function peek(): int throw new \Exception('Buffer is empty'); } - /** - * + * Discard the next $n bytes. * * @param int $number * @return void @@ -84,9 +82,8 @@ public function skip(int $number): void throw new Exception('Trying to skip more elements than the buffer has'); } } - /** - * + * Consume and return the next $n bytes. * * @param int $number * @return Buffer @@ -103,9 +100,8 @@ public function read(int $number): Buffer throw new Exception('Trying to read more elements than the buffer has'); } - /** - * + * Consume $n bytes and read them as a big endian unsigned integer. * * @param int $number * @return Buffer @@ -121,13 +117,15 @@ public function readUIntN(int $number): Buffer //BigInteger throw new Exception('Invalid number'); } + /** + * Consume one byte as an unsigned integer. + */ public function readUInt8(): Buffer { return $this->readUIntN(1); } - /** - * + * Consume two bytes as an unsigned integer. * * @return Buffer * @throws Exception @@ -136,9 +134,8 @@ public function readUInt16(): Buffer { return $this->readUIntN(2); } - /** - * + * Consume four bytes as an unsigned integer. * * @return Buffer * @throws Exception @@ -147,9 +144,8 @@ public function readUInt32(): Buffer { return $this->readUIntN(4); } - /** - * + * Consume eight bytes as an unsigned integer. * * @return Buffer * @throws Exception @@ -158,9 +154,8 @@ public function readUInt64(): Buffer { return $this->readUIntN(8); } - /** - * + * Whether the stream is exhausted. * * @param int|null $customEnd * @return bool @@ -179,9 +174,10 @@ public function end(?int $customEnd = null): bool return false; } - /** - * + * Read the type and field ordinal that introduce the next field. + * Both are packed into one byte where they are small enough, and spill into + * following bytes otherwise, which is why this is not a fixed width read. * * @return FieldHeader * @throws Exception @@ -208,9 +204,8 @@ public function readFieldHeader(): FieldHeader return new FieldHeader($typeCode, $nth); } - /** - * + * Read the next field header and resolve it to a field of the definitions. * * @return FieldInstance * @throws Exception @@ -222,9 +217,8 @@ public function readField(): FieldInstance return $this->getDefinitions()->getFieldInstance($fieldName); } - /** - * + * Read one value of the given type from the stream. * * @param SerializedType $type * @return SerializedType @@ -234,11 +228,18 @@ public function readType(SerializedType $type): SerializedType return $type->fromParser($this); } + /** + * The type class that handles a field's values. + */ public function typeForField(FieldInstance $field): SerializedType { return SerializedType::getTypeByName($field->getType()); } + /** + * Read the value belonging to a field, taking its length from the stream first + * where the field is variable length. + */ public function readFieldValue(FieldInstance $field): SerializedType { $type = SerializedType::getTypeByName($field->getType()); @@ -250,9 +251,9 @@ public function readFieldValue(FieldInstance $field): SerializedType return $type->fromParser($this); } } - /** - * + * Read the length prefix of a variable length field. + * The prefix is one to three bytes; which it is follows from the first one. * * @return int * @throws Exception diff --git a/src/Core/RippleBinaryCodec/Serdes/BinarySerializer.php b/src/Core/RippleBinaryCodec/Serdes/BinarySerializer.php index 196a734..940b0e2 100644 --- a/src/Core/RippleBinaryCodec/Serdes/BinarySerializer.php +++ b/src/Core/RippleBinaryCodec/Serdes/BinarySerializer.php @@ -24,16 +24,26 @@ public function __construct(private readonly Buffer $bytes) { } + /** + * Append raw hex to the stream. + */ public function put(string $hexBytes): void { $this->bytes->appendHex($hexBytes); } + /** + * Append the bytes of a serialized value. + */ public function write(Buffer $bytes): void { $this->bytes->appendBuffer($bytes); } + /** + * Append a field: its header, a length prefix where the field needs one, and + * the value itself. + */ public function writeFieldAndValue(FieldInstance $field, SerializedType $value): void { $fieldHeaderHex = $field->getHeader()->toBytes()->toString(); @@ -46,6 +56,9 @@ public function writeFieldAndValue(FieldInstance $field, SerializedType $value): } } + /** + * Append a value preceded by its length, for the variable length fields. + */ public function writeLengthEncoded(SerializedType $value): void { $buffer = $value->toBytes(); diff --git a/src/Core/RippleBinaryCodec/Types/PathSet.php b/src/Core/RippleBinaryCodec/Types/PathSet.php index 15547c1..bfb2eed 100644 --- a/src/Core/RippleBinaryCodec/Types/PathSet.php +++ b/src/Core/RippleBinaryCodec/Types/PathSet.php @@ -82,6 +82,9 @@ public function toJson(): array|string|int return $result; } + /** + * Whether an array has the shape of a path set. + */ public static function isPathSet(mixed $testSubject): bool { return ( diff --git a/src/Core/RippleBinaryCodec/Types/PathStep.php b/src/Core/RippleBinaryCodec/Types/PathStep.php index 5c61205..36b047f 100644 --- a/src/Core/RippleBinaryCodec/Types/PathStep.php +++ b/src/Core/RippleBinaryCodec/Types/PathStep.php @@ -105,6 +105,9 @@ public function toJson(): array|string|int return $result; } + /** + * Whether an array has the shape of a path step. + */ public static function isPathStep(array $testSubject): bool { return ( diff --git a/src/Core/RippleBinaryCodec/Types/SerializedType.php b/src/Core/RippleBinaryCodec/Types/SerializedType.php index 5743de6..d1ee20e 100644 --- a/src/Core/RippleBinaryCodec/Types/SerializedType.php +++ b/src/Core/RippleBinaryCodec/Types/SerializedType.php @@ -187,10 +187,11 @@ public static function getTypeByName(string $name): SerializedType abstract static function fromParser(BinaryParser $parser, ?int $lengthHint = null): SerializedType; /** - * Build an instance from the JSON form rippled uses for this field. + * Build an instance from the JSON form rippled uses for this field: a + * scalar, or a JSON encoded array for the composite types. * - * @param string $serializedJson A scalar, or a JSON encoded array for the composite types - * @return SerializedType + * Some subclasses widen the parameter to accept an int as well, so the + * type is deliberately left to the signatures rather than pinned here. */ abstract static function fromJson(string $serializedJson): SerializedType; diff --git a/src/Core/RippleKeyPairs/AbstractKeyPairService.php b/src/Core/RippleKeyPairs/AbstractKeyPairService.php index 568ac8a..fe5eb03 100644 --- a/src/Core/RippleKeyPairs/AbstractKeyPairService.php +++ b/src/Core/RippleKeyPairs/AbstractKeyPairService.php @@ -26,6 +26,9 @@ public function __construct() $this->addressCodec = new AddressCodec(); } + /** + * The account address belonging to a public key. + */ public function deriveAddress(Buffer|string $publicKey): string { //TODO: Check if this works properly diff --git a/src/Core/RippleKeyPairs/Ed25519KeyPairService.php b/src/Core/RippleKeyPairs/Ed25519KeyPairService.php index a7d5bc4..fe62443 100644 --- a/src/Core/RippleKeyPairs/Ed25519KeyPairService.php +++ b/src/Core/RippleKeyPairs/Ed25519KeyPairService.php @@ -24,6 +24,9 @@ public function __construct() parent::__construct(); } + /** + * The shared instance. + */ public static function getInstance(): Ed25519KeyPairService { if (self::$instance === null) { @@ -33,6 +36,9 @@ public static function getInstance(): Ed25519KeyPairService return self::$instance; } + /** + * A new random seed for this algorithm. + */ public function generateSeed(?Buffer $entropy = null): string { if (is_null($entropy)) { @@ -42,6 +48,11 @@ public function generateSeed(?Buffer $entropy = null): string return $this->addressCodec->encodeSeed($entropy, 'ed25519'); } + /** + * Derive the key pair a seed stands for. + * The public key is prefixed with ED, which is how the ledger tells the two + * algorithms apart. + */ public function deriveKeyPair(Buffer|string $seed, bool $validator = false, int $accountIndex = 0): KeyPair { if (is_string($seed)) { @@ -58,6 +69,9 @@ public function deriveKeyPair(Buffer|string $seed, bool $validator = false, int return new KeyPair($publicKey, $privateKey); } + /** + * Sign a message. + */ public function sign(Buffer|string $message, string $privateKey): string { if ($message instanceof Buffer) { @@ -69,6 +83,9 @@ public function sign(Buffer|string $message, string $privateKey): string return $signed->toHex(); } + /** + * Check a signature against a message and a public key. + */ public function verify(Buffer|string $message, string $signature, string $publicKey): bool { if ($message instanceof Buffer) { diff --git a/src/Core/RippleKeyPairs/KeyPair.php b/src/Core/RippleKeyPairs/KeyPair.php index c37b3b0..b3b7145 100644 --- a/src/Core/RippleKeyPairs/KeyPair.php +++ b/src/Core/RippleKeyPairs/KeyPair.php @@ -49,6 +49,9 @@ public function setPrivateKey(string $privateKey): void $this->privateKey = $privateKey; } + /** + * The pair as a plain array of public and private key. + */ public function toArray(): array { return [ @@ -56,8 +59,9 @@ public function toArray(): array 'privateKey' => $this->getPrivateKey(), ]; } - /** + * The signing implementation for an algorithm name. + * * @throws Exception Error */ public static function getKeyPairServiceByType(string $type = self::EDDSA): KeyPairServiceInterface diff --git a/src/Core/RippleKeyPairs/Secp256k1KeyPairService.php b/src/Core/RippleKeyPairs/Secp256k1KeyPairService.php index 4927df6..a6fe8ed 100644 --- a/src/Core/RippleKeyPairs/Secp256k1KeyPairService.php +++ b/src/Core/RippleKeyPairs/Secp256k1KeyPairService.php @@ -26,6 +26,9 @@ public function __construct() parent::__construct(); } + /** + * The shared instance. + */ public static function getInstance(): Secp256k1KeyPairService { if (self::$instance === null) { @@ -35,6 +38,9 @@ public static function getInstance(): Secp256k1KeyPairService return self::$instance; } + /** + * A new random seed for this algorithm. + */ public function generateSeed(?Buffer $entropy = null): string { if (is_null($entropy)) { @@ -44,6 +50,11 @@ public function generateSeed(?Buffer $entropy = null): string return $this->addressCodec->encodeSeed($entropy, 'secp256k1'); } + /** + * Derive the key pair a seed stands for. + * secp256k1 goes through an intermediate root key and a sequence, so the same + * seed can yield further pairs; the ledger uses the first. + */ public function deriveKeyPair(Buffer|string $seed, bool $validator = false, int $accountIndex = 0): KeyPair { if (is_string($seed)) { @@ -60,6 +71,9 @@ public function deriveKeyPair(Buffer|string $seed, bool $validator = false, int ); } + /** + * Sign a message, returning a DER encoded signature. + */ public function sign(Buffer|string $message, string $privateKey): string { $messageBytes = ($message instanceof Buffer) ? $message->toUtf8() : $message; @@ -75,6 +89,9 @@ public function sign(Buffer|string $message, string $privateKey): string return strtoupper($signed); } + /** + * Check a signature against a message and a public key. + */ public function verify(Buffer|string $message, string $signature, string $publicKey): bool { $messageBytes = ($message instanceof Buffer) ? $message->toUtf8() : $message; diff --git a/src/Utils/Hashes/HashLedger.php b/src/Utils/Hashes/HashLedger.php index 7935f50..7b3184d 100644 --- a/src/Utils/Hashes/HashLedger.php +++ b/src/Utils/Hashes/HashLedger.php @@ -18,6 +18,9 @@ class HashLedger private readonly BinaryCodec $binaryCodec; + /** + * The shared instance. + */ public static function getInstance(): HashLedger { if (self::$instance === null) { diff --git a/src/Utils/Utilities.php b/src/Utils/Utilities.php index 255751c..4c089ad 100644 --- a/src/Utils/Utilities.php +++ b/src/Utils/Utilities.php @@ -13,6 +13,9 @@ class Utilities public const UPPERCASE_HEX_REGEX = '/^[A-F0-9]+$/'; public const ISSUED_CURRENCY_SIZE = 3; + /** + * Whether the string consists only of hex digits. + */ public static function isHex(string $str, bool $checkUppercase = false): bool { if ($checkUppercase) { @@ -22,6 +25,10 @@ public static function isHex(string $str, bool $checkUppercase = false): bool return (bool) preg_match(self::HEX_REGEX, $str); } + /** + * Pad a three character currency code to the 40 character hex form the ledger + * stores it in. + */ public static function isoToHex(string $iso): string { $bytes = Buffer::alloc(20); @@ -33,6 +40,9 @@ public static function isoToHex(string $iso): string return $bytes->toString(); } + /** + * Whether an amount describes an issued token rather than XRP. + */ public static function isIssuedCurrency(mixed $input): bool { return ( diff --git a/src/Wallet/Wallet.php b/src/Wallet/Wallet.php index 5ee400e..81579e2 100644 --- a/src/Wallet/Wallet.php +++ b/src/Wallet/Wallet.php @@ -35,6 +35,10 @@ class Wallet private string $classicAddress; + /** + * Build a wallet from an existing key pair. + * Use generate() or fromSeed() unless the keys already exist elsewhere. + */ public function __construct( string $publicKey, private readonly string $privateKey, @@ -64,6 +68,9 @@ public function __construct( } } + /** + * Create a wallet from a fresh random seed. + */ public static function generate( string $type = self::DEFAULT_ALGORITHM, ?Definitions $definitions = null @@ -75,6 +82,11 @@ public static function generate( return Wallet::fromSeed($seed, $definitions); } + /** + * Restore a wallet from its seed. + * The algorithm follows from the seed itself, so a secp256k1 and an Ed25519 + * seed both work here. + */ public static function fromSeed(string $seed, ?Definitions $definitions = null): Wallet { return self::deriveWallet($seed, $definitions); From 608082dfea3d8576dd3d06e941dcbff2bf492477 Mon Sep 17 00:00:00 2001 From: AlexanderBuzz <102560752+AlexanderBuzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:21:10 +0200 Subject: [PATCH 16/16] docs: add the 2.2.0 changelog entry Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac0324..4d6af3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,64 @@ 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 + +### Added +- Object form of the Sugar functions: `Autofiller`, `Submitter`, `AccountReader`, + `OrderbookReader`, `FeeCalculator` and `Faucet` hold the client instead of taking + it as a first argument. `JsonRpcClient` delegates to them and no longer imports a + function from `Sugar`. +- `JsonRpcClient::autofill()` accepts `$signersCount`, which the multi-signing fee + path needed but could not be reached from the client. +- `RpcMethodResponse`, a mock that serves rippled responses keyed by JSON-RPC method. + The previous mock routed by URL path, which the client never varies, so nothing + reaching rippled through the normal code path could be mocked. +- Tests: `FeeCalculationTest`, `SubmitTest`, `MathUtilitiesTest` and + `SubmitAndWaitTest`. The last one runs against the Testnet, carries the group + `integration-slow` and is excluded in CI; run it with + `vendor/bin/phpunit --group integration-slow`. +- A Psalm baseline plus a configuration suited to a library, and Psalm in CI. + +### Fixed +- `Sugar\getSignedTx()` returned the `tx_blob`/`hash` envelope of `Wallet::sign()` + while every caller expects a transaction array, so `submit()` and `submitAndWait()` + always failed with "Transaction must be signed" when given an unsigned transaction + and a wallet - the reason both take a `$wallet` at all. +- The AccountDelete blocker check never ran, being guarded by + `!isset($tx['TransactionType'])` rather than a comparison against AccountDelete, + and would not have fired either, counting blockers as `$objects['length']` - a + JavaScript idiom that is an undefined key in PHP. +- `DROPS_PER_XRP` was the float `1000000.0`, and `base_fee_xrp` arrives from rippled + as a JSON number; both reached brick/math as floats. +- `MathUtilities` used `BigDecimal::getIntegralPart()` and `getFractionalPart()`, + which brick/math 0.15 removes and 0.16 reintroduces with a different meaning. + `exactlyDividedBy()` is renamed to `dividedByExact()`. The test suite runs without + deprecations again. +- `AccountOffersResponse` imported `BaseRequest` while extending `BaseResponse`, so + the class could not be autoloaded at all. +- `docker-compose.linux.yml` mounted `php.ini` twice and `xdebug.ini` not at all. + +### Changed +- `JsonRpcClient::autofill()` no longer takes the transaction by reference. It was + never written through, and the reference only forced callers to assign the array + to a variable first. Existing calls keep working. +- The Sugar functions are marked `@deprecated` and delegate to the classes above. + They emit no runtime warning yet; that follows once the object form has settled. +- `containers/php/` becomes `docker/`, and the three compose files become one. + The platform differences move into `.env` (`DOCKER_UID`, `DOCKER_GID`) and an + `extra_hosts` entry, so the same `xdebug.ini` works on Linux and macOS. + `xdebug-mac.ini` and `xdebug-linux.ini` are gone; no compose file mounted them. +- `examples/custom_currency_codes.php` and `examples/xrpBalance.php` are renamed to + kebab-case, matching every other example. +- Documentation: every class now has a description, and the share of documented + public methods rises from 18 to 47 per cent. The contract the 29 serialized types + share is described once on `SerializedType` rather than repeated per subclass. + +### Removed +- The dead stubs `Sugar\formatBalances()`, `Sugar\getUpdatedBalance()` and + `Sugar\getHttpOptions()`. Two of them raised a `TypeError` when called; none was + referenced. + ## [2.1.0] - 2026-08-24 ### Added