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);
+ }
+}