Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 1 addition & 12 deletions src/custodians/on-ledger-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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'
import { pollDelayMs, sleep } from './poll-schedule.js'

/** The one XRPL engine result that means the transaction achieved its intent. */
const TES_SUCCESS = 'tesSUCCESS'
Expand All @@ -20,17 +20,6 @@ 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<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
}

/**
* Read the XRPL engine result from a transaction response, if present.
*
Expand Down
11 changes: 11 additions & 0 deletions src/custodians/poll-schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,14 @@ export function pollDelayMs(attempt: number, schedule: PollSchedule): number {
// yields maxMs, so the clamp holds without a separate guard.
return Math.min(maxMs, grown)
}

/**
* Wait for `ms` milliseconds.
*
* @param ms - How long to wait.
*/
export async function sleep(ms: number): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
}
30 changes: 30 additions & 0 deletions src/custodians/ripple/construction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { CustodyVersionOptions } from './versioning/detect.js'
import type { VersionGuard } from './versioning/version-guard.js'

const DEFAULT_TIMEOUT_MS = 60_000
const DEFAULT_QUARANTINE_POLL_TIMEOUT_MS = 60_000

/**
* Auth construction options. The Custody API user's key — which signs the
Expand Down Expand Up @@ -86,6 +87,17 @@ export interface RippleCustodyOptions extends CustodyVersionOptions {
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
/**
Expand Down Expand Up @@ -136,6 +148,17 @@ export interface RippleCustodyFromEnvOptions extends CustodyVersionOptions {
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<Record<string, string | undefined>>
/** Injectable transport; defaults to `FetchHttpPort`. */
Expand Down Expand Up @@ -166,6 +189,8 @@ export interface RippleCustodyState {
readonly defaultFee: FeeIntent | undefined
readonly defaultDryRun: boolean
readonly defaultTimeoutMs: number
readonly autoReleaseQuarantine: boolean
readonly quarantinePollTimeoutMs: number
readonly primaryAddress: string
/** Blocks calls the instance's Custody version can't serve. */
readonly versionGuard: VersionGuard
Expand Down Expand Up @@ -266,6 +291,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,
versionGuard,
}
Expand Down Expand Up @@ -312,6 +340,8 @@ export async function resolveFromEnvOptions(
defaultFee: options.defaultFee,
defaultDryRun: options.defaultDryRun,
defaultTimeoutMs: options.defaultTimeoutMs,
defaultAutoReleaseQuarantine: options.defaultAutoReleaseQuarantine,
quarantinePollTimeoutMs: options.quarantinePollTimeoutMs,
http: options.http,
debug: options.debug,
requestTimeoutMs: options.requestTimeoutMs,
Expand Down
1 change: 1 addition & 0 deletions src/custodians/ripple/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type {
CustodyDryRunTransactionOptions,
CustodyIntentOutcome,
CustodyProposeAndWaitResult,
CustodyReleaseQuarantineParams,
CustodyTransactionDryRun,
CustodyTransactionOrder,
CustodyTransactionOrderOptions,
Expand Down
10 changes: 9 additions & 1 deletion src/custodians/ripple/intent-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { IntentOutcome } from './submission/intent-polling.js'

/*
* Public types for waiting on intents and dry-running them through
* `CustodyApi`.
* `CustodyApi`, and for proposing quarantine releases through `RippleCustody`.
*/

/**
Expand Down Expand Up @@ -101,3 +101,11 @@ export type CustodyTransactionOrderOptions = ProposeEnvelopeOverrides & {
*/
readonly dryRun?: boolean
}

/** Parameters for {@link RippleCustody.releaseQuarantinedTransfers}. */
export interface CustodyReleaseQuarantineParams {
/** The custodied account holding the quarantined transfers. */
readonly accountId: string
/** The transfer ids to release. */
readonly transferIds: readonly string[]
}
84 changes: 41 additions & 43 deletions src/custodians/ripple/ripple-custody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -35,11 +34,16 @@ import type {
} from './construction.js'
import { AccountContext } from './discovery/account-context.js'
import { discoverXrplAccounts } from './discovery/account-discovery.js'
import type { CustodyReleaseQuarantineParams } from './intent-types.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,
} from './submission/quarantine-release.js'
import { signRawTransaction } from './submission/raw-flow.js'
import { pollTransactionOnChain as pollTxOnChain } from './submission/transaction-polling.js'

Expand Down Expand Up @@ -320,6 +324,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: CustodyReleaseQuarantineParams,
): Promise<string> {
return proposeQuarantineRelease(this.api, params)
}

/**
* Submit a native operation intent and poll it to a terminal state.
*
Expand All @@ -333,22 +351,18 @@ export class RippleCustody implements Custodian, IntentObserver {
): Promise<SubmissionResult> {
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.
Expand All @@ -369,6 +383,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,
}),
}
}

Expand Down Expand Up @@ -403,11 +424,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) {
Expand Down Expand Up @@ -443,31 +465,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_ProposeUserIntentPayload'],
customProperties: components['schemas']['Core_StringsMap'],
): Promise<void> {
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 }),
})
}

Expand Down
34 changes: 34 additions & 0 deletions src/custodians/ripple/submission/dry-run.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { randomUUID } from 'node:crypto'

import type { SubmissionContext } from '../../../domain/index.js'
import { DryRunFailedError } from '../../../errors.js'
import type { components } from '../../../generated/custody.js'
import type { RippleCustodyState } from '../construction.js'
import { buildEnvelopeRequest } from '../mapping/propose-envelope.js'
import type { EnvelopeRequest } from '../mapping/propose-envelope.js'
import type { CustodyHttpClient } from '../transport/custody-http-client.js'
Expand Down Expand Up @@ -116,3 +118,35 @@ export async function runDryRun(
throw new DryRunFailedError(describeDryRunFailure(response), response)
}
}

/** 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: RunDryRunOptions['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<void> {
const { state, ctx, payload, customProperties } = options
if (!(ctx.dryRun ?? state.defaultDryRun)) {
return
}
await runDryRun(state.client, {
domainId: state.domainId,
authorUserId: state.authorUserId,
payload,
customProperties,
})
}
13 changes: 1 addition & 12 deletions src/custodians/ripple/submission/intent-polling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from '../../../errors.js'
import type { components } from '../../../generated/custody.js'
import type { PollSchedule } from '../../poll-schedule.js'
import { pollDelayMs } from '../../poll-schedule.js'
import { pollDelayMs, sleep } from '../../poll-schedule.js'
import type { CustodyHttpClient } from '../transport/custody-http-client.js'

type TrustedIntent = components['schemas']['Core_TrustedIntent']
Expand Down Expand Up @@ -36,17 +36,6 @@ const TERMINAL_FAILURE_STATUSES: ReadonlySet<IntentStatus> = new Set([
'Failed',
])

/**
* Wait for `ms` milliseconds.
*
* @param ms - How long to wait.
*/
async function sleep(ms: number): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
}

/**
* Describe an intent's terminal failure for the thrown error message.
*
Expand Down
Loading
Loading