diff --git a/src/custodians/ripple/construction.ts b/src/custodians/ripple/construction.ts index a39c8a2..a9fa28b 100644 --- a/src/custodians/ripple/construction.ts +++ b/src/custodians/ripple/construction.ts @@ -20,6 +20,7 @@ import { HttpCustodyAuthPort } from './transport/http-custody-auth-port.js' import type { CustodyHttpPort } from './transport/http-port.js' const DEFAULT_TIMEOUT_MS = 60_000 +const DEFAULT_QUARANTINE_POLL_TIMEOUT_MS = 60_000 /** Auth construction options. */ export interface RippleCustodyAuthOptions { @@ -65,6 +66,17 @@ export interface RippleCustodyOptions { readonly defaultDryRun?: boolean /** How long `submitAndWait` polls before throwing `IntentPendingError`. */ readonly defaultTimeoutMs?: number + /** + * After a token-movement transaction confirms, auto-propose release of any + * transfers compliance quarantined. Proposes only — release still runs the + * account's approval policy. Defaults to `false`. + */ + readonly defaultAutoReleaseQuarantine?: boolean + /** + * How long to wait for compliance to decide a transaction's transfers before + * giving up on auto-release. Only used when auto-release is enabled. + */ + readonly quarantinePollTimeoutMs?: number /** Injectable transport; defaults to `FetchHttpPort`. */ readonly http?: CustodyHttpPort } @@ -97,6 +109,17 @@ export interface RippleCustodyFromEnvOptions { readonly defaultDryRun?: boolean /** How long `submitAndWait` polls before throwing `IntentPendingError`. */ readonly defaultTimeoutMs?: number + /** + * After a token-movement transaction confirms, auto-propose release of any + * transfers compliance quarantined. Proposes only — release still runs the + * account's approval policy. Defaults to `false`. + */ + readonly defaultAutoReleaseQuarantine?: boolean + /** + * How long to wait for compliance to decide a transaction's transfers before + * giving up on auto-release. Only used when auto-release is enabled. + */ + readonly quarantinePollTimeoutMs?: number /** Environment source to scan. Defaults to `process.env`. */ readonly env?: Readonly> /** Injectable transport; defaults to `FetchHttpPort`. */ @@ -114,6 +137,8 @@ export interface RippleCustodyState { readonly defaultFee: FeeIntent | undefined readonly defaultDryRun: boolean readonly defaultTimeoutMs: number + readonly autoReleaseQuarantine: boolean + readonly quarantinePollTimeoutMs: number readonly primaryAddress: string } @@ -289,20 +314,16 @@ function requireEnv( } /** - * Authenticate with Custody and resolve the intent-author's identity for a - * new RippleCustody. Account discovery and primary validation - * happen after this, in {@link RippleCustody.create} — they need a - * constructed instance to back-reference. + * Assemble the authenticated Custody client and the intent signer from the + * auth/gateway config — the transport half of {@link buildRippleCustodyState}. * - * @param options - Gateway/auth/domain config, the primary account, and - * optional raw-signing/fee/dry-run/timeout defaults. - * @returns The assembled construction state. - * @throws {@link CustodyAuthError} if the authenticated user has no access - * to `options.domainId`. + * @param options - Gateway and auth config (and optional injected transport). + * @returns The authenticated client and the intent signer. */ -export async function buildRippleCustodyState( - options: RippleCustodyOptions, -): Promise { +function buildAuthenticatedClient(options: RippleCustodyOptions): { + client: CustodyHttpClient + intentSigner: IntentSigner +} { const http = options.http ?? new FetchHttpPort() const keypair = KeypairService.fromPrivateKey(options.auth.signingKey) const authService = new CustodyAuthService({ @@ -319,7 +340,25 @@ export async function buildRippleCustodyState( auth: authService, }) const intentSigner = new IntentSigner(keypair, options.auth.signingKey) + return { client, intentSigner } +} +/** + * Authenticate with Custody and resolve the intent-author's identity for a + * new RippleCustody. Account discovery and primary validation happen after + * this, in {@link RippleCustody.create} — they need a constructed instance to + * back-reference. + * + * @param options - Gateway/auth/domain config, the primary account, and + * optional raw-signing/fee/dry-run/timeout defaults. + * @returns The assembled construction state. + * @throws {@link CustodyAuthError} if the authenticated user has no access + * to `options.domainId`. + */ +export async function buildRippleCustodyState( + options: RippleCustodyOptions, +): Promise { + const { client, intentSigner } = buildAuthenticatedClient(options) const me = await client.get('/v1/me') const domain = me.domains.find((entry) => entry.id === options.domainId) @@ -328,7 +367,6 @@ export async function buildRippleCustodyState( `The authenticated Custody user has no access to domain '${options.domainId}'`, ) } - return { client, domainId: options.domainId, @@ -338,6 +376,9 @@ export async function buildRippleCustodyState( defaultFee: options.defaultFee, defaultDryRun: options.defaultDryRun ?? false, defaultTimeoutMs: options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, + autoReleaseQuarantine: options.defaultAutoReleaseQuarantine ?? false, + quarantinePollTimeoutMs: + options.quarantinePollTimeoutMs ?? DEFAULT_QUARANTINE_POLL_TIMEOUT_MS, primaryAddress: options.primary, } } @@ -371,6 +412,8 @@ export async function resolveFromEnvOptions( defaultFee: options.defaultFee, defaultDryRun: options.defaultDryRun, defaultTimeoutMs: options.defaultTimeoutMs, + defaultAutoReleaseQuarantine: options.defaultAutoReleaseQuarantine, + quarantinePollTimeoutMs: options.quarantinePollTimeoutMs, http: options.http, } } diff --git a/src/custodians/ripple/ripple-custody.ts b/src/custodians/ripple/ripple-custody.ts index b3270d9..076ff0b 100644 --- a/src/custodians/ripple/ripple-custody.ts +++ b/src/custodians/ripple/ripple-custody.ts @@ -20,7 +20,6 @@ import { SimpleXRPLError, XrpldSubmitError, } from '../../errors.js' -import type { components } from '../../generated/custody.js' import { assertOnLedgerSuccess, engineResultOf } from '../on-ledger-result.js' import { CustodyApi } from './api.js' @@ -37,9 +36,14 @@ import { AccountContext } from './discovery/account-context.js' import { discoverXrplAccounts } from './discovery/account-discovery.js' import { buildProposeIntentBody } from './mapping/envelope.js' import { NATIVE_XRPL_TRANSACTORS } from './mapping/xrpl-operations.js' -import { runDryRun } from './submission/dry-run.js' +import { maybeDryRun } from './submission/dry-run.js' import { createCustodyIntentHandle } from './submission/intent-handle.js' import { pollIntentUntilExecuted } from './submission/intent-polling.js' +import { + proposeQuarantineRelease, + runAutoRelease, + type ReleaseQuarantineParams, +} from './submission/quarantine-release.js' import { signRawTransaction } from './submission/raw-flow.js' import { pollTransactionOnChain as pollTxOnChain } from './submission/transaction-polling.js' @@ -299,6 +303,20 @@ export class RippleCustody implements Custodian, IntentObserver { }) } + /** + * Propose releasing quarantined transfers — the manual counterpart to + * auto-release, and the way to release for the async submission path. The + * release is a governed intent still subject to the account's approval policy. + * + * @param params - The custodied account and the transfer ids to release. + * @returns The release intent id, for tracking to execution. + */ + public async releaseQuarantinedTransfers( + params: ReleaseQuarantineParams, + ): Promise { + return proposeQuarantineRelease(this.api, params) + } + /** * Submit a native operation intent and poll it to a terminal state. * @@ -312,22 +330,18 @@ export class RippleCustody implements Custodian, IntentObserver { ): 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, - }) - const onChain = await pollTxOnChain({ + const pollArgs = { client: this.state.client, domainId: this.state.domainId, intentId, timeoutMs, - }) + } + // 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(pollArgs) + const onChain = await pollTxOnChain(pollArgs) // `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. @@ -348,6 +362,13 @@ export class RippleCustody implements Custodian, IntentObserver { intent: undefined, intentId, txHash: onChain.txHash, + quarantineReleaseIntentIds: await runAutoRelease({ + state: this.state, + api: this.api, + tx, + ctx, + transactionId: onChain.transactionId, + }), } } @@ -381,11 +402,12 @@ export class RippleCustody implements Custodian, IntentObserver { fee: ctx.fee ?? this.state.defaultFee, idempotencyKey: ctx.idempotencyKey, }) - await this.maybeDryRun( + await maybeDryRun({ + state: this.state, ctx, - body.request.payload, - body.request.customProperties, - ) + payload: body.request.payload, + customProperties: body.request.customProperties, + }) try { await this.state.client.post('/v1/intents', body) } catch (error) { @@ -421,31 +443,7 @@ export class RippleCustody implements Custodian, IntentObserver { ctx, accountId: this.requireAccountId(ctx.account), maybeDryRun: async (payload, customProperties) => - this.maybeDryRun(ctx, payload, customProperties), - }) - } - - /** - * Pre-flight an intent payload through Custody's dry-run when requested, - * per-call or via the custodian's own default. - * - * @param ctx - The submission context (carries the per-call `dryRun` override). - * @param payload - The intent payload about to be submitted. - * @param customProperties - The same summary the real intent will carry. - */ - private async maybeDryRun( - ctx: SubmissionContext, - payload: components['schemas']['Core_IntentDryRunRequest']['payload'], - customProperties: components['schemas']['Core_StringsMap'], - ): Promise { - if (!(ctx.dryRun ?? this.state.defaultDryRun)) { - return - } - await runDryRun(this.state.client, { - domainId: this.state.domainId, - authorUserId: this.state.authorUserId, - payload, - customProperties, + maybeDryRun({ state: this.state, ctx, payload, customProperties }), }) } diff --git a/src/custodians/ripple/submission/dry-run.ts b/src/custodians/ripple/submission/dry-run.ts index dfd8447..fde70f7 100644 --- a/src/custodians/ripple/submission/dry-run.ts +++ b/src/custodians/ripple/submission/dry-run.ts @@ -1,7 +1,9 @@ import { randomUUID } from 'node:crypto' +import type { SubmissionContext } from '../../../domain/index.js' import { IntentValidationError } from '../../../errors.js' import type { components } from '../../../generated/custody.js' +import type { RippleCustodyState } from '../construction.js' import type { CustodyHttpClient } from '../transport/custody-http-client.js' type DryRunRequest = components['schemas']['Core_IntentDryRunRequest'] @@ -62,3 +64,35 @@ export async function runDryRun( ) } } + +/** Inputs for {@link maybeDryRun}. */ +export interface MaybeDryRunOptions { + /** The custodian state (client, domain, author, dry-run default). */ + readonly state: RippleCustodyState + /** The submission context (carries the per-call `dryRun` override). */ + readonly ctx: SubmissionContext + /** The intent payload about to be submitted. */ + readonly payload: DryRunRequest['payload'] + /** The summary the real intent will carry. */ + readonly customProperties: components['schemas']['Core_StringsMap'] +} + +/** + * Pre-flight an intent payload through {@link runDryRun} when the submission + * asks for it (per-call `dryRun`, else the custodian default). A no-op when + * dry-run is off. + * + * @param options - The state, context, payload, and custom properties. + */ +export async function maybeDryRun(options: MaybeDryRunOptions): Promise { + const { state, ctx, payload, customProperties } = options + if (!(ctx.dryRun ?? state.defaultDryRun)) { + return + } + await runDryRun(state.client, { + domainId: state.domainId, + authorUserId: state.authorUserId, + payload, + customProperties, + }) +} diff --git a/src/custodians/ripple/submission/quarantine-release.ts b/src/custodians/ripple/submission/quarantine-release.ts new file mode 100644 index 0000000..48e2adf --- /dev/null +++ b/src/custodians/ripple/submission/quarantine-release.ts @@ -0,0 +1,283 @@ +import type { Transaction } from 'xrpl' + +import type { SubmissionContext } from '../../../domain/index.js' +import type { components } from '../../../generated/custody.js' +import { uuidV7 } from '../../../ids/index.js' +import type { PollSchedule } from '../../poll-schedule.js' +import { pollDelayMs } from '../../poll-schedule.js' +import type { CustodyApi } from '../api.js' +import type { RippleCustodyState } from '../construction.js' +import type { CustodyHttpClient } from '../transport/custody-http-client.js' + +type ApiTransfer = components['schemas']['Core_ApiTransfer'] +type TransfersCollection = components['schemas']['Core_TransfersCollection'] + +/** + * Transactors that move tokens and can therefore produce a quarantinable + * transfer. Auto-release only runs for these, so non-movement transactors (e.g. + * AccountSet, TrustSet) don't pay the detection poll. + */ +export const TOKEN_MOVEMENT_TRANSACTORS: ReadonlySet = new Set([ + 'Payment', +]) + +/** Parameters for a manual quarantine release. */ +export interface ReleaseQuarantineParams { + /** The custodied account holding the quarantined transfers. */ + readonly accountId: string + /** The transfer ids to release. */ + readonly transferIds: readonly string[] +} + +/** + * Poll cadence while waiting for compliance to decide a transaction's transfers. + * Starts responsive (a verdict is often quick) and backs off. See + * {@link pollDelayMs}. + */ +const POLL_SCHEDULE: PollSchedule = { initialMs: 2000, maxMs: 15_000 } + +/** Inputs for {@link autoReleaseQuarantined}. */ +export interface AutoReleaseOptions { + /** The authenticated Custody client (for reading transfers). */ + readonly client: CustodyHttpClient + /** The propose surface used to submit the release intents. */ + readonly api: CustodyApi + /** The Custody domain the transaction belongs to. */ + readonly domainId: string + /** The Custody transaction whose transfers to inspect. */ + readonly transactionId: string + /** How long to wait for compliance to decide before giving up. */ + readonly timeoutMs: number +} + +/** + * Wait for `ms` milliseconds. + * + * @param ms - How long to wait. + */ +async function sleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +/** + * The custodied account a quarantined transfer should be released under: + * the recipient when it's a custodied `Account`, else the first custodied + * `Account` sender. `undefined` when neither party is custodied (an external + * transfer the SDK can't release). + * + * @param transfer - The transfer to inspect. + * @returns The custodied account UUID, or `undefined`. + */ +function custodiedAccountId(transfer: ApiTransfer): string | undefined { + if (transfer.recipient?.type === 'Account') { + return transfer.recipient.accountId + } + return transfer.senders.find((sender) => sender.type === 'Account')?.accountId +} + +/** + * List every transfer Custody has recorded for `transactionId`, following + * pagination to completion. + * + * @param options - The client, domain, and transaction id. + * @returns All transfers linked to the transaction. + */ +async function listTransfersForTransaction( + options: AutoReleaseOptions, +): Promise { + const { client, domainId, transactionId } = options + const path = `/v1/domains/${domainId}/transactions/transfers` + const transfers: ApiTransfer[] = [] + let startingAfter: string | undefined + do { + // eslint-disable-next-line no-await-in-loop -- pagination is inherently sequential + const page = await client.get(path, { + transactionId, + startingAfter, + }) + transfers.push(...page.items) + // Custody returns a literal `null` for `nextStartingAfter` on the last page + // (the generated type says `string`), so coalesce null/'' to a stop — an + // `!== undefined` check alone would loop forever. + const next: string | null | undefined = page.nextStartingAfter + startingAfter = next ?? undefined + } while (startingAfter !== undefined && startingAfter !== '') + return transfers +} + +/** + * Whether compliance has ruled on every transfer: at least one transfer exists + * and all carry a resolved `quarantineStatus` (Quarantined/Released/Skipped). + * + * @param transfers - The transaction's transfers. + * @returns `true` once every transfer's status is decided. + */ +function allDecided(transfers: ApiTransfer[]): boolean { + return ( + transfers.length > 0 && + transfers.every((transfer) => transfer.quarantineStatus !== undefined) + ) +} + +/** + * Poll a transaction's transfers until compliance has decided them all, or the + * timeout elapses, then return only those it quarantined. Since quarantine is + * applied asynchronously after settlement, this is the wait that bridges the gap + * — non-movement transactions simply never surface a transfer and time out to an + * empty result. + * + * @param options - The client, domain, transaction id, and timeout. + * @returns The transfers compliance quarantined (possibly empty). + */ +async function pollQuarantinedTransfers( + options: AutoReleaseOptions, +): Promise { + const deadline = Date.now() + options.timeoutMs + for (let attempt = 0; ; attempt += 1) { + // eslint-disable-next-line no-await-in-loop -- sequential polling is inherent to the wait + const transfers = await listTransfersForTransaction(options) + const quarantined = transfers.filter( + (transfer) => transfer.quarantineStatus === 'Quarantined', + ) + if (allDecided(transfers)) { + return quarantined + } + const delay = pollDelayMs(attempt, POLL_SCHEDULE) + if (Date.now() + delay >= deadline) { + // Out of time: release whatever is already quarantined, best-effort. + return quarantined + } + // eslint-disable-next-line no-await-in-loop -- sequential polling is inherent to the wait + await sleep(delay) + } +} + +/** + * Propose a release intent per custodied account, batching that account's + * quarantined transfer ids into one intent. Each release id is generated here + * (and passed as the intent id) so it can be surfaced for the caller to track; + * transfers with no custodied party are skipped. + * + * @param api - The propose surface. + * @param transfers - The quarantined transfers to release. + * @returns The generated release intent ids. + */ +async function proposeReleases( + api: CustodyApi, + transfers: ApiTransfer[], +): Promise { + const byAccount = new Map() + for (const transfer of transfers) { + const accountId = custodiedAccountId(transfer) + if (accountId !== undefined) { + const ids = byAccount.get(accountId) ?? [] + ids.push(transfer.id) + byAccount.set(accountId, ids) + } + } + return Promise.all( + Array.from(byAccount, async ([accountId, transferIds]) => { + const intentId = uuidV7() + await api.propose( + { accountId, transferIds, type: 'v0_ReleaseQuarantinedTransfers' }, + { id: intentId }, + ) + return intentId + }), + ) +} + +/** + * Detect the transfers a confirmed transaction produced that compliance + * quarantined, and auto-propose their release (grouped by custodied account). + * Proposes only — each release still runs the account's approval policy. + * + * @param options - The client, propose surface, domain, transaction id, and + * timeout. + * @returns The proposed release intent ids (empty when nothing was quarantined). + */ +export async function autoReleaseQuarantined( + options: AutoReleaseOptions, +): Promise { + const quarantined = await pollQuarantinedTransfers(options) + if (quarantined.length === 0) { + return [] + } + return proposeReleases(options.api, quarantined) +} + +/** Inputs for {@link runAutoRelease}. */ +export interface RunAutoReleaseInput { + /** The custodian state (enable flag, timeout, domain, client). */ + readonly state: RippleCustodyState + /** The propose surface. */ + readonly api: CustodyApi + /** The submitted transaction; its type gates whether auto-release runs. */ + readonly tx: Transaction + /** The submission context (carries the per-call override). */ + readonly ctx: SubmissionContext + /** The Custody transaction id, if the transaction produced one. */ + readonly transactionId: string | undefined +} + +/** + * The `submitAndWait` hook: run auto-release for a just-confirmed transaction + * when it's enabled, moves tokens, and yielded a Custody transaction id. + * Best-effort — the transaction already confirmed on-chain, so a detection or + * propose failure is swallowed rather than surfaced as a failed submission. + * + * @param input - The custodian state, propose surface, transaction, context, + * and the Custody transaction id (if one was produced). + * @returns The proposed release intent ids, or `undefined` when it didn't run. + */ +export async function runAutoRelease( + input: RunAutoReleaseInput, +): Promise { + const { state, api, tx, ctx, transactionId } = input + const enabled = ctx.autoReleaseQuarantine ?? state.autoReleaseQuarantine + if ( + !enabled || + !TOKEN_MOVEMENT_TRANSACTORS.has(tx.TransactionType) || + transactionId === undefined + ) { + return undefined + } + try { + return await autoReleaseQuarantined({ + client: state.client, + api, + domainId: state.domainId, + transactionId, + timeoutMs: state.quarantinePollTimeoutMs, + }) + } catch { + return undefined + } +} + +/** + * Propose a release for a caller-supplied set of quarantined transfers. Backs + * the public `releaseQuarantinedTransfers` — for manual release or the async + * submission path, which the inline hook doesn't cover. + * + * @param api - The propose surface. + * @param params - The account and transfer ids to release. + * @returns The generated release intent id, for tracking. + */ +export async function proposeQuarantineRelease( + api: CustodyApi, + params: ReleaseQuarantineParams, +): Promise { + const intentId = uuidV7() + await api.propose( + { + accountId: params.accountId, + transferIds: Array.from(params.transferIds), + type: 'v0_ReleaseQuarantinedTransfers', + }, + { id: intentId }, + ) + return intentId +} diff --git a/src/custodians/ripple/submission/transaction-polling.ts b/src/custodians/ripple/submission/transaction-polling.ts index bf01513..0f96aa1 100644 --- a/src/custodians/ripple/submission/transaction-polling.ts +++ b/src/custodians/ripple/submission/transaction-polling.ts @@ -131,7 +131,11 @@ function toOnChainResult(tx: ApiTransaction): OnChainResult { const mptIssuanceId = (onLedger?.type === 'Xrpl' ? onLedger.tokenData?.issuanceId : undefined) ?? mptIssuanceIdFromRaw(ledgerData?.rawTransaction) - return { txHash, ...(mptIssuanceId !== undefined && { mptIssuanceId }) } + return { + txHash, + transactionId: tx.id, + ...(mptIssuanceId !== undefined && { mptIssuanceId }), + } } /** Inputs for {@link pollTransactionOnChain}. */ diff --git a/src/domain/model.ts b/src/domain/model.ts index 3b28c1e..6da2232 100644 --- a/src/domain/model.ts +++ b/src/domain/model.ts @@ -136,6 +136,13 @@ export interface SubmissionContext { /** How long to wait before handing control back to the caller. */ readonly timeoutMs?: number + + /** + * Per-call override for auto-releasing quarantined transfers a token movement + * produces (Ripple Custody only). Falls back to the custodian's configured + * default when omitted; a backend without the feature ignores it. + */ + readonly autoReleaseQuarantine?: boolean } /** @@ -185,6 +192,15 @@ export interface SubmissionResultFields { * retry those only once the prior attempt is known to be provably dead. */ readonly idempotencyKey?: string + + /** + * Release intents auto-proposed for transfers this transaction produced that + * compliance quarantined (Ripple Custody, when auto-release is enabled). Each + * is a governed intent still subject to the account's approval policy — the + * ids let the caller track them to execution. Absent/empty when the feature + * is off, the transactor moves no tokens, or nothing was quarantined. + */ + readonly quarantineReleaseIntentIds?: readonly string[] } /** @@ -308,6 +324,12 @@ export interface IntentObserver { export interface OnChainResult { /** The XRPL transaction hash. */ readonly txHash: string + /** + * The Custody transaction's own id (a UUID), distinct from the intent id. + * Keys transfer/compliance lookups (e.g. quarantine detection). Present for + * the Ripple Custody path; absent otherwise. + */ + readonly transactionId?: string /** Present when the transaction created an MPT issuance. */ readonly mptIssuanceId?: string } diff --git a/src/pipeline/wrap.ts b/src/pipeline/wrap.ts index b640a0a..5ba3555 100644 --- a/src/pipeline/wrap.ts +++ b/src/pipeline/wrap.ts @@ -12,7 +12,8 @@ export function withIntent( result: SubmissionResult, intent: T, ): SubmissionResult { - const { intentId, txHash, idempotencyKey } = result + const { intentId, txHash, idempotencyKey, quarantineReleaseIntentIds } = + result switch (result.source) { case 'custody': return { @@ -20,6 +21,7 @@ export function withIntent( intentId, txHash, idempotencyKey, + quarantineReleaseIntentIds, source: 'custody', response: result.response, } @@ -29,6 +31,7 @@ export function withIntent( intentId, txHash, idempotencyKey, + quarantineReleaseIntentIds, source: 'palisade', response: result.response, } @@ -39,6 +42,7 @@ export function withIntent( intentId, txHash, idempotencyKey, + quarantineReleaseIntentIds, source: 'xrpld', response: result.response, } diff --git a/test/contract/ripple-custody.contract.test.ts b/test/contract/ripple-custody.contract.test.ts index 7a91d50..3f97638 100644 --- a/test/contract/ripple-custody.contract.test.ts +++ b/test/contract/ripple-custody.contract.test.ts @@ -9,6 +9,7 @@ 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 { autoReleaseQuarantined } from '../../src/custodians/ripple/submission/quarantine-release.js' import type { Account } from '../../src/domain/index.js' import { IntentValidationError } from '../../src/errors.js' import { RippleCustody, SimpleXRPL } from '../../src/index.js' @@ -291,4 +292,25 @@ describeContract('RippleCustody (live Custody sandbox)', () => { LIVE_TIMEOUT_MS, ) }) + + describe('quarantine auto-release', () => { + it( + 'reads transfers by transaction id and proposes nothing when none are quarantined', + async () => { + // A synthetic transaction id resolves no transfers, so detection times + // out to an empty result — proving the getTransfers query wire shape and + // the poll loop against the live API, without mutating anything. + const released = await autoReleaseQuarantined({ + client: state.client, + api: custody.api, + domainId: state.domainId, + transactionId: randomUUID(), + timeoutMs: 1500, + }) + + expect(released).toEqual([]) + }, + LIVE_TIMEOUT_MS, + ) + }) }) diff --git a/test/unit/ripple-custody/quarantine-release.test.ts b/test/unit/ripple-custody/quarantine-release.test.ts new file mode 100644 index 0000000..5e2d493 --- /dev/null +++ b/test/unit/ripple-custody/quarantine-release.test.ts @@ -0,0 +1,391 @@ +import type { Payment } from 'xrpl' + +import { CustodyApi } from '../../../src/custodians/ripple/api.js' +import { CustodyAuthService } from '../../../src/custodians/ripple/auth/custody-auth.service.js' +import { IntentSigner } from '../../../src/custodians/ripple/auth/intent-signer.js' +import { KeypairService } from '../../../src/custodians/ripple/auth/keypair.service.js' +import type { RippleCustodyState } from '../../../src/custodians/ripple/construction.js' +import { + autoReleaseQuarantined, + proposeQuarantineRelease, + runAutoRelease, +} from '../../../src/custodians/ripple/submission/quarantine-release.js' +import { CustodyHttpClient } from '../../../src/custodians/ripple/transport/custody-http-client.js' +import type { HttpRequest } from '../../../src/custodians/ripple/transport/http-port.js' +import type { SubmissionContext } from '../../../src/domain/index.js' +import { + FakeAuthPort, + generateTestKey, + makeJwt, +} from '../custody-auth/test-utils.js' +import { FakeHttpPort, ok } from '../custody-discovery/test-utils.js' + +const KEY = generateTestKey('ed25519') +const GATEWAY = 'https://custody.example.com' +const DOMAIN = 'domain-1' + +/** A quarantined transfer's wire shape, trimmed to the fields the code reads. */ +interface TransferFixture { + readonly id: string + readonly quarantineStatus?: 'Quarantined' | 'Released' | 'Skipped' + readonly recipient?: { type: string; accountId?: string } + readonly senders?: ReadonlyArray<{ type: string; accountId?: string }> +} + +/** + * Build a quarantined fixture transfer whose recipient is a custodied account. + * + * @param id - The transfer id. + * @param accountId - The recipient's custodied account id. + * @returns The fixture transfer. + */ +function toAccount(id: string, accountId: string): TransferFixture { + return { + id, + quarantineStatus: 'Quarantined', + recipient: { type: 'Account', accountId }, + senders: [], + } +} + +/** + * A `CustodyApi` + client whose transport answers transfer reads with `transfers` + * and records every request. The GET returns the transfers collection; the POST + * (`propose`) returns an acknowledgement. + * + * @param transfers - The transfers every `getTransfers` read returns. + * @param nextStartingAfter - The pagination cursor to echo on the read (Custody + * returns a literal `null` on the last page). + * @returns The api, client, and recording port. + */ +function harness( + transfers: readonly TransferFixture[], + nextStartingAfter?: string | null, +): { + api: CustodyApi + client: CustodyHttpClient + http: FakeHttpPort +} { + const http = new FakeHttpPort((request: HttpRequest) => { + if (request.method === 'GET') { + return ok({ + items: transfers, + count: transfers.length, + nextStartingAfter, + }) + } + return ok({ requestId: 'req-1' }) + }) + const auth = new CustodyAuthService({ + authPort: new FakeAuthPort(makeJwt({ exp: 9_999_999_999 })), + privateKey: KEY, + }) + const client = new CustodyHttpClient({ gatewayUrl: GATEWAY, http, auth }) + const intentSigner = new IntentSigner(KeypairService.fromPrivateKey(KEY), KEY) + const api = new CustodyApi(client, { + intentSigner, + domainId: DOMAIN, + authorUserId: 'user-1', + }) + return { api, client, http } +} + +/** + * The account + transfer ids of every release proposed via a recorded POST. + * + * @param http - The recording port. + * @returns One `{ accountId, transferIds }` per proposed release. + */ +function releasePayloads( + http: FakeHttpPort, +): Array<{ accountId: string; transferIds: string[] }> { + return http.requests + .filter((request) => request.method === 'POST') + .map((request) => { + const body = JSON.parse(request.body ?? '{}') as { + request: { payload: { accountId: string; transferIds: string[] } } + } + const { accountId, transferIds } = body.request.payload + return { accountId, transferIds } + }) +} + +describe('autoReleaseQuarantined', () => { + it('proposes a release per account, batching that account’s transfers', async () => { + const { api, client, http } = harness([ + toAccount('tr1', 'acc-A'), + toAccount('tr2', 'acc-A'), + toAccount('tr3', 'acc-B'), + ]) + + const ids = await autoReleaseQuarantined({ + client, + api, + domainId: DOMAIN, + transactionId: 'tx-1', + timeoutMs: 50, + }) + + expect(ids).toHaveLength(2) + const payloads = releasePayloads(http) + expect(payloads).toContainEqual({ + accountId: 'acc-A', + transferIds: ['tr1', 'tr2'], + }) + expect(payloads).toContainEqual({ + accountId: 'acc-B', + transferIds: ['tr3'], + }) + }) + + it('stops paginating when Custody signals the last page with a null cursor', async () => { + // Custody returns a literal `null` for nextStartingAfter on the final page; + // the loop must treat that as the end rather than looping forever. + const { api, client } = harness([toAccount('tr1', 'acc-A')], null) + + const ids = await autoReleaseQuarantined({ + client, + api, + domainId: DOMAIN, + transactionId: 'tx-1', + timeoutMs: 50, + }) + + expect(ids).toHaveLength(1) + }) + + it('proposes nothing when every transfer was skipped', async () => { + const { api, client, http } = harness([ + { + id: 'tr1', + quarantineStatus: 'Skipped', + recipient: { type: 'Account', accountId: 'acc-A' }, + }, + ]) + + const ids = await autoReleaseQuarantined({ + client, + api, + domainId: DOMAIN, + transactionId: 'tx-1', + timeoutMs: 50, + }) + + expect(ids).toEqual([]) + expect(releasePayloads(http)).toHaveLength(0) + }) + + it('releases under the sender when the recipient is external', async () => { + const { api, client, http } = harness([ + { + id: 'tr1', + quarantineStatus: 'Quarantined', + recipient: { type: 'Address' }, + senders: [{ type: 'Account', accountId: 'acc-sender' }], + }, + ]) + + await autoReleaseQuarantined({ + client, + api, + domainId: DOMAIN, + transactionId: 'tx-1', + timeoutMs: 50, + }) + + expect(releasePayloads(http)).toEqual([ + { accountId: 'acc-sender', transferIds: ['tr1'] }, + ]) + }) + + it('skips transfers with no custodied party (nothing to release under)', async () => { + const { api, client, http } = harness([ + { + id: 'tr1', + quarantineStatus: 'Quarantined', + recipient: { type: 'Address' }, + senders: [{ type: 'Address' }], + }, + ]) + + const ids = await autoReleaseQuarantined({ + client, + api, + domainId: DOMAIN, + transactionId: 'tx-1', + timeoutMs: 50, + }) + + expect(ids).toEqual([]) + expect(releasePayloads(http)).toHaveLength(0) + }) + + it('on timeout, releases only what is already quarantined', async () => { + // One decided (Quarantined) + one still pending → not all decided, so the + // short timeout returns the quarantined one best-effort. + const { api, client, http } = harness([ + toAccount('tr1', 'acc-A'), + { id: 'tr2', recipient: { type: 'Account', accountId: 'acc-A' } }, + ]) + + const ids = await autoReleaseQuarantined({ + client, + api, + domainId: DOMAIN, + transactionId: 'tx-1', + timeoutMs: 1, + }) + + expect(ids).toHaveLength(1) + expect(releasePayloads(http)).toEqual([ + { accountId: 'acc-A', transferIds: ['tr1'] }, + ]) + }) +}) + +const PAYMENT: Payment = { + TransactionType: 'Payment', + Account: 'rFrom', + Destination: 'rTo', + Amount: '1', +} + +/** + * A partial state carrying only what {@link runAutoRelease} reads. + * + * @param client - The authenticated Custody client. + * @param autoReleaseQuarantine - The configured default for the feature. + * @returns The state stub. + */ +function stateFor( + client: CustodyHttpClient, + autoReleaseQuarantine: boolean, +): RippleCustodyState { + return { + client, + domainId: DOMAIN, + autoReleaseQuarantine, + quarantinePollTimeoutMs: 50, + } as RippleCustodyState +} + +const NO_OVERRIDE = {} as SubmissionContext + +describe('runAutoRelease gating', () => { + it('is a no-op when disabled', async () => { + const { api, client, http } = harness([toAccount('tr1', 'acc-A')]) + + const ids = await runAutoRelease({ + state: stateFor(client, false), + api, + tx: PAYMENT, + ctx: NO_OVERRIDE, + transactionId: 'tx-1', + }) + + expect(ids).toBeUndefined() + expect(http.requests).toHaveLength(0) + }) + + it('is a no-op for a non-token-movement transactor', async () => { + const { api, client, http } = harness([toAccount('tr1', 'acc-A')]) + + const ids = await runAutoRelease({ + state: stateFor(client, true), + api, + tx: { TransactionType: 'AccountSet', Account: 'rFrom' }, + ctx: NO_OVERRIDE, + transactionId: 'tx-1', + }) + + expect(ids).toBeUndefined() + expect(http.requests).toHaveLength(0) + }) + + it('is a no-op when there is no Custody transaction id', async () => { + const { api, client } = harness([toAccount('tr1', 'acc-A')]) + + const ids = await runAutoRelease({ + state: stateFor(client, true), + api, + tx: PAYMENT, + ctx: NO_OVERRIDE, + transactionId: undefined, + }) + + expect(ids).toBeUndefined() + }) + + it('runs when enabled, a Payment, and a transaction id is present', async () => { + const { api, client } = harness([toAccount('tr1', 'acc-A')]) + + const ids = await runAutoRelease({ + state: stateFor(client, true), + api, + tx: PAYMENT, + ctx: NO_OVERRIDE, + transactionId: 'tx-1', + }) + + expect(ids).toHaveLength(1) + }) + + it('the per-call override turns it on over a disabled default', async () => { + const { api, client } = harness([toAccount('tr1', 'acc-A')]) + + const ids = await runAutoRelease({ + state: stateFor(client, false), + api, + tx: PAYMENT, + + ctx: { autoReleaseQuarantine: true } as SubmissionContext, + transactionId: 'tx-1', + }) + + expect(ids).toHaveLength(1) + }) + + it('swallows a detection failure rather than failing the submission', async () => { + const http = new FakeHttpPort(() => ({ status: 500, body: '{}' })) + const auth = new CustodyAuthService({ + authPort: new FakeAuthPort(makeJwt({ exp: 9_999_999_999 })), + privateKey: KEY, + }) + const client = new CustodyHttpClient({ gatewayUrl: GATEWAY, http, auth }) + const intentSigner = new IntentSigner( + KeypairService.fromPrivateKey(KEY), + KEY, + ) + const api = new CustodyApi(client, { + intentSigner, + domainId: DOMAIN, + authorUserId: 'user-1', + }) + + const ids = await runAutoRelease({ + state: stateFor(client, true), + api, + tx: PAYMENT, + ctx: NO_OVERRIDE, + transactionId: 'tx-1', + }) + + expect(ids).toBeUndefined() + }) +}) + +describe('proposeQuarantineRelease', () => { + it('proposes a release for the given transfers and returns the intent id', async () => { + const { api, http } = harness([]) + + const intentId = await proposeQuarantineRelease(api, { + accountId: 'acc-A', + transferIds: ['tr1', 'tr2'], + }) + + expect(intentId).toBeTruthy() + expect(releasePayloads(http)).toEqual([ + { accountId: 'acc-A', transferIds: ['tr1', 'tr2'] }, + ]) + }) +}) diff --git a/test/unit/ripple-custody/transaction-polling.test.ts b/test/unit/ripple-custody/transaction-polling.test.ts index 67a2063..371b037 100644 --- a/test/unit/ripple-custody/transaction-polling.test.ts +++ b/test/unit/ripple-custody/transaction-polling.test.ts @@ -66,7 +66,11 @@ describe('pollTransactionOnChain', () => { const result = await pollTransactionOnChain({ client, ...options }) - expect(result).toEqual({ txHash: 'HASH1', mptIssuanceId: 'STRUCTURED' }) + expect(result).toEqual({ + txHash: 'HASH1', + transactionId: 'tx-1', + mptIssuanceId: 'STRUCTURED', + }) }) it('reconstructs the issuance id from rawTransaction when ledgerData is null', async () => { @@ -85,7 +89,11 @@ describe('pollTransactionOnChain', () => { const result = await pollTransactionOnChain({ client, ...options }) - expect(result).toEqual({ txHash: 'HASH2', mptIssuanceId: MPT_CREATE_ID }) + expect(result).toEqual({ + txHash: 'HASH2', + transactionId: 'tx-1', + mptIssuanceId: MPT_CREATE_ID, + }) }) it('prefers structured tokenData over the raw blob when both are present', async () => { @@ -119,7 +127,7 @@ describe('pollTransactionOnChain', () => { const result = await pollTransactionOnChain({ client, ...options }) - expect(result).toEqual({ txHash: 'HASH4' }) + expect(result).toEqual({ txHash: 'HASH4', transactionId: 'tx-1' }) }) it('omits the issuance id when neither structured data nor a raw blob is present', async () => { @@ -135,7 +143,7 @@ describe('pollTransactionOnChain', () => { const result = await pollTransactionOnChain({ client, ...options }) - expect(result).toEqual({ txHash: 'HASH5' }) + expect(result).toEqual({ txHash: 'HASH5', transactionId: 'tx-1' }) }) it('returns undefined when confirmation never arrives before the timeout', async () => {