From 28fa295d8e4f42310731428f59562f1c3c9931d4 Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Fri, 6 Nov 2026 14:48:30 -0500 Subject: [PATCH 1/2] propose Custody XRPL transaction orders from an address, and harden the on-chain wait --- docs/ripple-custody.md | 31 +++ src/custodians/ripple/api-types.ts | 63 +++++ src/custodians/ripple/api.ts | 199 ++++++++------- src/custodians/ripple/index.ts | 4 +- src/custodians/ripple/intent-types.ts | 38 +++ src/custodians/ripple/mapping/envelope.ts | 76 ++++-- .../ripple/submission/transaction-polling.ts | 150 +++++++---- .../ripple/versioning/version-guard.ts | 23 ++ test/contract/ripple-custody.contract.test.ts | 33 +++ test/unit/ripple-custody/custody-api.test.ts | 189 ++++++++++++++ .../ripple-custody/ripple-custody.test.ts | 2 +- .../transaction-polling.test.ts | 236 +++++++++++++++++- 12 files changed, 877 insertions(+), 167 deletions(-) create mode 100644 src/custodians/ripple/api-types.ts diff --git a/docs/ripple-custody.md b/docs/ripple-custody.md index 42341c8..67acc1b 100644 --- a/docs/ripple-custody.md +++ b/docs/ripple-custody.md @@ -7,6 +7,7 @@ the bundled spec. - [Setting up](#setting-up) - [Calling the Custody API](#calling-the-custody-api) +- [XRPL transaction orders](#xrpl-transaction-orders) - [Waiting on intents](#waiting-on-intents) - [Dry runs](#dry-runs) - [Lists, lookups, and account keys](#lists-lookups-and-account-keys) @@ -65,6 +66,36 @@ await custody.api.reject(intentId, { reason: 'unexpected destination' }) `approve` and `reject` fetch the intent and sign the decision for you; the API user must be allowed to decide on that intent under its policy. +## XRPL transaction orders + +The verticals build an XRPL transaction for you. To send Custody's own XRPL +operation exactly as Custody defines it — for example a `Payment` to a Custody +`Endpoint`, or an `MPTokenIssuanceCreate` with your own flags — use +`api.proposeTransactionOrder`. It looks up the Custody account that holds the +sending address, fills in what Custody requires (`Priority: Low` fee and no +memos, unless you set them), and signs and proposes the order: + +```ts +const { intentId } = await custody.api.proposeTransactionOrder( + { + account: 'r...', // the sending address + operation: { + type: 'Payment', + destination: { type: 'Endpoint', endpointId }, + amount: '1000', + currency: { type: 'MultiPurposeToken', issuanceId }, + }, + }, + { dryRun: true }, // check with Custody first +) +``` + +With `dryRun: true`, Custody checks the order first; if it would fail, a +`DryRunFailedError` is thrown and nothing is proposed. The other options are the +same as `api.propose`'s. `id` is the idempotency key, and the returned +`intentId` is also the order id, which the on-chain transaction is filed under +(`client.intent.awaitOnChain(intentId)`). + ## Waiting on intents To find out how an intent ended, `api.waitForIntent` reports the outcome diff --git a/src/custodians/ripple/api-types.ts b/src/custodians/ripple/api-types.ts new file mode 100644 index 0000000..955d2cf --- /dev/null +++ b/src/custodians/ripple/api-types.ts @@ -0,0 +1,63 @@ +import type { CUSTODY_ROUTES } from '../../generated/custody-routes.js' +import type { components, operations, paths } from '../../generated/custody.js' + +import type { CallArgs, OperationArgs, OperationResponse } from './api-call.js' +import type { ProposeEnvelopeOverrides } from './mapping/propose-envelope.js' + +/* + * The public types of `CustodyApi`'s call, request, and propose surfaces. + */ + +/** Every Custody operationId that has both a route and typed schema. */ +export type CustodyOperationId = keyof typeof CUSTODY_ROUTES & keyof operations + +/** A governed-intent payload to propose (any `v0_*` variant Custody accepts). */ +export type CustodyProposePayload = + components['schemas']['Core_ProposeUserIntentPayload'] +/** Per-call envelope overrides for {@link CustodyApi.propose}. */ +export type CustodyProposeOptions = ProposeEnvelopeOverrides + +/** + * What {@link CustodyApi.propose} returns: Custody's acknowledgement plus the + * `intentId` the proposal was signed under — the handle for reading, awaiting, + * approving, or rejecting the intent. + */ +export type CustodyProposeResponse = + components['schemas']['Core_IntentResponse'] & { + /** The proposed intent's id (the signed envelope's `request.id`). */ + readonly intentId: string + } + +/** The typed arguments for one operation: path params, query, and/or body. */ +export type CustodyCallArgs = CallArgs< + operations, + Op +> + +/** The HTTP methods {@link CustodyApi.request} sends. */ +export type CustodyHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + +/** The operation `path` serves for `method` (`never` if it serves none). */ +type PathOperation< + Path extends keyof paths, + Method extends CustodyHttpMethod, +> = NonNullable]> + +/** Every Custody path template that serves `Method`. */ +export type CustodyRequestPath = { + [Path in keyof paths]: [PathOperation] extends [never] + ? never + : Path +}[keyof paths] + +/** The typed arguments for one `request`: path params, query, and/or body. */ +export type CustodyRequestArgs< + Method extends CustodyHttpMethod, + Path extends CustodyRequestPath, +> = OperationArgs> + +/** The typed response of one `request`. */ +export type CustodyRequestResponse< + Method extends CustodyHttpMethod, + Path extends CustodyRequestPath, +> = OperationResponse> diff --git a/src/custodians/ripple/api.ts b/src/custodians/ripple/api.ts index d43b328..6b456a6 100644 --- a/src/custodians/ripple/api.ts +++ b/src/custodians/ripple/api.ts @@ -1,16 +1,27 @@ import type { Transaction } from 'xrpl' -import { AccountNotFoundError, SimpleXRPLError } from '../../errors.js' +import { + AccountNotFoundError, + DryRunFailedError, + SimpleXRPLError, +} from '../../errors.js' import { CUSTODY_ROUTES } from '../../generated/custody-routes.js' -import type { components, operations, paths } from '../../generated/custody.js' +import type { components, operations } from '../../generated/custody.js' +import { uuidV7 } from '../../ids/index.js' import { fillPath } from './api-call.js' +import type { ResponseBody } from './api-call.js' import type { - CallArgs, - OperationArgs, - OperationResponse, - ResponseBody, -} from './api-call.js' + CustodyCallArgs, + CustodyHttpMethod, + CustodyOperationId, + CustodyProposeOptions, + CustodyProposePayload, + CustodyProposeResponse, + CustodyRequestArgs, + CustodyRequestPath, + CustodyRequestResponse, +} from './api-types.js' import type { IntentSigner } from './auth/intent-signer.js' import { ambiguousAddressMessage, @@ -26,16 +37,20 @@ import type { CustodyIntentOutcome, CustodyProposeAndWaitResult, CustodyTransactionDryRun, + CustodyTransactionOrder, + CustodyTransactionOrderOptions, CustodyWaitOptions, } from './intent-types.js' import { buildDecisionEnvelope } from './mapping/decision-envelope.js' import type { IntentDecision } from './mapping/decision-envelope.js' -import { buildTransactionOrderPayload } from './mapping/envelope.js' +import { + buildTransactionOrderPayload, + transactionOrderPayload, +} from './mapping/envelope.js' import { buildEnvelopeRequest, buildProposeEnvelope, type ProposeEnvelopeContext, - type ProposeEnvelopeOverrides, } from './mapping/propose-envelope.js' import { paginate } from './pagination.js' import type { @@ -44,65 +59,11 @@ import type { CustodyPaginatedOperationId, } from './pagination.js' import { requireXrplPublicKey } from './submission/account-key.js' -import { postDryRun } from './submission/dry-run.js' +import { describeDryRunFailure, postDryRun } from './submission/dry-run.js' import { waitForIntentOutcome } from './submission/intent-polling.js' import type { CustodyHttpClient } from './transport/custody-http-client.js' import { VersionGuard } from './versioning/version-guard.js' -/** Every Custody operationId that has both a route and typed schema. */ -export type CustodyOperationId = keyof typeof CUSTODY_ROUTES & keyof operations - -/** A governed-intent payload to propose (any `v0_*` variant Custody accepts). */ -export type CustodyProposePayload = - components['schemas']['Core_ProposeUserIntentPayload'] -/** Per-call envelope overrides for {@link CustodyApi.propose}. */ -export type CustodyProposeOptions = ProposeEnvelopeOverrides - -/** - * What {@link CustodyApi.propose} returns: Custody's acknowledgement plus the - * `intentId` the proposal was signed under — the handle for reading, awaiting, - * approving, or rejecting the intent. - */ -export type CustodyProposeResponse = - components['schemas']['Core_IntentResponse'] & { - /** The proposed intent's id (the signed envelope's `request.id`). */ - readonly intentId: string - } - -/** The typed arguments for one operation: path params, query, and/or body. */ -export type CustodyCallArgs = CallArgs< - operations, - Op -> - -/** The HTTP methods {@link CustodyApi.request} sends. */ -export type CustodyHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' - -/** The operation `path` serves for `method` (`never` if it serves none). */ -type PathOperation< - Path extends keyof paths, - Method extends CustodyHttpMethod, -> = NonNullable]> - -/** Every Custody path template that serves `Method`. */ -export type CustodyRequestPath = { - [Path in keyof paths]: [PathOperation] extends [never] - ? never - : Path -}[keyof paths] - -/** The typed arguments for one `request`: path params, query, and/or body. */ -export type CustodyRequestArgs< - Method extends CustodyHttpMethod, - Path extends CustodyRequestPath, -> = OperationArgs> - -/** The typed response of one `request`. */ -export type CustodyRequestResponse< - Method extends CustodyHttpMethod, - Path extends CustodyRequestPath, -> = OperationResponse> - /** How long {@link CustodyApi.waitForIntent} waits, unless configured. */ const DEFAULT_WAIT_TIMEOUT_MS = 60_000 @@ -428,7 +389,7 @@ export class CustodyApi { payload: CustodyProposePayload, options?: CustodyProposeOptions, ): Promise { - this.assertPayload(payload, 'api.propose') + this.versionGuard.assertPayload(payload, 'api.propose') const body = await buildProposeEnvelope(this.intentSigner, { ...this.proposeContext, payload, @@ -516,7 +477,7 @@ export class CustodyApi { payload: Payload, options?: CustodyProposeOptions, ): Promise> { - this.assertPayload(payload, 'api.dryRun') + this.versionGuard.assertPayload(payload, 'api.dryRun') const route = CUSTODY_ROUTES.intentDryRun this.versionGuard.assertEndpoint(route.method, route.path, 'api.dryRun') const response = await postDryRun( @@ -554,13 +515,10 @@ export class CustodyApi { options: CustodyDryRunTransactionOptions = {}, ): Promise { this.versionGuard.assertNativeTransaction(transaction.TransactionType) - const reference = await this.findByAddress(transaction.Account, { - ledgerId: options.ledgerId, - domainId: this.proposeContext.domainId, - }) - if (reference === undefined) { - throw new AccountNotFoundError(transaction.Account) - } + const reference = await this.resolveSender( + transaction.Account, + options.ledgerId, + ) const { payload } = buildTransactionOrderPayload({ accountId: reference.accountId, ledgerId: reference.ledgerId, @@ -571,28 +529,85 @@ export class CustodyApi { } /** - * Check the instance's Custody version accepts a payload: its intent type - * and, for an XRPL transaction order, its operation. + * Propose a native XRPL transaction order from an XRPL address: the sending + * account is looked up, Custody's operation is wrapped in a + * `v0_CreateTransactionOrder` with the defaults Custody requires + * (`Priority: Low` fee, no memos), and the order is signed and proposed. + * Unlike the verticals, it takes Custody's operation as-is, so every + * operation and option Custody offers is available, such as a `Payment` to a + * Custody `Endpoint`: + * + * ```ts + * const { intentId } = await custody.api.proposeTransactionOrder( + * { + * account: 'r...', + * operation: { + * type: 'Payment', + * destination: { type: 'Endpoint', endpointId }, + * amount: '1000', + * currency: { type: 'MultiPurposeToken', issuanceId }, + * }, + * }, + * { dryRun: true }, + * ) + * ``` + * + * @param order - The sending address, the operation, and optional order + * fields (ledger, fee, memos, source tag, custom properties). + * @param options - Envelope overrides, as for {@link propose} (`id` is the + * idempotency key, used as both the intent id and the order id), and + * `dryRun` to check with Custody first. + * @returns Custody's `{ requestId }` and the `intentId`, which is also the + * order id the on-chain transaction is filed under. + * @throws {@link AccountNotFoundError} if no Custody account in the domain + * holds `order.account`. + * @throws {@link DryRunFailedError} if `dryRun` is set and Custody says the + * order would fail; nothing is proposed. + * @throws {@link UnsupportedInVersionError} if the instance's Custody + * version doesn't accept the operation. + * @throws A `CustodyApiError` if the API rejects the intent. + */ + public async proposeTransactionOrder( + order: CustodyTransactionOrder, + options: CustodyTransactionOrderOptions = {}, + ): Promise { + const { dryRun, ...overrides } = options + const reference = await this.resolveSender(order.account, order.ledgerId) + const payload = transactionOrderPayload({ + ...order, + accountId: reference.accountId, + ledgerId: reference.ledgerId, + id: overrides.id ?? uuidV7(), + }) + if (dryRun ?? false) { + const verdict = await this.dryRun(payload, overrides) + if (!verdict.success) { + throw new DryRunFailedError(describeDryRunFailure(verdict), verdict) + } + } + return this.propose(payload, overrides) + } + + /** + * The Custody account in this domain that holds a sending address. * - * @param payload - The intent payload. - * @param sdkMethod - The SDK call, for the error. - * @throws {@link UnsupportedInVersionError} if either isn't supported. + * @param address - The sending XRPL address. + * @param ledgerId - The ledger, when the address is on several. + * @returns The account address reference. + * @throws {@link AccountNotFoundError} if no account in the domain holds it. */ - private assertPayload( - payload: CustodyProposePayload, - sdkMethod: string, - ): void { - this.versionGuard.assertIntentType(payload.type, sdkMethod) - if ( - payload.type === 'v0_CreateTransactionOrder' && - payload.parameters.type === 'XRPL' && - payload.parameters.operation !== undefined - ) { - this.versionGuard.assertXrplOperation( - payload.parameters.operation.type, - sdkMethod, - ) + private async resolveSender( + address: string, + ledgerId: string | undefined, + ): Promise { + const reference = await this.findByAddress(address, { + ledgerId, + domainId: this.proposeContext.domainId, + }) + if (reference === undefined) { + throw new AccountNotFoundError(address) } + return reference } /** diff --git a/src/custodians/ripple/index.ts b/src/custodians/ripple/index.ts index fbd588e..7d595d3 100644 --- a/src/custodians/ripple/index.ts +++ b/src/custodians/ripple/index.ts @@ -15,7 +15,7 @@ export type { CustodyRequestArgs, CustodyRequestPath, CustodyRequestResponse, -} from './api.js' +} from './api-types.js' export type { CustodyAddressReference, CustodyFindByAddressOptions, @@ -30,6 +30,8 @@ export type { CustodyIntentOutcome, CustodyProposeAndWaitResult, CustodyTransactionDryRun, + CustodyTransactionOrder, + CustodyTransactionOrderOptions, CustodyWaitOptions, } from './intent-types.js' export { CUSTODY_ROUTES } from '../../generated/custody-routes.js' diff --git a/src/custodians/ripple/intent-types.ts b/src/custodians/ripple/intent-types.ts index b6bf209..af41d53 100644 --- a/src/custodians/ripple/intent-types.ts +++ b/src/custodians/ripple/intent-types.ts @@ -1,6 +1,7 @@ import type { FeeIntent } from '../../domain/index.js' import type { components } from '../../generated/custody.js' +import type { ProposeEnvelopeOverrides } from './mapping/propose-envelope.js' import type { IntentOutcome } from './submission/intent-polling.js' /* @@ -63,3 +64,40 @@ export interface CustodyDryRunTransactionOptions { /** Fee override; defaults to `Priority: Low`, as a submission would. */ readonly fee?: FeeIntent } + +/** + * A native XRPL transaction order, for + * {@link CustodyApi.proposeTransactionOrder}: Custody's own operation (e.g. a + * `Payment` to an `Endpoint`, an `MPTokenIssuanceCreate`, an + * `MPTokenAuthorize`), sent from an XRPL address. + */ +export interface CustodyTransactionOrder { + /** The sending account's XRPL address. Its Custody account is looked up. */ + readonly account: string + /** The operation, exactly as Custody's API defines it. */ + readonly operation: components['schemas']['Core_XrplOperation'] + /** + * The ledger the sending account is on, e.g. `xrpl`. Needed when the account + * holds its address on several ledgers. + */ + readonly ledgerId?: string + /** The fee strategy; defaults to `Priority: Low`. */ + readonly feeStrategy?: components['schemas']['Core_XrplFeeStrategy'] + /** The most the fee may be, in drops. */ + readonly maximumFee?: string + /** Memos; none by default. */ + readonly memos?: ReadonlyArray + /** The XRPL `SourceTag`. */ + readonly sourceTag?: number + /** The order's own custom properties (envelope ones go in the options). */ + readonly customProperties?: components['schemas']['Core_StringsMap'] +} + +/** Options for {@link CustodyApi.proposeTransactionOrder}. */ +export type CustodyTransactionOrderOptions = ProposeEnvelopeOverrides & { + /** + * Dry-run the order first. If Custody says it would fail, throw a + * `DryRunFailedError` (carrying the verdict) and propose nothing. + */ + readonly dryRun?: boolean +} diff --git a/src/custodians/ripple/mapping/envelope.ts b/src/custodians/ripple/mapping/envelope.ts index 5fe7986..fa01aab 100644 --- a/src/custodians/ripple/mapping/envelope.ts +++ b/src/custodians/ripple/mapping/envelope.ts @@ -12,8 +12,6 @@ import { buildProposeEnvelope } from './propose-envelope.js' import { txToOperation } from './xrpl-operations.js' type ProposeIntentBody = components['schemas']['Core_UserSigned'] -type TransactionOrderParametersXrpl = - components['schemas']['Core_TransactionOrderParameters_XRPL'] /** Inputs for building one signed `v0_CreateTransactionOrder` intent envelope. */ export interface BuildEnvelopeOptions { @@ -54,6 +52,56 @@ export interface TransactionOrderPayload { readonly customProperties: components['schemas']['Core_StringsMap'] } +/** The fields of one native XRPL transaction order, in Custody's terms. */ +export interface TransactionOrderFields { + /** The Custody account that owns and signs the transaction. */ + readonly accountId: string + /** The XRPL ledger backing the account (see {@link BuildEnvelopeOptions}). */ + readonly ledgerId?: string + /** The order's id: the idempotency key, and the intent's id. */ + readonly id: string + /** The operation, as Custody's API defines it. */ + readonly operation: components['schemas']['Core_XrplOperation'] + /** The fee strategy; defaults to `Priority: Low`. */ + readonly feeStrategy?: components['schemas']['Core_XrplFeeStrategy'] + /** The most the fee may be, in drops. */ + readonly maximumFee?: string + /** Memos; none by default. */ + readonly memos?: ReadonlyArray + /** The XRPL `SourceTag`. */ + readonly sourceTag?: number + /** The order's own custom properties; none by default. */ + readonly customProperties?: components['schemas']['Core_StringsMap'] +} + +/** + * Assemble an unsigned `v0_CreateTransactionOrder` payload from an order's + * fields, filling the defaults Custody requires: a `Priority: Low` fee + * strategy, no memos, and no custom properties. + * + * @param fields - The account, id, operation, and optional order fields. + * @returns The payload, ready to propose or dry-run. + */ +export function transactionOrderPayload( + fields: TransactionOrderFields, +): TransactionOrderPayload['payload'] { + return { + id: fields.id, + accountId: fields.accountId, + ledgerId: fields.ledgerId, + parameters: { + feeStrategy: fields.feeStrategy ?? toFeeStrategy(undefined).feeStrategy, + maximumFee: fields.maximumFee, + memos: Array.from(fields.memos ?? []), + sourceTag: fields.sourceTag, + operation: fields.operation, + type: 'XRPL', + }, + customProperties: fields.customProperties ?? {}, + type: 'v0_CreateTransactionOrder', + } +} + /** * Map a transaction to an unsigned `v0_CreateTransactionOrder` payload: the * native operation, the fee strategy, memos, and a human-readable @@ -67,32 +115,24 @@ export interface TransactionOrderPayload { export function buildTransactionOrderPayload( options: Omit, ): TransactionOrderPayload { - const operation = txToOperation(options.transaction) const { feeStrategy, maximumFee } = toFeeStrategy(options.fee) const customProperties = buildCustomProperties(options.transaction) - - const parameters: TransactionOrderParametersXrpl = { - feeStrategy, - maximumFee, - memos: toMemos(options.transaction), - sourceTag: options.transaction.SourceTag, - operation, - type: 'XRPL', - } - // The payload carries its own id, which must match the envelope id — the // caller's idempotency key resolves a retry to the same intent. Resolve it // once here (falling back to a fresh id) so both stay in sync. const intentId = options.idempotencyKey ?? uuidV7() return { - payload: { - id: intentId, + payload: transactionOrderPayload({ accountId: options.accountId, ledgerId: options.ledgerId, - parameters, + id: intentId, + operation: txToOperation(options.transaction), + feeStrategy, + maximumFee, + memos: toMemos(options.transaction), + sourceTag: options.transaction.SourceTag, customProperties, - type: 'v0_CreateTransactionOrder', - }, + }), intentId, customProperties, } diff --git a/src/custodians/ripple/submission/transaction-polling.ts b/src/custodians/ripple/submission/transaction-polling.ts index bf01513..1ed2add 100644 --- a/src/custodians/ripple/submission/transaction-polling.ts +++ b/src/custodians/ripple/submission/transaction-polling.ts @@ -1,7 +1,7 @@ import { decode, decodeAccountID } from 'xrpl' import type { OnChainResult } from '../../../domain/index.js' -import { IntentValidationError } from '../../../errors.js' +import { CustodyTimeoutError, IntentValidationError } from '../../../errors.js' import type { components } from '../../../generated/custody.js' import type { PollSchedule } from '../../poll-schedule.js' import { pollDelayMs } from '../../poll-schedule.js' @@ -10,9 +10,6 @@ import type { CustodyHttpClient } from '../transport/custody-http-client.js' type ApiTransaction = components['schemas']['Core_ApiTransaction'] type TransactionsCollection = components['schemas']['Core_TransactionsCollection'] -type LedgerTransactionData = components['schemas']['Core_LedgerTransactionData'] -type LedgerTransactionStatus = - components['schemas']['Core_LedgerTransactionStatus'] /** Poll cadence for ledger confirmation, backing off. See {@link pollDelayMs}. */ const POLL_SCHEDULE: PollSchedule = { initialMs: 5000, maxMs: 30_000 } @@ -23,18 +20,6 @@ const HEX_RADIX = 16 /** Hex-digit width of the 4-byte big-endian sequence prefix of an issuance id. */ const SEQUENCE_HEX_WIDTH = 8 -/** - * Ledger statuses a transaction can never leave for `Confirmed`: it aged out of - * every ledger it could have applied in (`Expired`), or another transaction on - * the same account sequence superseded it (`Replaced`). Either way it is dead — - * polling on would only burn the full timeout waiting for a confirmation that - * can no longer come. - */ -const TERMINAL_DEAD_STATUSES: ReadonlySet = new Set([ - 'Expired', - 'Replaced', -]) - /** * Wait for `ms` milliseconds. * @@ -92,29 +77,90 @@ function mptIssuanceIdFromRaw( } /** - * Classify a transaction's ledger state as provably dead, if it is. A dead - * transaction has been permanently rejected by the ledger — it recorded an - * on-chain `failure` (`FailedOnChain`, or `PartiallyFailedOnChain`, which is - * not the clean success the caller asked for either), or it reached a terminal - * `Expired`/`Replaced` status — so it can never become `Confirmed` no matter - * how long we keep polling. + * Pick the transaction an order is currently represented by. A fee-bumped + * resubmission leaves the superseded attempt behind under the same + * `orderReference.Id`, marked `Replaced` on the ledger; only the surviving + * attempt says whether the order lands. So `Replaced` attempts are dropped and + * the newest of the rest is taken — sorted here, so the choice doesn't depend + * on the endpoint's ordering. + * + * @param items - The order's transactions. + * @returns The current attempt, or `undefined` while none survives (e.g. only + * the replaced attempt is registered so far). + */ +function currentTransaction( + items: readonly ApiTransaction[], +): ApiTransaction | undefined { + return ( + items + .filter((item) => item.ledgerTransactionData?.ledgerStatus !== 'Replaced') + // `registeredAt` is ISO-8601, so it sorts as text. + .sort((left, right) => + right.registeredAt.localeCompare(left.registeredAt), + )[0] + ) +} + +/** + * Why a transaction will never confirm, if it won't. It is dead when the ledger + * recorded a `failure` (`FailedOnChain`, or `PartiallyFailedOnChain`, which + * isn't the clean success the caller asked for either), when it `Expired` out + * of every ledger it could have applied in, or when Custody gave up on it + * before the ledger (`Failed` / `Interrupted` processing). * - * @param ledgerData - The transaction's `ledgerTransactionData`. - * @returns A short reason string when dead, else `undefined` (still in flight). + * @param tx - The transaction. + * @returns A short reason, or `undefined` while it is still in flight. */ -function deadReason(ledgerData: LedgerTransactionData): string | undefined { +function deadReason(tx: ApiTransaction): string | undefined { + const ledgerData = tx.ledgerTransactionData // `failure` is typed as the failure enum but comes back as `null` while the - // transaction is still in flight, so test truthiness — only a real failure - // string (both values are non-empty) marks the transaction dead. - if (ledgerData.failure) { - return ledgerData.failure + // transaction is still in flight, so test truthiness: only a real failure + // string marks the transaction dead. + if (ledgerData?.failure) { + return `rejected by the ledger: ${ledgerData.failure}` + } + if (ledgerData?.ledgerStatus === 'Expired') { + return 'Expired' + } + // Custody gave up preparing or broadcasting it, so it never reached the ledger. + const { processing } = tx + if (processing?.status === 'Failed') { + return `Custody failed it before the ledger: ${processing.hint}` } - if (TERMINAL_DEAD_STATUSES.has(ledgerData.ledgerStatus)) { - return ledgerData.ledgerStatus + if (processing?.status === 'Interrupted') { + return `Custody interrupted it (${processing.cause}): ${processing.reason}` } return undefined } +/** + * List an order's transactions, treating a timed-out read as "no answer this + * time" rather than a failure. + * + * @param client - The authenticated Custody client. + * @param domainId - The Custody domain. + * @param orderId - The intent/order id. + * @returns The order's transactions; empty on a timed-out read. + */ +async function listOrderTransactions( + client: CustodyHttpClient, + domainId: string, + orderId: string, +): Promise { + try { + const collection = await client.get( + `/v1/domains/${domainId}/transactions`, + { 'orderReference.Id': orderId }, + ) + return collection.items + } catch (error) { + if (error instanceof CustodyTimeoutError) { + return [] + } + throw error + } +} + /** * Extract the on-chain result fields from a confirmed Custody transaction. * @@ -156,13 +202,18 @@ export interface PollTransactionOptions { * for ledger confirmation and reads the result directly from the Custody API — * no separate XRPL ledger query needed. * + * An order can map to several transactions when Custody resubmits one with a + * higher fee; the superseded attempt stays listed as `Replaced`. Only the + * newest surviving attempt is considered, so a replaced attempt is never + * mistaken for the order's outcome. + * * The wait has three outcomes, mirroring the on-ledger reality: * - **Confirmed** — the transaction is on the ledger; its hash is returned. * - **Provably dead** — the transaction reached a terminal non-confirmed state - * (`Expired`, `Replaced`, or an on-chain `failure`). Polling on would only - * waste the timeout, so this throws {@link IntentValidationError} at once. It - * will never apply, so a retry is a genuinely new attempt and must use a - * fresh idempotency key. + * (`Expired`, an on-chain `failure`, or Custody `Failed`/`Interrupted` it + * before the ledger). Polling on would only waste the timeout, so this throws + * {@link IntentValidationError} at once. It will never apply, so a retry is a + * genuinely new attempt and must use a fresh idempotency key. * - **Indeterminate** — still in flight when the timeout elapses; returns * `undefined`. The transaction may yet confirm, so a retry must re-drive the * *same* idempotency key rather than start a new attempt. @@ -181,22 +232,13 @@ export async function pollTransactionOnChain( for (let attempt = 0; ; attempt += 1) { // eslint-disable-next-line no-await-in-loop -- Sequential polling is inherent to waiting for ledger confirmation. - const collection = await client.get( - `/v1/domains/${domainId}/transactions`, - { 'orderReference.Id': intentId }, - ) - - if (collection.count > 0) { - const tx = collection.items[0] - const ledgerData = tx.ledgerTransactionData + const items = await listOrderTransactions(client, domainId, intentId) + const tx = currentTransaction(items) + if (tx !== undefined) { // Check for a dead outcome *before* honoring `Confirmed`: Custody can mark // a transaction `Confirmed` (it reached the ledger) while also recording a - // `failure` on it — not the clean success the caller asked for. A recorded - // failure wins. - // `ledgerTransactionData` is typed optional but comes back as `null` from - // Custody while the transaction is still in flight, so a truthiness guard - // covers both — keep polling rather than dereferencing a null. - const dead = ledgerData ? deadReason(ledgerData) : undefined + // `failure` on it. A recorded failure wins. + const dead = deadReason(tx) if (dead !== undefined) { throw new IntentValidationError( `Custody transaction for intent ${intentId} will not confirm ` + @@ -204,16 +246,18 @@ export async function pollTransactionOnChain( `idempotency key.`, ) } - if (ledgerData?.ledgerStatus === 'Confirmed') { + if (tx.ledgerTransactionData?.ledgerStatus === 'Confirmed') { return toOnChainResult(tx) } } - const delay = pollDelayMs(attempt, POLL_SCHEDULE) - if (Date.now() + delay >= deadline) { + const remaining = deadline - Date.now() + if (!(remaining > 0)) { return undefined } + // Never sleep past the deadline, so the last read lands on it and the whole + // budget is used. // eslint-disable-next-line no-await-in-loop -- Sequential polling is inherent to waiting for ledger confirmation. - await sleep(delay) + await sleep(Math.min(pollDelayMs(attempt, POLL_SCHEDULE), remaining)) } } diff --git a/src/custodians/ripple/versioning/version-guard.ts b/src/custodians/ripple/versioning/version-guard.ts index 02df166..5e9a104 100644 --- a/src/custodians/ripple/versioning/version-guard.ts +++ b/src/custodians/ripple/versioning/version-guard.ts @@ -1,6 +1,7 @@ import { UnsupportedInVersionError } from '../../../errors.js' import { CUSTODY_AVAILABILITY } from '../../../generated/custody-availability.js' import type { CustodyAvailability } from '../../../generated/custody-availability.js' +import type { components } from '../../../generated/custody.js' import { endpointKey } from './capabilities.js' import type { CustodyCapabilities } from './capabilities.js' @@ -144,6 +145,28 @@ export class VersionGuard { }) } + /** + * Assert the version accepts a governed-intent payload: its intent type and, + * for an XRPL transaction order, its operation. + * + * @param payload - The intent payload. + * @param sdkMethod - The SDK call that needs it, for the error. + * @throws {@link UnsupportedInVersionError} if either isn't supported. + */ + public assertPayload( + payload: components['schemas']['Core_ProposeUserIntentPayload'], + sdkMethod: string, + ): void { + this.assertIntentType(payload.type, sdkMethod) + if ( + payload.type === 'v0_CreateTransactionOrder' && + payload.parameters.type === 'XRPL' && + payload.parameters.operation !== undefined + ) { + this.assertXrplOperation(payload.parameters.operation.type, sdkMethod) + } + } + /** * Assert the version can take a transactor as a native transaction order: * the `v0_CreateTransactionOrder` intent, carrying the Custody operation of diff --git a/test/contract/ripple-custody.contract.test.ts b/test/contract/ripple-custody.contract.test.ts index 202c201..ef612c9 100644 --- a/test/contract/ripple-custody.contract.test.ts +++ b/test/contract/ripple-custody.contract.test.ts @@ -24,6 +24,7 @@ import type { Account } from '../../src/domain/index.js' import { CustodyApiError, CustodySignatureError, + DryRunFailedError, IntentValidationError, } from '../../src/errors.js' import type { components } from '../../src/generated/custody.js' @@ -570,6 +571,38 @@ describeContract('RippleCustody (live Custody sandbox)', () => { LIVE_TIMEOUT_MS, ) + it( + 'api.proposeTransactionOrder dry-runs first and proposes nothing on failure', + async () => { + const { ledgerId } = requirePrimary(await custody.listAccounts()) + // An MPT payment to a Custody Endpoint that doesn't exist: Custody + // parses the order (Endpoint destination included) and fails the dry + // run, so nothing is proposed. Non-mutating. + const endpointId = randomUUID() + + const proposing = custody.api.proposeTransactionOrder( + { + account: SANDBOX_PRIMARY, + ledgerId, + operation: { + type: 'Payment', + destination: { type: 'Endpoint', endpointId }, + amount: '1', + currency: { + type: 'MultiPurposeToken', + issuanceId: `00000001${'AB'.repeat(20)}`, + }, + }, + }, + { dryRun: true }, + ) + + await expect(proposing).rejects.toBeInstanceOf(DryRunFailedError) + await expect(proposing).rejects.toThrow(endpointId) + }, + LIVE_TIMEOUT_MS, + ) + it( 'api.dryRunTransaction returns the verdict and fee estimate', async () => { diff --git a/test/unit/ripple-custody/custody-api.test.ts b/test/unit/ripple-custody/custody-api.test.ts index 428ec8d..3dda314 100644 --- a/test/unit/ripple-custody/custody-api.test.ts +++ b/test/unit/ripple-custody/custody-api.test.ts @@ -17,6 +17,7 @@ import { pinnedCapabilities } from '../../../src/custodians/ripple/versioning/ca import { VersionGuard } from '../../../src/custodians/ripple/versioning/version-guard.js' import { AccountNotFoundError, + DryRunFailedError, SimpleXRPLError, UnsupportedInVersionError, } from '../../../src/errors.js' @@ -1003,3 +1004,191 @@ describe('CustodyApi.getPublicKey', () => { ) }) }) + +describe('CustodyApi.proposeTransactionOrder', () => { + /** An MPT payment to a Custody Endpoint — a destination no vertical takes. */ + const ENDPOINT_PAYMENT: components['schemas']['Core_XrplOperation'] = { + type: 'Payment', + destination: { type: 'Endpoint', endpointId: 'endpoint-1' }, + amount: '1000', + currency: { type: 'MultiPurposeToken', issuanceId: 'ISSUANCE' }, + } + + /** + * The request bodies POSTed to `path`, parsed. + * + * @param http - The recording transport. + * @param path - The request path. + * @returns The parsed bodies, in order. + */ + function posted(http: FakeHttpPort, path: string): unknown[] { + const bodies: unknown[] = [] + for (const request of http.requests) { + if (request.method === 'POST' && new URL(request.url).pathname === path) { + bodies.push(JSON.parse(request.body ?? '{}')) + } + } + return bodies + } + + /** + * The routes a proposal needs: the address lookup, and the propose POST. + * + * @param dryRun - The dry-run verdict to answer with, if dry-run is called. + * @returns A request handler. + */ + function custodyRoutes( + dryRun: unknown = { success: true, type: 'v0_CreateTransactionOrder' }, + ): (request: HttpRequest) => HttpResponse { + return routed([ + ['GET', '/v1/addresses', { items: [addressRef()], count: 1 }], + ['POST', '/v1/intents/dry-run', dryRun], + ['POST', '/v1/intents', { requestId: 'req-1' }], + ]) + } + + it('looks up the sender and proposes Custody’s operation as-is, with defaults', async () => { + const { api, http } = apiOn(custodyRoutes()) + + const response = await api.proposeTransactionOrder({ + account: 'rHolder', + operation: ENDPOINT_PAYMENT, + }) + + const [body] = posted(http, '/v1/intents') as Array< + components['schemas']['Core_UserSigned'] + > + expect(http.requests[0]?.url).toBe( + `${GATEWAY}/v1/addresses?address=rHolder`, + ) + expect(body.signature).toBeTruthy() + expect(body.request.payload).toEqual({ + type: 'v0_CreateTransactionOrder', + id: body.request.id, + accountId: 'account-1', + ledgerId: 'xrpl-testnet', + parameters: { + type: 'XRPL', + feeStrategy: { type: 'Priority', priority: 'Low' }, + memos: [], + operation: ENDPOINT_PAYMENT, + }, + customProperties: {}, + }) + // One id for the intent and the order, returned straight away. + expect(response).toEqual({ requestId: 'req-1', intentId: body.request.id }) + }) + + it('passes order fields and envelope options through; id is both intent and order id', async () => { + const { api, http } = apiOn(custodyRoutes()) + + const response = await api.proposeTransactionOrder( + { + account: 'rHolder', + operation: ENDPOINT_PAYMENT, + feeStrategy: { type: 'Priority', priority: 'High' }, + maximumFee: '100', + memos: [{ type: 'text/plain', data: 'invoice 42' }] as never, + sourceTag: 7, + customProperties: { ref: 'po-9' }, + }, + { id: 'order-1', description: 'pay the endpoint' }, + ) + + const [body] = posted(http, '/v1/intents') as Array< + components['schemas']['Core_UserSigned'] + > + expect(body.request).toMatchObject({ + id: 'order-1', + description: 'pay the endpoint', + payload: { + id: 'order-1', + parameters: { + feeStrategy: { type: 'Priority', priority: 'High' }, + maximumFee: '100', + sourceTag: 7, + }, + customProperties: { ref: 'po-9' }, + }, + }) + expect(response.intentId).toBe('order-1') + }) + + it('dry-runs the same order first when asked', async () => { + const { api, http } = apiOn(custodyRoutes()) + + await api.proposeTransactionOrder( + { account: 'rHolder', operation: ENDPOINT_PAYMENT }, + { dryRun: true, id: 'order-2' }, + ) + + const [dryRun] = posted(http, '/v1/intents/dry-run') as Array<{ + id: string + payload: unknown + }> + const [proposal] = posted(http, '/v1/intents') as Array< + components['schemas']['Core_UserSigned'] + > + expect(dryRun.id).toBe('order-2') + expect(dryRun.payload).toEqual(proposal.request.payload) + }) + + it('throws DryRunFailedError and proposes nothing when the dry run fails', async () => { + const verdict = { + success: false, + errors: ['destination endpoint is locked'], + type: 'v0_CreateTransactionOrder', + } + const { api, http } = apiOn(custodyRoutes(verdict)) + + const proposing = api.proposeTransactionOrder( + { account: 'rHolder', operation: ENDPOINT_PAYMENT }, + { dryRun: true }, + ) + + await expect(proposing).rejects.toBeInstanceOf(DryRunFailedError) + await expect(proposing).rejects.toMatchObject({ response: verdict }) + await expect(proposing).rejects.toThrow(/destination endpoint is locked/u) + expect(posted(http, '/v1/intents')).toHaveLength(0) + }) + + it('throws AccountNotFoundError when no account in the domain holds the sender', async () => { + const { api, http } = apiOn( + routed([['GET', '/v1/addresses', { items: [], count: 0 }]]), + ) + + await expect( + api.proposeTransactionOrder({ + account: 'rUnknown', + operation: ENDPOINT_PAYMENT, + }), + ).rejects.toBeInstanceOf(AccountNotFoundError) + expect(posted(http, '/v1/intents')).toHaveLength(0) + }) + + it('is checked against the Custody version before proposing', async () => { + // Native MPT operations need official 1.32 or later. + const { api, http } = apiOn( + custodyRoutes(), + new VersionGuard(pinnedCapabilities('1.30')), + ) + + await expect( + api.proposeTransactionOrder({ + account: 'rHolder', + operation: { + type: 'MPTokenAuthorize', + tokenIdentifier: { + type: 'MPTokenIssuanceId', + issuanceId: 'ISSUANCE', + }, + flags: [], + }, + }), + ).rejects.toMatchObject({ + constructor: UnsupportedInVersionError, + sdkMethod: 'api.propose', + }) + expect(posted(http, '/v1/intents')).toHaveLength(0) + }) +}) diff --git a/test/unit/ripple-custody/ripple-custody.test.ts b/test/unit/ripple-custody/ripple-custody.test.ts index 874e17e..5a130d9 100644 --- a/test/unit/ripple-custody/ripple-custody.test.ts +++ b/test/unit/ripple-custody/ripple-custody.test.ts @@ -7,7 +7,7 @@ import canonicalizeImport from 'canonicalize' import { Wallet } from 'xrpl' import type { OfferCancel, Payment, TxResponse } from 'xrpl' -import type { CustodyProposePayload } from '../../../src/custodians/ripple/api.js' +import type { CustodyProposePayload } from '../../../src/custodians/ripple/api-types.js' import type { CustodySignContext, CustodySignerPort, diff --git a/test/unit/ripple-custody/transaction-polling.test.ts b/test/unit/ripple-custody/transaction-polling.test.ts index 67a2063..72c36cb 100644 --- a/test/unit/ripple-custody/transaction-polling.test.ts +++ b/test/unit/ripple-custody/transaction-polling.test.ts @@ -1,5 +1,8 @@ import { pollTransactionOnChain } from '../../../src/custodians/ripple/submission/transaction-polling.js' -import { IntentValidationError } from '../../../src/errors.js' +import { + CustodyTimeoutError, + IntentValidationError, +} from '../../../src/errors.js' import { DOMAIN_ID, makeClient, ok } from './test-utils.js' @@ -203,7 +206,7 @@ describe('pollTransactionOnChain', () => { expect(result).toBeUndefined() }) - it.each(['Expired', 'Replaced'])( + it.each(['Expired'])( 'throws IntentValidationError at once when the transaction is %s (terminal)', async (ledgerStatus) => { const { client, http } = makeClient(() => @@ -261,4 +264,233 @@ describe('pollTransactionOnChain', () => { pollTransactionOnChain({ client, ...options, timeoutMs: 60_000 }), ).rejects.toThrow(IntentValidationError) }) + + it.each([ + [ + 'Failed', + { status: 'Failed', hint: 'InvalidUserPayload' }, + /Custody failed it before the ledger: InvalidUserPayload/u, + ], + [ + 'Interrupted', + { status: 'Interrupted', cause: 'Expiry', reason: 'order expired' }, + /Custody interrupted it \(Expiry\): order expired/u, + ], + ])( + 'throws at once when Custody %s the transaction before the ledger', + async (_status, processing, message) => { + const { client, http } = makeClient(() => + ok({ + count: 1, + items: [ + { + id: 'tx-1', + registeredAt: '2026-11-06T10:00:00Z', + processing, + ledgerTransactionData: null, + }, + ], + }), + ) + + const polling = pollTransactionOnChain({ + client, + ...options, + timeoutMs: 60_000, + }) + + await expect(polling).rejects.toThrow(IntentValidationError) + await expect(polling).rejects.toThrow(message) + expect(http.requests).toHaveLength(1) + }, + ) +}) + +describe('pollTransactionOnChain — an order with a replaced attempt', () => { + const options = { domainId: DOMAIN_ID, intentId: INTENT_ID, timeoutMs: 5000 } + + /** + * One attempt at the order's transaction. + * + * @param id - The transaction id. + * @param fields - When Custody registered it, its ledger status, and hash. + * @param fields.at - When Custody registered it. + * @param fields.status - Its ledger status. + * @param fields.hash - Its XRPL hash. + * @returns The transaction. + */ + function attempt( + id: string, + fields: { at: string; status: string; hash?: string }, + ): Record { + return { + id, + registeredAt: fields.at, + orderReference: { id: INTENT_ID, domainId: DOMAIN_ID }, + ledgerTransactionData: { + ledgerStatus: fields.status, + ledgerTransactionId: fields.hash ?? '', + ledgerData: null, + }, + } + } + + it('ignores the replaced attempt and follows the one that replaced it', async () => { + // A fee-bumped resubmission: Custody lists the replaced attempt first. + const { client } = makeClient(() => + ok({ + count: 2, + items: [ + attempt('tx-old', { + at: '2026-11-06T10:00:00Z', + status: 'Replaced', + hash: 'OLD', + }), + attempt('tx-new', { + at: '2026-11-06T10:05:00Z', + status: 'Confirmed', + hash: 'NEW', + }), + ], + }), + ) + + await expect( + pollTransactionOnChain({ client, ...options }), + ).resolves.toEqual({ txHash: 'NEW' }) + }) + + it('takes the newest surviving attempt, whatever the listing order', async () => { + const { client } = makeClient(() => + ok({ + count: 2, + items: [ + attempt('tx-a', { + at: '2026-11-06T10:00:00Z', + status: 'Confirmed', + hash: 'OLDER', + }), + attempt('tx-b', { + at: '2026-11-06T10:05:00Z', + status: 'Confirmed', + hash: 'NEWER', + }), + ].reverse(), + }), + ) + + await expect( + pollTransactionOnChain({ client, ...options }), + ).resolves.toEqual({ txHash: 'NEWER' }) + }) + + it('keeps waiting while only the replaced attempt is listed', async () => { + jest.useFakeTimers() + try { + let calls = 0 + const { client } = makeClient(() => { + calls += 1 + const items = [ + attempt('tx-old', { + at: '2026-11-06T10:00:00Z', + status: 'Replaced', + hash: 'OLD', + }), + ] + if (calls > 1) { + items.push( + attempt('tx-new', { + at: '2026-11-06T10:05:00Z', + status: 'Confirmed', + hash: 'NEW', + }), + ) + } + return ok({ count: items.length, items }) + }) + + const polling = pollTransactionOnChain({ + client, + ...options, + timeoutMs: 60_000, + }) + await jest.advanceTimersByTimeAsync(6000) + + // Previously a lone replaced attempt was reported as dead at once. + await expect(polling).resolves.toEqual({ txHash: 'NEW' }) + } finally { + jest.useRealTimers() + } + }) +}) + +describe('pollTransactionOnChain — the time budget', () => { + it('uses the whole budget, with a last read at the deadline', async () => { + jest.useFakeTimers() + try { + const reads: number[] = [] + const start = Date.now() + const { client } = makeClient(() => { + reads.push(Date.now() - start) + return ok(txCollection(null)) + }) + + let settled = false + const polling = pollTransactionOnChain({ + client, + domainId: DOMAIN_ID, + intentId: INTENT_ID, + timeoutMs: 12_000, + }).finally(() => { + settled = true + }) + + await jest.advanceTimersByTimeAsync(11_900) + expect(settled).toBe(false) + await jest.advanceTimersByTimeAsync(200) + + // A 5 s first backoff, then a 7 s sleep that lands on the deadline (the + // next backoff, 10 s, would have overshot it). + expect(reads).toEqual([0, 5000, 12_000]) + await expect(polling).resolves.toBeUndefined() + } finally { + jest.useRealTimers() + } + }) + + it('treats a read that timed out as no answer, and keeps waiting', async () => { + jest.useFakeTimers() + try { + let calls = 0 + const { client } = makeClient(() => { + calls += 1 + if (calls === 1) { + throw new CustodyTimeoutError({ + method: 'GET', + path: `/v1/domains/${DOMAIN_ID}/transactions`, + timeoutMs: 30_000, + }) + } + return ok( + txCollection({ + ledgerStatus: 'Confirmed', + ledgerTransactionId: 'HASH8', + ledgerData: null, + }), + ) + }) + + const polling = pollTransactionOnChain({ + client, + domainId: DOMAIN_ID, + intentId: INTENT_ID, + timeoutMs: 60_000, + }) + await jest.advanceTimersByTimeAsync(6000) + + await expect(polling).resolves.toEqual({ txHash: 'HASH8' }) + } finally { + jest.useRealTimers() + } + }) }) From c34a93310e98bf31e23cfb9aa63655c0caa9aa3c Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Fri, 6 Nov 2026 14:50:40 -0500 Subject: [PATCH 2/2] bump version --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2ad24a..d6763d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simplexrpl", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simplexrpl", - "version": "0.3.0", + "version": "0.3.1", "license": "ISC", "dependencies": { "@aws-sdk/client-secrets-manager": "^3.700.0", diff --git a/package.json b/package.json index cc3656d..baea3ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simplexrpl", - "version": "0.3.0", + "version": "0.3.1", "description": "A high-level TypeScript SDK for the XRP Ledger, scoped to the institutional / Web2 audience.", "license": "ISC", "author": "Ripple", diff --git a/src/version.ts b/src/version.ts index 6553add..329e34c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -5,4 +5,4 @@ * The simpleXRPL package version, generated from `package.json` at build. * Exposed so callers can report the SDK version in diagnostics. */ -export const VERSION = '0.3.0' +export const VERSION = '0.3.1'