Skip to content
Merged
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
4 changes: 2 additions & 2 deletions examples/02-custodian-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const palisade = await PalisadeCustody.create({
// in config rather than hard-coded. `fromEnv` reads every `RIPPLE_CUSTODY_*`
// variable for exactly this reason:
const rippleCustody = await RippleCustody.fromEnv({
primary: process.env.RIPPLE_CUSTODY_PRIMARY ?? '',
primary: process.env.RIPPLE_CUSTODY_PRIMARY_ADDRESS ?? '',
})

// The explicit form, if you configure it yourself rather than via the env:
Expand All @@ -65,7 +65,7 @@ const rippleCustody = await RippleCustody.fromEnv({
// tokenUrl: process.env.RIPPLE_CUSTODY_AUTH_TOKEN_URL ?? '',
// },
// domainId: process.env.RIPPLE_CUSTODY_DOMAIN_ID ?? '',
// primary: process.env.RIPPLE_CUSTODY_PRIMARY ?? '',
// primary: process.env.RIPPLE_CUSTODY_PRIMARY_ADDRESS ?? '',
// })

// --- Local signing (self-custody; keys held in-process) -------------------
Expand Down
2 changes: 1 addition & 1 deletion examples/05-rwa-through-ripple-custody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { RippleCustody, SimpleXRPL } from 'simplexrpl'

// The Custody-held issuer account; Custody governs every write it signs.
const ISSUER_ADDRESS = process.env.RIPPLE_CUSTODY_PRIMARY ?? ''
const ISSUER_ADDRESS = process.env.RIPPLE_CUSTODY_PRIMARY_ADDRESS ?? ''

// Config (gateway, token endpoint, domain, intent-author key) comes from
// `RIPPLE_CUSTODY_*` environment variables via `fromEnv`.
Expand Down
21 changes: 7 additions & 14 deletions examples/07-place-dex-order.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
/**
* Place an order on the DEX.
*
* The `iou` vertical places orders to buy or sell an issued currency; the
* `token` vertical places generic offers between any two DEX-tradeable assets
* (XRP or IOU). Order type controls how the offer is worked.
* The `iou` vertical places orders to buy or sell an issued currency, priced in
* XRP or another IOU. Order type controls how the offer is worked.
*
* MPTs (the `token` vertical) are deliberately absent here: the MPT DEX
* amendment is not yet live on-chain, so MPTs cannot be traded on the order
* book and there is no token-offer verb. All DEX offers go through `iou`.
*/
import { iou, LocalSigner, SimpleXRPL, XRP_ASSET } from 'simplexrpl'
import { LocalSigner, SimpleXRPL } from 'simplexrpl'

const client = await SimpleXRPL.init({
xrpldUrl: 'wss://s.altnet.rippletest.net:51233',
Expand Down Expand Up @@ -54,14 +57,4 @@ if (mine.data.length > 0) {
await client.iou.cancelOffer({ offerSequence: mine.data[0].offerSequence })
}

// --- Via the token vertical: a generic XRP/IOU offer -----------------------
await client.token.createOffer({
takerGets: { asset: XRP_ASSET, value: '50' },
takerPays: {
asset: iou('USD', 'rIssuer00000000000000000000000000000'),
value: '100',
},
flags: { immediateOrCancel: true },
})

await client.disconnect()
20 changes: 15 additions & 5 deletions examples/09-cross-custodian-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { PalisadeCustody, RippleCustody, SimpleXRPL } from 'simplexrpl'
// approvals), the distribution/hot wallet in Palisade. One client drives both.
// Config comes from the environment / your secrets manager.
const custody = await RippleCustody.fromEnv({
primary: process.env.RIPPLE_CUSTODY_PRIMARY ?? '',
primary: process.env.RIPPLE_CUSTODY_PRIMARY_ADDRESS ?? '',
})
const palisade = await PalisadeCustody.create({
baseUrl: 'https://api.sandbox.palisade.co', // sandbox (TESTNET data)
Expand Down Expand Up @@ -40,10 +40,20 @@ const client = await SimpleXRPL.init({
// The distribution/hot wallet on the Palisade connector.
const hotWallet = client.resolveAccount(palisade.primary.address)

// Each operation targets a different custodian. Issue an IOU as the Custody issuer
// (the primary signer), then pay out from the Palisade hot wallet via `from` —
// the client routes each call to the connector that owns the account.
await client.iou.issue({ ticker: 'USD' })
// Each step targets a different custodian, and the client routes each call to
// the connector that owns the acting account. Issue a USD IOU with the Custody
// account as issuer (the primary signer, default `from`) and the Palisade wallet
// as the holder that extends trust — a genuinely cross-custodian issuance, since
// `issue` sequences AccountSet (issuer) → TrustSet (holder) → Payment (issuer).
// Naming `holder` is what selects this path; omitting it would instead bootstrap
// both accounts from the local `XRPL_ISSUER_SEED` / `XRPL_HOT_WALLET_SEED` seeds.
await client.iou.issue({
ticker: 'USD',
holder: hotWallet.address,
amount: '1000',
})

// Then pay out from the Palisade hot wallet via `from`.
await client.xrp.transfer(
{ to: 'rBeneficiary00000000000000000000000', amount: '25' },
{ from: hotWallet.address },
Expand Down
36 changes: 18 additions & 18 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,32 @@ account (and, for the Palisade samples, sandbox credentials).

## SDK mechanics

| File | Shows |
| ---- | ----- |
| [01-initialization.ts](./01-initialization.ts) | Choosing a network, connectors, primary signer, and binding/registering accounts |
| [02-custodian-connections.ts](./02-custodian-connections.ts) | Constructing each connector (Local, Palisade, Ripple Custody) |
| [03-account-discovery.ts](./03-account-discovery.ts) | Listing, resolving, and re-discovering accounts across connectors |
| [04-routing-report.ts](./04-routing-report.ts) | Reporting how each transactor routes for an account (`dispatch` / `isNativePath`) |
| File | Shows |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| [01-initialization.ts](./01-initialization.ts) | Choosing a network, connectors, primary signer, and binding/registering accounts |
| [02-custodian-connections.ts](./02-custodian-connections.ts) | Constructing each connector (Local, Palisade, Ripple Custody) |
| [03-account-discovery.ts](./03-account-discovery.ts) | Listing, resolving, and re-discovering accounts across connectors |
| [04-routing-report.ts](./04-routing-report.ts) | Reporting how each transactor routes for an account (`dispatch` / `isNativePath`) |

## Operations

| File | Shows |
| ---- | ----- |
| [05-rwa-through-ripple-custody.ts](./05-rwa-through-ripple-custody.ts) | Issuing a Real-World Asset as an MPT (XLS-89 metadata) through Ripple Custody |
| [06-iou-issue-and-distribute.ts](./06-iou-issue-and-distribute.ts) | Issuing and distributing an IOU |
| [07-place-dex-order.ts](./07-place-dex-order.ts) | Placing DEX orders (IOU offers and generic token offers) |
| [08-permissioned-domain.ts](./08-permissioned-domain.ts) | Creating a permissioned domain and scoping an offer to it |
| [09-cross-custodian-workflow.ts](./09-cross-custodian-workflow.ts) | Sequencing work across two custodians — Ripple Custody + Palisade — via vertical verbs and `runMultiStep` |
| [13-palisade-api-escape-hatch.ts](./13-palisade-api-escape-hatch.ts) | Calling any Palisade operation the verticals don't model, via the typed `palisade.api.call(operationId, …)` secondary surface |
| File | Shows |
| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| [05-rwa-through-ripple-custody.ts](./05-rwa-through-ripple-custody.ts) | Issuing a Real-World Asset as an MPT (XLS-89 metadata) through Ripple Custody |
| [06-iou-issue-and-distribute.ts](./06-iou-issue-and-distribute.ts) | Issuing and distributing an IOU |
| [07-place-dex-order.ts](./07-place-dex-order.ts) | Placing DEX orders (IOU offers priced in XRP or another IOU) |
| [08-permissioned-domain.ts](./08-permissioned-domain.ts) | Creating a permissioned domain and scoping an offer to it |
| [09-cross-custodian-workflow.ts](./09-cross-custodian-workflow.ts) | Sequencing work across two custodians — Ripple Custody issuer + Palisade holder — via vertical verbs |
| [13-palisade-api-escape-hatch.ts](./13-palisade-api-escape-hatch.ts) | Calling any Palisade operation the verticals don't model, via the typed `palisade.api.call(operationId, …)` secondary surface |

## External signing (KMS / HSM)

Keys held outside the process sign through the `ExternalSigner` connector. AWS
KMS has a built-in adapter (`import { AwsKmsSigner } from 'simplexrpl/aws-kms'`);
for an HSM you implement the same `ExternalSignerPort` seam.

| File | Shows |
| ---- | ----- |
| [10-hsm-pkcs11-signer.ts](./10-hsm-pkcs11-signer.ts) | Bring-your-own PKCS#11 HSM signer — implementing `ExternalSignerPort` (secp256k1) against your device |
| [11-kms-signer.ts](./11-kms-signer.ts) | Signing with an AWS KMS-held key via the built-in `simplexrpl/aws-kms` adapter |
| File | Shows |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| [10-hsm-pkcs11-signer.ts](./10-hsm-pkcs11-signer.ts) | Bring-your-own PKCS#11 HSM signer — implementing `ExternalSignerPort` (secp256k1) against your device |
| [11-kms-signer.ts](./11-kms-signer.ts) | Signing with an AWS KMS-held key via the built-in `simplexrpl/aws-kms` adapter |
| [12-external-signer-mock.ts](./12-external-signer-mock.ts) | **Runnable**: a mock in-process signer that signs & submits a real transfer, shown for both secp256k1 and ed25519 |
11 changes: 10 additions & 1 deletion src/client/intent-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,19 @@ export class IntentInspector {
*
* Only available when a Ripple Custody signer is configured.
*
* Resolves to the on-chain result once confirmed. Returns `undefined` if the
* timeout elapses with the transaction still in flight — indeterminate, so
* re-drive the *same* idempotency key. Throws {@link IntentValidationError}
* the moment the transaction reaches a terminal non-confirmed state
* (`Expired`, `Replaced`, or an on-chain failure) — provably dead, so a retry
* needs a *fresh* key.
*
* @param intentId - The intent id returned at submission.
* @param timeoutMs - How long to poll before giving up (custodian default if omitted).
* @returns The on-chain result, or `undefined` when the timeout elapses.
* @returns The on-chain result, or `undefined` when the timeout elapses with
* the transaction still in flight.
* @throws {@link SimpleXRPLError} if no Ripple Custody signer is configured.
* @throws {@link IntentValidationError} if the transaction is provably dead.
*/
public async awaitOnChain(
intentId: string,
Expand Down
7 changes: 2 additions & 5 deletions src/custodians/external/external-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
} from '../../domain/index.js'
import { XrpldSubmitError } from '../../errors.js'
import { assertDryRunHonored } from '../context-guards.js'
import { engineResultOf } from '../on-ledger-result.js'

import type { ExternalSignerPort } from './external-signer-port.js'
import { signTransactionExternally } from './signing.js'
Expand Down Expand Up @@ -135,11 +136,7 @@ export class ExternalSigner implements Custodian {
): Promise<SubmissionResult> {
const envelope = await this.sign(tx, ctx)
const response = await ctx.ledger.submitAndWait(envelope.txBlob)
const { meta } = response.result
const engineResult =
meta !== undefined && typeof meta !== 'string'
? meta.TransactionResult
: undefined
const engineResult = engineResultOf(response)
if (engineResult !== undefined && engineResult !== 'tesSUCCESS') {
throw new XrpldSubmitError(engineResult, response)
}
Expand Down
7 changes: 2 additions & 5 deletions src/custodians/local/local-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
SimpleXRPLError,
} from '../../errors.js'
import { assertDryRunHonored } from '../context-guards.js'
import { engineResultOf } from '../on-ledger-result.js'

/** Options for {@link LocalSigner.create}. */
export interface LocalSignerCreateOptions {
Expand Down Expand Up @@ -224,11 +225,7 @@ export class LocalSigner implements Custodian {
): Promise<SubmissionResult> {
const envelope = await this.sign(tx, ctx)
const response = await ctx.ledger.submitAndWait(envelope.txBlob)
const { meta } = response.result
const engineResult =
meta !== undefined && typeof meta !== 'string'
? meta.TransactionResult
: undefined
const engineResult = engineResultOf(response)
if (engineResult !== undefined && engineResult !== 'tesSUCCESS') {
throw new XrpldSubmitError(engineResult, response)
}
Expand Down
134 changes: 134 additions & 0 deletions src/custodians/on-ledger-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type { TxResponse } from 'xrpl'

import { IntentPendingError, XrpldSubmitError } from '../errors.js'
import type { LedgerPort } from '../ports/index.js'

import type { PollSchedule } from './poll-schedule.js'
import { pollDelayMs } from './poll-schedule.js'

/** The one XRPL engine result that means the transaction achieved its intent. */
const TES_SUCCESS = 'tesSUCCESS'

/**
* Cadence for re-reading a transaction the custodian has already reported
* on-ledger. Responsive at first, capped low: this only absorbs the brief lag
* between the custodian's view and the transaction being queryable/validated
* here — it is not a long governance wait.
*/
const CONFIRM_SCHEDULE: PollSchedule = { initialMs: 1000, maxMs: 5000 }

/** How long to keep re-reading before declaring the result indeterminate. */
const CONFIRM_TIMEOUT_MS = 30_000

/**
* Wait for `ms` milliseconds.
*
* @param ms - How long to wait.
*/
async function sleep(ms: number): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
}

/**
* Read the XRPL engine result from a transaction response, if present.
*
* A `submitAndWait` / `tx` response carries the result in `meta.TransactionResult`,
* but `meta` is absent while the transaction is unconfirmed and a bare string
* when the response is requested in binary form — neither yields a result code.
*
* @param response - The transaction response.
* @returns The engine result code (e.g. `tesSUCCESS`, `tecPATH_DRY`), or
* `undefined` when no structured metadata is available.
*/
export function engineResultOf(response: TxResponse): string | undefined {
const { meta } = response.result
return meta !== undefined && typeof meta !== 'string'
? meta.TransactionResult
: undefined
}

/** Inputs for {@link assertOnLedgerSuccess}. */
export interface AssertOnLedgerSuccessOptions {
/** The shared ledger connection to read the transaction back from. */
readonly ledger: LedgerPort
/** The XRPL transaction hash the custodian reported on-ledger. */
readonly txHash: string
/** The custodian kind, for the pending-error the indeterminate case throws. */
readonly custodian: 'ripple-custody' | 'palisade-custody'
/** The intent/transaction id to resume with if the result stays indeterminate. */
readonly intentId: string
}

/**
* Assert that an on-ledger transaction achieved its intent — that its XRPL
* engine result is `tesSUCCESS`.
*
* A custodian can report a transaction as on-ledger (it has a hash, it claimed a
* fee) while the transaction actually failed with a `tec` — included in the
* ledger but with its intended effect not applied. A hash alone is therefore not
* success. Custody's wire schema exposes no engine result at all, so this reads
* the authoritative result straight off the ledger by hash.
*
* The check is positive: it confirms the transaction is `validated` and carries
* a readable result, then gates on that result. It never treats an unreadable
* or not-yet-found transaction as success.
*
* - **tesSUCCESS** — returns.
* - **Any other engine result** (`tec*`, `tem*`, `tef*`, …) — throws
* {@link XrpldSubmitError}. The transaction is on-ledger and terminal, so a
* retry is a genuinely new attempt and needs a fresh idempotency key.
* - **Not yet queryable / validated** — re-reads on a short schedule to absorb
* propagation lag, then throws {@link IntentPendingError} (indeterminate): the
* transaction may still confirm, so a retry must re-drive the *same* key.
*
* @param options - The ledger, the transaction hash, and the resume context.
* @throws {@link XrpldSubmitError} on a non-`tesSUCCESS` engine result.
* @throws {@link IntentPendingError} if the result stays indeterminate.
*/
export async function assertOnLedgerSuccess(
options: AssertOnLedgerSuccessOptions,
): Promise<void> {
const { ledger, txHash, custodian, intentId } = options
const deadline = Date.now() + CONFIRM_TIMEOUT_MS

for (let attempt = 0; ; attempt += 1) {
let response: TxResponse | undefined
try {
// eslint-disable-next-line no-await-in-loop -- Sequential polling is inherent to waiting for the validated result.
response = await ledger.request<TxResponse>({
command: 'tx',
transaction: txHash,
})
} catch {
// A `txnNotFound` (or a transient transport error) means the transaction
// the custodian reported on-ledger is not queryable here yet — not that it
// failed. Fall through and retry rather than misreport it.
response = undefined
}

if (response?.result.validated) {
const engineResult = engineResultOf(response)
// Only decide once the result is actually readable; a validated response
// whose metadata has not materialized yet keeps polling.
if (engineResult === TES_SUCCESS) {
return
}
if (engineResult !== undefined) {
throw new XrpldSubmitError(engineResult, response)
}
}

const delay = pollDelayMs(attempt, CONFIRM_SCHEDULE)
if (Date.now() + delay >= deadline) {
throw new IntentPendingError(
intentId,
custodian,
'on-ledger transaction not yet confirmed',
)
}
// eslint-disable-next-line no-await-in-loop -- Sequential polling is inherent to waiting for the validated result.
await sleep(delay)
}
}
Loading
Loading