-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add Ripple Custody full api support #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
pdp2121
wants to merge
1
commit into
main
Choose a base branch
from
add-ripple-custody-full-api
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| --> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]) | ||
| } | ||
| } | ||
| 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)` : ''), | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }, | ||
| ) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }, | ||
| }) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 missingoperationIds but has no check for duplicates. A duplicateoperationIdwould produce duplicateCUSTODY_ROUTESobject key