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
69 changes: 56 additions & 13 deletions src/custodians/ripple/construction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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<Record<string, string | undefined>>
/** Injectable transport; defaults to `FetchHttpPort`. */
Expand All @@ -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
}

Expand Down Expand Up @@ -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<RippleCustodyState> {
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({
Expand All @@ -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<RippleCustodyState> {
const { client, intentSigner } = buildAuthenticatedClient(options)
const me =
await client.get<components['schemas']['Core_MeReference']>('/v1/me')
const domain = me.domains.find((entry) => entry.id === options.domainId)
Expand All @@ -328,7 +367,6 @@ export async function buildRippleCustodyState(
`The authenticated Custody user has no access to domain '${options.domainId}'`,
)
}

return {
client,
domainId: options.domainId,
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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,
}
}
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 @@ -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'

Expand Down Expand Up @@ -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<string> {
return proposeQuarantineRelease(this.api, params)
}

/**
* Submit a native operation intent and poll it to a terminal state.
*
Expand All @@ -312,22 +330,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 @@ -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,
}),
}
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<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 { 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']
Expand Down Expand Up @@ -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<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,
})
}
Loading
Loading