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
43 changes: 43 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
## High Level Overview of Change

<!--
Please include a summary/list of the changes.
If too broad, please consider splitting into multiple PRs.
If a relevant Asana task, please link it here.
-->

### Context of Change

<!--
Please include the context of a change.
If a bug fix, when was the bug introduced? What was the behavior?
If a new feature, why was this architecture chosen? What were the alternatives?
If a refactor, how is this better than the previous implementation?

If there is a design document for this feature, please link it here.
-->

### Type of Change

<!--
Please check relevant options, delete irrelevant ones.
-->

- [ ] 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

<!--
Please describe the tests that you ran to verify your changes and provide instructions so that others can reproduce.
-->

<!--
## Future Tasks
For future tasks related to PR.
-->
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
70 changes: 70 additions & 0 deletions scripts/gen-custody-routes.mjs
Original file line number Diff line number Diff line change
@@ -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])
}
}
Comment on lines +22 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a risk of a repeated operationIds? Looks like the generator tracks missing operationIds but has no check for duplicates. A duplicate operationId would produce duplicate CUSTODY_ROUTES object key

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<string, CustodyRoute>
`

writeFileSync(join(ROOT, 'src/generated/custody-routes.ts'), out)
console.log(
`custody-routes.ts: ${entries.length} operations` +
(skipped > 0 ? ` (${skipped} skipped — no operationId)` : ''),
)
169 changes: 169 additions & 0 deletions src/custodians/ripple/api.ts
Original file line number Diff line number Diff line change
@@ -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<Op extends keyof operations> =
operations[Op]['parameters']['path']
/** The query parameters an operation takes (or `never`/`undefined`). */
type QueryParams<Op extends keyof operations> =
operations[Op]['parameters']['query']
/** The JSON request body an operation takes (or `never` if it has none). */
type RequestBody<Op extends keyof operations> = 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<Op extends keyof operations> = 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<Op extends CustodyOperationId> {
readonly path?: PathParams<Op>
readonly query?: QueryParams<Op>
readonly body?: RequestBody<Op>
}

/**
* 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, unknown>): string {
return template.replace(/\{(?<key>\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<Op extends CustodyOperationId>(
operationId: Op,
args?: CustodyCallArgs<Op>,
): Promise<ResponseBody<Op>> {
const route = CUSTODY_ROUTES[operationId]
const path = fillPath(route.path, args?.path)
return this.client.invoke<ResponseBody<Op>>(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<components['schemas']['Core_IntentResponse']> {
const body = buildProposeEnvelope(this.intentSigner, {
...this.proposeContext,
payload,
overrides: options,
})
const route = CUSTODY_ROUTES.createIntent
return this.client.invoke<components['schemas']['Core_IntentResponse']>(
route.method,
route.path,
{ body },
)
}
}
9 changes: 9 additions & 0 deletions src/custodians/ripple/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
35 changes: 12 additions & 23 deletions src/custodians/ripple/mapping/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone passes their own id but no matching options.id would a random id be generated for the envelope id? In that case the 2 values would diverge which goes against the added comment here. Is this divergence ok?

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 },
})
}
Loading
Loading