From b4db6f787f924221e23925062c18291291da816f Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Wed, 12 Aug 2026 13:25:42 -0700 Subject: [PATCH 1/2] Add Ripple Custody usage docs to .claude/ Manual walkthroughs (construction, discovery, native/raw submission, env-var setup) for using RippleCustody end-to-end, written against a real Custody gateway since no live sandbox integration test exists yet. --- .claude/custody-integration-tests-e2e.md | 88 +++++++ .claude/using-ripple-custody.md | 283 +++++++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 .claude/custody-integration-tests-e2e.md create mode 100644 .claude/using-ripple-custody.md diff --git a/.claude/custody-integration-tests-e2e.md b/.claude/custody-integration-tests-e2e.md new file mode 100644 index 0000000..9a83280 --- /dev/null +++ b/.claude/custody-integration-tests-e2e.md @@ -0,0 +1,88 @@ +# Running the Custody Integration Tests End-to-End + +This walks through the `test/integration` suite as it relates to Ripple +Custody transaction flows: what runs out of the box today, and what's +missing. See [`README.md`](../README.md) for the general test tiers and +[`test/integration/ripple-raw.test.ts`](../test/integration/ripple-raw.test.ts) +for the source of truth. + +## Why "custody" integration coverage isn't fully running today + +| Test file | Custodian path | Gate | Runs in CI? | +|---|---|---|---| +| `ripple-raw.test.ts` | Ripple raw-signing (build preimage → external sign → reassemble → submit) | None — funds itself via the public XRPL Testnet faucet | Yes, every PR (`.github/workflows/ci.yml` → `integration` job) | +| — | Real Ripple Custody API adapter (`custody-http-client.ts`, `custody-auth.service.ts`) | No live/sandbox integration test exists at all — only unit tests under `test/unit/` | N/A | + +So today's "custody" coverage that actually exercises a network end-to-end is +the raw-signing path against XRPL Testnet. The Ripple Custody HTTP adapter +has no integration-level test at all yet — unit tests only, with mocked HTTP +— though `scripts/custody-e2e-demo.mjs` (see +[`using-ripple-custody.md`](./using-ripple-custody.md)) exercises it manually +against a real gateway. + +## 1. Run the always-on tier (raw-sign, live XRPL Testnet) + +No credentials needed — this funds itself via the public faucet. + +```bash +npm ci +npm run test:integration -- ripple-raw +``` + +What it does (see `ripple-raw.test.ts`): +1. `fundedTestnetClient()` faucet-funds a source and destination wallet on + `wss://s.altnet.rippletest.net:51233` (override with `XRPL_TESTNET_WS`). +2. Builds a `Payment` and autofills it via `client.ledger.autofill(...)`. +3. `buildSigningPreimage(...)` produces the exact bytes a custodian vault + would be asked to sign (`Core_ManifestValue_Unsafe.signature` equivalent). +4. The funded wallet signs that preimage locally, standing in for the vault. +5. `assembleSignedTransaction(...)` reassembles the signed blob exactly as a + real signer response would. +6. Submits with `client.ledger.submitAndWait(...)` and asserts + `tesSUCCESS` plus a matching transaction hash. + +Run the full integration tier the same way CI does: + +```bash +npm run test:integration -- --runInBand +``` + +## 2. Ripple Custody API adapter — no packaged live test yet + +`custody-http-client.ts` and `custody-auth.service.ts` (the real Ripple +Custody REST client) are only covered by mocked unit tests today: + +```bash +npm test -- custody-http-client custody-auth.service ripple-custody +``` + +There's no `describe.skip`-gated integration test pointed at a live Ripple +Custody gateway yet. The closest thing today is running +[`scripts/custody-e2e-demo.mjs`](../scripts/custody-e2e-demo.mjs) manually +with `RIPPLE_CUSTODY_*` env vars set — see +[`using-ripple-custody.md`](./using-ripple-custody.md) for the full +construction + discovery walkthrough. + +To close this gap properly: add a +`test/integration/ripple-custody-contract.test.ts` gated on required env vars +(gateway URL, signing key, token URL, domain id, primary address), +`describe.skip` when absent, mirroring the discover → bind → capabilities → +submit flow the demo script already runs manually. + +## Quick reference + +```bash +# Everything that runs without any setup: +npm ci +npm run test:integration -- ripple-raw + +# Full integration tier, serial (matches CI): +npm run test:integration -- --runInBand + +# Manual live-gateway run against Ripple Custody: +npm run build +RIPPLE_CUSTODY_GATEWAY_URL=... RIPPLE_CUSTODY_AUTH_SIGNING_KEY=... \ +RIPPLE_CUSTODY_AUTH_TOKEN_URL=... RIPPLE_CUSTODY_DOMAIN_ID=... \ +RIPPLE_CUSTODY_PRIMARY_ADDRESS=... \ + npm run demo:custody +``` diff --git a/.claude/using-ripple-custody.md b/.claude/using-ripple-custody.md new file mode 100644 index 0000000..cbf9255 --- /dev/null +++ b/.claude/using-ripple-custody.md @@ -0,0 +1,283 @@ +# Using Ripple Custody with simpleXRPL + +How to wire `RippleCustody` into simpleXRPL and manually run a transaction +end-to-end — from a business-intent call down to an on-ledger result. The +steps below are drawn directly from the live integration tests, which are +the only place in the repo that exercises these paths against a real backend +rather than a mock: + +- [`test/integration/ripple-raw.test.ts`](../test/integration/ripple-raw.test.ts) — live XRPL Testnet, raw-signing primitives +- [`test/integration/async-submit.test.ts`](../test/integration/async-submit.test.ts) — the async submission handle +- [`test/unit/ripple-custody/*.test.ts`](../test/unit/ripple-custody/) — every `RippleCustody` code path, against a fake HTTP transport + +There is no live sandbox integration test for `RippleCustody` yet — the +manual steps here are the closest thing, run directly against a real +Custody gateway. + +## Just run it + +[`scripts/custody-e2e-demo.mjs`](../scripts/custody-e2e-demo.mjs) puts every +stage below into one runnable script. The default way to run it is via a +git-ignored `.env` file at the **repo root** (`.env` — not `.env.local` or +anything else; that's the name `.gitignore` already covers alongside +`.env.*` and `*.pem`, and what the rest of the repo's live tier expects, see +`README.md`'s test-tier table): + +```bash +npm run build # the script imports the built package, not src/ +set -a && source .env && set +a && npm run demo:custody +``` + +With no `RIPPLE_CUSTODY_*` vars set (or no `.env` at all), it still runs +Stage 1 for real — faucet-funds two Testnet accounts and submits a live +Payment through the Local signer, e.g.: + +``` +=== Stage 1 — Local signer, live XRPL Testnet === +source: rLuCMK8aGZxBnCV7Mp4docQGmYhMGPiYxi +destination: rGyuNwAHWCD6Eh943yr42NH8nBSNzAZLbH +submitted: 9A6F86FE2C4C347ED7A95DAF0DF721C7435F2E04340E1C84C157FE1EC637EF8A +source: rippled +``` + +Put `RIPPLE_CUSTODY_*` (see the script's header comment for the full list) +in `.env` to also run Stage 2 against a real Custody gateway — it prints +what it needs and skips itself cleanly if those vars are absent. An example +`.env` (private key referenced by path, per §2 below): + +```bash +RIPPLE_CUSTODY_GATEWAY_URL=https:// +RIPPLE_CUSTODY_AUTH_SIGNING_KEY=./ripple-custody-signing-key.pem +RIPPLE_CUSTODY_AUTH_PUBLIC_KEY= +RIPPLE_CUSTODY_AUTH_TOKEN_URL=https://openid./realms/Metaco/protocol/openid-connect/token +RIPPLE_CUSTODY_DOMAIN_ID= +RIPPLE_CUSTODY_PRIMARY_ADDRESS= +``` + +Extra runtime flags (also read from `.env` or passed inline): + +| Variable | Effect | +|---|---| +| `RIPPLE_CUSTODY_ALLOW_RAW=true` | opt into the raw-signing fallback | +| `CUSTODY_DEMO_SUBMIT=true` + `CUSTODY_DEMO_DESTINATION=` | actually submit a real Payment, not just discover — **caution**: native submission goes through Custody's own backend, not the script's `rippledUrl`, so if your primary account is activated on both a production and a test ledger (check `listAccounts()`/`capabilities()` output first), you can't assume this lands on testnet | +| `CUSTODY_DEMO_DEBUG=true` | log every raw HTTP request/response to/from Custody | + +## 1. What `RippleCustody` actually does + +`RippleCustody` (`src/custodians/ripple/ripple-custody.ts`) wraps the Custody +REST API behind the SDK's `Custodian` interface: + +- **Native transactors** (`AccountSet`, `Payment`, etc. — see + `NATIVE_XRPL_TRANSACTORS`) submit as a governed `v0_CreateTransactionOrder` + intent. Custody signs and submits atomically; there's no separate signed + blob to inspect. +- **Everything else** falls back to the raw-signing path + (`v0_SignManifest` + `Unsafe`), only if you opt in with + `allowRawSigning: true`. + +## 2. Construct a `RippleCustody` instance + +Two ways, both ending in an authenticated, account-discovered custodian. + +### Option A — explicit options + +```ts +import { RippleCustody } from 'simplexrpl' + +const custody = await RippleCustody.create({ + gatewayUrl: 'https://custody.example.com', + auth: { + signingKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----', + // publicKey is optional — derived from signingKey if omitted + tokenUrl: 'https://auth.example.com/oauth/token', + }, + domainId: 'domain-abc123', + primary: 'rYourPrimaryAccountAddress...', + allowRawSigning: true, // needed for any non-native transactor (e.g. Payment) + defaultTimeoutMs: 60_000, +}) +``` + +`signingKey` can be literal PEM contents or a path to a `.pem` file — +`resolveSigningKeyPem` in `construction.ts` checks for the `-----BEGIN` marker +and reads the file for you if it's a path. + +### Option B — from environment variables + +This is the path the demo script uses, and the recommended default: put the +five `RIPPLE_CUSTODY_*` vars in a `.env` file at the repo root (see "Just run +it" above for the exact names/example), load it, then call `fromEnv()`: + +```bash +set -a && source .env && set +a +``` + +```ts +import { RippleCustody } from 'simplexrpl' + +const custody = await RippleCustody.fromEnv({ + primary: process.env.RIPPLE_CUSTODY_PRIMARY_ADDRESS!, + allowRawSigning: true, +}) +``` + +`fromEnv()` reads `RIPPLE_CUSTODY_PRIMARY_ADDRESS` from `.env` for you if you +pass it through as shown above — everything else (`gatewayUrl`, `auth.*`, +`domainId`) comes straight from the matching `RIPPLE_CUSTODY_*` vars, no +other wiring needed. Required env vars (`construction.ts` → +`resolveFromEnvOptions`): + +| Variable | Purpose | +|---|---| +| `RIPPLE_CUSTODY_GATEWAY_URL` | Custody REST API base URL | +| `RIPPLE_CUSTODY_AUTH_SIGNING_KEY` | Intent-author private key (PEM or path to `.pem`) | +| `RIPPLE_CUSTODY_AUTH_TOKEN_URL` | OAuth token endpoint — the realm-specific path, e.g. `https://openid./realms/Metaco/protocol/openid-connect/token`, not just the Keycloak host root | +| `RIPPLE_CUSTODY_DOMAIN_ID` | The Custody domain to operate in | +| `RIPPLE_CUSTODY_AUTH_PUBLIC_KEY` | Optional — derived from the signing key if omitted | + +`create()`/`fromEnv()` will: +1. Authenticate against `tokenUrl` and mint a JWT. +2. Call `GET /v1/me` and confirm the authenticated user has access to + `domainId` (throws `CustodyAuthError` if not). +3. Call `listAccounts()` and validate that `primary` is one of the domain's + discovered XRPL accounts (throws `AccountNotFoundError` if not). + +## 3. Bind it to a `SimpleXRPLClient` + +```ts +import { SimpleXRPL } from 'simplexrpl' + +const client = await SimpleXRPL.init({ + rippledUrl: 'wss://s.altnet.rippletest.net:51233', // or your production rippled + signers: [custody], +}) +``` + +From here, every business-intent verb on `client` (`client.xrp`, `client.iou`, +`client.token`, `client.credential`, `client.domain`, `client.account`) +resolves to `custody` automatically for any account it discovered. + +## 4. Run a native transaction end-to-end (governed intent) + +This is the common path — a native transactor (e.g. a `Payment`) goes through +Custody's governance flow. + +**Blocking, wait for terminal state** (mirrors `submitAndWait` in +`ripple-custody.ts`, exercised by `test/unit/ripple-custody/ripple-custody.test.ts`): + +```ts +const result = await client.xrp.transfer({ + to: 'rDestinationAddress...', + amount: '10', +}) +// result.source === 'custody-native' (or similar) once the intent resolves +// terminally; result.txHash is the on-ledger hash. +``` + +**Non-blocking, hand back a handle** (mirrors `submitAsync` + +`IntentObserver`, and the pattern in `test/integration/async-submit.test.ts` +for the Local equivalent): + +```ts +import { submitTransactionAsync } from 'simplexrpl' + +const account = client.resolveAccount('rYourPrimaryAccountAddress...') +const handle = await submitTransactionAsync(client, { + transaction: { + TransactionType: 'Payment', + Account: account.address, + Destination: 'rDestinationAddress...', + Amount: '10000000', // drops + }, + account, +}) + +// Poll or block on the handle whenever you're ready — useful for M-of-N +// approval flows that may span hours. +const status = await handle.poll() +// ...or... +const result = await handle.wait() +``` + +Async submission only works for native transactors — attempting it for a +raw-signed one throws `SimpleXRPLError` (`ripple-custody.ts`'s `submitAsync`). + +Resume/inspect a pending intent later via `client.intent` +(`IntentInspector`), without needing the original handle object. + +## 5. Run a raw-signed transaction end-to-end (non-native transactor) + +When the transactor isn't in `NATIVE_XRPL_TRANSACTORS` and you've set +`allowRawSigning: true`, Custody signs a preimage instead of governing the +whole operation, and *you* submit the resulting blob yourself. This exact +sequence — build preimage → external sign → reassemble → submit — is what +`test/integration/ripple-raw.test.ts` proves against a real testnet account, +just standing in a local wallet for what Custody's vault does: + +```ts +import { decode } from 'xrpl' +import { + assembleSignedTransaction, + buildSigningPreimage, +} from 'simplexrpl' // internal path: src/custodians/ripple/submission/raw-sign.js + +// 1. Autofill the transaction as usual. +const autofilled = await client.ledger.autofill({ + TransactionType: 'Payment', + Account: sourceAddress, + Destination: destinationAddress, + Amount: '10', +}) + +// 2. Ask Custody to sign it (this is what `custody.sign(tx, ctx)` does under +// the hood via `signRawTransaction` in submission/raw-flow.ts): +const envelope = await custody.sign(autofilled, { + ledger: client.ledger, + // ...other SubmissionContext fields as required by your call site +}) + +// `envelope.txBlob` is ready to submit; `envelope.hash` is the resulting +// transaction hash, computed locally so you can assert it matches what +// rippled records. +const response = await client.ledger.submitAndWait(envelope.txBlob) +// response.result.meta.TransactionResult === 'tesSUCCESS' +// response.result.hash === envelope.hash +``` + +Under the hood, `buildSigningPreimage` produces the exact bytes Custody's +`v0_SignManifest` is asked to sign (base64), and +`assembleSignedTransaction` takes the base64 signature Custody returns +(`Core_ManifestValue_Unsafe.signature`) and reassembles the final signed +blob — this is the pair of primitives the integration test exercises +directly, without going through the full `RippleCustody` wrapper, to prove +the wire format survives a real rippled round-trip. + +## 6. Dry-run before submitting (optional) + +`RippleCustody` supports pre-flighting a write through Custody's dry-run +endpoint before it's actually submitted — set `defaultDryRun: true` at +construction, or pass an equivalent per-call option if your call site exposes +one (see `submission/dry-run.ts`'s `runDryRun`). Useful for surfacing policy +rejections before spending a real submission attempt. + +## 7. Manually verifying against a live gateway + +There's no packaged integration test for this yet (see +[`docs/custody-integration-tests-e2e.md`](./custody-integration-tests-e2e.md)), +but `scripts/custody-e2e-demo.mjs`'s Stage 2 does exactly what §2–§3 above +describe: construct via `fromEnv()` → `listAccounts()` → confirm the primary +is discovered → check `capabilities()`. + +Put the five `RIPPLE_CUSTODY_*` vars from "Just run it" above into a `.env` +file at the repo root, then: + +```bash +npm run build +set -a && source .env && set +a && npm run demo:custody +``` + +The always-on raw-signing test needs no credentials at all: + +```bash +npm run test:integration -- ripple-raw +``` From ae059fdf851994b680a889fd3c0a9b7d8b52101f Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Wed, 12 Aug 2026 13:36:06 -0700 Subject: [PATCH 2/2] Add runnable Ripple Custody end-to-end demo script Backs the .claude/ usage docs' "just run it" instructions: always runs a Local-signer Testnet Payment, and additionally exercises RippleCustody construction/discovery/submission against a real gateway when RIPPLE_CUSTODY_* env vars are set. --- scripts/custody-e2e-demo.mjs | 189 +++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 scripts/custody-e2e-demo.mjs diff --git a/scripts/custody-e2e-demo.mjs b/scripts/custody-e2e-demo.mjs new file mode 100644 index 0000000..d133133 --- /dev/null +++ b/scripts/custody-e2e-demo.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +// End-to-end Ripple Custody transaction demo — the runnable version of +// docs/using-ripple-custody.md and docs/custody-integration-tests-e2e.md. +// +// Always runs (no credentials needed): +// - Stage 1: faucet-fund two XRPL Testnet accounts and submit a Payment +// via the Local signing backend, exactly like +// test/integration/xrp-transfer.test.ts. +// +// Runs only when the matching env vars are present: +// - Stage 2: construct a real RippleCustody (RIPPLE_CUSTODY_*), discover +// accounts, print capabilities. Submits a real Payment only if +// CUSTODY_DEMO_SUBMIT=true and CUSTODY_DEMO_DESTINATION is set. +// +// Usage: +// npm run build # this script imports the built package, not src/ +// npm run demo:custody +// +// Env vars: +// XRPL_TESTNET_WS override the testnet WS endpoint +// +// RIPPLE_CUSTODY_GATEWAY_URL +// RIPPLE_CUSTODY_AUTH_SIGNING_KEY +// RIPPLE_CUSTODY_AUTH_TOKEN_URL +// RIPPLE_CUSTODY_DOMAIN_ID +// RIPPLE_CUSTODY_PRIMARY_ADDRESS +// RIPPLE_CUSTODY_ALLOW_RAW 'true' to opt into raw signing +// CUSTODY_DEMO_SUBMIT 'true' to actually submit a Payment +// CUSTODY_DEMO_DESTINATION destination address for that Payment + +import { Client } from 'xrpl' + +import { LocalSigner, RippleCustody, SimpleXRPL } from '../dist/esm/index.js' + +const TESTNET_WS = process.env.XRPL_TESTNET_WS ?? 'wss://s.altnet.rippletest.net:51233' + +/** Small section banner so a long run stays readable. */ +function heading(title) { + console.log(`\n=== ${title} ===`) +} + +/** `capabilities().nativeOps` is a Set — JSON.stringify can't see into it. */ +function formatCapabilities(capabilities) { + return JSON.stringify({ + ...capabilities, + nativeOps: [...capabilities.nativeOps], + }) +} + +/** + * A `CustodyHttpPort` that logs the raw status/body of every response, for + * diagnosing auth/gateway failures that the SDK otherwise reports as a + * generic `CustodyAuthError`. Enable with `CUSTODY_DEMO_DEBUG=true`. + */ +class DebugHttpPort { + async send(request) { + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body, + }) + const body = await response.text() + console.log(` [debug] ${request.method} ${request.url} -> ${response.status}`) + console.log(` [debug] body: ${body.slice(0, 500)}`) + return { status: response.status, body } + } +} + +/** + * Stage 1: no credentials needed. Faucet-fund two accounts and submit a + * Payment through the Local signing backend, so there's always something + * that runs even with no custody sandbox available. + */ +async function runLocalStage() { + heading('Stage 1 — Local signer, live XRPL Testnet') + + const faucet = new Client(TESTNET_WS) + await faucet.connect() + let source + let destination + try { + source = (await faucet.fundWallet()).wallet + destination = (await faucet.fundWallet()).wallet + } finally { + await faucet.disconnect() + } + console.log(`source: ${source.classicAddress}`) + console.log(`destination: ${destination.classicAddress}`) + + const client = await SimpleXRPL.init({ + rippledUrl: TESTNET_WS, + signers: [LocalSigner.fromSeed(source.seed)], + }) + await client.connect() + + try { + const result = await client.xrp.transfer({ + to: destination.classicAddress, + amount: '10', + }) + console.log(`submitted: ${result.txHash}`) + console.log(`source: ${result.source}`) + } finally { + await client.disconnect() + } +} + +/** + * Stage 2: only runs when RIPPLE_CUSTODY_* env vars are set. Mirrors the + * construction + discovery steps from docs/using-ripple-custody.md §2-3, + * and (opt-in only) a real native Payment via §4. + */ +async function runRippleCustodyStage() { + const required = [ + 'RIPPLE_CUSTODY_GATEWAY_URL', + 'RIPPLE_CUSTODY_AUTH_SIGNING_KEY', + 'RIPPLE_CUSTODY_AUTH_TOKEN_URL', + 'RIPPLE_CUSTODY_DOMAIN_ID', + 'RIPPLE_CUSTODY_PRIMARY_ADDRESS', + ] + if (required.some((key) => process.env[key] === undefined)) { + heading('Stage 2 — Ripple Custody (skipped)') + console.log(`set ${required.join(', ')} to enable`) + return + } + + heading('Stage 2 — Ripple Custody, live gateway') + + // fromEnv() (not create()) so RIPPLE_CUSTODY_AUTH_SIGNING_KEY is resolved + // the same way it is for any real caller: literal PEM contents, or a path + // to a .pem file, via resolveSigningKeyPem in construction.ts. + const custody = await RippleCustody.fromEnv({ + primary: process.env.RIPPLE_CUSTODY_PRIMARY_ADDRESS, + allowRawSigning: process.env.RIPPLE_CUSTODY_ALLOW_RAW === 'true', + http: process.env.CUSTODY_DEMO_DEBUG === 'true' ? new DebugHttpPort() : undefined, + }) + + const accounts = await custody.listAccounts() + console.log(`discovered accounts: ${accounts.map((a) => a.address).join(', ')}`) + console.log(`capabilities: ${formatCapabilities(custody.capabilities())}`) + + const shouldSubmit = process.env.CUSTODY_DEMO_SUBMIT === 'true' + const destination = process.env.CUSTODY_DEMO_DESTINATION + if (!shouldSubmit || destination === undefined) { + console.log( + 'submit skipped — set CUSTODY_DEMO_SUBMIT=true and CUSTODY_DEMO_DESTINATION to send a real Payment', + ) + return + } + + const client = await SimpleXRPL.init({ + rippledUrl: TESTNET_WS, + signers: [custody], + }) + await client.connect() + try { + const result = await client.xrp.transfer({ to: destination, amount: '10' }) + console.log(`submitted: ${result.txHash} (source: ${result.source})`) + } finally { + await client.disconnect() + } +} + +/** + * Run one stage; a failure is reported and swallowed so the remaining + * stages still run (each stage is independent — one bad credential set + * shouldn't hide whether the others work). + */ +async function runStage(fn) { + try { + await fn() + } catch (error) { + console.error(`\n[failed] ${error.message ?? error}`) + if (process.env.CUSTODY_DEMO_DEBUG === 'true') { + console.error(error) + } + } +} + +async function main() { + await runStage(runLocalStage) + await runStage(runRippleCustodyStage) + console.log('\nDone.') +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +})