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/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index ece30f0..1d3b05d 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 --exclude-group integration-slow + + - name: Run Psalm + run: vendor/bin/psalm --config=psalm.xml --no-progress diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dbc31b..4d6af3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,65 @@ 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.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 - The binary codec works against definitions handed in from outside, so a package 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 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 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 @@ + + + + + + + + + + + + + + + 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/Autofiller.php b/src/Client/Autofiller.php new file mode 100644 index 0000000..fbd0e14 --- /dev/null +++ b/src/Client/Autofiller.php @@ -0,0 +1,354 @@ +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'); + } + + /** + * 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 + * @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; + } + } + + /** + * 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 + { + 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 + ]; + } + + /** + * 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 + * @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; + } + } + + /** + * 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 + */ + 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); + } + + /** + * 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 Number of signatures the transaction will carry + * @return void + * @throws MathException + */ + public function calculateFeePerTransactionType(array &$tx, ?int $signersCount = 0): void + { + $netFeeXrp = (new FeeCalculator($this->client))->getFeeXrp(); + $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); + } + + /** + * Multiply a decimal string by a factor. + * + * @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); + } + + /** + * 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 + */ + 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/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..369b7f5 --- /dev/null +++ b/src/Client/FeeCalculator.php @@ -0,0 +1,61 @@ +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; + + // 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); + + $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 39550f7..607ffd9 100644 --- a/src/Client/JsonRpcClient.php +++ b/src/Client/JsonRpcClient.php @@ -29,16 +29,14 @@ 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; -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; +/** + * 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; @@ -57,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, @@ -108,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,10 +323,13 @@ private function getCollectKeyFromCommand(string $command): string|null */ public function getXrpBalance(string $address): string { - return getXrpBalance($this, $address); + 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,10 +346,12 @@ 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); } - /** + * 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,10 +376,11 @@ 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); } - /** + * The offers standing in one order book of the decentralized exchange. + * * @param array $takerGets * @param array $takerPays * @param string|null $ledgerHash @@ -386,7 +399,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); } /** @@ -397,11 +410,11 @@ public function getOrderbook( */ public function getFeeXrp(?int $cushion = null): string { - return getFeeXrp($this, $cushion); + 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 @@ -409,28 +422,34 @@ 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']; } /** + * 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); } - /** - * + * 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 */ @@ -441,17 +460,17 @@ public function submit( ?Wallet $wallet = null ): SubmitResponse { - return submit($this, $transaction, $autofill, $failHard, $wallet); + 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 */ @@ -462,7 +481,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/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/Client/Submitter.php b/src/Client/Submitter.php new file mode 100644 index 0000000..8a7fc5f --- /dev/null +++ b/src/Client/Submitter.php @@ -0,0 +1,269 @@ +client->getDefinitions(); + + $submitRequest = new SubmitRequest( + txBlob: (new BinaryCodec($definitions))->encode($signedTransaction), + failHard: self::isAccountDelete($signedTransaction, $definitions) || $failHard + ); + + return $this->client->request($submitRequest); + } + + /** + * 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 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 + */ + 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']); + } + + /** + * 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 + */ + public static function isSigned(array $tx): bool + { + return (!empty($tx['SigningPubKey']) || !empty($tx['TxnSignature'])); + } + + /** + * 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 Needed to decode a blob of another network + * @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; + } + + /** + * 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 Needed to decode a blob of another network + * @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/Core/CoreUtilities.php b/src/Core/CoreUtilities.php index c9a6f1c..576b493 100644 --- a/src/Core/CoreUtilities.php +++ b/src/Core/CoreUtilities.php @@ -14,12 +14,19 @@ 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; private readonly AddressCodec $addressCodec; + /** + * The shared instance. + */ public static function getInstance(): CoreUtilities { if (self::$instance === null) { @@ -29,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(); @@ -52,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 @@ -63,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(); @@ -70,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(); @@ -77,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 @@ -101,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 @@ -110,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/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 ec802f4..3cad0c8 100644 --- a/src/Core/MathUtilities.php +++ b/src/Core/MathUtilities.php @@ -13,8 +13,17 @@ 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 { + /** + * 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) { @@ -24,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); @@ -32,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) { @@ -56,44 +71,69 @@ 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; } + /** + * Render a token amount without trailing zeros, and without a fractional part + * when there is none left. + */ 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/Core/Networks.php b/src/Core/Networks.php index ad54f7d..1022d38 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 = [ @@ -46,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 @@ -60,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 78495e1..4ebecc5 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 = [ @@ -25,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 @@ -35,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); @@ -61,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)); @@ -72,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); @@ -85,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 a6e8ccf..17ef7ce 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; @@ -57,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; @@ -99,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); @@ -109,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 9362dcb..b68dd3b 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; @@ -22,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); @@ -66,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 4f7b598..8210970 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) @@ -31,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 @@ -52,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 @@ -69,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 */ @@ -82,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 @@ -96,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 */ @@ -109,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 @@ -123,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 */ @@ -136,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 @@ -150,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/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..a6ae580 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; @@ -41,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()); @@ -73,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 @@ -104,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/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..a3a2ed8 100644 --- a/src/Core/RippleBinaryCodec/Definitions/FieldHeader.php +++ b/src/Core/RippleBinaryCodec/Definitions/FieldHeader.php @@ -5,12 +5,23 @@ 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) { } + /** + * 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 = []; @@ -74,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 40ec496..940b0e2 100644 --- a/src/Core/RippleBinaryCodec/Serdes/BinarySerializer.php +++ b/src/Core/RippleBinaryCodec/Serdes/BinarySerializer.php @@ -15,22 +15,35 @@ 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) { } + /** + * 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(); @@ -43,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/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..bfb2eed 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; @@ -76,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 530b9e2..36b047f 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; @@ -99,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 3aa4d51..d1ee20e 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,22 @@ 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: a + * scalar, or a JSON encoded array for the composite types. + * + * 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/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..fe5eb03 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'; @@ -22,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 152b28e..fe62443 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; @@ -20,6 +24,9 @@ public function __construct() parent::__construct(); } + /** + * The shared instance. + */ public static function getInstance(): Ed25519KeyPairService { if (self::$instance === null) { @@ -29,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)) { @@ -38,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)) { @@ -54,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) { @@ -65,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 e78ccb6..b3b7145 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'; @@ -46,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 [ @@ -53,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/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..a6fe8ed 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; @@ -22,6 +26,9 @@ public function __construct() parent::__construct(); } + /** + * The shared instance. + */ public static function getInstance(): Secp256k1KeyPairService { if (self::$instance === null) { @@ -31,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)) { @@ -40,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)) { @@ -56,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; @@ -71,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/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 @@ $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 { - return BigDecimal::of($value)->multipliedBy($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( @@ -269,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/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/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 5bd5817..5304a65 100644 --- a/src/Sugar/getFeeXrp.php +++ b/src/Sugar/getFeeXrp.php @@ -2,53 +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($serverInfo['load_factor'])->multipliedBy($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 +} 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 +} 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/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/src/Utils/Hashes/HashLedger.php b/src/Utils/Hashes/HashLedger.php index bbb3af7..7b3184d 100644 --- a/src/Utils/Hashes/HashLedger.php +++ b/src/Utils/Hashes/HashLedger.php @@ -10,7 +10,7 @@ use Hardcastle\XRPL_PHP\Models\Transaction\TransactionTypes\BaseTransaction as Transaction; /** - * + * Computes the hashes rippled uses, above all the id of a signed transaction. */ class HashLedger { @@ -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 400a6c9..4c089ad 100644 --- a/src/Utils/Utilities.php +++ b/src/Utils/Utilities.php @@ -4,12 +4,18 @@ use Hardcastle\Buffer\Buffer; +/** + * Assorted helpers that have no better home yet. + */ class Utilities { public const HEX_REGEX = '/^[A-F0-9a-f]+$/'; 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) { @@ -19,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); @@ -30,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/DefaultFaucets.php b/src/Wallet/DefaultFaucets.php index f9efd68..833ceb4 100644 --- a/src/Wallet/DefaultFaucets.php +++ b/src/Wallet/DefaultFaucets.php @@ -5,6 +5,10 @@ use Exception; use Hardcastle\XRPL_PHP\Client\JsonRpcClient; +/** + * The faucet endpoints of the test networks, and which one belongs to a given + * connection. + */ class DefaultFaucets { const FAUCET_NETWORK = [ diff --git a/src/Wallet/Wallet.php b/src/Wallet/Wallet.php index 1883f90..81579e2 100644 --- a/src/Wallet/Wallet.php +++ b/src/Wallet/Wallet.php @@ -16,6 +16,12 @@ use Hardcastle\XRPL_PHP\Models\Transaction\TransactionTypes\BaseTransaction as Transaction; use Hardcastle\XRPL_PHP\Utils\Hashes\HashLedger; +/** + * A key pair with the ability to sign. + * + * Signing runs through the binary codec, so a wallet for another network has to + * be constructed with that network's definitions. + */ class Wallet { @@ -29,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, @@ -58,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 @@ -69,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); 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'))); + } +} 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/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..f732dc6 --- /dev/null +++ b/tests/Sugar/FeeCalculationTest.php @@ -0,0 +1,283 @@ +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 + { + $tx = $this->client->autofill([ + 'TransactionType' => 'Payment', + 'Account' => self::ACCOUNT, + 'Destination' => self::DESTINATION, + 'Amount' => '1000', + ]); + + $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 + { + $tx = $this->client->autofill([ + 'TransactionType' => 'AMMCreate', + 'Account' => self::ACCOUNT, + 'Amount' => '1000', + 'Amount2' => ['currency' => 'USD', 'issuer' => self::DESTINATION, 'value' => '10'], + 'TradingFee' => 10, + ]); + + $this->assertEquals(self::RESERVE_INC, $tx['Fee']); + } + + public function testAccountDeletePaysTheOwnerReserve(): void + { + $tx = $this->client->autofill([ + 'TransactionType' => 'AccountDelete', + 'Account' => self::ACCOUNT, + 'Destination' => self::DESTINATION, + ]); + + $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'); + + $tx = $client->autofill([ + 'TransactionType' => 'AMMCreate', + 'Account' => self::ACCOUNT, + 'Amount' => '1000', + 'Amount2' => ['currency' => 'USD', 'issuer' => self::DESTINATION, 'value' => '10'], + 'TradingFee' => 10, + ]); + + $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'); + + $tx = $client->autofill([ + 'TransactionType' => 'Payment', + 'Account' => self::ACCOUNT, + 'Destination' => self::DESTINATION, + 'Amount' => '1000', + ]); + + $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); + + $tx = $this->client->autofill([ + 'TransactionType' => 'EscrowFinish', + 'Account' => self::ACCOUNT, + 'Owner' => self::DESTINATION, + 'OfferSequence' => 7, + 'Fulfillment' => $fulfillment, + ]); + + $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 + { + $small = $this->client->autofill([ + 'TransactionType' => 'EscrowFinish', + 'Account' => self::ACCOUNT, + 'Owner' => self::DESTINATION, + 'OfferSequence' => 7, + 'Fulfillment' => str_repeat('A0', 16), + ]); + + $large = $this->client->autofill([ + 'TransactionType' => 'EscrowFinish', + 'Account' => self::ACCOUNT, + 'Owner' => self::DESTINATION, + 'OfferSequence' => 7, + 'Fulfillment' => str_repeat('A0', 256), + ]); + + $this->assertGreaterThan((int)$small['Fee'], (int)$large['Fee']); + } + + /** + * autofill() fills in what the transaction does not carry already. + */ + public function testAutofillSetsSequenceAndLastLedgerSequence(): void + { + $tx = $this->client->autofill([ + 'TransactionType' => 'Payment', + 'Account' => self::ACCOUNT, + 'Destination' => self::DESTINATION, + 'Amount' => '1000', + ]); + + $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']); + } +} diff --git a/tests/Sugar/SubmitTest.php b/tests/Sugar/SubmitTest.php new file mode 100644 index 0000000..0086d45 --- /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); + } +}