diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d48cba2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,43 @@ +## High Level Overview of Change + + + +### Context of Change + + + +### Type of Change + + + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Refactor (non-breaking change that only restructures code) +- [ ] Tests (You added tests for code that already exists, or your new feature included in this PR) +- [ ] Documentation Updates +- [ ] Release + +## Test Plan + + + + diff --git a/package.json b/package.json index 8313729..d586b21 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "scripts": { "clean": "rimraf dist coverage tsconfig.*.tsbuildinfo", "typegen": "run-s typegen:custody typegen:palisade", - "typegen:custody": "openapi-typescript openapi/custody-v1.35-openapi.json -o src/generated/custody.ts && prettier --write src/generated/custody.ts", + "typegen:custody": "openapi-typescript openapi/custody-v1.35-openapi.json -o src/generated/custody.ts && node scripts/gen-custody-routes.mjs && prettier --write src/generated/custody.ts src/generated/custody-routes.ts", "typegen:palisade": "openapi-typescript openapi/palisade-api.yaml -o src/generated/palisade.ts && node scripts/gen-palisade-routes.mjs && prettier --write src/generated/palisade.ts src/generated/palisade-routes.ts", "gen:version": "node scripts/gen-version.mjs", "prebuild": "npm run clean", diff --git a/scripts/gen-custody-routes.mjs b/scripts/gen-custody-routes.mjs new file mode 100644 index 0000000..a4c30c9 --- /dev/null +++ b/scripts/gen-custody-routes.mjs @@ -0,0 +1,70 @@ +// Generates the Ripple Custody route map from the vendored OpenAPI spec: each +// operationId → its HTTP method and path template. This is the runtime +// companion to the generated `operations` types (openapi-typescript emits +// types only), used by `CustodyApi.call(operationId, …)` to resolve the +// request. Regenerated by `npm run typegen`, so it never drifts from the spec. +// +// Custody uses a single credential for every endpoint, so — unlike Palisade — +// there is no per-tag auth scope: the route carries only method + path. +// +// Output: src/generated/custody-routes.ts + +import { readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const spec = JSON.parse( + readFileSync(join(ROOT, 'openapi/custody-v1.35-openapi.json'), 'utf8'), +) + +const METHODS = ['get', 'post', 'put', 'patch', 'delete'] +const entries = [] +let skipped = 0 +for (const [path, item] of Object.entries(spec.paths ?? {})) { + for (const method of METHODS) { + const op = item[method] + if (op === undefined) continue + // openapi-typescript keys the generated `operations` off operationId, so an + // operation without one is neither typed nor routable — skip it and report + // the count rather than emitting an unusable entry. + if (op.operationId === undefined) { + skipped += 1 + continue + } + entries.push([op.operationId, method.toUpperCase(), path]) + } +} +entries.sort((a, b) => a[0].localeCompare(b[0])) + +const body = entries + .map( + ([id, m, p]) => + ` ${JSON.stringify(id)}: { method: '${m}', path: '${p}' },`, + ) + .join('\n') + +const out = `/** + * This file was auto-generated by scripts/gen-custody-routes.mjs. + * Do not make direct changes to the file. Regenerate with \`npm run typegen\`. + * + * Each Ripple Custody operationId → its HTTP method and path template (the + * runtime companion to the \`operations\` types in ./custody.js). Consumed by + * \`CustodyApi.call\`. + */ + +export interface CustodyRoute { + readonly method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + readonly path: string +} + +export const CUSTODY_ROUTES = { +${body} +} as const satisfies Record +` + +writeFileSync(join(ROOT, 'src/generated/custody-routes.ts'), out) +console.log( + `custody-routes.ts: ${entries.length} operations` + + (skipped > 0 ? ` (${skipped} skipped — no operationId)` : ''), +) diff --git a/src/custodians/ripple/api.ts b/src/custodians/ripple/api.ts new file mode 100644 index 0000000..f0e0a28 --- /dev/null +++ b/src/custodians/ripple/api.ts @@ -0,0 +1,169 @@ +import { SimpleXRPLError } from '../../errors.js' +import { CUSTODY_ROUTES } from '../../generated/custody-routes.js' +import type { components, operations } from '../../generated/custody.js' + +import type { IntentSigner } from './auth/intent-signer.js' +import { + buildProposeEnvelope, + type ProposeEnvelopeContext, + type ProposeEnvelopeOverrides, +} from './mapping/propose-envelope.js' +import type { CustodyHttpClient } from './transport/custody-http-client.js' + +/** Every Custody operationId that has both a route and typed schema. */ +export type CustodyOperationId = keyof typeof CUSTODY_ROUTES & keyof operations + +/** A governed-intent payload to propose (any `v0_*` variant Custody accepts). */ +export type CustodyProposePayload = + components['schemas']['Core_ProposeUserIntentPayload'] +/** Per-call envelope overrides for {@link CustodyApi.propose}. */ +export type CustodyProposeOptions = ProposeEnvelopeOverrides + +/** The path parameters an operation takes (or `never` if it has none). */ +type PathParams = + operations[Op]['parameters']['path'] +/** The query parameters an operation takes (or `never`/`undefined`). */ +type QueryParams = + operations[Op]['parameters']['query'] +/** The JSON request body an operation takes (or `never` if it has none). */ +type RequestBody = operations[Op] extends { + requestBody: { content: { 'application/json': infer Body } } +} + ? Body + : never +/* eslint-disable @typescript-eslint/no-magic-numbers -- 200 indexes the OpenAPI success-response type */ +/** The JSON response body an operation returns (or `unknown` if untyped). */ +type ResponseBody = operations[Op] extends { + responses: { 200: { content: { 'application/json': infer Res } } } +} + ? Res + : unknown +/* eslint-enable @typescript-eslint/no-magic-numbers */ + +/** The typed arguments for one operation: path params, query, and/or body. */ +export interface CustodyCallArgs { + readonly path?: PathParams + readonly query?: QueryParams + readonly body?: RequestBody +} + +/** + * Fill `{name}` placeholders in a route template from the supplied path params. + * + * @param template - The route path template (e.g. `/v1/domains/{domainId}`). + * @param params - The path parameters, keyed by placeholder name. + * @returns The interpolated path. + * @throws {@link SimpleXRPLError} if a placeholder has no matching parameter. + */ +function fillPath(template: string, params?: Record): string { + return template.replace(/\{(?\w+)\}/gu, (_match, key: string) => { + const value = params?.[key] + if (value === undefined) { + throw new SimpleXRPLError( + `Missing path parameter '${key}' for Custody route ${template}`, + ) + } + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- path params are scalars (ids / r-addresses) + return encodeURIComponent(String(value)) + }) +} + +/** + * Low-level, typed access to the full Ripple Custody v1 API — a **secondary** + * surface beside the first-class verticals, for endpoints simpleXRPL doesn't + * model (domains, policies, backups, reading intents/transfers, and so on). + * + * `call(operationId, args)` resolves the route from the generated route map and + * infers the path/query/body and response types from the generated `operations` + * schema, so every endpoint is typed without a hand-written method per resource. + * + * Custody uses a single credential for every endpoint, so all calls go through + * the one authenticated client — there is no per-scope routing. + * + * Two surfaces: {@link call} is a plain HTTP passthrough for reads and + * plain-body writes; {@link propose} is the signed-intent passthrough for + * governed writes — it builds and signs the `Core_Propose` envelope with the + * intent-author key, so callers reach any governed intent (e.g. releasing + * quarantined transfers) without a dedicated vertical. + */ +export class CustodyApi { + private readonly client: CustodyHttpClient + private readonly intentSigner: IntentSigner + private readonly proposeContext: ProposeEnvelopeContext + + /** + * Construct the API surface over the authenticated client. + * + * @param client - The authenticated Custody HTTP client. + * @param propose - The signer and domain/author context {@link propose} needs + * to build and sign intent envelopes. + * @param propose.intentSigner - Signs the canonicalized intent request. + * @param propose.domainId - The Custody domain intents are proposed under. + * @param propose.authorUserId - The intent-author's Custody user id. + */ + public constructor( + client: CustodyHttpClient, + propose: ProposeEnvelopeContext & { intentSigner: IntentSigner }, + ) { + this.client = client + this.intentSigner = propose.intentSigner + this.proposeContext = { + domainId: propose.domainId, + authorUserId: propose.authorUserId, + } + } + + /** + * Call any Custody operation by its operationId. Path/query/body and the + * response are typed from the generated schema. + * + * @param operationId - The Custody operationId (autocompletes to all routes). + * @param args - Typed path params, query params, and/or JSON body. + * @returns The typed response body. + * @throws {@link SimpleXRPLError} if a required path parameter is missing. + * @throws A `CustodyApiError` if the API rejects the request (e.g. 403/404). + */ + public async call( + operationId: Op, + args?: CustodyCallArgs, + ): Promise> { + const route = CUSTODY_ROUTES[operationId] + const path = fillPath(route.path, args?.path) + return this.client.invoke>(route.method, path, { + query: args?.query, + body: args?.body, + }) + } + + /** + * Propose a governed intent: wrap `payload` in a `Core_Propose` envelope, + * sign the canonicalized request with the intent-author key, and POST it to + * `/v1/intents`. This is the signed counterpart to {@link call} — for + * governed writes simpleXRPL has no vertical for (e.g. releasing quarantined + * transfers). The intent still runs the account's approval policy; this only + * proposes it. + * + * @param payload - The governed-intent payload (any `v0_*` variant). + * @param options - Optional envelope overrides (idempotency id, expiry, + * custom properties, and so on). + * @returns The Custody `{ requestId }` acknowledging the accepted intent. + * @throws {@link CustodyAuthError} if the request cannot be canonicalized. + * @throws A `CustodyApiError` if the API rejects the intent. + */ + public async propose( + payload: CustodyProposePayload, + options?: CustodyProposeOptions, + ): Promise { + const body = buildProposeEnvelope(this.intentSigner, { + ...this.proposeContext, + payload, + overrides: options, + }) + const route = CUSTODY_ROUTES.createIntent + return this.client.invoke( + route.method, + route.path, + { body }, + ) + } +} diff --git a/src/custodians/ripple/index.ts b/src/custodians/ripple/index.ts index e0d502c..2226a0e 100644 --- a/src/custodians/ripple/index.ts +++ b/src/custodians/ripple/index.ts @@ -4,3 +4,12 @@ export type { RippleCustodyFromEnvOptions, RippleCustodyOptions, } from './ripple-custody.js' +export { CustodyApi } from './api.js' +export type { + CustodyCallArgs, + CustodyOperationId, + CustodyProposeOptions, + CustodyProposePayload, +} from './api.js' +export { CUSTODY_ROUTES } from '../../generated/custody-routes.js' +export type { CustodyRoute } from '../../generated/custody-routes.js' diff --git a/src/custodians/ripple/mapping/envelope.ts b/src/custodians/ripple/mapping/envelope.ts index d516996..f08e0d8 100644 --- a/src/custodians/ripple/mapping/envelope.ts +++ b/src/custodians/ripple/mapping/envelope.ts @@ -8,24 +8,13 @@ import type { IntentSigner } from '../auth/intent-signer.js' import { buildCustomProperties } from './custom-properties.js' import { toFeeStrategy } from './fee-strategy.js' import { toMemos } from './memos.js' +import { buildProposeEnvelope } from './propose-envelope.js' import { txToOperation } from './xrpl-operations.js' type ProposeIntentBody = components['schemas']['Core_ProposeIntentBody'] type TransactionOrderParametersXrpl = components['schemas']['Core_TransactionOrderParameters_XRPL'] -const MS_PER_SECOND = 1000 -const SECONDS_PER_MINUTE = 60 -const MINUTES_PER_HOUR = 60 -const HOURS_PER_DAY = 24 -/** - * Default intent lifetime: ~1 day, meant to be overridable per call or at - * client init. No override knob yet — that lands with a later async/governance - * refinement. - */ -const DEFAULT_EXPIRY_MS = - HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MS_PER_SECOND - /** Inputs for building one signed `v0_CreateTransactionOrder` intent envelope. */ export interface BuildEnvelopeOptions { /** The Custody domain this intent targets. */ @@ -82,23 +71,23 @@ export function buildProposeIntentBody( type: 'XRPL', } + // The payload carries its own id, which must match the envelope id — the + // caller's idempotency key resolves a retry to the same intent. Resolve it + // once here (falling back to a fresh id) so both stay in sync, since + // `buildProposeEnvelope` would otherwise generate the envelope id on its own. const intentId = options.idempotencyKey ?? uuidV7() - const request = { - author: { id: options.authorUserId, domainId: options.domainId }, - expiryAt: new Date(Date.now() + DEFAULT_EXPIRY_MS).toISOString(), - targetDomainId: options.domainId, - id: intentId, + + return buildProposeEnvelope(intentSigner, { + domainId: options.domainId, + authorUserId: options.authorUserId, payload: { id: intentId, accountId: options.accountId, ledgerId: options.ledgerId, parameters, customProperties, - type: 'v0_CreateTransactionOrder' as const, + type: 'v0_CreateTransactionOrder', }, - customProperties, - type: 'Propose' as const, - } - - return intentSigner.signEnvelope({ request }) + overrides: { id: intentId, customProperties }, + }) } diff --git a/src/custodians/ripple/mapping/propose-envelope.ts b/src/custodians/ripple/mapping/propose-envelope.ts new file mode 100644 index 0000000..75a4fb7 --- /dev/null +++ b/src/custodians/ripple/mapping/propose-envelope.ts @@ -0,0 +1,94 @@ +import type { components } from '../../../generated/custody.js' +import { uuidV7 } from '../../../ids/index.js' +import type { IntentSigner } from '../auth/intent-signer.js' + +type ProposeIntentBody = components['schemas']['Core_ProposeIntentBody'] +type ProposeUserIntentPayload = + components['schemas']['Core_ProposeUserIntentPayload'] +type UserReference = components['schemas']['Core_UserReference'] +type StringsMap = components['schemas']['Core_StringsMap'] + +const MS_PER_SECOND = 1000 +const SECONDS_PER_MINUTE = 60 +const MINUTES_PER_HOUR = 60 +const HOURS_PER_DAY = 24 +/** + * Default intent lifetime: ~1 day. Overridable per call via + * {@link ProposeEnvelopeOverrides.expiryAt}. + */ +export const DEFAULT_INTENT_EXPIRY_MS = + HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MS_PER_SECOND + +/** The context every proposed intent shares: which domain and author it's from. */ +export interface ProposeEnvelopeContext { + /** The Custody domain the intent targets and the author belongs to. */ + readonly domainId: string + /** The intent-author's Custody user id (resolved once via `GET /v1/me`). */ + readonly authorUserId: string +} + +/** The payload to propose plus the context and overrides its envelope needs. */ +export interface ProposeEnvelopeInput extends ProposeEnvelopeContext { + /** The governed-intent payload (any `v0_*` variant). */ + readonly payload: ProposeUserIntentPayload + /** Optional per-call envelope overrides. */ + readonly overrides?: ProposeEnvelopeOverrides +} + +/** Per-call overrides for the envelope Custody wraps around the payload. */ +export interface ProposeEnvelopeOverrides { + /** + * The intent's id — the caller's idempotency key. A retry with the same id + * resolves to the same intent. Falls back to a fresh {@link uuidV7}. + */ + readonly id?: string + /** ISO-8601 expiry; defaults to ~1 day out ({@link DEFAULT_INTENT_EXPIRY_MS}). */ + readonly expiryAt?: string + /** The domain the intent targets; defaults to the context domain. */ + readonly targetDomainId?: string + /** The intent author; defaults to `{ id: authorUserId, domainId }`. */ + readonly author?: UserReference + /** A human-readable description carried on the intent. */ + readonly description?: string + /** Envelope-level custom properties; defaults to `{}`. */ + readonly customProperties?: StringsMap +} + +/** + * Wrap an intent payload in a signed `Core_ProposeIntentBody`: fill the envelope + * scaffolding (author, expiry, target domain, id, custom properties) from the + * context and any overrides, then sign the canonicalized request with the + * intent-author key. The generic core shared by {@link buildProposeIntentBody} + * (native transactions) and the `CustodyApi.propose` passthrough. + * + * @param intentSigner - Signs the canonicalized request. + * @param input - The payload, the domain/author context, and any overrides. + * @returns The signed `{ request, signature }` body ready to POST to + * `/v1/intents`. + */ +export function buildProposeEnvelope( + intentSigner: IntentSigner, + input: ProposeEnvelopeInput, +): ProposeIntentBody { + const overrides = input.overrides ?? {} + const request = { + author: overrides.author ?? { + id: input.authorUserId, + domainId: input.domainId, + }, + expiryAt: + overrides.expiryAt ?? + new Date(Date.now() + DEFAULT_INTENT_EXPIRY_MS).toISOString(), + targetDomainId: overrides.targetDomainId ?? input.domainId, + id: overrides.id ?? uuidV7(), + payload: input.payload, + customProperties: overrides.customProperties ?? {}, + type: 'Propose' as const, + // Omit `description` entirely when absent rather than carry an undefined key. + ...(overrides.description === undefined + ? {} + : { description: overrides.description }), + } + + return intentSigner.signEnvelope({ request }) +} diff --git a/src/custodians/ripple/ripple-custody.ts b/src/custodians/ripple/ripple-custody.ts index 4271ef7..b3270d9 100644 --- a/src/custodians/ripple/ripple-custody.ts +++ b/src/custodians/ripple/ripple-custody.ts @@ -23,6 +23,7 @@ import { import type { components } from '../../generated/custody.js' import { assertOnLedgerSuccess, engineResultOf } from '../on-ledger-result.js' +import { CustodyApi } from './api.js' import { buildRippleCustodyState, resolveFromEnvOptions, @@ -66,10 +67,19 @@ export class RippleCustody implements Custodian, IntentObserver { /** This custodian wraps the Custody REST API. */ public readonly kind: CustodianKind = 'ripple-custody' + /** + * Low-level typed access to the full Custody v1 API, for endpoints the + * verticals don't model: `api.call` for reads and plain-body writes, and + * `api.propose` for governed intents (signed with the intent-author key). + */ + public readonly api: CustodyApi + private readonly state: RippleCustodyState private constructor(state: RippleCustodyState) { this.state = state + // `state` carries the intentSigner + domain/author the propose surface needs. + this.api = new CustodyApi(state.client, state) } /** @@ -238,12 +248,9 @@ export class RippleCustody implements Custodian, IntentObserver { } /** - * Poll the Custody transaction layer until the on-chain transaction linked to - * `intentId` is confirmed, then return its MPT issuance ID. Returns an empty - * string if the transaction is not confirmed within `timeoutMs`. - * * Poll the Custody transaction layer until the XRPL transaction linked to - * `intentId` is confirmed on-chain, then return its outcome. + * `intentId` is confirmed on-chain, then return its outcome — or `undefined` + * if it isn't confirmed within `timeoutMs`. * * @param intentId - The intent/order ID to look up. * @param timeoutMs - How long to poll (defaults to the custodian's configured diff --git a/src/custodians/ripple/transport/custody-http-client.ts b/src/custodians/ripple/transport/custody-http-client.ts index c10f001..e8e1c3f 100644 --- a/src/custodians/ripple/transport/custody-http-client.ts +++ b/src/custodians/ripple/transport/custody-http-client.ts @@ -20,9 +20,8 @@ export interface CustodyHttpClientOptions { auth: CustodyAuthService } -/** A scalar query value; `undefined` entries are dropped. */ -type QueryValue = string | number | undefined -type Query = Record +/** A query value; `undefined`/`null`/non-scalar entries are dropped. */ +type Query = Record /** * Parse a JSON response body into its OpenAPI-generated type. @@ -148,6 +147,32 @@ export class CustodyHttpClient { return parseJsonBody(response.body) } + /** + * Authenticated request for an arbitrary operation — the low-level primitive + * behind {@link CustodyApi}. Appends `query` and serializes `body` as JSON. + * + * @param method - The HTTP method. + * @param path - API path beginning with `/` (path params already filled in). + * @param options - Optional query parameters and/or JSON body. + * @param options.query - Scalar query parameters. + * @param options.body - The JSON request payload. + * @returns The parsed response body. + */ + public async invoke( + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string, + options?: { query?: Query; body?: unknown }, + ): Promise { + const body = + options?.body === undefined ? undefined : JSON.stringify(options.body) + const response = await this.send( + method, + this.buildUrl(path, options?.query), + body, + ) + return parseJsonBody(response.body) + } + /** * Send with bearer injection and a single 401 refresh-and-replay. * @@ -158,7 +183,7 @@ export class CustodyHttpClient { * @throws {@link CustodyAuthError} or {@link CustodyApiError} on failure. */ private async send( - method: 'GET' | 'POST', + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', url: string, body?: string, ): Promise { @@ -199,7 +224,9 @@ export class CustodyHttpClient { } const params = new URLSearchParams() for (const [key, value] of Object.entries(query)) { - if (value !== undefined) { + // Custody query params are scalars; skip null/undefined and non-scalars. + if (value !== undefined && value !== null && typeof value !== 'object') { + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- narrowed to primitives above params.append(key, String(value)) } } diff --git a/src/custodians/ripple/transport/http-port.ts b/src/custodians/ripple/transport/http-port.ts index 722ba0e..b2b7804 100644 --- a/src/custodians/ripple/transport/http-port.ts +++ b/src/custodians/ripple/transport/http-port.ts @@ -8,7 +8,7 @@ /** A raw HTTP request. `body` is already serialized (JSON or form-encoded). */ export interface HttpRequest { - method: 'GET' | 'POST' + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' url: string headers: Record body?: string diff --git a/src/generated/custody-routes.ts b/src/generated/custody-routes.ts new file mode 100644 index 0000000..316d5a9 --- /dev/null +++ b/src/generated/custody-routes.ts @@ -0,0 +1,303 @@ +/** + * This file was auto-generated by scripts/gen-custody-routes.mjs. + * Do not make direct changes to the file. Regenerate with `npm run typegen`. + * + * Each Ripple Custody operationId → its HTTP method and path template (the + * runtime companion to the `operations` types in ./custody.js). Consumed by + * `CustodyApi.call`. + */ + +export interface CustodyRoute { + readonly method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + readonly path: string +} + +export const CUSTODY_ROUTES = { + analysis: { + method: 'POST', + path: '/v1/domains/{domainId}/compliance/analysis', + }, + AppendPIIToTransfer: { + method: 'POST', + path: '/v1/domains/{domainId}/compliance/travel-rule/providers/{provider}/messages/{travelRuleId}/pii', + }, + approveIntent: { method: 'POST', path: '/v1/intents/approve' }, + ConfigureScreeningRulesForDomainAndProvider: { + method: 'POST', + path: '/v1/domains/{domainId}/compliance/providers/{provider}/screening-rules', + }, + ConnectProvider: { + method: 'POST', + path: '/v1/domains/{domainId}/compliance/providers', + }, + createChannel: { method: 'POST', path: '/v1/domains/{domainId}/channels' }, + CreateDomainPayload: { + method: 'POST', + path: '/v1/domains/{domainId}/compliance/domain', + }, + createIntent: { method: 'POST', path: '/v1/intents' }, + CreatePolicyPayload: { + method: 'POST', + path: '/v1/domains/{domainId}/compliance/policy', + }, + CreateRelationship: { + method: 'POST', + path: '/v1/domains/{domainId}/compliance/travel-rule/providers/{provider}/relationships', + }, + CreateTravelRuleTransfer: { + method: 'POST', + path: '/v1/domains/{domainId}/compliance/travel-rule/providers/{provider}/messages', + }, + deleteChannel: { + method: 'DELETE', + path: '/v1/domains/{domainId}/channels/{channelId}', + }, + DeleteComplianceDomain: { + method: 'DELETE', + path: '/v1/domains/{domainId}/compliance/domain', + }, + DeleteProviderConnectionAndScreeningRules: { + method: 'DELETE', + path: '/v1/domains/{domainId}/compliance/providers/{provider}', + }, + dryRunTransaction: { + method: 'POST', + path: '/v1/domains/{domainId}/transactions/dry-run', + }, + forceUpdateAccountBalances: { + method: 'POST', + path: '/v1/domains/{domainId}/accounts/{accountId}/balances/refresh', + }, + generateNewExternalAddress: { + method: 'POST', + path: '/v1/domains/{domainId}/accounts/{accountId}/addresses/{ledgerId}', + }, + generateNewExternalAddressDeprecated: { + method: 'POST', + path: '/v1/domains/{domainId}/accounts/{accountId}/addresses', + }, + getAccount: { + method: 'GET', + path: '/v1/domains/{domainId}/accounts/{accountId}', + }, + getAccountAddress: { + method: 'GET', + path: '/v1/domains/{domainId}/accounts/{accountId}/addresses/{accountAddressId}', + }, + getAccountBalances: { + method: 'GET', + path: '/v1/domains/{domainId}/accounts/{accountId}/balances', + }, + getAccountConfirmedBalance: { + method: 'GET', + path: '/v1/domains/{domainId}/accounts/{accountId}/confirmed-balance/{tickerId}', + }, + getAccounts: { method: 'GET', path: '/v1/domains/{domainId}/accounts' }, + getAddresses: { + method: 'GET', + path: '/v1/domains/{domainId}/accounts/{accountId}/addresses', + }, + getAllChannelEvents: { + method: 'GET', + path: '/v1/domains/{domainId}/channels/{channelId}/events', + }, + getAllChannels: { method: 'GET', path: '/v1/domains/{domainId}/channels' }, + getAllDomainsAddresses: { method: 'GET', path: '/v1/addresses' }, + getAllEvents: { + method: 'GET', + path: '/v1/domains/{domainId}/channels/events', + }, + GetAllProviderConnectionForDomain: { + method: 'GET', + path: '/v1/domains/{domainId}/compliance/provider-connection', + }, + GetAllProvidersForDomain: { + method: 'GET', + path: '/v1/domains/{domainId}/compliance/providers', + }, + getAllUserRequestsState: { method: 'GET', path: '/v1/me/requests' }, + getAllUserRequestsStateInDomain: { + method: 'GET', + path: '/v1/domains/{domainId}/requests', + }, + getBackup: { method: 'GET', path: '/v1/backups/{backupId}' }, + getBackups: { method: 'GET', path: '/v1/backups' }, + getBackupTrustedEntity: { + method: 'GET', + path: '/v1/backups/{backupId}/trusted-entity', + }, + getChannel: { + method: 'GET', + path: '/v1/domains/{domainId}/channels/{channelId}', + }, + getComplianceConfiguration: { + method: 'GET', + path: '/v1/domains/{domainId}/accounts/{accountId}/compliance-configuration', + }, + getDomain: { method: 'GET', path: '/v1/domains/{domainId}' }, + getDomains: { method: 'GET', path: '/v1/domains' }, + getEndpoint: { + method: 'GET', + path: '/v1/domains/{domainId}/endpoints/{endpointId}', + }, + getEndpoints: { method: 'GET', path: '/v1/domains/{domainId}/endpoints' }, + getEvent: { + method: 'GET', + path: '/v1/domains/{domainId}/channels/{channelId}/events/{eventId}', + }, + getEvents: { method: 'GET', path: '/v1/domains/{domainId}/events' }, + GetExceptionRole: { + method: 'GET', + path: '/v1/domains/{domainId}/compliance/transactionType/{transactionType}/exception-role', + }, + getIntent: { + method: 'GET', + path: '/v1/domains/{domainId}/intents/{intentId}', + }, + getIntents: { method: 'GET', path: '/v1/domains/{domainId}/intents' }, + getKnownUserRoles: { + method: 'GET', + path: '/v1/domains/{domainId}/users/roles', + }, + getLatestAddress: { + method: 'GET', + path: '/v1/domains/{domainId}/accounts/{accountId}/addresses/latest', + }, + getLedger: { method: 'GET', path: '/v1/ledgers/{ledgerId}' }, + getLedgerFees: { method: 'GET', path: '/v1/ledgers/{ledgerId}/fees' }, + getLedgers: { method: 'GET', path: '/v1/ledgers' }, + getManifest: { + method: 'GET', + path: '/v1/domains/{domainId}/accounts/{accountId}/manifests/{manifestId}', + }, + getManifests: { + method: 'GET', + path: '/v1/domains/{domainId}/accounts/{accountId}/manifests', + }, + getMe: { method: 'GET', path: '/v1/me' }, + getOrder: { + method: 'GET', + path: '/v1/domains/{domainId}/transactions/orders/{transactionOrderId}', + }, + getOrders: { + method: 'GET', + path: '/v1/domains/{domainId}/transactions/orders', + }, + getPolicies: { method: 'GET', path: '/v1/domains/{domainId}/policies' }, + getPolicy: { + method: 'GET', + path: '/v1/domains/{domainId}/policies/{policyId}', + }, + GetPolicy: { + method: 'GET', + path: '/v1/domains/{domainId}/compliance/policy/{policyType}/transaction/{transactionType}', + }, + getRemainingUsers: { + method: 'GET', + path: '/v1/domains/{domainId}/intents/{intentId}/remaining-users', + }, + getRequestState: { + method: 'GET', + path: '/v1/domains/{domainId}/requests/{requestId}', + }, + GetScreeningRulesForDomainAndProvider: { + method: 'GET', + path: '/v1/domains/{domainId}/compliance/providers/{provider}/screening-rules', + }, + getSystemProperties: { method: 'GET', path: '/v1/properties' }, + getTicker: { method: 'GET', path: '/v1/tickers/{tickerId}' }, + getTickers: { method: 'GET', path: '/v1/tickers' }, + getTransaction: { + method: 'GET', + path: '/v1/domains/{domainId}/transactions/{transactionId}', + }, + getTransactions: { + method: 'GET', + path: '/v1/domains/{domainId}/transactions', + }, + getTransfer: { + method: 'GET', + path: '/v1/domains/{domainId}/transactions/transfers/{transferId}', + }, + getTransfers: { + method: 'GET', + path: '/v1/domains/{domainId}/transactions/transfers', + }, + GetTravelRuleDetails: { + method: 'POST', + path: '/v1/domains/{domainId}/compliance/travel-rule/details', + }, + GetTravelRuleTransfer: { + method: 'GET', + path: '/v1/domains/{domainId}/compliance/travel-rule/providers/{provider}/messages/{travelRuleId}', + }, + getTrustedLedger: { method: 'GET', path: '/v1/trusted-ledgers/{ledgerId}' }, + getTrustedLedgers: { method: 'GET', path: '/v1/trusted-ledgers' }, + getTrustedPublicKeysApi: { + method: 'GET', + path: '/v1/trusted-public-keys/api', + }, + getTrustedPublicKeysMessages: { + method: 'GET', + path: '/v1/trusted-public-keys/messages', + }, + getTrustedPublicKeysTrustedCollection: { + method: 'GET', + path: '/v1/trusted-public-keys/trusted-collection', + }, + getUser: { method: 'GET', path: '/v1/domains/{domainId}/users/{userId}' }, + getUsers: { method: 'GET', path: '/v1/domains/{domainId}/users' }, + getVault: { method: 'GET', path: '/v1/vaults/{vaultId}' }, + getVaults: { method: 'GET', path: '/v1/vaults' }, + intentDryRun: { method: 'POST', path: '/v1/intents/dry-run' }, + listComplianceConfigurations: { + method: 'GET', + path: '/v1/domains/{domainId}/compliance-configurations', + }, + ListRelationships: { + method: 'GET', + path: '/v1/domains/{domainId}/compliance/travel-rule/providers/{provider}/relationships', + }, + PauseProviderConnection: { + method: 'PUT', + path: '/v1/domains/{domainId}/compliance/providers/{provider}/pause-connection', + }, + PresentEncryptedPIIToTransfer: { + method: 'POST', + path: '/v1/domains/{domainId}/compliance/travel-rule/providers/{provider}/messages/{travelRuleId}/policies/{travelRulePolicyId}/encrypted-pii', + }, + PresentEncryptedPIIToTransferWithoutPolicy: { + method: 'POST', + path: '/v1/domains/{domainId}/compliance/travel-rule/providers/{provider}/messages/{travelRuleId}/encrypted-pii', + }, + PreviewAnalysis: { + method: 'POST', + path: '/v1/domains/{domainId}/compliance/analysis/preview', + }, + processEthereumContractCall: { + method: 'POST', + path: '/v1/ledgers/{ledgerId}/ethereum/call', + }, + rejectIntent: { method: 'POST', path: '/v1/intents/reject' }, + runGenesis: { method: 'POST', path: '/v1/genesis' }, + testChannel: { + method: 'POST', + path: '/v1/domains/{domainId}/channels/{channelId}/test', + }, + TogglePreviewScreening: { + method: 'PUT', + path: '/v1/domains/{domainId}/compliance/providers/{provider}/toggle-preview-screening', + }, + updateChannel: { + method: 'PATCH', + path: '/v1/domains/{domainId}/channels/{channelId}', + }, + upsertComplianceConfiguration: { + method: 'PUT', + path: '/v1/domains/{domainId}/accounts/{accountId}/compliance-configuration', + }, + ValidateComplianceDomainCreation: { + method: 'POST', + path: '/v1/domains/{domainId}/compliance/domain/validation', + }, +} as const satisfies Record diff --git a/test/contract/ripple-custody.contract.test.ts b/test/contract/ripple-custody.contract.test.ts index 12bbe0d..7a91d50 100644 --- a/test/contract/ripple-custody.contract.test.ts +++ b/test/contract/ripple-custody.contract.test.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto' + import type { AccountSet } from 'xrpl' import { @@ -7,6 +9,8 @@ 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 type { Account } from '../../src/domain/index.js' +import { IntentValidationError } from '../../src/errors.js' import { RippleCustody, SimpleXRPL } from '../../src/index.js' import { TESTNET_FAUCET, TESTNET_WS, ensureFunded } from '../helpers/testnet.js' @@ -14,6 +18,34 @@ import { SANDBOX_PRIMARY, describeContract } from './helpers/custody-sandbox.js' const LIVE_TIMEOUT_MS = 120_000 +/** A no-op AccountSet on the sandbox primary — the benign shape the dry-run and + * propose contract checks both reuse (it never executes without approval). */ +const PRIMARY_ACCOUNT_SET: AccountSet = { + TransactionType: 'AccountSet', + Account: SANDBOX_PRIMARY, + SetFlag: 8, +} + +/** + * Resolve the discovered sandbox primary's Custody ids for a native intent. + * + * @param accounts - The discovered accounts. + * @returns The primary's Custody account UUID and (optional) ledger id. + * @throws {@link Error} if the primary wasn't discovered or lacks a Custody id. + */ +function requirePrimary(accounts: Account[]): { + accountId: string + ledgerId?: string +} { + const primary = accounts.find( + (account) => account.address === SANDBOX_PRIMARY, + ) + if (primary === undefined || typeof primary.custodianRef !== 'string') { + throw new Error('sandbox primary account was not discovered') + } + return { accountId: primary.custodianRef, ledgerId: primary.ledgerId } +} + /** * The three-step custody issuance (AccountSet + TrustSet + Payment), each polled * to on-chain confirmation, runs well past the read tests' budget. Give the @@ -161,4 +193,102 @@ describeContract('RippleCustody (live Custody sandbox)', () => { }, ISSUE_TEST_TIMEOUT_MS, ) + + describe('api passthrough (call + propose)', () => { + it( + 'api.call resolves getMe and the response parses to the expected shape', + async () => { + // A plain GET through the generic passthrough: proves the route map and + // generated response type still match the live server. + const me = await custody.api.call('getMe') + expect(me.domains.some((domain) => domain.id === state.domainId)).toBe( + true, + ) + }, + LIVE_TIMEOUT_MS, + ) + + it( + 'api.call interpolates a path param and lists accounts', + async () => { + // Exercises live `{domainId}` interpolation + a query param, and that the + // collection response shape parses. + const accounts = await custody.api.call('getAccounts', { + path: { domainId: state.domainId }, + query: { limit: 5 }, + }) + expect(Array.isArray(accounts.items)).toBe(true) + expect(typeof accounts.count).toBe('number') + }, + LIVE_TIMEOUT_MS, + ) + + it( + 'api.propose signs an envelope the sandbox accepts', + async () => { + const { accountId, ledgerId } = requirePrimary( + await custody.listAccounts(), + ) + const intentId = randomUUID() + // Reuse the vetted create-transaction-order payload builder to get a + // valid payload, then drive it through the *generic* propose surface — + // exercising envelope-signing + the createIntent route end to end. + const envelope = buildProposeIntentBody(state.intentSigner, { + domainId: state.domainId, + authorUserId: state.authorUserId, + accountId, + ledgerId, + transaction: PRIMARY_ACCOUNT_SET, + idempotencyKey: intentId, + }) + // A `requestId` back means the sandbox verified the signature and parsed + // the envelope — the contract this guards. A shape or canonicalization + // drift surfaces as CustodyApiError/CustodyAuthError and fails the test. + // The intent sits unapproved and lapses at this short expiry. + const response = await custody.api.propose(envelope.request.payload, { + id: intentId, + expiryAt: new Date(Date.now() + 120_000).toISOString(), + }) + expect(typeof response.requestId).toBe('string') + expect(response.requestId).toBeTruthy() + }, + LIVE_TIMEOUT_MS, + ) + + it( + 'the sandbox accepts and validates a release-quarantine payload shape', + async () => { + const { accountId } = requirePrimary(await custody.listAccounts()) + // A fresh sandbox has no quarantined transfers to release, so dry-run a + // synthetic one: the goal is to prove the server accepts and *processes* + // the `v0_ReleaseQuarantinedTransfers` payload shape, not to release + // anything. It parses our transferId and reaches its existence check — + // a business-level rejection (IntentValidationError naming our id), + // rather than rejecting the request outright (CustodyApiError), which is + // what a wire-shape drift would produce. Non-mutating: dry-run creates + // no intent, and the transfer doesn't exist regardless. + const transferId = randomUUID() + let caught: unknown + try { + await runDryRun(state.client, { + domainId: state.domainId, + authorUserId: state.authorUserId, + payload: { + accountId, + transferIds: [transferId], + type: 'v0_ReleaseQuarantinedTransfers', + }, + customProperties: {}, + }) + } catch (error) { + caught = error + } + // Shape accepted + processed: a business rejection that echoes the + // transferId we sent, not an API/transport error. + expect(caught).toBeInstanceOf(IntentValidationError) + expect((caught as Error).message).toContain(transferId) + }, + LIVE_TIMEOUT_MS, + ) + }) }) diff --git a/test/unit/ripple-custody/custody-api.test.ts b/test/unit/ripple-custody/custody-api.test.ts new file mode 100644 index 0000000..d317798 --- /dev/null +++ b/test/unit/ripple-custody/custody-api.test.ts @@ -0,0 +1,183 @@ +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 { CustodyHttpClient } from '../../../src/custodians/ripple/transport/custody-http-client.js' +import type { components } from '../../../src/generated/custody.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' +const AUTHOR = 'user-1' + +/** + * Build a `CustodyApi` over a real `CustodyHttpClient` whose transport is faked. + * The auth port is separate from the HTTP port, so `http.requests` holds only + * the API calls the route map drives — no token-exchange noise. A real + * `IntentSigner` backs `propose`, so its envelopes are genuinely signed. + * + * @param responseBody - The JSON body every recorded request returns. + * @returns The api under test and the recording HTTP port. + */ +function apiOn(responseBody: unknown = {}): { + api: CustodyApi + http: FakeHttpPort +} { + const http = new FakeHttpPort(() => ok(responseBody)) + 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: AUTHOR, + }) + return { api, http } +} + +/** A minimal release-quarantine payload, a payload with no id of its own. */ +const RELEASE_PAYLOAD: components['schemas']['Core_v0_ReleaseQuarantinedTransfers'] = + { + accountId: 'account-1', + transferIds: ['transfer-1', 'transfer-2'], + type: 'v0_ReleaseQuarantinedTransfers', + } + +/** + * Parse the `Core_ProposeIntentBody` a recorded POST carried. + * + * @param http - The recording HTTP port. + * @returns The parsed envelope body. + */ +function proposedBody( + http: FakeHttpPort, +): components['schemas']['Core_ProposeIntentBody'] { + return JSON.parse( + http.requests[0]?.body ?? '{}', + ) as components['schemas']['Core_ProposeIntentBody'] +} + +describe('CustodyApi.call', () => { + it('resolves a GET route and interpolates multiple path params', async () => { + const { api, http } = apiOn({ id: 'i1' }) + + await api.call('getIntent', { path: { domainId: 'D', intentId: 'I' } }) + + expect(http.requests).toHaveLength(1) + expect(http.requests[0]?.method).toBe('GET') + expect(http.requests[0]?.url).toBe(`${GATEWAY}/v1/domains/D/intents/I`) + }) + + it('appends query params on a GET', async () => { + const { api, http } = apiOn({ items: [] }) + + await api.call('getAllDomainsAddresses', { query: { address: 'rAddr' } }) + + expect(http.requests[0]?.url).toBe(`${GATEWAY}/v1/addresses?address=rAddr`) + }) + + it('routes a POST with a JSON body', async () => { + const { api, http } = apiOn({ id: 'r1' }) + + const body = { + request: { + author: { id: 'u1', domainId: 'd1' }, + targetDomainId: 'd1', + intentId: 'i1', + proposalSignature: 'sig', + type: 'Approve' as const, + }, + signature: 'sig', + } + await api.call('approveIntent', { body }) + + expect(http.requests[0]?.method).toBe('POST') + expect(http.requests[0]?.url).toBe(`${GATEWAY}/v1/intents/approve`) + expect(JSON.parse(http.requests[0]?.body ?? '{}')).toEqual(body) + }) + + it('throws when a required path parameter is missing', async () => { + const { api } = apiOn() + + await expect( + // @ts-expect-error -- incomplete path, to exercise the runtime guard + api.call('getIntent', { path: { domainId: 'D' } }), + ).rejects.toThrow(/Missing path parameter 'intentId'/u) + }) +}) + +describe('CustodyApi.propose', () => { + it('POSTs a signed envelope for the payload to /v1/intents', async () => { + const { api, http } = apiOn({ requestId: 'r1' }) + + await api.propose(RELEASE_PAYLOAD) + + expect(http.requests).toHaveLength(1) + expect(http.requests[0]?.method).toBe('POST') + expect(http.requests[0]?.url).toBe(`${GATEWAY}/v1/intents`) + const body = proposedBody(http) + expect(body.signature).toBeTruthy() + expect(body.request.payload).toEqual(RELEASE_PAYLOAD) + }) + + it('fills the envelope from the domain/author context by default', async () => { + const { api, http } = apiOn() + + await api.propose(RELEASE_PAYLOAD) + + const { request } = proposedBody(http) + expect(request.author).toEqual({ id: AUTHOR, domainId: DOMAIN }) + expect(request.targetDomainId).toBe(DOMAIN) + expect(request.type).toBe('Propose') + expect(request.id).toBeTruthy() + expect(request.customProperties).toEqual({}) + expect(new Date(request.expiryAt).getTime()).toBeGreaterThan(Date.now()) + }) + + it('applies per-call overrides', async () => { + const { api, http } = apiOn() + const expiryAt = new Date(Date.now() + 60_000).toISOString() + + await api.propose(RELEASE_PAYLOAD, { + id: 'idem-1', + expiryAt, + targetDomainId: 'domain-2', + author: { id: 'user-2', domainId: 'domain-2' }, + description: 'release two transfers', + customProperties: { note: 'ops' }, + }) + + const { request } = proposedBody(http) + expect(request.id).toBe('idem-1') + expect(request.expiryAt).toBe(expiryAt) + expect(request.targetDomainId).toBe('domain-2') + expect(request.author).toEqual({ id: 'user-2', domainId: 'domain-2' }) + expect(request.description).toBe('release two transfers') + expect(request.customProperties).toEqual({ note: 'ops' }) + }) + + it('omits description when none is given', async () => { + const { api, http } = apiOn() + + await api.propose(RELEASE_PAYLOAD) + + expect(proposedBody(http).request).not.toHaveProperty('description') + }) + + it('returns the Custody intent acknowledgement', async () => { + const { api } = apiOn({ requestId: 'req-42' }) + + const response = await api.propose(RELEASE_PAYLOAD) + + expect(response.requestId).toBe('req-42') + }) +})