From 9b720a685058fc5d0cc5e7b5ca6d86193b7553d1 Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Tue, 25 Aug 2026 09:26:42 -1000 Subject: [PATCH 1/3] unite intent polling and termination --- src/client/intent-inspector.ts | 11 +- src/custodians/ripple/ripple-custody.ts | 40 +++- .../ripple/submission/transaction-polling.ts | 63 +++++- src/domain/model.ts | 29 ++- src/verticals/account.types.ts | 6 +- src/verticals/credential.types.ts | 6 +- src/verticals/domain.types.ts | 6 +- src/verticals/index.ts | 4 + src/verticals/iou.types.ts | 6 +- src/verticals/token.types.ts | 6 +- src/verticals/xrp.ts | 181 ++++++++++++++- .../ripple-custody/ripple-custody.test.ts | 36 +++ .../transaction-polling.test.ts | 42 +++- test/unit/verticals/xrp.test.ts | 209 ++++++++++++++++++ 14 files changed, 622 insertions(+), 23 deletions(-) create mode 100644 test/unit/verticals/xrp.test.ts diff --git a/src/client/intent-inspector.ts b/src/client/intent-inspector.ts index a1ebaa5..a2f1f4b 100644 --- a/src/client/intent-inspector.ts +++ b/src/client/intent-inspector.ts @@ -90,10 +90,19 @@ export class IntentInspector { * * Only available when a Ripple Custody signer is configured. * + * Resolves to the on-chain result once confirmed. Returns `undefined` if the + * timeout elapses with the transaction still in flight — indeterminate, so + * re-drive the *same* idempotency key. Throws {@link IntentValidationError} + * the moment the transaction reaches a terminal non-confirmed state + * (`Expired`, `Replaced`, or an on-chain failure) — provably dead, so a retry + * needs a *fresh* key. + * * @param intentId - The intent id returned at submission. * @param timeoutMs - How long to poll before giving up (custodian default if omitted). - * @returns The on-chain result, or `undefined` when the timeout elapses. + * @returns The on-chain result, or `undefined` when the timeout elapses with + * the transaction still in flight. * @throws {@link SimpleXRPLError} if no Ripple Custody signer is configured. + * @throws {@link IntentValidationError} if the transaction is provably dead. */ public async awaitOnChain( intentId: string, diff --git a/src/custodians/ripple/ripple-custody.ts b/src/custodians/ripple/ripple-custody.ts index 2693dfd..ea3452d 100644 --- a/src/custodians/ripple/ripple-custody.ts +++ b/src/custodians/ripple/ripple-custody.ts @@ -15,6 +15,7 @@ import type { } from '../../domain/index.js' import { AccountNotFoundError, + CustodyApiError, SimpleXRPLError, XrpldSubmitError, } from '../../errors.js' @@ -45,6 +46,13 @@ export type { RippleCustodyOptions, } from './construction.js' +/** + * HTTP status Custody returns when an intent with the posted id already exists. + * The intent id is the caller's idempotency key, so a 409 is the custodian's + * de-duplication firing on a same-key re-drive — expected, not an error. + */ +const HTTP_CONFLICT = 409 + /** * Ripple Custody adapter (TDD §3.3, §7.2): wraps the Custody REST API v1. * Native transactors ({@link NATIVE_XRPL_TRANSACTORS}) submit as a governed @@ -163,6 +171,11 @@ export class RippleCustody implements Custodian, IntentObserver { * `v0_CreateTransactionOrder` intent; everything else through the * raw-signing fallback, then the shared `xrpl.js` client. * + * Re-submitting a native transactor with an idempotency key the custodian has + * already accepted is transparent: it resolves to the existing intent instead + * of failing or creating a duplicate. Use this to safely re-drive a call + * whose result you never saw. + * * @param tx - The fully autofilled transaction to submit. * @param ctx - The submission context. * @returns The submission result. @@ -203,6 +216,10 @@ export class RippleCustody implements Custodian, IntentObserver { * §10.2). Best for M-of-N approval flows that may span hours: the caller * polls or waits on the handle, or resumes later via `client.intent`. * + * Re-submitting with an already-accepted idempotency key hands back a handle + * over the existing intent rather than creating a duplicate (the custodian's + * conflict response is absorbed). + * * @param tx - The fully autofilled transaction to submit. * @param ctx - The submission context. * @returns A handle over the accepted intent. @@ -311,9 +328,16 @@ export class RippleCustody implements Custodian, IntentObserver { * intent. Shared by the sync ({@link submitNative}) and async * ({@link submitAsync}) paths, which differ only in how they wait afterward. * + * The intent id is derived from the caller's idempotency key, so re-driving a + * key the custodian has already accepted is safe: Custody answers the second + * POST with `409 Conflict`, which this treats as "already submitted" and + * absorbs, returning the existing intent id. The caller's wait/observe then + * resolves that original intent rather than creating a duplicate — so a retry + * with the same key can never double-apply. Any other error is re-thrown. + * * @param tx - The transaction to map and submit. * @param ctx - The submission context. - * @returns The client-generated intent id Custody accepted. + * @returns The client-generated intent id Custody accepted (or already had). */ private async postNativeIntent( tx: Transaction, @@ -334,7 +358,19 @@ export class RippleCustody implements Custodian, IntentObserver { body.request.payload, body.request.customProperties, ) - await this.state.client.post('/v1/intents', body) + try { + await this.state.client.post('/v1/intents', body) + } catch (error) { + // A 409 means this idempotency key already created an intent: the + // de-duplication working as intended, not a failure. Absorb it and fall + // through to return the existing intent id. + if ( + !(error instanceof CustodyApiError) || + error.status !== HTTP_CONFLICT + ) { + throw error + } + } return body.request.id } diff --git a/src/custodians/ripple/submission/transaction-polling.ts b/src/custodians/ripple/submission/transaction-polling.ts index 68484a6..f12e9ca 100644 --- a/src/custodians/ripple/submission/transaction-polling.ts +++ b/src/custodians/ripple/submission/transaction-polling.ts @@ -1,6 +1,7 @@ import { decode, decodeAccountID } from 'xrpl' import type { OnChainResult } from '../../../domain/index.js' +import { IntentValidationError } from '../../../errors.js' import type { components } from '../../../generated/custody.js' import type { PollSchedule } from '../../poll-schedule.js' import { pollDelayMs } from '../../poll-schedule.js' @@ -9,10 +10,25 @@ 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 } +/** + * 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. * @@ -66,6 +82,27 @@ function mptIssuanceIdFromRaw( return `${sequenceHex}${accountIdHex}` } +/** + * 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. + * + * @param ledgerData - The transaction's `ledgerTransactionData`. + * @returns A short reason string when dead, else `undefined` (still in flight). + */ +function deadReason(ledgerData: LedgerTransactionData): string | undefined { + if (ledgerData.failure !== undefined) { + return ledgerData.failure + } + if (TERMINAL_DEAD_STATUSES.has(ledgerData.ledgerStatus)) { + return ledgerData.ledgerStatus + } + return undefined +} + /** * Extract the on-chain result fields from a confirmed Custody transaction. * @@ -107,9 +144,22 @@ export interface PollTransactionOptions { * for ledger confirmation and reads the result directly from the Custody API — * no separate XRPL ledger query needed. * + * 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. + * - **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. + * * @param options - The client, domain, intent id, and polling timeout. * @returns The on-chain result once confirmed, or `undefined` when the timeout - * elapses before confirmation. + * elapses with the transaction still in flight. + * @throws {@link IntentValidationError} if the transaction reaches a terminal + * non-confirmed state (provably dead). */ export async function pollTransactionOnChain( options: PollTransactionOptions, @@ -126,9 +176,18 @@ export async function pollTransactionOnChain( if (collection.count > 0) { const tx = collection.items[0] - if (tx.ledgerTransactionData?.ledgerStatus === 'Confirmed') { + const ledgerData = tx.ledgerTransactionData + if (ledgerData?.ledgerStatus === 'Confirmed') { return toOnChainResult(tx) } + const dead = ledgerData === undefined ? undefined : deadReason(ledgerData) + if (dead !== undefined) { + throw new IntentValidationError( + `Custody transaction for intent ${intentId} will not confirm ` + + `on-chain (${dead}) — it is terminal. Retry only with a fresh ` + + `idempotency key.`, + ) + } } const delay = pollDelayMs(attempt, POLL_SCHEDULE) diff --git a/src/domain/model.ts b/src/domain/model.ts index 7675a4d..89fd6e0 100644 --- a/src/domain/model.ts +++ b/src/domain/model.ts @@ -123,7 +123,12 @@ export interface SubmissionContext { /** Return a handle instead of blocking until the transaction is terminal. */ readonly async?: boolean - /** Stable, client-generated id that makes a retry resolve to the same intent. */ + /** + * Stable, client-generated id that lets a retry resolve to the same + * submission rather than duplicating it. De-duplication is enforced by the + * backend and its coverage varies — see + * {@link SubmissionResultFields.idempotencyKey}. + */ readonly idempotencyKey?: string /** Human-readable approval metadata stamped on custody intents. */ @@ -159,9 +164,25 @@ export interface SubmissionResultFields { readonly txHash?: string /** - * The stable, client-generated id (a UUIDv7) this submission carried (§8). - * Re-submitting with the same id resolves to the same intent rather than - * creating a duplicate; pass it back as an operation's `idempotencyKey` to retry. + * The stable, client-generated id (a UUIDv7) this submission carried. + * Re-submitting with the same id lets a retry resolve to the same submission + * rather than creating a duplicate; pass it back as an operation's + * `idempotencyKey` to retry safely. + * + * How completely the key de-duplicates depends on the backend: + * - **Local (`xrpld`):** no custodian de-duplication layer. A re-submit with + * the same key builds a distinct transaction — safety rests on the + * operation being idempotent and on `LastLedgerSequence` bounding it, so + * wait for a transaction to reach a terminal on-ledger state before + * retrying. + * - **Ripple Custody:** de-duplicated at the intent layer for every + * operation. A re-submit with a used key resolves to the existing intent + * transparently (the SDK absorbs the custodian's conflict response). + * - **Palisade:** de-duplicated only on the payment path (`xrp.transfer`, + * `iou.transfer`) — the sole operation whose wire schema carries the key. + * Other operations (account settings, trust lines, offers) are NOT + * de-duplicated custodian-side, so re-submitting one can apply it twice; + * retry those only once the prior attempt is known to be provably dead. */ readonly idempotencyKey?: string } diff --git a/src/verticals/account.types.ts b/src/verticals/account.types.ts index daee54f..88243a7 100644 --- a/src/verticals/account.types.ts +++ b/src/verticals/account.types.ts @@ -44,8 +44,10 @@ export interface AccountWriteOptions { readonly fee?: FeeIntent /** - * A prior submission's `idempotencyKey` (from its result), to retry to the - * same intent instead of creating a duplicate (§8). Auto-generated when omitted. + * A prior submission's `idempotencyKey` (from its result) so a retry resolves + * to the same submission instead of duplicating it. How completely this + * de-duplicates is set by the backend — see the note on the result's + * `idempotencyKey`. Auto-generated when omitted. */ readonly idempotencyKey?: string } diff --git a/src/verticals/credential.types.ts b/src/verticals/credential.types.ts index e20ef3a..68751dd 100644 --- a/src/verticals/credential.types.ts +++ b/src/verticals/credential.types.ts @@ -9,8 +9,10 @@ export interface CredentialWriteOptions { readonly fee?: FeeIntent /** - * A prior submission's `idempotencyKey` (from its result), to retry to the - * same intent instead of creating a duplicate (§8). Auto-generated when omitted. + * A prior submission's `idempotencyKey` (from its result) so a retry resolves + * to the same submission instead of duplicating it. How completely this + * de-duplicates is set by the backend — see the note on the result's + * `idempotencyKey`. Auto-generated when omitted. */ readonly idempotencyKey?: string } diff --git a/src/verticals/domain.types.ts b/src/verticals/domain.types.ts index d175c17..49da1cd 100644 --- a/src/verticals/domain.types.ts +++ b/src/verticals/domain.types.ts @@ -9,8 +9,10 @@ export interface DomainWriteOptions { readonly fee?: FeeIntent /** - * A prior submission's `idempotencyKey` (from its result), to retry to the - * same intent instead of creating a duplicate (§8). Auto-generated when omitted. + * A prior submission's `idempotencyKey` (from its result) so a retry resolves + * to the same submission instead of duplicating it. How completely this + * de-duplicates is set by the backend — see the note on the result's + * `idempotencyKey`. Auto-generated when omitted. */ readonly idempotencyKey?: string } diff --git a/src/verticals/index.ts b/src/verticals/index.ts index 827d833..1961f08 100644 --- a/src/verticals/index.ts +++ b/src/verticals/index.ts @@ -1,8 +1,12 @@ export { XRP } from './xrp.js' export type { + XrpCancelOfferParams, + XrpOfferParams, + XrpOfferPrice, XrpTransferIntent, XrpTransferOptions, XrpTransferParams, + XrpWriteOptions, } from './xrp.js' export { IOU } from './iou.js' export type { diff --git a/src/verticals/iou.types.ts b/src/verticals/iou.types.ts index 5e2a4f7..2d80101 100644 --- a/src/verticals/iou.types.ts +++ b/src/verticals/iou.types.ts @@ -27,8 +27,10 @@ export interface IOUWriteOptions { readonly fee?: FeeIntent /** - * A prior submission's `idempotencyKey` (from its result), to retry to the - * same intent instead of creating a duplicate (§8). Auto-generated when omitted. + * A prior submission's `idempotencyKey` (from its result) so a retry resolves + * to the same submission instead of duplicating it. How completely this + * de-duplicates is set by the backend — see the note on the result's + * `idempotencyKey`. Auto-generated when omitted. */ readonly idempotencyKey?: string } diff --git a/src/verticals/token.types.ts b/src/verticals/token.types.ts index 79fa118..71abdfb 100644 --- a/src/verticals/token.types.ts +++ b/src/verticals/token.types.ts @@ -12,8 +12,10 @@ export interface TokenWriteOptions { readonly fee?: FeeIntent /** - * A prior submission's `idempotencyKey` (from its result), to retry to the - * same intent instead of creating a duplicate (§8). Auto-generated when omitted. + * A prior submission's `idempotencyKey` (from its result) so a retry resolves + * to the same submission instead of duplicating it. How completely this + * de-duplicates is set by the backend — see the note on the result's + * `idempotencyKey`. Auto-generated when omitted. */ readonly idempotencyKey?: string } diff --git a/src/verticals/xrp.ts b/src/verticals/xrp.ts index 0919f08..0f4ef65 100644 --- a/src/verticals/xrp.ts +++ b/src/verticals/xrp.ts @@ -1,5 +1,5 @@ import { xrpToDrops } from 'xrpl' -import type { Payment } from 'xrpl' +import type { OfferCancel, Payment } from 'xrpl' import type { AccountSelector, @@ -9,6 +9,13 @@ import type { import type { SubmissionHost } from '../pipeline/index.js' import { submitTransaction, withIntent } from '../pipeline/index.js' +import { + buildOfferCreate, + priceToLedgerAmount, + xrpDrops, +} from './iou.helpers.js' +import type { IOUOrderType } from './iou.types.js' + /** Parameters for {@link XRP.transfer}. */ export interface XrpTransferParams { /** Destination r-address. */ @@ -27,8 +34,10 @@ export interface XrpTransferOptions { readonly fee?: FeeIntent /** - * A prior submission's `idempotencyKey` (from its result), to retry to the - * same intent instead of creating a duplicate (§8). Auto-generated when omitted. + * A prior submission's `idempotencyKey` (from its result) so a retry resolves + * to the same submission instead of duplicating it. How completely this + * de-duplicates is set by the backend — see the note on the result's + * `idempotencyKey`. Auto-generated when omitted. */ readonly idempotencyKey?: string } @@ -42,6 +51,75 @@ export interface XrpTransferIntent { readonly amount: string } +/** + * Source account and fee overrides shared by the XRP offer operations. The + * resolved account signs the `OfferCreate`/`OfferCancel`. + */ +export interface XrpWriteOptions { + /** Source account; defaults to the primary signer's primary account. */ + readonly from?: AccountSelector + + /** Fee override. */ + readonly fee?: FeeIntent + + /** + * A prior submission's `idempotencyKey` (from its result) so a retry resolves + * to the same submission instead of duplicating it. How completely this + * de-duplicates is set by the backend — see the note on the result's + * `idempotencyKey`. Auto-generated when omitted. + */ + readonly idempotencyKey?: string +} + +/** + * How an XRP offer is priced: the counter-asset paid ({@link XRP.buyOffer}) or + * received ({@link XRP.sellOffer}) for the XRP. It is an MPT or another IOU — + * never XRP, since an XRP-for-XRP offer is meaningless. + */ +export type XrpOfferPrice = + | { readonly mptIssuanceId: string; readonly amount: string } + | { + readonly ticker: string + readonly issuer: string + readonly amount: string + } + +/** Parameters for {@link XRP.buyOffer} and {@link XRP.sellOffer}. */ +export interface XrpOfferParams { + /** The amount of XRP to buy or sell, as a decimal string. */ + readonly amount: string + /** The order type. */ + readonly orderType: IOUOrderType + /** + * What's offered in payment ({@link XRP.buyOffer}) or wanted in return + * ({@link XRP.sellOffer}) — an MPT or another IOU. + */ + readonly price: XrpOfferPrice + /** + * Restrict the offer to a permissioned domain. Omit for the open DEX. When + * set, the offer defaults to hybrid (also crosses the open DEX) unless + * `hybrid` is explicitly `false`. + * + * @defaultValue Unset — the offer works the open DEX only. + */ + readonly domainID?: string + /** + * Whether a domain-scoped offer also works the open DEX (hybrid). Only + * meaningful together with `domainID`. + * + * @defaultValue `true` when `domainID` is set (otherwise not applicable). + */ + readonly hybrid?: boolean + /** A prior offer sequence to replace. */ + readonly offerSequence?: number +} + +/** Parameters for {@link XRP.cancelOffer}. */ +export interface XrpCancelOfferParams { + /** The sequence number of the offer to cancel. */ + readonly offerSequence: number +} + /** * The XRP helper vertical: native-XRP value transfers. */ @@ -83,4 +161,101 @@ export class XRP { }) return withIntent(result, { to: params.to, amount: params.amount }) } + + /** + * Place an order on the DEX to acquire XRP. + * + * @param params - The amount of XRP to buy, order type, and price offered. + * @param options - Source account, fee override, and idempotency key (see + * {@link XrpWriteOptions}). + * @returns The submission result. + * @throws {@link IntentValidationError} if `params.price` is MPT-denominated. + */ + public async buyOffer( + params: XrpOfferParams, + options?: XrpWriteOptions, + ): Promise> { + return this.placeOffer(params, false, options) + } + + /** + * Place an order on the DEX to sell XRP. + * + * @param params - The amount of XRP to sell, order type, and price wanted. + * @param options - Source account, fee override, and idempotency key (see + * {@link XrpWriteOptions}). + * @returns The submission result. + * @throws {@link IntentValidationError} if `params.price` is MPT-denominated. + */ + public async sellOffer( + params: XrpOfferParams, + options?: XrpWriteOptions, + ): Promise> { + return this.placeOffer(params, true, options) + } + + /** + * Cancel a standing offer placed by the acting account. + * + * @param params - The sequence number of the offer to cancel. + * @param options - Source account, fee override, and idempotency key (see + * {@link XrpWriteOptions}). + * @returns The submission result, with `{ offerSequence }` as the intent + * output. + */ + public async cancelOffer( + params: XrpCancelOfferParams, + options?: XrpWriteOptions, + ): Promise> { + const account = this.host.resolveAccount(options?.from) + const transaction: OfferCancel = { + TransactionType: 'OfferCancel', + Account: account.address, + OfferSequence: params.offerSequence, + } + const result = await submitTransaction(this.host, { + transaction, + account, + fee: options?.fee, + idempotencyKey: options?.idempotencyKey, + }) + return withIntent(result, { offerSequence: params.offerSequence }) + } + + /** + * Build and submit an `OfferCreate` trading XRP against a counter-asset, on + * the given side. XRP is the base asset; `params.price` is the other side. + * + * @param params - The XRP amount, order type, price, and domain options. + * @param sell - Whether this is a sell offer (offering XRP for the price). + * @param options - Source account, fee override, and idempotency key (see + * {@link XrpWriteOptions}). + * @returns The submission result. + */ + private async placeOffer( + params: XrpOfferParams, + sell: boolean, + options?: XrpWriteOptions, + ): Promise> { + const account = this.host.resolveAccount(options?.from) + const xrp = xrpDrops(params.amount, 'amount') + const price = priceToLedgerAmount(params.price) + const transaction = buildOfferCreate({ + account: account.address, + takerGets: sell ? xrp : price, + takerPays: sell ? price : xrp, + orderType: params.orderType, + sell, + domainID: params.domainID, + hybrid: params.hybrid, + offerSequence: params.offerSequence, + }) + const result = await submitTransaction(this.host, { + transaction, + account, + fee: options?.fee, + idempotencyKey: options?.idempotencyKey, + }) + return withIntent(result, undefined) + } } diff --git a/test/unit/ripple-custody/ripple-custody.test.ts b/test/unit/ripple-custody/ripple-custody.test.ts index cf0b92a..05d04fb 100644 --- a/test/unit/ripple-custody/ripple-custody.test.ts +++ b/test/unit/ripple-custody/ripple-custody.test.ts @@ -35,6 +35,7 @@ import { intentBody, meBody, ok, + status, } from './test-utils.js' const PRIMARY_ADDRESS = Wallet.generate().classicAddress @@ -427,6 +428,29 @@ describe('RippleCustody.submitAndWait', () => { ).rejects.toBeInstanceOf(XrpldSubmitError) }) + it('absorbs a 409 on the intent POST and resolves the existing intent (same-key re-drive)', async () => { + // Re-driving an already-accepted idempotency key: Custody returns 409, and + // the SDK observes the existing (Executed) intent rather than throwing. + const { custody } = await makeCustody({ + intentCreate: () => status(409, { message: 'intent already exists' }), + }) + + const result = await custody.submitAndWait(PAYMENT_TX, makeContext(custody)) + + expect(result.source).toBe('custody') + expect(result.intentId).toBeTruthy() + }) + + it('re-throws a non-409 error from the intent POST', async () => { + const { custody } = await makeCustody({ + intentCreate: () => status(500, { message: 'boom' }), + }) + + await expect( + custody.submitAndWait(PAYMENT_TX, makeContext(custody)), + ).rejects.toThrow(SimpleXRPLError) + }) + it('throws IntentPendingError when the native intent never reaches a terminal state', async () => { jest.useFakeTimers() try { @@ -493,6 +517,18 @@ describe('RippleCustody.submitAsync', () => { expect(result.intentId).toBe(handle.id) }) + it('absorbs a 409 on the intent POST and returns a handle over the existing intent', async () => { + const { custody } = await makeCustody({ + intentCreate: () => status(409, { message: 'intent already exists' }), + intentGet: () => ok(intentBody('intent-1', 'Open')), + }) + + const handle = await custody.submitAsync(PAYMENT_TX, makeContext(custody)) + + expect(handle.kind).toBe('ripple-custody') + expect(handle.id).toBeTruthy() + }) + it('throws for the raw-signing path (async not supported there)', async () => { const { custody } = await makeCustody({}, { allowRawSigning: true }) diff --git a/test/unit/ripple-custody/transaction-polling.test.ts b/test/unit/ripple-custody/transaction-polling.test.ts index af2cd49..10d7968 100644 --- a/test/unit/ripple-custody/transaction-polling.test.ts +++ b/test/unit/ripple-custody/transaction-polling.test.ts @@ -1,4 +1,5 @@ import { pollTransactionOnChain } from '../../../src/custodians/ripple/submission/transaction-polling.js' +import { IntentValidationError } from '../../../src/errors.js' import { DOMAIN_ID, makeClient, ok } from './test-utils.js' @@ -141,7 +142,7 @@ describe('pollTransactionOnChain', () => { const { client } = makeClient(() => ok( txCollection({ - ledgerStatus: 'Pending', + ledgerStatus: 'Detected', ledgerTransactionId: '', ledgerData: null, }), @@ -159,4 +160,43 @@ describe('pollTransactionOnChain', () => { expect(result).toBeUndefined() }) + + it.each(['Expired', 'Replaced'])( + 'throws IntentValidationError at once when the transaction is %s (terminal)', + async (ledgerStatus) => { + const { client, http } = makeClient(() => + ok( + txCollection({ + ledgerStatus, + ledgerTransactionId: '', + ledgerData: null, + }), + ), + ) + + // A generous timeout: if it polled to the deadline this would hang, so + // reaching the throw proves the short-circuit fired on the first read. + await expect( + pollTransactionOnChain({ client, ...options, timeoutMs: 60_000 }), + ).rejects.toThrow(IntentValidationError) + expect(http.requests).toHaveLength(1) + }, + ) + + it('throws IntentValidationError when the transaction records an on-chain failure', async () => { + const { client } = makeClient(() => + ok( + txCollection({ + ledgerStatus: 'Detected', + failure: 'FailedOnChain', + ledgerTransactionId: 'HASH6', + ledgerData: null, + }), + ), + ) + + await expect( + pollTransactionOnChain({ client, ...options, timeoutMs: 60_000 }), + ).rejects.toThrow(IntentValidationError) + }) }) diff --git a/test/unit/verticals/xrp.test.ts b/test/unit/verticals/xrp.test.ts new file mode 100644 index 0000000..575f78a --- /dev/null +++ b/test/unit/verticals/xrp.test.ts @@ -0,0 +1,209 @@ +import { OfferCreateFlags, Wallet } from 'xrpl' +import type { + OfferCancel, + OfferCreate, + SubmitResponse, + Transaction, + TxResponse, +} from 'xrpl' + +import { + IntentValidationError, + LocalSigner, + SimpleXRPL, +} from '../../../src/index.js' +import type { SimpleXRPLClient } from '../../../src/index.js' + +interface XrpFixture { + client: SimpleXRPLClient + txs: Transaction[] + address: string +} + +/** + * Build a client whose primary signer is a fresh wallet, over a ledger that + * captures every built transaction (via `autofill`). XRP operations default + * their acting account to this signer. + * + * @returns The client, captured txs, and the signer's address. + */ +async function xrpClient(): Promise { + const wallet = Wallet.generate() + const txs: Transaction[] = [] + const ledger = { + async autofill(tx: Transaction): Promise { + txs.push(tx) + return { ...tx, Sequence: 1, Fee: '12', LastLedgerSequence: 100 } + }, + submit: async (): Promise => + ({ result: {} }) as unknown as SubmitResponse, + submitAndWait: async (): Promise => + ({ result: { hash: 'HASH' } }) as unknown as TxResponse, + request: async (): Promise => ({}) as T, + } + const client = await SimpleXRPL.init({ + xrpldUrl: 'wss://x.invalid', + signers: [LocalSigner.fromSeed(wallet.seed as string)], + ledger, + }) + return { client, txs, address: wallet.classicAddress } +} + +describe('XRP.buyOffer / XRP.sellOffer / XRP.cancelOffer', () => { + it('sells XRP for an IOU: TakerGets is XRP drops and tfSell is set', async () => { + const { client, txs, address } = await xrpClient() + + const priceIssuer = Wallet.generate().classicAddress + await client.xrp.sellOffer({ + amount: '50', + orderType: 'limit', + price: { ticker: 'USD', issuer: priceIssuer, amount: '100' }, + }) + const tx = txs[0] as OfferCreate + expect(tx.TransactionType).toBe('OfferCreate') + expect(tx.Account).toBe(address) + expect(tx.TakerGets).toBe('50000000') + expect(tx.TakerPays).toEqual({ + currency: 'USD', + issuer: priceIssuer, + value: '100', + }) + expect(tx.Flags).toBe(OfferCreateFlags.tfSell) + }) + + it('buys XRP with an IOU: TakerPays is XRP drops and flags are omitted', async () => { + const { client, txs, address } = await xrpClient() + + const priceIssuer = Wallet.generate().classicAddress + await client.xrp.buyOffer({ + amount: '50', + orderType: 'limit', + price: { ticker: 'USD', issuer: priceIssuer, amount: '90' }, + }) + const tx = txs[0] as OfferCreate + expect(tx.Account).toBe(address) + expect(tx.TakerGets).toEqual({ + currency: 'USD', + issuer: priceIssuer, + value: '90', + }) + expect(tx.TakerPays).toBe('50000000') + expect(tx.Flags).toBeUndefined() + }) + + it('scopes an offer to a permissioned domain and defaults to hybrid', async () => { + const { client, txs } = await xrpClient() + + const domainID = 'A'.repeat(64) + await client.xrp.buyOffer({ + amount: '1', + orderType: 'limit', + price: { + ticker: 'USD', + issuer: Wallet.generate().classicAddress, + amount: '1', + }, + domainID, + }) + const tx = txs[0] as OfferCreate + expect(tx.DomainID).toBe(domainID) + expect(tx.Flags).toBe(OfferCreateFlags.tfHybrid) + }) + + it('sets DomainID without tfHybrid for a permissioned-only offer', async () => { + const { client, txs } = await xrpClient() + + const domainID = 'B'.repeat(64) + await client.xrp.buyOffer({ + amount: '1', + orderType: 'limit', + price: { + ticker: 'USD', + issuer: Wallet.generate().classicAddress, + amount: '1', + }, + domainID, + hybrid: false, + }) + const tx = txs[0] as OfferCreate + expect(tx.DomainID).toBe(domainID) + expect(tx.Flags).toBeUndefined() + }) + + it('maps market/fok/passive order types to their flag combinations', async () => { + const { client, txs } = await xrpClient() + const price = { + ticker: 'USD', + issuer: Wallet.generate().classicAddress, + amount: '1', + } + + await client.xrp.buyOffer({ amount: '1', orderType: 'market', price }) + expect((txs[0] as OfferCreate).Flags).toBe( + OfferCreateFlags.tfImmediateOrCancel, + ) + + await client.xrp.buyOffer({ amount: '1', orderType: 'fok', price }) + expect((txs[1] as OfferCreate).Flags).toBe(OfferCreateFlags.tfFillOrKill) + + await client.xrp.sellOffer({ amount: '1', orderType: 'passive', price }) + expect((txs[2] as OfferCreate).Flags).toBe( + OfferCreateFlags.tfSell | OfferCreateFlags.tfPassive, + ) + }) + + it('rejects an MPT-denominated price', async () => { + const { client } = await xrpClient() + + await expect( + client.xrp.sellOffer({ + amount: '1', + orderType: 'limit', + price: { mptIssuanceId: 'ID', amount: '1' }, + }), + ).rejects.toBeInstanceOf(IntentValidationError) + }) + + it('rejects an XRP amount with sub-drop precision', async () => { + const { client } = await xrpClient() + + await expect( + client.xrp.sellOffer({ + amount: '0.0000001', + orderType: 'limit', + price: { + ticker: 'USD', + issuer: Wallet.generate().classicAddress, + amount: '1', + }, + }), + ).rejects.toThrow(/6 decimal places/u) + }) + + it('carries offerSequence through', async () => { + const { client, txs } = await xrpClient() + + await client.xrp.sellOffer({ + amount: '1', + orderType: 'limit', + price: { + ticker: 'USD', + issuer: Wallet.generate().classicAddress, + amount: '1', + }, + offerSequence: 3, + }) + expect((txs[0] as OfferCreate).OfferSequence).toBe(3) + }) + + it('builds an OfferCancel with the offer sequence', async () => { + const { client, txs, address } = await xrpClient() + + const result = await client.xrp.cancelOffer({ offerSequence: 7 }) + const tx = txs[0] as OfferCancel + expect(tx.TransactionType).toBe('OfferCancel') + expect(tx.Account).toBe(address) + expect(tx.OfferSequence).toBe(7) + expect(result.intent).toEqual({ offerSequence: 7 }) + }) +}) From 570d5526764afa0d98b43150e865ae95a2018c25 Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Tue, 25 Aug 2026 09:38:11 -1000 Subject: [PATCH 2/3] fix demos --- examples/02-custodian-connections.ts | 4 +- examples/05-rwa-through-ripple-custody.ts | 2 +- examples/07-place-dex-order.ts | 21 ++++------ examples/09-cross-custodian-workflow.ts | 20 ++++++--- examples/README.md | 36 ++++++++-------- .../ripple/submission/transaction-polling.ts | 10 ++++- .../transaction-polling.test.ts | 42 +++++++++++++++++++ 7 files changed, 93 insertions(+), 42 deletions(-) diff --git a/examples/02-custodian-connections.ts b/examples/02-custodian-connections.ts index c06fca0..4c73403 100644 --- a/examples/02-custodian-connections.ts +++ b/examples/02-custodian-connections.ts @@ -54,7 +54,7 @@ const palisade = await PalisadeCustody.create({ // in config rather than hard-coded. `fromEnv` reads every `RIPPLE_CUSTODY_*` // variable for exactly this reason: const rippleCustody = await RippleCustody.fromEnv({ - primary: process.env.RIPPLE_CUSTODY_PRIMARY ?? '', + primary: process.env.RIPPLE_CUSTODY_PRIMARY_ADDRESS ?? '', }) // The explicit form, if you configure it yourself rather than via the env: @@ -65,7 +65,7 @@ const rippleCustody = await RippleCustody.fromEnv({ // tokenUrl: process.env.RIPPLE_CUSTODY_AUTH_TOKEN_URL ?? '', // }, // domainId: process.env.RIPPLE_CUSTODY_DOMAIN_ID ?? '', -// primary: process.env.RIPPLE_CUSTODY_PRIMARY ?? '', +// primary: process.env.RIPPLE_CUSTODY_PRIMARY_ADDRESS ?? '', // }) // --- Local signing (self-custody; keys held in-process) ------------------- diff --git a/examples/05-rwa-through-ripple-custody.ts b/examples/05-rwa-through-ripple-custody.ts index af803a9..cffcfc9 100644 --- a/examples/05-rwa-through-ripple-custody.ts +++ b/examples/05-rwa-through-ripple-custody.ts @@ -11,7 +11,7 @@ import { RippleCustody, SimpleXRPL } from 'simplexrpl' // The Custody-held issuer account; Custody governs every write it signs. -const ISSUER_ADDRESS = process.env.RIPPLE_CUSTODY_PRIMARY ?? '' +const ISSUER_ADDRESS = process.env.RIPPLE_CUSTODY_PRIMARY_ADDRESS ?? '' // Config (gateway, token endpoint, domain, intent-author key) comes from // `RIPPLE_CUSTODY_*` environment variables via `fromEnv`. diff --git a/examples/07-place-dex-order.ts b/examples/07-place-dex-order.ts index bf278dd..fae6e99 100644 --- a/examples/07-place-dex-order.ts +++ b/examples/07-place-dex-order.ts @@ -1,11 +1,14 @@ /** * Place an order on the DEX. * - * The `iou` vertical places orders to buy or sell an issued currency; the - * `token` vertical places generic offers between any two DEX-tradeable assets - * (XRP or IOU). Order type controls how the offer is worked. + * The `iou` vertical places orders to buy or sell an issued currency, priced in + * XRP or another IOU. Order type controls how the offer is worked. + * + * MPTs (the `token` vertical) are deliberately absent here: the MPT DEX + * amendment is not yet live on-chain, so MPTs cannot be traded on the order + * book and there is no token-offer verb. All DEX offers go through `iou`. */ -import { iou, LocalSigner, SimpleXRPL, XRP_ASSET } from 'simplexrpl' +import { LocalSigner, SimpleXRPL } from 'simplexrpl' const client = await SimpleXRPL.init({ xrpldUrl: 'wss://s.altnet.rippletest.net:51233', @@ -54,14 +57,4 @@ if (mine.data.length > 0) { await client.iou.cancelOffer({ offerSequence: mine.data[0].offerSequence }) } -// --- Via the token vertical: a generic XRP/IOU offer ----------------------- -await client.token.createOffer({ - takerGets: { asset: XRP_ASSET, value: '50' }, - takerPays: { - asset: iou('USD', 'rIssuer00000000000000000000000000000'), - value: '100', - }, - flags: { immediateOrCancel: true }, -}) - await client.disconnect() diff --git a/examples/09-cross-custodian-workflow.ts b/examples/09-cross-custodian-workflow.ts index ecbffa8..cea1f76 100644 --- a/examples/09-cross-custodian-workflow.ts +++ b/examples/09-cross-custodian-workflow.ts @@ -11,7 +11,7 @@ import { PalisadeCustody, RippleCustody, SimpleXRPL } from 'simplexrpl' // approvals), the distribution/hot wallet in Palisade. One client drives both. // Config comes from the environment / your secrets manager. const custody = await RippleCustody.fromEnv({ - primary: process.env.RIPPLE_CUSTODY_PRIMARY ?? '', + primary: process.env.RIPPLE_CUSTODY_PRIMARY_ADDRESS ?? '', }) const palisade = await PalisadeCustody.create({ baseUrl: 'https://api.sandbox.palisade.co', // sandbox (TESTNET data) @@ -40,10 +40,20 @@ const client = await SimpleXRPL.init({ // The distribution/hot wallet on the Palisade connector. const hotWallet = client.resolveAccount(palisade.primary.address) -// Each operation targets a different custodian. Issue an IOU as the Custody issuer -// (the primary signer), then pay out from the Palisade hot wallet via `from` — -// the client routes each call to the connector that owns the account. -await client.iou.issue({ ticker: 'USD' }) +// Each step targets a different custodian, and the client routes each call to +// the connector that owns the acting account. Issue a USD IOU with the Custody +// account as issuer (the primary signer, default `from`) and the Palisade wallet +// as the holder that extends trust — a genuinely cross-custodian issuance, since +// `issue` sequences AccountSet (issuer) → TrustSet (holder) → Payment (issuer). +// Naming `holder` is what selects this path; omitting it would instead bootstrap +// both accounts from the local `XRPL_ISSUER_SEED` / `XRPL_HOT_WALLET_SEED` seeds. +await client.iou.issue({ + ticker: 'USD', + holder: hotWallet.address, + amount: '1000', +}) + +// Then pay out from the Palisade hot wallet via `from`. await client.xrp.transfer( { to: 'rBeneficiary00000000000000000000000', amount: '25' }, { from: hotWallet.address }, diff --git a/examples/README.md b/examples/README.md index c450c2c..1474ece 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,23 +10,23 @@ account (and, for the Palisade samples, sandbox credentials). ## SDK mechanics -| File | Shows | -| ---- | ----- | -| [01-initialization.ts](./01-initialization.ts) | Choosing a network, connectors, primary signer, and binding/registering accounts | -| [02-custodian-connections.ts](./02-custodian-connections.ts) | Constructing each connector (Local, Palisade, Ripple Custody) | -| [03-account-discovery.ts](./03-account-discovery.ts) | Listing, resolving, and re-discovering accounts across connectors | -| [04-routing-report.ts](./04-routing-report.ts) | Reporting how each transactor routes for an account (`dispatch` / `isNativePath`) | +| File | Shows | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------- | +| [01-initialization.ts](./01-initialization.ts) | Choosing a network, connectors, primary signer, and binding/registering accounts | +| [02-custodian-connections.ts](./02-custodian-connections.ts) | Constructing each connector (Local, Palisade, Ripple Custody) | +| [03-account-discovery.ts](./03-account-discovery.ts) | Listing, resolving, and re-discovering accounts across connectors | +| [04-routing-report.ts](./04-routing-report.ts) | Reporting how each transactor routes for an account (`dispatch` / `isNativePath`) | ## Operations -| File | Shows | -| ---- | ----- | -| [05-rwa-through-ripple-custody.ts](./05-rwa-through-ripple-custody.ts) | Issuing a Real-World Asset as an MPT (XLS-89 metadata) through Ripple Custody | -| [06-iou-issue-and-distribute.ts](./06-iou-issue-and-distribute.ts) | Issuing and distributing an IOU | -| [07-place-dex-order.ts](./07-place-dex-order.ts) | Placing DEX orders (IOU offers and generic token offers) | -| [08-permissioned-domain.ts](./08-permissioned-domain.ts) | Creating a permissioned domain and scoping an offer to it | -| [09-cross-custodian-workflow.ts](./09-cross-custodian-workflow.ts) | Sequencing work across two custodians — Ripple Custody + Palisade — via vertical verbs and `runMultiStep` | -| [13-palisade-api-escape-hatch.ts](./13-palisade-api-escape-hatch.ts) | Calling any Palisade operation the verticals don't model, via the typed `palisade.api.call(operationId, …)` secondary surface | +| File | Shows | +| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| [05-rwa-through-ripple-custody.ts](./05-rwa-through-ripple-custody.ts) | Issuing a Real-World Asset as an MPT (XLS-89 metadata) through Ripple Custody | +| [06-iou-issue-and-distribute.ts](./06-iou-issue-and-distribute.ts) | Issuing and distributing an IOU | +| [07-place-dex-order.ts](./07-place-dex-order.ts) | Placing DEX orders (IOU offers priced in XRP or another IOU) | +| [08-permissioned-domain.ts](./08-permissioned-domain.ts) | Creating a permissioned domain and scoping an offer to it | +| [09-cross-custodian-workflow.ts](./09-cross-custodian-workflow.ts) | Sequencing work across two custodians — Ripple Custody issuer + Palisade holder — via vertical verbs | +| [13-palisade-api-escape-hatch.ts](./13-palisade-api-escape-hatch.ts) | Calling any Palisade operation the verticals don't model, via the typed `palisade.api.call(operationId, …)` secondary surface | ## External signing (KMS / HSM) @@ -34,8 +34,8 @@ Keys held outside the process sign through the `ExternalSigner` connector. AWS KMS has a built-in adapter (`import { AwsKmsSigner } from 'simplexrpl/aws-kms'`); for an HSM you implement the same `ExternalSignerPort` seam. -| File | Shows | -| ---- | ----- | -| [10-hsm-pkcs11-signer.ts](./10-hsm-pkcs11-signer.ts) | Bring-your-own PKCS#11 HSM signer — implementing `ExternalSignerPort` (secp256k1) against your device | -| [11-kms-signer.ts](./11-kms-signer.ts) | Signing with an AWS KMS-held key via the built-in `simplexrpl/aws-kms` adapter | +| File | Shows | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| [10-hsm-pkcs11-signer.ts](./10-hsm-pkcs11-signer.ts) | Bring-your-own PKCS#11 HSM signer — implementing `ExternalSignerPort` (secp256k1) against your device | +| [11-kms-signer.ts](./11-kms-signer.ts) | Signing with an AWS KMS-held key via the built-in `simplexrpl/aws-kms` adapter | | [12-external-signer-mock.ts](./12-external-signer-mock.ts) | **Runnable**: a mock in-process signer that signs & submits a real transfer, shown for both secp256k1 and ed25519 | diff --git a/src/custodians/ripple/submission/transaction-polling.ts b/src/custodians/ripple/submission/transaction-polling.ts index f12e9ca..c6d5543 100644 --- a/src/custodians/ripple/submission/transaction-polling.ts +++ b/src/custodians/ripple/submission/transaction-polling.ts @@ -94,7 +94,10 @@ function mptIssuanceIdFromRaw( * @returns A short reason string when dead, else `undefined` (still in flight). */ function deadReason(ledgerData: LedgerTransactionData): string | undefined { - if (ledgerData.failure !== undefined) { + // `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 } if (TERMINAL_DEAD_STATUSES.has(ledgerData.ledgerStatus)) { @@ -180,7 +183,10 @@ export async function pollTransactionOnChain( if (ledgerData?.ledgerStatus === 'Confirmed') { return toOnChainResult(tx) } - const dead = ledgerData === undefined ? undefined : deadReason(ledgerData) + // `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 if (dead !== undefined) { throw new IntentValidationError( `Custody transaction for intent ${intentId} will not confirm ` + diff --git a/test/unit/ripple-custody/transaction-polling.test.ts b/test/unit/ripple-custody/transaction-polling.test.ts index 10d7968..781a440 100644 --- a/test/unit/ripple-custody/transaction-polling.test.ts +++ b/test/unit/ripple-custody/transaction-polling.test.ts @@ -161,6 +161,48 @@ describe('pollTransactionOnChain', () => { expect(result).toBeUndefined() }) + it('keeps polling when ledgerTransactionData itself is null (in flight)', async () => { + // Custody returns the transaction row with a top-level null + // `ledgerTransactionData` before the XRPL submission lands. That is not a + // dead transaction — it must not be dereferenced, and the poll should give + // up cleanly at the timeout rather than throw. + const { client } = makeClient(() => ok(txCollection(null))) + + const result = await pollTransactionOnChain({ + client, + domainId: DOMAIN_ID, + intentId: INTENT_ID, + timeoutMs: 500, + }) + + expect(result).toBeUndefined() + }) + + it('keeps polling when failure is null on an in-flight transaction', async () => { + // While the transaction is in flight Custody sends the row with an explicit + // `failure: null` (not absent). That is not an on-chain failure — it must + // not be read as terminal, so the poll gives up cleanly at the timeout. + const { client } = makeClient(() => + ok( + txCollection({ + ledgerStatus: 'Detected', + failure: null, + ledgerTransactionId: '', + ledgerData: null, + }), + ), + ) + + const result = await pollTransactionOnChain({ + client, + domainId: DOMAIN_ID, + intentId: INTENT_ID, + timeoutMs: 500, + }) + + expect(result).toBeUndefined() + }) + it.each(['Expired', 'Replaced'])( 'throws IntentValidationError at once when the transaction is %s (terminal)', async (ledgerStatus) => { From 03fbf4898731fd990c2419fd010e62f416f29afa Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Tue, 25 Aug 2026 11:21:26 -1000 Subject: [PATCH 3/3] update ripple custody iou scale and successful criteria --- src/custodians/external/external-signer.ts | 7 +- src/custodians/local/local-signer.ts | 7 +- src/custodians/on-ledger-result.ts | 134 ++++++++++++++ src/custodians/palisade/palisade-custody.ts | 22 ++- src/custodians/ripple/mapping/clawback.ts | 8 +- src/custodians/ripple/mapping/currency.ts | 11 +- src/custodians/ripple/mapping/iou-amount.ts | 43 +++++ src/custodians/ripple/mapping/payment.ts | 8 +- src/custodians/ripple/mapping/trust-set.ts | 3 +- src/custodians/ripple/ripple-custody.ts | 38 +++- .../ripple/submission/transaction-polling.ts | 21 ++- test/contract/ripple-custody.contract.test.ts | 78 +++++++- .../palisade/palisade-custody.test.ts | 45 ++++- test/unit/custody-mapping/currency.test.ts | 5 +- test/unit/custody-mapping/iou-amount.test.ts | 44 +++++ .../custody-mapping/xrpl-operations.test.ts | 11 +- test/unit/on-ledger-result.test.ts | 167 ++++++++++++++++++ test/unit/pipeline/fake-ledger.ts | 34 +++- .../ripple-custody/ripple-custody.test.ts | 81 ++++++++- .../transaction-polling.test.ts | 20 +++ 20 files changed, 740 insertions(+), 47 deletions(-) create mode 100644 src/custodians/on-ledger-result.ts create mode 100644 src/custodians/ripple/mapping/iou-amount.ts create mode 100644 test/unit/custody-mapping/iou-amount.test.ts create mode 100644 test/unit/on-ledger-result.test.ts diff --git a/src/custodians/external/external-signer.ts b/src/custodians/external/external-signer.ts index c930885..d446062 100644 --- a/src/custodians/external/external-signer.ts +++ b/src/custodians/external/external-signer.ts @@ -14,6 +14,7 @@ import type { } from '../../domain/index.js' import { XrpldSubmitError } from '../../errors.js' import { assertDryRunHonored } from '../context-guards.js' +import { engineResultOf } from '../on-ledger-result.js' import type { ExternalSignerPort } from './external-signer-port.js' import { signTransactionExternally } from './signing.js' @@ -135,11 +136,7 @@ export class ExternalSigner implements Custodian { ): Promise { const envelope = await this.sign(tx, ctx) const response = await ctx.ledger.submitAndWait(envelope.txBlob) - const { meta } = response.result - const engineResult = - meta !== undefined && typeof meta !== 'string' - ? meta.TransactionResult - : undefined + const engineResult = engineResultOf(response) if (engineResult !== undefined && engineResult !== 'tesSUCCESS') { throw new XrpldSubmitError(engineResult, response) } diff --git a/src/custodians/local/local-signer.ts b/src/custodians/local/local-signer.ts index 37980fc..ea5de95 100644 --- a/src/custodians/local/local-signer.ts +++ b/src/custodians/local/local-signer.ts @@ -18,6 +18,7 @@ import { SimpleXRPLError, } from '../../errors.js' import { assertDryRunHonored } from '../context-guards.js' +import { engineResultOf } from '../on-ledger-result.js' /** Options for {@link LocalSigner.create}. */ export interface LocalSignerCreateOptions { @@ -224,11 +225,7 @@ export class LocalSigner implements Custodian { ): Promise { const envelope = await this.sign(tx, ctx) const response = await ctx.ledger.submitAndWait(envelope.txBlob) - const { meta } = response.result - const engineResult = - meta !== undefined && typeof meta !== 'string' - ? meta.TransactionResult - : undefined + const engineResult = engineResultOf(response) if (engineResult !== undefined && engineResult !== 'tesSUCCESS') { throw new XrpldSubmitError(engineResult, response) } diff --git a/src/custodians/on-ledger-result.ts b/src/custodians/on-ledger-result.ts new file mode 100644 index 0000000..f06fc39 --- /dev/null +++ b/src/custodians/on-ledger-result.ts @@ -0,0 +1,134 @@ +import type { TxResponse } from 'xrpl' + +import { IntentPendingError, XrpldSubmitError } from '../errors.js' +import type { LedgerPort } from '../ports/index.js' + +import type { PollSchedule } from './poll-schedule.js' +import { pollDelayMs } from './poll-schedule.js' + +/** The one XRPL engine result that means the transaction achieved its intent. */ +const TES_SUCCESS = 'tesSUCCESS' + +/** + * Cadence for re-reading a transaction the custodian has already reported + * on-ledger. Responsive at first, capped low: this only absorbs the brief lag + * between the custodian's view and the transaction being queryable/validated + * here — it is not a long governance wait. + */ +const CONFIRM_SCHEDULE: PollSchedule = { initialMs: 1000, maxMs: 5000 } + +/** How long to keep re-reading before declaring the result indeterminate. */ +const CONFIRM_TIMEOUT_MS = 30_000 + +/** + * Wait for `ms` milliseconds. + * + * @param ms - How long to wait. + */ +async function sleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +/** + * Read the XRPL engine result from a transaction response, if present. + * + * A `submitAndWait` / `tx` response carries the result in `meta.TransactionResult`, + * but `meta` is absent while the transaction is unconfirmed and a bare string + * when the response is requested in binary form — neither yields a result code. + * + * @param response - The transaction response. + * @returns The engine result code (e.g. `tesSUCCESS`, `tecPATH_DRY`), or + * `undefined` when no structured metadata is available. + */ +export function engineResultOf(response: TxResponse): string | undefined { + const { meta } = response.result + return meta !== undefined && typeof meta !== 'string' + ? meta.TransactionResult + : undefined +} + +/** Inputs for {@link assertOnLedgerSuccess}. */ +export interface AssertOnLedgerSuccessOptions { + /** The shared ledger connection to read the transaction back from. */ + readonly ledger: LedgerPort + /** The XRPL transaction hash the custodian reported on-ledger. */ + readonly txHash: string + /** The custodian kind, for the pending-error the indeterminate case throws. */ + readonly custodian: 'ripple-custody' | 'palisade-custody' + /** The intent/transaction id to resume with if the result stays indeterminate. */ + readonly intentId: string +} + +/** + * Assert that an on-ledger transaction achieved its intent — that its XRPL + * engine result is `tesSUCCESS`. + * + * A custodian can report a transaction as on-ledger (it has a hash, it claimed a + * fee) while the transaction actually failed with a `tec` — included in the + * ledger but with its intended effect not applied. A hash alone is therefore not + * success. Custody's wire schema exposes no engine result at all, so this reads + * the authoritative result straight off the ledger by hash. + * + * The check is positive: it confirms the transaction is `validated` and carries + * a readable result, then gates on that result. It never treats an unreadable + * or not-yet-found transaction as success. + * + * - **tesSUCCESS** — returns. + * - **Any other engine result** (`tec*`, `tem*`, `tef*`, …) — throws + * {@link XrpldSubmitError}. The transaction is on-ledger and terminal, so a + * retry is a genuinely new attempt and needs a fresh idempotency key. + * - **Not yet queryable / validated** — re-reads on a short schedule to absorb + * propagation lag, then throws {@link IntentPendingError} (indeterminate): the + * transaction may still confirm, so a retry must re-drive the *same* key. + * + * @param options - The ledger, the transaction hash, and the resume context. + * @throws {@link XrpldSubmitError} on a non-`tesSUCCESS` engine result. + * @throws {@link IntentPendingError} if the result stays indeterminate. + */ +export async function assertOnLedgerSuccess( + options: AssertOnLedgerSuccessOptions, +): Promise { + const { ledger, txHash, custodian, intentId } = options + const deadline = Date.now() + CONFIRM_TIMEOUT_MS + + for (let attempt = 0; ; attempt += 1) { + let response: TxResponse | undefined + try { + // eslint-disable-next-line no-await-in-loop -- Sequential polling is inherent to waiting for the validated result. + response = await ledger.request({ + command: 'tx', + transaction: txHash, + }) + } catch { + // A `txnNotFound` (or a transient transport error) means the transaction + // the custodian reported on-ledger is not queryable here yet — not that it + // failed. Fall through and retry rather than misreport it. + response = undefined + } + + if (response?.result.validated) { + const engineResult = engineResultOf(response) + // Only decide once the result is actually readable; a validated response + // whose metadata has not materialized yet keeps polling. + if (engineResult === TES_SUCCESS) { + return + } + if (engineResult !== undefined) { + throw new XrpldSubmitError(engineResult, response) + } + } + + const delay = pollDelayMs(attempt, CONFIRM_SCHEDULE) + if (Date.now() + delay >= deadline) { + throw new IntentPendingError( + intentId, + custodian, + 'on-ledger transaction not yet confirmed', + ) + } + // eslint-disable-next-line no-await-in-loop -- Sequential polling is inherent to waiting for the validated result. + await sleep(delay) + } +} diff --git a/src/custodians/palisade/palisade-custody.ts b/src/custodians/palisade/palisade-custody.ts index c5ffecd..76cd9b2 100644 --- a/src/custodians/palisade/palisade-custody.ts +++ b/src/custodians/palisade/palisade-custody.ts @@ -14,6 +14,7 @@ import type { } from '../../domain/index.js' import { AccountNotFoundError, + IntentPendingError, XrpldSubmitError, SignerCapabilityError, SimpleXRPLError, @@ -21,6 +22,7 @@ import { import type { PalisadeScope } from '../../generated/palisade-routes.js' import type { components } from '../../generated/palisade.js' import { assertDryRunHonored, assertFeeHonored } from '../context-guards.js' +import { assertOnLedgerSuccess, engineResultOf } from '../on-ledger-result.js' import { PalisadeApi } from './api.js' import type { PalisadeScopedClients } from './api.js' @@ -330,6 +332,20 @@ export class PalisadeCustody implements Custodian { submitted, ctx.timeoutMs, ) + // Palisade's `CONFIRMED` means the transaction reached the ledger, not that + // it achieved its intent — a `tec` (on-ledger, fee burned) can wear it too. + // Palisade surfaces no engine result, so confirm `tesSUCCESS` off the ledger + // by hash; a `tec` throws here rather than being reported as success. A + // `CONFIRMED` transaction without a hash is indeterminate, not success. + if (final.hash === undefined) { + throw new IntentPendingError(final.id, 'palisade-custody', final.status) + } + await assertOnLedgerSuccess({ + ledger: ctx.ledger, + txHash: final.hash, + custodian: 'palisade-custody', + intentId: final.id, + }) return this.tracker.toResult(final) } @@ -380,11 +396,7 @@ export class PalisadeCustody implements Custodian { ): Promise { const envelope = await this.sign(tx, ctx) const response = await ctx.ledger.submitAndWait(envelope.txBlob) - const { meta } = response.result - const engineResult = - meta !== undefined && typeof meta !== 'string' - ? meta.TransactionResult - : undefined + const engineResult = engineResultOf(response) if (engineResult !== undefined && engineResult !== 'tesSUCCESS') { throw new XrpldSubmitError(engineResult, response) } diff --git a/src/custodians/ripple/mapping/clawback.ts b/src/custodians/ripple/mapping/clawback.ts index bc983f5..2a070cb 100644 --- a/src/custodians/ripple/mapping/clawback.ts +++ b/src/custodians/ripple/mapping/clawback.ts @@ -5,6 +5,7 @@ import type { components } from '../../../generated/custody.js' import { toClawbackCurrency } from './currency.js' import { toDestination } from './destination.js' +import { toCustodyIouAmount } from './iou-amount.js' /** * Map an xrpl.js `Clawback` to Custody's native `Clawback` operation. @@ -22,10 +23,15 @@ export function mapClawback( 'RippleCustody requires Clawback.Holder to identify the account being clawed back from.', ) } + // An MPT `value` is already an integer count of base units; only an + // issued-currency decimal needs scaling into Custody's minimum unit. return { type: 'Clawback', currency: toClawbackCurrency(tx.Amount), holder: toDestination(tx.Holder), - value: tx.Amount.value, + value: + 'mpt_issuance_id' in tx.Amount + ? tx.Amount.value + : toCustodyIouAmount(tx.Amount.value), } } diff --git a/src/custodians/ripple/mapping/currency.ts b/src/custodians/ripple/mapping/currency.ts index c8f75dd..f8908a3 100644 --- a/src/custodians/ripple/mapping/currency.ts +++ b/src/custodians/ripple/mapping/currency.ts @@ -3,6 +3,8 @@ import type { IssuedCurrencyAmount, MPTAmount } from 'xrpl' import { SignerCapabilityError } from '../../../errors.js' import type { components } from '../../../generated/custody.js' +import { toCustodyIouAmount } from './iou-amount.js' + type IouCurrency = components['schemas']['Core_XrplIouCurrency'] type ClawbackCurrency = components['schemas']['Core_XrplClawbackCurrency'] type PaymentCurrency = components['schemas']['Core_XrplPaymentCurrency'] @@ -56,7 +58,9 @@ export function toPaymentCurrency( /** * Map an xrpl.js `Amount` (drops string or issued-currency object) to * Custody's `AssetQuantity` (`OfferCreate.takerGets`/`takerPays`) — an omitted - * `currency` means native XRP. + * `currency` means native XRP. An issued-currency value is scaled to Custody's + * integer minimum unit (see {@link toCustodyIouAmount}); an XRP drops string is + * already an integer and passes through unchanged. * * @param amount - The drops string or issued-currency amount. * @returns The Custody asset quantity. @@ -67,7 +71,10 @@ export function toAssetQuantity( if (typeof amount === 'string') { return { amount } } - return { amount: amount.value, currency: toIouCurrency(amount) } + return { + amount: toCustodyIouAmount(amount.value), + currency: toIouCurrency(amount), + } } /** diff --git a/src/custodians/ripple/mapping/iou-amount.ts b/src/custodians/ripple/mapping/iou-amount.ts new file mode 100644 index 0000000..6c8b316 --- /dev/null +++ b/src/custodians/ripple/mapping/iou-amount.ts @@ -0,0 +1,43 @@ +import BigNumber from 'bignumber.js' + +import { IntentValidationError } from '../../../errors.js' + +/** + * Exponent between an XRPL issued-currency's human decimal value and Custody's + * integer wire amount. + * + * An XRPL issued-currency amount has a minimum representable magnitude of + * 10^-81 — the normalized mantissa floor (10^15) at the minimum exponent + * (10^-96). Ripple Custody's IOU amount fields (`AssetQuantity.amount`, + * `LimitAmount.value`, and the issued-currency `Payment`/`Clawback` amounts) + * are *integers* counted in that minimum unit — the issued-currency analogue of + * XRP drops — not the human decimal `value` xrpl.js carries. So a token value + * scales up by 10^81 to reach the integer Custody expects; forwarding the raw + * decimal makes Custody read it as a vanishingly small fraction of the intended + * amount. + */ +const IOU_MIN_UNIT_EXPONENT = 81 + +/** + * Convert an xrpl.js issued-currency `value` (a human decimal string) to the + * integer Ripple Custody expects for an IOU amount, by scaling it into Custody's + * 10^-81 minimum unit. + * + * @param value - The issued-currency decimal value (e.g. `'50'`, `'12.345'`). + * @returns The value scaled to Custody's minimum-unit integer, as a string. + * @throws {@link IntentValidationError} if `value` is not a finite decimal, or + * carries more precision than the 10^-81 minimum unit can represent. + */ +export function toCustodyIouAmount(value: string): string { + const scaled = new BigNumber(value).shiftedBy(IOU_MIN_UNIT_EXPONENT) + if (!scaled.isFinite()) { + throw new IntentValidationError(`Invalid IOU amount value: '${value}'`) + } + if (!scaled.isInteger()) { + throw new IntentValidationError( + `IOU value '${value}' has more precision than Ripple Custody's minimum ` + + `representable unit (10^-${IOU_MIN_UNIT_EXPONENT}) allows.`, + ) + } + return scaled.toFixed(0) +} diff --git a/src/custodians/ripple/mapping/payment.ts b/src/custodians/ripple/mapping/payment.ts index 7cff7d1..c572dcf 100644 --- a/src/custodians/ripple/mapping/payment.ts +++ b/src/custodians/ripple/mapping/payment.ts @@ -4,6 +4,7 @@ import type { components } from '../../../generated/custody.js' import { toPaymentCurrency } from './currency.js' import { toDestination } from './destination.js' +import { toCustodyIouAmount } from './iou-amount.js' import { unsupported } from './unsupported.js' /** @@ -43,10 +44,15 @@ export function mapPayment( destinationTag: tx.DestinationTag, } } + // An MPT `value` is already an integer count of base units; only an + // issued-currency decimal needs scaling into Custody's minimum unit. return { type: 'Payment', destination: toDestination(tx.Destination), - amount: amount.value, + amount: + 'mpt_issuance_id' in amount + ? amount.value + : toCustodyIouAmount(amount.value), currency: toPaymentCurrency(amount), destinationTag: tx.DestinationTag, } diff --git a/src/custodians/ripple/mapping/trust-set.ts b/src/custodians/ripple/mapping/trust-set.ts index b0eb48d..75995ad 100644 --- a/src/custodians/ripple/mapping/trust-set.ts +++ b/src/custodians/ripple/mapping/trust-set.ts @@ -5,6 +5,7 @@ import type { components } from '../../../generated/custody.js' import { toIouCurrency } from './currency.js' import { collectFlags, hasFlag } from './flags.js' +import { toCustodyIouAmount } from './iou-amount.js' import { unsupported } from './unsupported.js' type TrustSetFlag = components['schemas']['Core_Xrpl_TrustSetFlag'] @@ -71,7 +72,7 @@ export function mapTrustSet( flags: collectFlags(tx.Flags, TRUST_SET_FLAGS), limitAmount: { currency: toIouCurrency(tx.LimitAmount), - value: tx.LimitAmount.value, + value: toCustodyIouAmount(tx.LimitAmount.value), }, enableRippling, } diff --git a/src/custodians/ripple/ripple-custody.ts b/src/custodians/ripple/ripple-custody.ts index ea3452d..f8b7f3e 100644 --- a/src/custodians/ripple/ripple-custody.ts +++ b/src/custodians/ripple/ripple-custody.ts @@ -16,10 +16,12 @@ import type { import { AccountNotFoundError, CustodyApiError, + IntentPendingError, SimpleXRPLError, XrpldSubmitError, } from '../../errors.js' import type { components } from '../../generated/custody.js' +import { assertOnLedgerSuccess, engineResultOf } from '../on-ledger-result.js' import { buildRippleCustodyState, @@ -194,11 +196,7 @@ export class RippleCustody implements Custodian, IntentObserver { } const envelope = await this.signRaw(tx, ctx) const response = await ctx.ledger.submitAndWait(envelope.txBlob) - const { meta } = response.result - const engineResult = - meta !== undefined && typeof meta !== 'string' - ? meta.TransactionResult - : undefined + const engineResult = engineResultOf(response) if (engineResult !== undefined && engineResult !== 'tesSUCCESS') { throw new XrpldSubmitError(engineResult, response) } @@ -306,20 +304,44 @@ export class RippleCustody implements Custodian, IntentObserver { tx: Transaction, ctx: SubmissionContext, ): Promise { + const timeoutMs = ctx.timeoutMs ?? this.state.defaultTimeoutMs const intentId = await this.postNativeIntent(tx, ctx) + // The intent reaching `Executed` only means Custody submitted the XRPL + // transaction — a separate, on-chain layer decides whether it actually + // applied. Stopping here reported a `tec` (on-ledger, fee burned, intent + // *not* achieved) as success, so drive on to the on-chain outcome. const executed = await pollIntentUntilExecuted({ client: this.state.client, domainId: this.state.domainId, intentId, - timeoutMs: ctx.timeoutMs ?? this.state.defaultTimeoutMs, + timeoutMs, + }) + const onChain = await pollTxOnChain({ + client: this.state.client, + domainId: this.state.domainId, + intentId, + timeoutMs, + }) + // `undefined` is the indeterminate outcome: the transaction never reached a + // terminal ledger state within the budget. It may yet confirm, so surface it + // as pending rather than success — a retry must re-drive the same intent. + if (onChain === undefined) { + throw new IntentPendingError(intentId, 'ripple-custody', 'Executed') + } + // Custody exposes no raw engine result, so confirm `tesSUCCESS` straight off + // the ledger by hash. A `tec` on-ledger transaction throws here. + await assertOnLedgerSuccess({ + ledger: ctx.ledger, + txHash: onChain.txHash, + custodian: 'ripple-custody', + intentId, }) return { source: 'custody', - // Resolving the on-ledger txHash from the executed intent is a later - // refinement; the raw executed entity is exposed verbatim in the meantime. response: executed, intent: undefined, intentId, + txHash: onChain.txHash, } } diff --git a/src/custodians/ripple/submission/transaction-polling.ts b/src/custodians/ripple/submission/transaction-polling.ts index c6d5543..bf01513 100644 --- a/src/custodians/ripple/submission/transaction-polling.ts +++ b/src/custodians/ripple/submission/transaction-polling.ts @@ -17,6 +17,12 @@ type LedgerTransactionStatus = /** Poll cadence for ledger confirmation, backing off. See {@link pollDelayMs}. */ const POLL_SCHEDULE: PollSchedule = { initialMs: 5000, maxMs: 30_000 } +/** Radix for hex-encoding the sequence when reconstructing an issuance id. */ +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 @@ -75,7 +81,10 @@ function mptIssuanceIdFromRaw( } const sequence = tx.Sequence const account = tx.Account - const sequenceHex = sequence.toString(16).toUpperCase().padStart(8, '0') + const sequenceHex = sequence + .toString(HEX_RADIX) + .toUpperCase() + .padStart(SEQUENCE_HEX_WIDTH, '0') const accountIdHex = Buffer.from(decodeAccountID(account)) .toString('hex') .toUpperCase() @@ -180,9 +189,10 @@ export async function pollTransactionOnChain( if (collection.count > 0) { const tx = collection.items[0] const ledgerData = tx.ledgerTransactionData - if (ledgerData?.ledgerStatus === 'Confirmed') { - return toOnChainResult(tx) - } + // 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. @@ -194,6 +204,9 @@ export async function pollTransactionOnChain( `idempotency key.`, ) } + if (ledgerData?.ledgerStatus === 'Confirmed') { + return toOnChainResult(tx) + } } const delay = pollDelayMs(attempt, POLL_SCHEDULE) diff --git a/test/contract/ripple-custody.contract.test.ts b/test/contract/ripple-custody.contract.test.ts index ad09e5f..12bbe0d 100644 --- a/test/contract/ripple-custody.contract.test.ts +++ b/test/contract/ripple-custody.contract.test.ts @@ -7,12 +7,30 @@ import { import type { RippleCustodyState } from '../../src/custodians/ripple/construction.js' import { buildProposeIntentBody } from '../../src/custodians/ripple/mapping/envelope.js' import { runDryRun } from '../../src/custodians/ripple/submission/dry-run.js' -import { RippleCustody } from '../../src/index.js' +import { RippleCustody, SimpleXRPL } from '../../src/index.js' +import { TESTNET_FAUCET, TESTNET_WS, ensureFunded } from '../helpers/testnet.js' import { SANDBOX_PRIMARY, describeContract } from './helpers/custody-sandbox.js' const LIVE_TIMEOUT_MS = 120_000 +/** + * The three-step custody issuance (AccountSet + TrustSet + Payment), each polled + * to on-chain confirmation, runs well past the read tests' budget. Give the + * custody poll a generous per-step deadline and the Jest case room around it. + */ +const ISSUE_TIMEOUT_MS = 240_000 +const ISSUE_TEST_TIMEOUT_MS = 300_000 + +/** + * A distinctive, fractional issuance amount. It pins the on-chain magnitude + * precisely: were the pre-fix scaling still in place — forwarding the raw + * decimal as an integer count of Custody's 10^-81 minimum unit — the holder's + * balance would be ~10^-79 (or the Payment would fail on-ledger and throw), not + * this value. So a balance that matches proves the 10^81 scale end-to-end. + */ +const ISSUE_AMOUNT = '73.5' + describeContract('RippleCustody (live Custody sandbox)', () => { let custody: RippleCustody let state: RippleCustodyState @@ -85,4 +103,62 @@ describeContract('RippleCustody (live Custody sandbox)', () => { }, LIVE_TIMEOUT_MS, ) + + it( + 'issues an IOU through Custody and reads back the correct on-chain magnitude', + async () => { + // The regression guard the earlier bug slipped past: no test asserted the + // *magnitude* of a Custody-issued IOU on-chain, only that a trust line + // existed. This issues a known amount from the custody-held issuer to a + // fresh faucet-funded holder, then reads the holder's trust-line balance + // straight off the validated ledger and asserts it equals what we issued. + // + // The issuer is the sandbox primary (custody-signed); the holder is a + // fresh keypair registered as a local signer on the same client, so + // iou.issue() can sign the issuer's AccountSet/Payment through governance + // and the holder's TrustSet locally in one multi-step flow. + await ensureFunded(SANDBOX_PRIMARY) + + const issuer = await RippleCustody.fromEnv({ + primary: SANDBOX_PRIMARY, + defaultTimeoutMs: ISSUE_TIMEOUT_MS, + }) + const client = await SimpleXRPL.init({ + xrpldUrl: TESTNET_WS, + faucetUrl: TESTNET_FAUCET, + signers: [issuer], + }) + await client.connect() + try { + const holder = client.account.create() + await client.account.fund({ destination: holder.address }) + + const issued = await client.iou.issue({ + ticker: 'USD', + holder: holder.address, + amount: ISSUE_AMOUNT, + }) + // The native path now returns the real on-ledger hash, gated on + // tesSUCCESS — a tec (as the mis-scaled amount produced) would throw. + expect(issued.txHash).toMatch(/^[0-9A-F]{64}$/u) + + const retrieved = await client.iou.retrieve({ + ticker: 'USD', + issuer: SANDBOX_PRIMARY, + account: holder.address, + }) + expect(retrieved.data).toBeDefined() + // The holder's freshly-created line starts at zero, so its balance is + // exactly what was just issued — and this is the assertion that pins the + // scale: ~10^-79 (the pre-fix magnitude) would fail it decisively. + expect(Number(retrieved.data?.balance)).toBeCloseTo( + Number(ISSUE_AMOUNT), + 6, + ) + } finally { + await client.disconnect() + } + }, + ISSUE_TEST_TIMEOUT_MS, + ) }) diff --git a/test/unit/custodians/palisade/palisade-custody.test.ts b/test/unit/custodians/palisade/palisade-custody.test.ts index 859a752..1084be3 100644 --- a/test/unit/custodians/palisade/palisade-custody.test.ts +++ b/test/unit/custodians/palisade/palisade-custody.test.ts @@ -17,6 +17,7 @@ import { import type { Account, LedgerPort, + LedgerRequest, SubmissionContext, } from '../../../../src/index.js' @@ -80,7 +81,20 @@ function fakePort(handlers: { return { send, posts } } -function ledgerStub(response?: TxResponse): LedgerPort { +/** + * Overrides for how the stub answers the on-ledger `tx` lookup: `validated` + * (the validation flag, defaults to `true`) and `txResult` (the engine result, + * defaults to `tesSUCCESS`). + */ +interface OnLedgerStub { + readonly validated?: boolean + readonly txResult?: string +} + +function ledgerStub( + response?: TxResponse, + onLedger: OnLedgerStub = {}, +): LedgerPort { return { autofill: async (tx: Transaction): Promise => ({ ...tx, @@ -91,7 +105,18 @@ function ledgerStub(response?: TxResponse): LedgerPort { submit: async () => ({}) as never, submitAndWait: async () => response ?? ({ result: { hash: 'RAWHASH' } } as unknown as TxResponse), - request: async () => ({}) as T, + async request(req: LedgerRequest): Promise { + if (req.command === 'tx') { + return { + result: { + hash: 'H1', + validated: onLedger.validated ?? true, + meta: { TransactionResult: onLedger.txResult ?? 'tesSUCCESS' }, + }, + } as unknown as T + } + return {} as T + }, } } @@ -207,6 +232,22 @@ describe('PalisadeCustody.submitAndWait — native', () => { ) }) + it('throws XrpldSubmitError when a CONFIRMED transaction lands on-ledger with a tec', async () => { + // Palisade reports CONFIRMED (it reached the ledger), but the ledger's own + // engine result is a tec: on-ledger, fee burned, intent not achieved. + const port = fakePort({ + onSubmit: () => ({ id: 'tx1', status: 'CONFIRMED', hash: 'H1' }), + }) + const custody = await makeCustody(port) + const account = (await custody.listAccounts())[0] + await expect( + custody.submitAndWait( + payment, + contextFor(account, ledgerStub(undefined, { txResult: 'tecNO_LINE' })), + ), + ).rejects.toBeInstanceOf(XrpldSubmitError) + }) + it('carries the idempotency key to the transfer op as externalId', async () => { const port = fakePort({ onSubmit: () => ({ id: 'tx1', status: 'CONFIRMED', hash: 'H1' }), diff --git a/test/unit/custody-mapping/currency.test.ts b/test/unit/custody-mapping/currency.test.ts index e61fa60..1f718c3 100644 --- a/test/unit/custody-mapping/currency.test.ts +++ b/test/unit/custody-mapping/currency.test.ts @@ -7,6 +7,7 @@ import { toIouCurrency, toPaymentCurrency, } from '../../../src/custodians/ripple/mapping/currency.js' +import { toCustodyIouAmount } from '../../../src/custodians/ripple/mapping/iou-amount.js' import { SignerCapabilityError } from '../../../src/errors.js' const IOU: IssuedCurrencyAmount = { @@ -65,9 +66,9 @@ describe('toAssetQuantity', () => { expect(toAssetQuantity('1000')).toEqual({ amount: '1000' }) }) - it('maps an issued-currency amount to an asset quantity with currency', () => { + it('maps an issued-currency amount to an asset quantity, scaling the value', () => { expect(toAssetQuantity(IOU)).toEqual({ - amount: '10', + amount: toCustodyIouAmount('10'), currency: { code: 'USD', issuer: 'rIssuer', type: 'Currency' }, }) }) diff --git a/test/unit/custody-mapping/iou-amount.test.ts b/test/unit/custody-mapping/iou-amount.test.ts new file mode 100644 index 0000000..80afa1e --- /dev/null +++ b/test/unit/custody-mapping/iou-amount.test.ts @@ -0,0 +1,44 @@ +import { toCustodyIouAmount } from '../../../src/custodians/ripple/mapping/iou-amount.js' +import { IntentValidationError } from '../../../src/errors.js' + +describe('toCustodyIouAmount', () => { + it('scales a whole number into Custody 10^-81 minimum units', () => { + // 50 tokens = 50 × 10^81 minimum units = 5 × 10^82. + expect(toCustodyIouAmount('50')).toBe(`5${'0'.repeat(82)}`) + expect(toCustodyIouAmount('1000')).toBe(`1${'0'.repeat(84)}`) + }) + + it('scales a fractional value exactly, without floating-point drift', () => { + // 0.5 × 10^81 = 5 × 10^80; 12.345 × 10^81 = 12345 × 10^78. + expect(toCustodyIouAmount('0.5')).toBe(`5${'0'.repeat(80)}`) + expect(toCustodyIouAmount('12.345')).toBe(`12345${'0'.repeat(78)}`) + }) + + it('maps zero to zero', () => { + expect(toCustodyIouAmount('0')).toBe('0') + }) + + it('accepts a value exactly at the minimum unit (10^-81)', () => { + expect(toCustodyIouAmount('1e-81')).toBe('1') + }) + + it('round-trips back to the original value when divided out', () => { + for (const value of ['50', '1000', '0.5', '12.345', '1e-81']) { + const scaled = toCustodyIouAmount(value) + // scaled × 10^-81 must recover the input. + expect(Number(scaled) * 1e-81).toBeCloseTo(Number(value)) + } + }) + + it('throws for precision finer than the minimum unit', () => { + // 10^-82 cannot be an integer count of 10^-81 units. + expect(() => toCustodyIouAmount('1e-82')).toThrow(IntentValidationError) + expect(() => toCustodyIouAmount('1e-82')).toThrow(/minimum/u) + }) + + it('throws for a non-finite value', () => { + expect(() => toCustodyIouAmount('not-a-number')).toThrow( + IntentValidationError, + ) + }) +}) diff --git a/test/unit/custody-mapping/xrpl-operations.test.ts b/test/unit/custody-mapping/xrpl-operations.test.ts index 53b4585..aded22a 100644 --- a/test/unit/custody-mapping/xrpl-operations.test.ts +++ b/test/unit/custody-mapping/xrpl-operations.test.ts @@ -13,6 +13,7 @@ import type { TrustSet, } from 'xrpl' +import { toCustodyIouAmount } from '../../../src/custodians/ripple/mapping/iou-amount.js' import { NATIVE_XRPL_TRANSACTORS, txToOperation, @@ -121,7 +122,7 @@ describe('txToOperation', () => { type: 'Clawback', currency: { code: 'USD', issuer: 'rHolder', type: 'Currency' }, holder: { address: 'rHolder', type: 'Address' }, - value: '10', + value: toCustodyIouAmount('10'), }) }) @@ -361,7 +362,7 @@ describe('txToOperation', () => { flags: [], takerGets: { amount: '1000000' }, takerPays: { - amount: '10', + amount: toCustodyIouAmount('10'), currency: { code: 'USD', issuer: 'rIssuer', type: 'Currency' }, }, }) @@ -457,7 +458,7 @@ describe('txToOperation', () => { expect(txToOperation(tx)).toEqual({ type: 'Payment', destination: { address: 'rTo', type: 'Address' }, - amount: '10', + amount: toCustodyIouAmount('10'), currency: { code: 'USD', issuer: 'rIssuer', type: 'Currency' }, destinationTag: undefined, }) @@ -519,7 +520,7 @@ describe('txToOperation', () => { flags: [], limitAmount: { currency: { code: 'USD', issuer: 'rIssuer', type: 'Currency' }, - value: '100', + value: toCustodyIouAmount('100'), }, enableRippling: false, }) @@ -551,7 +552,7 @@ describe('txToOperation', () => { flags: ['tfSetFreeze', 'tfClearFreeze', 'tfSetfAuth'], limitAmount: { currency: { code: 'USD', issuer: 'rIssuer', type: 'Currency' }, - value: '100', + value: toCustodyIouAmount('100'), }, enableRippling: undefined, }) diff --git a/test/unit/on-ledger-result.test.ts b/test/unit/on-ledger-result.test.ts new file mode 100644 index 0000000..d96a42a --- /dev/null +++ b/test/unit/on-ledger-result.test.ts @@ -0,0 +1,167 @@ +import type { TxResponse } from 'xrpl' + +import { + assertOnLedgerSuccess, + engineResultOf, +} from '../../src/custodians/on-ledger-result.js' +import { IntentPendingError, XrpldSubmitError } from '../../src/errors.js' +import type { LedgerPort } from '../../src/ports/index.js' + +/** + * Shape one `tx` response `result` from a loose partial, defaulting `hash`. + * + * @param partial - Fields to set on `result` (e.g. `validated`, `meta`). + * @returns A `TxResponse` carrying those fields. + */ +function txResult(partial: Record): TxResponse { + return { result: { hash: 'H', ...partial } } as unknown as TxResponse +} + +/** + * A ledger whose `tx` lookup returns each scripted response in turn, repeating + * the last one once the script is exhausted. Only `request` is exercised here. + * + * @param responses - The `tx` responses to return, in order. + * @returns A ledger port and a counter of `request` calls made. + */ +function scriptedLedger(responses: TxResponse[]): { + ledger: LedgerPort + calls: () => number +} { + let call = 0 + const ledger: LedgerPort = { + autofill: async (tx) => tx, + submit: async () => ({}) as never, + submitAndWait: async () => ({}) as never, + async request(): Promise { + const response = responses[Math.min(call, responses.length - 1)] + call += 1 + return response as unknown as T + }, + } + return { ledger, calls: () => call } +} + +const OPTIONS = { + txHash: 'H', + custodian: 'ripple-custody' as const, + intentId: 'intent-1', +} + +describe('engineResultOf', () => { + it('reads the engine result from structured metadata', () => { + const response = txResult({ meta: { TransactionResult: 'tesSUCCESS' } }) + expect(engineResultOf(response)).toBe('tesSUCCESS') + }) + + it('returns undefined when metadata is absent', () => { + expect(engineResultOf(txResult({}))).toBeUndefined() + }) + + it('returns undefined when metadata is a bare (binary) string', () => { + const response = txResult({ meta: 'BINARY' }) + expect(engineResultOf(response)).toBeUndefined() + }) +}) + +describe('assertOnLedgerSuccess', () => { + it('resolves when the transaction is validated with tesSUCCESS', async () => { + const { ledger, calls } = scriptedLedger([ + txResult({ validated: true, meta: { TransactionResult: 'tesSUCCESS' } }), + ]) + + await expect( + assertOnLedgerSuccess({ ledger, ...OPTIONS }), + ).resolves.toBeUndefined() + // A validated result is authoritative on the first read — no polling. + expect(calls()).toBe(1) + }) + + it('throws XrpldSubmitError when the validated engine result is a tec', async () => { + const { ledger } = scriptedLedger([ + txResult({ + validated: true, + meta: { TransactionResult: 'tecUNFUNDED_PAYMENT' }, + }), + ]) + + let caught: unknown + try { + await assertOnLedgerSuccess({ ledger, ...OPTIONS }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(XrpldSubmitError) + expect((caught as XrpldSubmitError).engineResult).toBe( + 'tecUNFUNDED_PAYMENT', + ) + }) + + it('polls past an unvalidated read until the transaction is validated', async () => { + jest.useFakeTimers() + try { + const { ledger, calls } = scriptedLedger([ + // Not yet validated — must not be read as success. + txResult({ validated: false }), + txResult({ + validated: true, + meta: { TransactionResult: 'tesSUCCESS' }, + }), + ]) + + const promise = assertOnLedgerSuccess({ ledger, ...OPTIONS }) + await jest.advanceTimersByTimeAsync(2000) + + await expect(promise).resolves.toBeUndefined() + expect(calls()).toBe(2) + } finally { + jest.useRealTimers() + } + }) + + it('throws IntentPendingError when the transaction never validates', async () => { + jest.useFakeTimers() + try { + // Always in flight: no validated result ever arrives. + const { ledger } = scriptedLedger([txResult({ validated: false })]) + + const promise = assertOnLedgerSuccess({ ledger, ...OPTIONS }) + const assertion = + expect(promise).rejects.toBeInstanceOf(IntentPendingError) + await jest.advanceTimersByTimeAsync(31_000) + await assertion + } finally { + jest.useRealTimers() + } + }) + + it('keeps polling through a txnNotFound (thrown) lookup', async () => { + jest.useFakeTimers() + try { + let call = 0 + const ledger: LedgerPort = { + autofill: async (tx) => tx, + submit: async () => ({}) as never, + submitAndWait: async () => ({}) as never, + async request(): Promise { + call += 1 + if (call === 1) { + throw new Error('txnNotFound') + } + return txResult({ + validated: true, + meta: { TransactionResult: 'tesSUCCESS' }, + }) as unknown as T + }, + } + + const promise = assertOnLedgerSuccess({ ledger, ...OPTIONS }) + await jest.advanceTimersByTimeAsync(2000) + + await expect(promise).resolves.toBeUndefined() + expect(call).toBe(2) + } finally { + jest.useRealTimers() + } + }) +}) diff --git a/test/unit/pipeline/fake-ledger.ts b/test/unit/pipeline/fake-ledger.ts index 5d015bf..0ca3560 100644 --- a/test/unit/pipeline/fake-ledger.ts +++ b/test/unit/pipeline/fake-ledger.ts @@ -1,20 +1,35 @@ import type { SubmitResponse, Transaction, TxResponse } from 'xrpl' -import type { LedgerPort } from '../../../src/index.js' +import type { LedgerPort, LedgerRequest } from '../../../src/index.js' /** An in-memory {@link LedgerPort} that records submitted blobs. */ export interface FakeLedger extends LedgerPort { readonly submitted: string[] } +/** + * Per-ledger overrides for how a `tx` lookup resolves. `txResult` is the engine + * result a `tx` lookup reports for the (validated) transaction; it defaults to + * `tesSUCCESS`. Set a `tec*`/`tem*` code to exercise the on-ledger success gate. + */ +export interface FakeLedgerOptions { + readonly txResult?: string +} + /** * Build an offline ledger: `autofill` stamps network fields, `submitAndWait` - * records the blob and returns a canned response with `hash`. + * records the blob and returns a canned response with `hash`, and a `tx` + * `request` reports the transaction as validated with a `tesSUCCESS` (or the + * overridden) engine result. * * @param hash - The transaction hash the fake reports. + * @param options - Overrides for the `tx` lookup result. * @returns A fake ledger port. */ -export function fakeLedger(hash = 'FAKEHASH'): FakeLedger { +export function fakeLedger( + hash = 'FAKEHASH', + options: FakeLedgerOptions = {}, +): FakeLedger { const submitted: string[] = [] return { submitted, @@ -30,6 +45,17 @@ export function fakeLedger(hash = 'FAKEHASH'): FakeLedger { submitted.push(blob) return { result: { hash } } as unknown as TxResponse }, - request: async (): Promise => ({}) as T, + async request(req: LedgerRequest): Promise { + if (req.command === 'tx') { + return { + result: { + hash, + validated: true, + meta: { TransactionResult: options.txResult ?? 'tesSUCCESS' }, + }, + } as unknown as T + } + return {} as T + }, } } diff --git a/test/unit/ripple-custody/ripple-custody.test.ts b/test/unit/ripple-custody/ripple-custody.test.ts index 05d04fb..feadb20 100644 --- a/test/unit/ripple-custody/ripple-custody.test.ts +++ b/test/unit/ripple-custody/ripple-custody.test.ts @@ -49,10 +49,35 @@ interface Routes { account?: () => HttpResponse intentCreate?: (body: unknown) => HttpResponse intentGet?: () => HttpResponse + transactions?: () => HttpResponse manifestGet?: () => HttpResponse dryRun?: (body: unknown) => HttpResponse } +/** + * A one-item transactions collection linking a Confirmed on-ledger transaction + * to the submitted intent — the default the native submit path polls for. + * + * @param hash - The on-ledger transaction hash to report. + * @returns A `Core_TransactionsCollection`-shaped body. + */ +function confirmedTxCollection(hash = 'FAKEHASH'): Record { + return { + count: 1, + items: [ + { + id: 'tx-1', + orderReference: { id: 'intent-1', domainId: DOMAIN_ID }, + ledgerTransactionData: { + ledgerStatus: 'Confirmed', + ledgerTransactionId: hash, + ledgerData: null, + }, + }, + ], + } +} + /** One routing rule: matches a request, then produces its response. */ interface Route { readonly test: (request: HttpRequest) => boolean @@ -105,6 +130,14 @@ function buildRoutes(routes: Routes): Route[] { ((): HttpResponse => ok({ requestId: 'req-1' })) )(JSON.parse(request.body ?? '{}')), }, + { + test: (request): boolean => request.url.includes('/transactions'), + respond: (): HttpResponse => + ( + routes.transactions ?? + ((): HttpResponse => ok(confirmedTxCollection())) + )(), + }, { test: (request): boolean => request.url.includes('/intents/') && @@ -366,19 +399,65 @@ describe('RippleCustody.sign', () => { }) describe('RippleCustody.submitAndWait', () => { - it('submits a native transactor as a governed intent and polls to Executed', async () => { + it('submits a native transactor as a governed intent, confirms on-chain, and returns the on-ledger hash', async () => { const { custody, http } = await makeCustody() const result = await custody.submitAndWait(PAYMENT_TX, makeContext(custody)) expect(result.source).toBe('custody') expect(result.intentId).toBeTruthy() + // Success now carries the real on-ledger hash, not just the executed intent. + expect(result.txHash).toBe('FAKEHASH') expect( http.requests.some( (request) => request.method === 'POST' && request.url.endsWith('/v1/intents'), ), ).toBe(true) + // It drove past the governance intent to the on-chain transaction layer. + expect( + http.requests.some((request) => request.url.includes('/transactions')), + ).toBe(true) + }) + + it('throws XrpldSubmitError when a native transactor lands on-ledger with a tec', async () => { + // Custody confirms the transaction on-chain, but the ledger reports a tec: + // on-ledger, fee burned, intent not achieved — never a success. + const { custody } = await makeCustody() + const ledger = fakeLedger('FAKEHASH', { txResult: 'tecUNFUNDED_PAYMENT' }) + + await expect( + custody.submitAndWait(PAYMENT_TX, makeContext(custody, { ledger })), + ).rejects.toBeInstanceOf(XrpldSubmitError) + }) + + it('throws IntentPendingError when the on-chain transaction never confirms', async () => { + // The intent executed, but the linked transaction stays in flight: an + // indeterminate outcome, surfaced as pending rather than success. + const { custody } = await makeCustody({ + transactions: () => + ok({ + count: 1, + items: [ + { + id: 'tx-1', + orderReference: { id: 'intent-1', domainId: DOMAIN_ID }, + ledgerTransactionData: { + ledgerStatus: 'Detected', + ledgerTransactionId: '', + ledgerData: null, + }, + }, + ], + }), + }) + + await expect( + custody.submitAndWait( + PAYMENT_TX, + makeContext(custody, { timeoutMs: 500 }), + ), + ).rejects.toBeInstanceOf(IntentPendingError) }) it('throws IntentValidationError when the native intent is rejected', async () => { diff --git a/test/unit/ripple-custody/transaction-polling.test.ts b/test/unit/ripple-custody/transaction-polling.test.ts index 781a440..67a2063 100644 --- a/test/unit/ripple-custody/transaction-polling.test.ts +++ b/test/unit/ripple-custody/transaction-polling.test.ts @@ -241,4 +241,24 @@ describe('pollTransactionOnChain', () => { pollTransactionOnChain({ client, ...options, timeoutMs: 60_000 }), ).rejects.toThrow(IntentValidationError) }) + + it('treats a recorded failure as dead even when the status is Confirmed', async () => { + // Custody can mark a transaction Confirmed (it reached the ledger) while + // also recording a failure on it — a tec representation. The failure wins: + // it must not be reported as the clean success the caller asked for. + const { client } = makeClient(() => + ok( + txCollection({ + ledgerStatus: 'Confirmed', + failure: 'FailedOnChain', + ledgerTransactionId: 'HASH7', + ledgerData: null, + }), + ), + ) + + await expect( + pollTransactionOnChain({ client, ...options, timeoutMs: 60_000 }), + ).rejects.toThrow(IntentValidationError) + }) })