diff --git a/.env.example b/.env.example index c5d3169..44044ab 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,10 @@ # ─── IOU integration tests ──────────────────────────────────────────────────── # Two funded Testnet accounts (issuer + hot wallet). Create with # Account.create(), fund via the faucet. Seeds are secrets — do not commit. +# Both must be set: the token and IOU demos/tests fail fast without the hot +# wallet. If the built-in faucet is drained or rate-limits, top accounts up at +# https://test.bithomp.com/faucet — a drained account otherwise surfaces as an +# unrelated-looking submission failure rather than "out of funds". XRPL_ISSUER_SEED= XRPL_HOT_WALLET_SEED= diff --git a/docs/api-md/_media/connector-routing.md b/docs/api-md/_media/connector-routing.md index d3c45b1..5ba565f 100644 --- a/docs/api-md/_media/connector-routing.md +++ b/docs/api-md/_media/connector-routing.md @@ -17,7 +17,7 @@ is rejected. | Transactor | Local | Ripple Custody | Palisade | | ------ | ------ | ------ | ------ | | `AccountSet` | signs locally | **native** | **native** | -| `Clawback` | signs locally | **native** | **native** | +| `Clawback` | signs locally | **native** | raw fallback¹ | | `CredentialAccept` | signs locally | raw fallback¹ | raw fallback¹ | | `CredentialCreate` | signs locally | raw fallback¹ | raw fallback¹ | | `CredentialDelete` | signs locally | raw fallback¹ | raw fallback¹ | @@ -69,7 +69,7 @@ operation in-process. Read operations emit no transactor and are omitted. | `IOU.authorize()` | `TrustSet` | **native** | **native** | | `IOU.lock()` | `TrustSet` | **native** | **native** | | `IOU.unlock()` | `TrustSet` | **native** | **native** | -| `IOU.clawback()` | `Clawback` | **native** | **native** | +| `IOU.clawback()` | `Clawback` | **native** | raw fallback¹ | | `IOU.transfer()` | `Payment` | **native** | **native** | | `IOU.buyOffer()` | `OfferCreate` | **native** | **native** | | `IOU.sellOffer()` | `OfferCreate` | **native** | **native** | diff --git a/docs/api-md/classes/IOU.md b/docs/api-md/classes/IOU.md index 24b285f..0d0fda6 100644 --- a/docs/api-md/classes/IOU.md +++ b/docs/api-md/classes/IOU.md @@ -1,11 +1,11 @@ # Class: IOU -Defined in: [verticals/iou.ts:59](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L59) +Defined in: [verticals/iou.ts:60](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L60) The IOU (trust-line currency) vertical, exposed as `client.iou`. Write operations act as the issuer ([IOUWriteOptions.from](../interfaces/IOUWriteOptions.md#from), default the primary signer); reads take an explicit `account` or default to the primary. Callers name -their own counterparty (`holder`/`destination`) per call. +their own counterparty (`holder`/`to`) per call. ## Constructors @@ -13,7 +13,7 @@ their own counterparty (`holder`/`destination`) per call. > **new IOU**(`host`): [`IOU`](IOU.md) -Defined in: [verticals/iou.ts:67](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L67) +Defined in: [verticals/iou.ts:68](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L68) Construct the IOU vertical. @@ -33,7 +33,7 @@ Construct the IOU vertical. > **authorize**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<[`IOUAuthorizeIntent`](../interfaces/IOUAuthorizeIntent.md)\>\> -Defined in: [verticals/iou.ts:182](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L182) +Defined in: [verticals/iou.ts:183](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L183) Grant authorization for a holder to hold this IOU. Only meaningful when the issuer's account has `asfRequireAuth` set. @@ -61,7 +61,7 @@ The submission result, with `{ holder }` as the intent output. > **buyOffer**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<`undefined`\>\> -Defined in: [verticals/iou.ts:333](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L333) +Defined in: [verticals/iou.ts:332](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L332) Place an order on the DEX to acquire more of this IOU. @@ -88,7 +88,7 @@ The submission result. > **cancelOffer**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<\{ `offerSequence`: `number`; \}\>\> -Defined in: [verticals/iou.ts:365](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L365) +Defined in: [verticals/iou.ts:364](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L364) Cancel a standing offer placed by this IOU's issuer. @@ -112,7 +112,7 @@ output. > **clawback**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<[`IOUClawbackIntent`](../interfaces/IOUClawbackIntent.md)\>\> -Defined in: [verticals/iou.ts:264](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L264) +Defined in: [verticals/iou.ts:267](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L267) Reclaim a holder's balance back to the issuer. @@ -141,7 +141,7 @@ output. > **issue**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<[`IOUIssueIntent`](../interfaces/IOUIssueIntent.md)\>\> -Defined in: [verticals/iou.ts:100](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L100) +Defined in: [verticals/iou.ts:101](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L101) Generate a new trust-line-based IOU in one call: the issuer enables rippling (`AccountSet`), the hot wallet extends trust to the maximum limit @@ -191,7 +191,7 @@ one was requested) as its intent output. > **list**(`params`?): `Promise`\<[`IOUListResult`](../interfaces/IOUListResult.md)\> -Defined in: [verticals/iou.ts:149](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L149) +Defined in: [verticals/iou.ts:150](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L150) List every IOU trust line for an account. No signer required. @@ -213,7 +213,7 @@ The `iouID`s and shaped trust lines, index-aligned. > **listOffers**(`params`): `Promise`\<[`ListOffersResult`](../interfaces/ListOffersResult.md)\> -Defined in: [verticals/iou.ts:160](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L160) +Defined in: [verticals/iou.ts:161](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L161) List all open offers in the market for this IOU (both sides), tagged buy/sell relative to it. No signer required. @@ -236,7 +236,7 @@ The shaped offers, composable into `buyOffer`/`sellOffer`. > **lock**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<[`IOULockIntent`](../interfaces/IOULockIntent.md)\>\> -Defined in: [verticals/iou.ts:217](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L217) +Defined in: [verticals/iou.ts:220](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L220) Freeze a holder's ability to send and receive this IOU: Individual Freeze followed by Deep Freeze. @@ -265,7 +265,7 @@ intent output. > **retrieve**(`params`): `Promise`\<[`IOURetrieveResult`](../interfaces/IOURetrieveResult.md)\> -Defined in: [verticals/iou.ts:139](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L139) +Defined in: [verticals/iou.ts:140](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L140) Read a single IOU trust line (point-in-time). No signer required. @@ -287,7 +287,7 @@ The `iouID` and the trust-line snapshot (or `undefined`). > **sellOffer**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<`undefined`\>\> -Defined in: [verticals/iou.ts:349](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L349) +Defined in: [verticals/iou.ts:348](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L348) Place an order on the DEX to sell this IOU. @@ -314,7 +314,7 @@ The submission result. > **transfer**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<[`IOUTransferIntent`](../interfaces/IOUTransferIntent.md)\>\> -Defined in: [verticals/iou.ts:297](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L297) +Defined in: [verticals/iou.ts:300](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L300) Send a specified amount of this IOU to a destination account. @@ -329,7 +329,7 @@ Send a specified amount of this IOU to a destination account. `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<[`IOUTransferIntent`](../interfaces/IOUTransferIntent.md)\>\> -The submission result, with `{ destination, amount }` as the +The submission result, with `{ to, amount }` as the intent output. *** @@ -338,7 +338,7 @@ intent output. > **unlock**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<[`IOULockIntent`](../interfaces/IOULockIntent.md)\>\> -Defined in: [verticals/iou.ts:239](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L239) +Defined in: [verticals/iou.ts:242](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.ts#L242) Restore a holder's ability to send and receive this IOU: clears Deep Freeze then Individual Freeze. diff --git a/docs/api-md/classes/IntentInspector.md b/docs/api-md/classes/IntentInspector.md index 2ddfc86..b8a5aab 100644 --- a/docs/api-md/classes/IntentInspector.md +++ b/docs/api-md/classes/IntentInspector.md @@ -1,6 +1,6 @@ # Class: IntentInspector -Defined in: [client/intent-inspector.ts:34](https://github.com/ripple/simpleXRPL/blob/main/src/client/intent-inspector.ts#L34) +Defined in: [client/intent-inspector.ts:36](https://github.com/ripple/simpleXRPL/blob/main/src/client/intent-inspector.ts#L36) Read-only observation of custodian governance intents the SDK previously created (TDD §10.4): resume polling or waiting on an intent by id after its @@ -20,7 +20,7 @@ and can't be addressed by an intent id alone. > **new IntentInspector**(`signers`): [`IntentInspector`](IntentInspector.md) -Defined in: [client/intent-inspector.ts:43](https://github.com/ripple/simpleXRPL/blob/main/src/client/intent-inspector.ts#L43) +Defined in: [client/intent-inspector.ts:46](https://github.com/ripple/simpleXRPL/blob/main/src/client/intent-inspector.ts#L46) Construct an intent inspector over the client's signers. @@ -40,7 +40,7 @@ Construct an intent inspector over the client's signers. > **await**(`intentId`, `timeoutMs`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\> -Defined in: [client/intent-inspector.ts:68](https://github.com/ripple/simpleXRPL/blob/main/src/client/intent-inspector.ts#L68) +Defined in: [client/intent-inspector.ts:74](https://github.com/ripple/simpleXRPL/blob/main/src/client/intent-inspector.ts#L74) Resume blocking on an intent until it reaches a terminal state. @@ -71,11 +71,47 @@ The terminal submission result. *** +### awaitOnChain() + +> **awaitOnChain**(`intentId`, `timeoutMs`?): `Promise`\<`undefined` \| [`OnChainResult`](../interfaces/OnChainResult.md)\> + +Defined in: [client/intent-inspector.ts:98](https://github.com/ripple/simpleXRPL/blob/main/src/client/intent-inspector.ts#L98) + +Poll the custodian's transaction layer until the XRPL transaction linked to +`intentId` is confirmed on-chain, then return its outcome. + +This covers the second async layer that [await](IntentInspector.md#await) does not: `await` +returns when the governance intent reaches `Executed` (policy approved), +while `awaitOnChain` returns when the XRPL transaction is actually +confirmed on the ledger. Both calls are needed to know that funds or state +changes have fully landed. + +Only available when a Ripple Custody signer is configured. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `intentId` | `string` | The intent id returned at submission. | +| `timeoutMs`? | `number` | How long to poll before giving up (custodian default if omitted). | + +#### Returns + +`Promise`\<`undefined` \| [`OnChainResult`](../interfaces/OnChainResult.md)\> + +The on-chain result, or `undefined` when the timeout elapses. + +#### Throws + +[SimpleXRPLError](SimpleXRPLError.md) if no Ripple Custody signer is configured. + +*** + ### handleFor() > **handleFor**(`intentId`): [`SubmissionHandle`](../interfaces/SubmissionHandle.md) -Defined in: [client/intent-inspector.ts:83](https://github.com/ripple/simpleXRPL/blob/main/src/client/intent-inspector.ts#L83) +Defined in: [client/intent-inspector.ts:118](https://github.com/ripple/simpleXRPL/blob/main/src/client/intent-inspector.ts#L118) Build a handle over an intent by id, via the first custodian that can observe governance intents. @@ -102,7 +138,7 @@ A handle to poll or wait on the intent. > **status**(`intentId`): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\> -Defined in: [client/intent-inspector.ts:54](https://github.com/ripple/simpleXRPL/blob/main/src/client/intent-inspector.ts#L54) +Defined in: [client/intent-inspector.ts:60](https://github.com/ripple/simpleXRPL/blob/main/src/client/intent-inspector.ts#L60) A non-blocking snapshot of an intent's current state. diff --git a/docs/api-md/classes/LocalSigner.md b/docs/api-md/classes/LocalSigner.md index a069480..b93c717 100644 --- a/docs/api-md/classes/LocalSigner.md +++ b/docs/api-md/classes/LocalSigner.md @@ -36,7 +36,7 @@ This custodian signs locally. > **get** **primary**(): [`AccountRef`](../interfaces/AccountRef.md) -Defined in: [custodians/local/local-signer.ts:72](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L72) +Defined in: [custodians/local/local-signer.ts:77](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L77) The primary account this signer owns. @@ -58,7 +58,7 @@ The custodian's primary account; it owns this account. > **capabilities**(): [`SignerCapabilities`](../interfaces/SignerCapabilities.md) -Defined in: [custodians/local/local-signer.ts:168](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L168) +Defined in: [custodians/local/local-signer.ts:173](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L173) What this custodian can sign, consulted at dispatch time. @@ -78,7 +78,7 @@ Capabilities allowing any transactor via raw signing. > **listAccounts**(): `Promise`\<[`Account`](../interfaces/Account.md)[]\> -Defined in: [custodians/local/local-signer.ts:177](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L177) +Defined in: [custodians/local/local-signer.ts:182](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L182) The full account list, discovered at construction. @@ -98,7 +98,7 @@ One account per wallet, keyed by r-address. > **sign**(`tx`, `ctx`): `Promise`\<[`SignedEnvelope`](../interfaces/SignedEnvelope.md)\> -Defined in: [custodians/local/local-signer.ts:193](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L193) +Defined in: [custodians/local/local-signer.ts:198](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L198) Produce a signed envelope for a transaction (raw-signing paths). @@ -133,7 +133,7 @@ The signed envelope (blob + hash). > **submitAndWait**(`tx`, `ctx`): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\> -Defined in: [custodians/local/local-signer.ts:216](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L216) +Defined in: [custodians/local/local-signer.ts:221](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L221) Submit and block until the transaction reaches a terminal state. The custodian returns the transport result; the vertical attaches the typed @@ -167,7 +167,7 @@ The xrpld-sourced submission result. > **submitAsync**(`tx`, `ctx`): `Promise`\<[`SubmissionHandle`](../interfaces/SubmissionHandle.md)\> -Defined in: [custodians/local/local-signer.ts:250](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L250) +Defined in: [custodians/local/local-signer.ts:255](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L255) Submit and return a handle once the backend has accepted the intent. @@ -198,7 +198,7 @@ A pre-resolved handle over the submitted transaction. > `static` **create**(`options`): [`LocalSigner`](LocalSigner.md) -Defined in: [custodians/local/local-signer.ts:93](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L93) +Defined in: [custodians/local/local-signer.ts:98](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L98) Build a signer from pre-constructed wallets. @@ -224,7 +224,7 @@ A signer holding the given wallets. > `static` **fromEnv**(`options`?): [`LocalSigner`](LocalSigner.md) -Defined in: [custodians/local/local-signer.ts:118](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L118) +Defined in: [custodians/local/local-signer.ts:123](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L123) Build a signer from `XRPL_*_SEED` environment variables (one wallet per seed). The primary defaults to the first seed in scan order. @@ -251,7 +251,7 @@ A signer holding one wallet per discovered seed. > `static` **fromSeed**(`seed`): [`LocalSigner`](LocalSigner.md) -Defined in: [custodians/local/local-signer.ts:82](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L82) +Defined in: [custodians/local/local-signer.ts:87](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/local/local-signer.ts#L87) Build a signer from a single seed. diff --git a/docs/api-md/classes/RippleCustody.md b/docs/api-md/classes/RippleCustody.md index 2cfd26a..07c813c 100644 --- a/docs/api-md/classes/RippleCustody.md +++ b/docs/api-md/classes/RippleCustody.md @@ -1,6 +1,6 @@ # Class: RippleCustody -Defined in: [custodians/ripple/ripple-custody.ts:53](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L53) +Defined in: [custodians/ripple/ripple-custody.ts:55](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L55) Ripple Custody adapter (TDD §3.3, §7.2): wraps the Custody REST API v1. Native transactors (NATIVE\_XRPL\_TRANSACTORS) submit as a governed @@ -19,7 +19,7 @@ opt-in raw-signing path (`v0_SignManifest` + `Unsafe`) when > `readonly` **kind**: [`CustodianKind`](../type-aliases/CustodianKind.md) = `'ripple-custody'` -Defined in: [custodians/ripple/ripple-custody.ts:55](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L55) +Defined in: [custodians/ripple/ripple-custody.ts:57](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L57) This custodian wraps the Custody REST API. @@ -35,7 +35,7 @@ This custodian wraps the Custody REST API. > **get** **primary**(): [`AccountRef`](../interfaces/AccountRef.md) -Defined in: [custodians/ripple/ripple-custody.ts:78](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L78) +Defined in: [custodians/ripple/ripple-custody.ts:80](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L80) The primary account this custodian owns. @@ -59,7 +59,7 @@ The custodian's primary account; it owns this account. > **get** **tenantId**(): `string` -Defined in: [custodians/ripple/ripple-custody.ts:69](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L69) +Defined in: [custodians/ripple/ripple-custody.ts:71](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L71) The Custody domain this custodian is bound to — the tenant two instances collide on, which the client rejects at init. @@ -86,7 +86,7 @@ rejects at init (§3.1). `undefined` for backends with no tenant notion > **capabilities**(): [`SignerCapabilities`](../interfaces/SignerCapabilities.md) -Defined in: [custodians/ripple/ripple-custody.ts:122](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L122) +Defined in: [custodians/ripple/ripple-custody.ts:124](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L124) What this custodian can sign, consulted at dispatch time. @@ -106,7 +106,7 @@ This custodian's capabilities. > **listAccounts**(): `Promise`\<[`Account`](../interfaces/Account.md)[]\> -Defined in: [custodians/ripple/ripple-custody.ts:135](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L135) +Defined in: [custodians/ripple/ripple-custody.ts:137](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L137) The full account list, discovered at construction. @@ -126,7 +126,7 @@ The discovered accounts. > **observeIntent**(`intentId`): [`SubmissionHandle`](../interfaces/SubmissionHandle.md) -Defined in: [custodians/ripple/ripple-custody.ts:232](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L232) +Defined in: [custodians/ripple/ripple-custody.ts:271](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L271) Build a handle over an intent this custodian previously created. @@ -148,11 +148,62 @@ A handle to poll or wait on the intent's outcome. *** +### pollMptIssuanceId() + +> **pollMptIssuanceId**(`intentId`): `Promise`\<`string`\> + +Defined in: [custodians/ripple/ripple-custody.ts:258](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L258) + +Convenience wrapper for `Token.issue`: polls until the transaction is +confirmed and returns the MPT issuance ID, or an empty string on timeout. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `intentId` | `string` | The intent/order ID to look up. | + +#### Returns + +`Promise`\<`string`\> + +The MPT issuance ID, or an empty string on timeout. + +*** + +### pollTransactionOnChain() + +> **pollTransactionOnChain**(`intentId`, `timeoutMs`?): `Promise`\<`undefined` \| [`OnChainResult`](../interfaces/OnChainResult.md)\> + +Defined in: [custodians/ripple/ripple-custody.ts:239](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L239) + +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. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `intentId` | `string` | The intent/order ID to look up. | +| `timeoutMs`? | `number` | How long to poll (defaults to the custodian's configured intent timeout). | + +#### Returns + +`Promise`\<`undefined` \| [`OnChainResult`](../interfaces/OnChainResult.md)\> + +The on-chain result once confirmed, or `undefined` on timeout. + +*** + ### sign() > **sign**(`tx`, `ctx`): `Promise`\<[`SignedEnvelope`](../interfaces/SignedEnvelope.md)\> -Defined in: [custodians/ripple/ripple-custody.ts:151](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L151) +Defined in: [custodians/ripple/ripple-custody.ts:153](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L153) Produce a signed envelope for a transaction (raw-signing paths). @@ -184,7 +235,7 @@ raw signing is disabled. > **submitAndWait**(`tx`, `ctx`): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\> -Defined in: [custodians/ripple/ripple-custody.ts:173](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L173) +Defined in: [custodians/ripple/ripple-custody.ts:175](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L175) Submit and block until the transaction reaches a terminal state. The custodian returns the transport result; the vertical attaches the typed @@ -227,7 +278,7 @@ the raw path. > **submitAsync**(`tx`, `ctx`): `Promise`\<[`SubmissionHandle`](../interfaces/SubmissionHandle.md)\> -Defined in: [custodians/ripple/ripple-custody.ts:211](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L211) +Defined in: [custodians/ripple/ripple-custody.ts:213](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L213) Submit and return a handle once the backend has accepted the intent. @@ -263,7 +314,7 @@ submission there is not yet supported (use `submitAndWait`). > `static` **create**(`options`): `Promise`\<[`RippleCustody`](RippleCustody.md)\> -Defined in: [custodians/ripple/ripple-custody.ts:93](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L93) +Defined in: [custodians/ripple/ripple-custody.ts:95](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L95) Authenticate, resolve the intent-author's identity, and discover the domain's XRPL accounts. @@ -295,7 +346,7 @@ to `options.domainId`. > `static` **fromEnv**(`options`): `Promise`\<[`RippleCustody`](RippleCustody.md)\> -Defined in: [custodians/ripple/ripple-custody.ts:111](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L111) +Defined in: [custodians/ripple/ripple-custody.ts:113](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/ripple/ripple-custody.ts#L113) Build a RippleCustody from `RIPPLE_CUSTODY_*` environment variables (TDD §3.3). diff --git a/docs/api-md/classes/SimpleXRPLClient.md b/docs/api-md/classes/SimpleXRPLClient.md index 07e0b58..55a8f09 100644 --- a/docs/api-md/classes/SimpleXRPLClient.md +++ b/docs/api-md/classes/SimpleXRPLClient.md @@ -1,6 +1,6 @@ # Class: SimpleXRPLClient -Defined in: [client/client.ts:42](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L42) +Defined in: [client/client.ts:43](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L43) The runtime client. Binds a set of pre-constructed custodians to a network, flattens their discovered accounts into a single address to custodian index, @@ -20,7 +20,7 @@ its custodian through the acted-on account at call time. > `readonly` **account**: [`AccountVertical`](AccountVertical.md) -Defined in: [client/client.ts:68](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L68) +Defined in: [client/client.ts:69](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L69) Account settings, regular key, and deposit preauthorization. @@ -30,7 +30,7 @@ Account settings, regular key, and deposit preauthorization. > `readonly` **credential**: [`Credential`](Credential.md) -Defined in: [client/client.ts:62](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L62) +Defined in: [client/client.ts:63](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L63) On-ledger credentials (issue, accept, delete). @@ -40,7 +40,7 @@ On-ledger credentials (issue, accept, delete). > `readonly` **domain**: [`Domain`](Domain.md) -Defined in: [client/client.ts:65](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L65) +Defined in: [client/client.ts:66](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L66) Permissioned domains (create, update, delete). @@ -50,7 +50,7 @@ Permissioned domains (create, update, delete). > `readonly` **intent**: [`IntentInspector`](IntentInspector.md) -Defined in: [client/client.ts:71](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L71) +Defined in: [client/client.ts:72](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L72) Read-only observation of custodian governance intents (status/await). @@ -60,7 +60,7 @@ Read-only observation of custodian governance intents (status/await). > `readonly` **iou**: [`IOU`](IOU.md) -Defined in: [client/client.ts:56](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L56) +Defined in: [client/client.ts:57](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L57) Issued-currency (IOU) operations: issue, transfer, authorize, lock, offers. @@ -70,17 +70,33 @@ Issued-currency (IOU) operations: issue, transfer, authorize, lock, offers. > `readonly` **network**: [`NetworkInfo`](../interfaces/NetworkInfo.md) -Defined in: [client/client.ts:44](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L44) +Defined in: [client/client.ts:45](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L45) The network this client is bound to. *** +### pollMptIssuanceId + +> `readonly` **pollMptIssuanceId**: `undefined` \| (`intentId`) => `Promise`\<`string`\> + +Defined in: [client/client.ts:79](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L79) + +Poll the Ripple Custody transaction layer until the on-chain transaction +linked to `intentId` is confirmed, then return its MPT issuance ID. +`undefined` when the primary signer is not a Ripple Custody instance. + +#### Implementation of + +[`SubmissionHost`](../interfaces/SubmissionHost.md).[`pollMptIssuanceId`](../interfaces/SubmissionHost.md#pollmptissuanceid) + +*** + ### primarySigner > `readonly` **primarySigner**: `undefined` \| [`Custodian`](../interfaces/Custodian.md) -Defined in: [client/client.ts:50](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L50) +Defined in: [client/client.ts:51](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L51) The default signer, used when an operation is called without an explicit account. @@ -90,7 +106,7 @@ The default signer, used when an operation is called without an explicit account > `readonly` **signers**: readonly [`Custodian`](../interfaces/Custodian.md)[] -Defined in: [client/client.ts:47](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L47) +Defined in: [client/client.ts:48](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L48) The registered custodians (0..N). @@ -100,7 +116,7 @@ The registered custodians (0..N). > `readonly` **token**: [`Token`](Token.md) -Defined in: [client/client.ts:59](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L59) +Defined in: [client/client.ts:60](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L60) Multi-Purpose Token (MPT) family and DEX offers. @@ -110,7 +126,7 @@ Multi-Purpose Token (MPT) family and DEX offers. > `readonly` **xrp**: [`XRP`](XRP.md) -Defined in: [client/client.ts:53](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L53) +Defined in: [client/client.ts:54](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L54) Native-XRP value transfers. @@ -122,7 +138,7 @@ Native-XRP value transfers. > **get** **accounts**(): `ReadonlyMap`\<`string`, [`Account`](../interfaces/Account.md)\> -Defined in: [client/client.ts:105](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L105) +Defined in: [client/client.ts:119](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L119) All discovered accounts, keyed by r-address. @@ -140,7 +156,7 @@ The address to account index. > **get** **ledger**(): [`LedgerPort`](../interfaces/LedgerPort.md) -Defined in: [client/client.ts:115](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L115) +Defined in: [client/client.ts:129](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L129) The ledger connection for reads, autofill, and Local/raw submission. Created lazily from `network.xrpldUrl` when none was injected. @@ -163,7 +179,7 @@ The shared ledger connection for autofill and Local/raw submission. > **connect**(): `Promise`\<`void`\> -Defined in: [client/client.ts:258](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L258) +Defined in: [client/client.ts:272](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L272) Open the ledger connection. Optional — the ledger connects lazily on first use (reads, autofill, submission), so most callers never need to call this; @@ -179,7 +195,7 @@ it's useful only to pre-warm the connection. Idempotent. > **disconnect**(): `Promise`\<`void`\> -Defined in: [client/client.ts:263](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L263) +Defined in: [client/client.ts:277](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L277) Close the ledger connection (no-op for a ledger that manages its own). @@ -193,7 +209,7 @@ Close the ledger connection (no-op for a ledger that manages its own). > **primaryAddress**(): `undefined` \| `string` -Defined in: [client/client.ts:202](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L202) +Defined in: [client/client.ts:216](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L216) The primary signer's account address, or `undefined` on a no-signer client. Read methods use this as the default account to query; it never throws, so @@ -215,7 +231,7 @@ The primary account's r-address, or `undefined`. > **refreshAccounts**(): `Promise`\<`void`\> -Defined in: [client/client.ts:178](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L178) +Defined in: [client/client.ts:192](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L192) Re-discover every custodian's accounts and rebuild the index. New accounts become addressable; accounts removed upstream are gone on next lookup. @@ -234,7 +250,7 @@ become addressable; accounts removed upstream are gone on next lookup. > **registerLocalAccount**(`seed`): [`Account`](../interfaces/Account.md) -Defined in: [client/client.ts:189](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L189) +Defined in: [client/client.ts:203](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L203) Register a locally-signed account at runtime so subsequent operations can act on it. Used by `Account.create` to make a freshly generated account usable @@ -262,7 +278,7 @@ The registered account. > **requireSigner**(): [`Custodian`](../interfaces/Custodian.md) -Defined in: [client/client.ts:244](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L244) +Defined in: [client/client.ts:258](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L258) Return the primary signer, or throw if the client has none. @@ -282,7 +298,7 @@ The primary signer. > **resolveAccount**(`selector`?): [`Account`](../interfaces/Account.md) -Defined in: [client/client.ts:216](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L216) +Defined in: [client/client.ts:230](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L230) Resolve the account an operation acts on. @@ -317,7 +333,7 @@ The resolved account. > `static` **init**(`config`): `Promise`\<[`SimpleXRPLClient`](SimpleXRPLClient.md)\> -Defined in: [client/client.ts:132](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L132) +Defined in: [client/client.ts:146](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L146) Bind custodians to a network and discover their accounts. The only entry point; the runtime client is never constructed via `new`. diff --git a/docs/api-md/classes/Token.md b/docs/api-md/classes/Token.md index d64f1f9..0e96807 100644 --- a/docs/api-md/classes/Token.md +++ b/docs/api-md/classes/Token.md @@ -30,7 +30,7 @@ Construct the Token vertical. > **authorize**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<\{ `mptIssuanceId`: `string`; \}\>\> -Defined in: [verticals/token.ts:155](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L155) +Defined in: [verticals/token.ts:168](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L168) Opt the calling account in to hold an MPT issuance. @@ -53,7 +53,7 @@ The submission result. > **cancelOffer**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<\{ `offerSequence`: `number`; \}\>\> -Defined in: [verticals/token.ts:336](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L336) +Defined in: [verticals/token.ts:366](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L366) Cancel a standing offer. @@ -76,7 +76,7 @@ The submission result. > **createOffer**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<`undefined`\>\> -Defined in: [verticals/token.ts:299](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L299) +Defined in: [verticals/token.ts:329](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L329) Place an offer on the decentralized exchange. @@ -103,7 +103,7 @@ The submission result. > **destroy**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<\{ `mptIssuanceId`: `string`; \}\>\> -Defined in: [verticals/token.ts:239](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L239) +Defined in: [verticals/token.ts:252](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L252) Destroy an MPT issuance (only when no tokens are outstanding). @@ -126,7 +126,7 @@ The submission result. > **grantHolder**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<\{ `mptIssuanceId`: `string`; \}\>\> -Defined in: [verticals/token.ts:183](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L183) +Defined in: [verticals/token.ts:196](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L196) Issuer grants a specific holder permission to hold this MPT (allow-listing). @@ -246,7 +246,7 @@ The shaped offers (composable into offer write operations). > **lock**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<\{ `locked`: `boolean`; `mptIssuanceId`: `string`; \}\>\> -Defined in: [verticals/token.ts:211](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L211) +Defined in: [verticals/token.ts:224](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L224) Lock an MPT issuance, or a specific holder's balance when `holder` is given. @@ -292,7 +292,7 @@ The issuance id and snapshot (or `undefined` data if absent). > **revokeHolder**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<\{ `mptIssuanceId`: `string`; \}\>\> -Defined in: [verticals/token.ts:197](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L197) +Defined in: [verticals/token.ts:210](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L210) Issuer revokes a specific holder's permission to hold this MPT. @@ -315,7 +315,7 @@ The submission result. > **transfer**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<\{ `amount`: `string`; `to`: `string`; \}\>\> -Defined in: [verticals/token.ts:266](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L266) +Defined in: [verticals/token.ts:296](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L296) Send an MPT amount to another account. @@ -342,7 +342,7 @@ The result, echoing the transfer as its intent output. > **unauthorize**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<\{ `mptIssuanceId`: `string`; \}\>\> -Defined in: [verticals/token.ts:169](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L169) +Defined in: [verticals/token.ts:182](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L182) Opt the calling account out of holding an MPT issuance (balance must be 0). @@ -365,7 +365,7 @@ The submission result. > **unlock**(`params`, `options`?): `Promise`\<[`SubmissionResult`](../type-aliases/SubmissionResult.md)\<\{ `locked`: `boolean`; `mptIssuanceId`: `string`; \}\>\> -Defined in: [verticals/token.ts:225](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L225) +Defined in: [verticals/token.ts:238](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.ts#L238) Unlock a previously locked MPT issuance or holder balance. diff --git a/docs/api-md/functions/txToNativeSubmit.md b/docs/api-md/functions/txToNativeSubmit.md index f8026c0..012e0b9 100644 --- a/docs/api-md/functions/txToNativeSubmit.md +++ b/docs/api-md/functions/txToNativeSubmit.md @@ -2,7 +2,7 @@ > **txToNativeSubmit**(`tx`, `idempotencyKey`?): [`NativeSubmit`](../interfaces/NativeSubmit.md) -Defined in: [custodians/palisade/mapping/submit-operations.ts:53](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/palisade/mapping/submit-operations.ts#L53) +Defined in: [custodians/palisade/mapping/submit-operations.ts:80](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/palisade/mapping/submit-operations.ts#L80) Map a built xrpl.js transaction to its Palisade native submission. Fields with no native slot throw [SignerCapabilityError](../classes/SignerCapabilityError.md) rather than being diff --git a/docs/api-md/functions/validateTokenMetadata.md b/docs/api-md/functions/validateTokenMetadata.md index 92e8694..fd653f8 100644 --- a/docs/api-md/functions/validateTokenMetadata.md +++ b/docs/api-md/functions/validateTokenMetadata.md @@ -2,7 +2,7 @@ > **validateTokenMetadata**(`metadata`): `string`[] -Defined in: [verticals/token.helpers.ts:172](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.helpers.ts#L172) +Defined in: [verticals/token.helpers.ts:173](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/token.helpers.ts#L173) Check MPT metadata against the XLS-89 standard without throwing — the pre-flight companion to `Token.issue`. Accepts a structured object or a raw diff --git a/docs/api-md/globals.md b/docs/api-md/globals.md index b1ff945..3501737 100644 --- a/docs/api-md/globals.md +++ b/docs/api-md/globals.md @@ -122,6 +122,7 @@ verticals, core types, and error classes) is built out incrementally. - [NetworkInfo](interfaces/NetworkInfo.md) - [OfferFlags](interfaces/OfferFlags.md) - [OfferSummary](interfaces/OfferSummary.md) +- [OnChainResult](interfaces/OnChainResult.md) - [PalisadeCallArgs](interfaces/PalisadeCallArgs.md) - [PalisadeClientCredentials](interfaces/PalisadeClientCredentials.md) - [PalisadeCredentials](interfaces/PalisadeCredentials.md) diff --git a/docs/api-md/interfaces/IOUTransferIntent.md b/docs/api-md/interfaces/IOUTransferIntent.md index d8c9294..f27d2ee 100644 --- a/docs/api-md/interfaces/IOUTransferIntent.md +++ b/docs/api-md/interfaces/IOUTransferIntent.md @@ -16,9 +16,9 @@ Amount sent. *** -### destination +### to -> `readonly` **destination**: `string` +> `readonly` **to**: `string` Defined in: [verticals/iou.types.ts:132](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.types.ts#L132) diff --git a/docs/api-md/interfaces/IOUTransferParams.md b/docs/api-md/interfaces/IOUTransferParams.md index 5fcde3c..728044d 100644 --- a/docs/api-md/interfaces/IOUTransferParams.md +++ b/docs/api-md/interfaces/IOUTransferParams.md @@ -26,16 +26,6 @@ digits against the IOU limit — so amounts are kept in decimal end to end. *** -### destination - -> `readonly` **destination**: `string` - -Defined in: [verticals/iou.types.ts:116](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.types.ts#L116) - -The destination r-address. - -*** - ### ticker > `readonly` **ticker**: `string` @@ -49,3 +39,13 @@ the 40-character hex form. #### Inherited from [`IOURef`](IOURef.md).[`ticker`](IOURef.md#ticker) + +*** + +### to + +> `readonly` **to**: `string` + +Defined in: [verticals/iou.types.ts:116](https://github.com/ripple/simpleXRPL/blob/main/src/verticals/iou.types.ts#L116) + +The destination r-address. diff --git a/docs/api-md/interfaces/NativeSubmit.md b/docs/api-md/interfaces/NativeSubmit.md index 2be69eb..dc7b8fb 100644 --- a/docs/api-md/interfaces/NativeSubmit.md +++ b/docs/api-md/interfaces/NativeSubmit.md @@ -1,6 +1,6 @@ # Interface: NativeSubmit -Defined in: [custodians/palisade/mapping/submit-operations.ts:32](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/palisade/mapping/submit-operations.ts#L32) +Defined in: [custodians/palisade/mapping/submit-operations.ts:59](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/palisade/mapping/submit-operations.ts#L59) A native submission: the wallet-relative sub-path and its typed JSON body. @@ -10,7 +10,7 @@ A native submission: the wallet-relative sub-path and its typed JSON body. > `readonly` **body**: `unknown` -Defined in: [custodians/palisade/mapping/submit-operations.ts:36](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/palisade/mapping/submit-operations.ts#L36) +Defined in: [custodians/palisade/mapping/submit-operations.ts:63](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/palisade/mapping/submit-operations.ts#L63) The typed Palisade request body. @@ -20,6 +20,6 @@ The typed Palisade request body. > `readonly` **subPath**: `string` -Defined in: [custodians/palisade/mapping/submit-operations.ts:34](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/palisade/mapping/submit-operations.ts#L34) +Defined in: [custodians/palisade/mapping/submit-operations.ts:61](https://github.com/ripple/simpleXRPL/blob/main/src/custodians/palisade/mapping/submit-operations.ts#L61) The wallet-relative op sub-path (e.g. `transfer`, `xrp/trust-set`). diff --git a/docs/api-md/interfaces/NetworkInfo.md b/docs/api-md/interfaces/NetworkInfo.md index 322ec99..27c1f4e 100644 --- a/docs/api-md/interfaces/NetworkInfo.md +++ b/docs/api-md/interfaces/NetworkInfo.md @@ -1,6 +1,6 @@ # Interface: NetworkInfo -Defined in: [client/client.ts:25](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L25) +Defined in: [client/client.ts:26](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L26) The network a client is bound to. @@ -10,7 +10,7 @@ The network a client is bound to. > `readonly` `optional` **faucetUrl**: `string` -Defined in: [client/client.ts:30](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L30) +Defined in: [client/client.ts:31](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L31) Faucet endpoint, used on test networks only. @@ -20,6 +20,6 @@ Faucet endpoint, used on test networks only. > `readonly` **xrpldUrl**: `string` -Defined in: [client/client.ts:27](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L27) +Defined in: [client/client.ts:28](https://github.com/ripple/simpleXRPL/blob/main/src/client/client.ts#L28) The xrpld endpoint (`ws(s)://` or `http(s)://`). diff --git a/docs/api-md/interfaces/OnChainResult.md b/docs/api-md/interfaces/OnChainResult.md new file mode 100644 index 0000000..6b262da --- /dev/null +++ b/docs/api-md/interfaces/OnChainResult.md @@ -0,0 +1,27 @@ +# Interface: OnChainResult + +Defined in: [domain/model.ts:276](https://github.com/ripple/simpleXRPL/blob/main/src/domain/model.ts#L276) + +The on-chain outcome of a custodian-submitted transaction, available once +the ledger has confirmed it. Returned by OnChainObserver.awaitOnChain +and surfaced via `client.intent.awaitOnChain`. + +## Properties + +### mptIssuanceId? + +> `readonly` `optional` **mptIssuanceId**: `string` + +Defined in: [domain/model.ts:280](https://github.com/ripple/simpleXRPL/blob/main/src/domain/model.ts#L280) + +Present when the transaction created an MPT issuance. + +*** + +### txHash + +> `readonly` **txHash**: `string` + +Defined in: [domain/model.ts:278](https://github.com/ripple/simpleXRPL/blob/main/src/domain/model.ts#L278) + +The XRPL transaction hash. diff --git a/docs/api-md/interfaces/SubmissionHost.md b/docs/api-md/interfaces/SubmissionHost.md index cd4a57b..a1cde53 100644 --- a/docs/api-md/interfaces/SubmissionHost.md +++ b/docs/api-md/interfaces/SubmissionHost.md @@ -18,6 +18,32 @@ The shared ledger connection for autofill and Local/raw submission. *** +### pollMptIssuanceId()? + +> `optional` **pollMptIssuanceId**: (`intentId`) => `Promise`\<`string`\> + +Defined in: [pipeline/host.ts:40](https://github.com/ripple/simpleXRPL/blob/main/src/pipeline/host.ts#L40) + +Poll the custodian's transaction layer until the on-chain transaction linked +to `intentId` is confirmed, then return its MPT issuance ID. Present only +when the primary signer supports custody-side transaction observation (e.g. +Ripple Custody). `Token.issue` uses this to deterministically recover the +issuance ID without querying the XRPL ledger. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `intentId` | `string` | The intent/order ID to look up. | + +#### Returns + +`Promise`\<`string`\> + +The MPT issuance ID, or an empty string on timeout. + +*** + ### primaryAddress() > **primaryAddress**: () => `undefined` \| `string` diff --git a/docs/connector-routing.md b/docs/connector-routing.md index d3c45b1..5ba565f 100644 --- a/docs/connector-routing.md +++ b/docs/connector-routing.md @@ -17,7 +17,7 @@ is rejected. | Transactor | Local | Ripple Custody | Palisade | | ------ | ------ | ------ | ------ | | `AccountSet` | signs locally | **native** | **native** | -| `Clawback` | signs locally | **native** | **native** | +| `Clawback` | signs locally | **native** | raw fallback¹ | | `CredentialAccept` | signs locally | raw fallback¹ | raw fallback¹ | | `CredentialCreate` | signs locally | raw fallback¹ | raw fallback¹ | | `CredentialDelete` | signs locally | raw fallback¹ | raw fallback¹ | @@ -69,7 +69,7 @@ operation in-process. Read operations emit no transactor and are omitted. | `IOU.authorize()` | `TrustSet` | **native** | **native** | | `IOU.lock()` | `TrustSet` | **native** | **native** | | `IOU.unlock()` | `TrustSet` | **native** | **native** | -| `IOU.clawback()` | `Clawback` | **native** | **native** | +| `IOU.clawback()` | `Clawback` | **native** | raw fallback¹ | | `IOU.transfer()` | `Payment` | **native** | **native** | | `IOU.buyOffer()` | `OfferCreate` | **native** | **native** | | `IOU.sellOffer()` | `OfferCreate` | **native** | **native** | diff --git a/docs/palisade-api-coverage.md b/docs/palisade-api-coverage.md index 38d2eb0..9e8025a 100644 --- a/docs/palisade-api-coverage.md +++ b/docs/palisade-api-coverage.md @@ -14,7 +14,7 @@ Every Palisade v2 API operation (from the vendored OpenAPI spec, `openapi/palisa | `POST` | `/v2/vaults/{vaultId}/wallets/{walletId}/transactions/raw` | Create a new raw transaction | Raw sign-only fallback — used for transactors Palisade has no native op for (e.g. all MPT ops), when `allowRawSigning` is on. | | `POST` | `/v2/vaults/{vaultId}/wallets/{walletId}/transactions/transfer` | Create a new transfer transaction | Native Payment — `xrp.transfer`, `iou.transfer`, `token.transfer`. | | `POST` | `/v2/vaults/{vaultId}/wallets/{walletId}/transactions/xrp/account-set` | Create a new XRP Account Set transaction | Native AccountSet — `account.set`, and `iou.issue` (issuer defaultRipple). | -| `POST` | `/v2/vaults/{vaultId}/wallets/{walletId}/transactions/xrp/clawback` | Create a new XRP Clawback transaction | Native Clawback — `iou.clawback`. | +| `POST` | `/v2/vaults/{vaultId}/wallets/{walletId}/transactions/xrp/clawback` | Create a new XRP Clawback transaction | **Not used.** Clawback is disabled for Palisade: the API has no field that carries an IOU clawback's holder (XRPL puts it in `Amount.issuer`; Palisade's `holder` is spec'd MPT-only, and MPT amounts are rejected), and Palisade rejects the `AccountSet` that enables `asfAllowTrustLineClawback`. `iou.clawback` takes the raw path when raw signing is enabled. | | `POST` | `/v2/vaults/{vaultId}/wallets/{walletId}/transactions/xrp/offer-cancel` | Create a new XRP Offer Cancel transaction | Native OfferCancel — `iou`/`token`.cancelOffer. | | `POST` | `/v2/vaults/{vaultId}/wallets/{walletId}/transactions/xrp/offer-create` | Create a new XRP Offer Create transaction | Native OfferCreate — `iou`/`token` `buyOffer` / `sellOffer` / `createOffer`. | | `POST` | `/v2/vaults/{vaultId}/wallets/{walletId}/transactions/xrp/trust-set` | Create a new XRP Trust Set transaction | Native TrustSet — `iou.issue` / `authorize` / `lock` / `unlock`. | diff --git a/eslint.config.mjs b/eslint.config.mjs index fea3293..c35c01d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,11 @@ export default [ // Doc samples: type-checked via tsconfig.examples.json, not linted with // the strict src rules (they use console + placeholder literals). 'examples/', + // Local demo / scratch scripts (.gitignore'd, never published). They are + // in no tsconfig project, so the type-aware parser errors on them — + // without this, creating the demos/ directory that .gitignore invites + // breaks `npm run lint` outright. + 'demos/', // Generated from vendored OpenAPI specs — never hand-edited or linted. 'src/generated/', // Tooling configs are CommonJS / plain JS, not part of the typed project. @@ -71,9 +76,15 @@ export default [ // and the Palisade custodian aggregates every signing/submission path // (native, async, raw) plus its API wiring — both legitimately run longer // than the default file budget. - files: ['src/verticals/*.ts', 'src/custodians/palisade/palisade-custody.ts'], + files: [ + 'src/verticals/*.ts', + 'src/custodians/palisade/palisade-custody.ts', + ], rules: { - 'max-lines': ['warn', { max: 350, skipBlankLines: true, skipComments: true }], + 'max-lines': [ + 'warn', + { max: 350, skipBlankLines: true, skipComments: true }, + ], }, }, { diff --git a/src/custodians/palisade/mapping/clawback.ts b/src/custodians/palisade/mapping/clawback.ts deleted file mode 100644 index 7f2a1ca..0000000 --- a/src/custodians/palisade/mapping/clawback.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Clawback } from 'xrpl' - -import type { operations } from '../../../generated/palisade.js' - -import { toCurrencyAmount } from './currency.js' - -type ClawbackBody = - operations['TransactionsService_SubmitClawback']['requestBody']['content']['application/json'] - -/** - * Map a `Clawback` to Palisade's `SubmitClawback` body. Only issued-currency - * clawback is native; MPT clawback is rejected by {@link toCurrencyAmount}. - * - * @param tx - The Clawback transaction. - * @returns The Palisade submit body. - */ -export function mapClawback(tx: Clawback): ClawbackBody { - return { - amount: toCurrencyAmount(tx.Amount, 'Amount'), - } -} diff --git a/src/custodians/palisade/mapping/submit-operations.ts b/src/custodians/palisade/mapping/submit-operations.ts index 2ad218d..b530846 100644 --- a/src/custodians/palisade/mapping/submit-operations.ts +++ b/src/custodians/palisade/mapping/submit-operations.ts @@ -4,7 +4,6 @@ import type { TransactorType } from '../../../domain/index.js' import { SignerCapabilityError } from '../../../errors.js' import { mapAccountSet } from './account-set.js' -import { mapClawback } from './clawback.js' import { mapOfferCancel } from './offer-cancel.js' import { mapOfferCreate } from './offer-create.js' import { mapPaymentToTransfer } from './payment.js' @@ -24,10 +23,38 @@ export const PALISADE_NATIVE_TRANSACTORS: ReadonlySet = new Set( 'OfferCancel', 'TrustSet', 'AccountSet', - 'Clawback', + // Clawback is deliberately absent (unquoted: scripts/gen-connector-routing + // .mjs scrapes quoted strings from this literal, comments included). + // See the note below. ], ) +/** + * Why Clawback is not listed above. + * + * Two independent problems, both observed against the Palisade sandbox: + * + * 1. There is no correct field for the holder. XRPL carries the account being + * clawed from in `Clawback.Amount.issuer` (counter-intuitive, but it is the + * protocol's convention, and what `IOU.clawback` builds). Palisade's + * `SubmitClawback` instead takes a separate `holder`, documented in its spec + * as "Optional holder address for MPTokens" — while Palisade rejects MPT + * amounts outright. So the holder either lands in a field Palisade reads as + * the issuer, or in one scoped to a token type it will not accept. + * + * 2. Clawback cannot be enabled in the first place. `asfAllowTrustLineClawback` + * must be set on the issuer before it owns any trust line, and Palisade + * rejects that AccountSet outright (`REJECTED action=PALISADE_MANAGED`, no + * reason exposed through the API). + * + * Omitting the transactor routes Clawback to the raw-signing path when the + * custodian allows it, and otherwise fails with a clear capability error — + * rather than silently submitting a request whose holder is in the wrong field. + * + * Re-enable only once Palisade confirms both: a field that carries an IOU + * clawback's holder, and that the enabling AccountSet is accepted. + */ + /** A native submission: the wallet-relative sub-path and its typed JSON body. */ export interface NativeSubmit { /** The wallet-relative op sub-path (e.g. `transfer`, `xrp/trust-set`). */ @@ -69,8 +96,6 @@ export function txToNativeSubmit( return { subPath: 'xrp/trust-set', body: mapTrustSet(tx) } case 'AccountSet': return { subPath: 'xrp/account-set', body: mapAccountSet(tx) } - case 'Clawback': - return { subPath: 'xrp/clawback', body: mapClawback(tx) } default: throw new SignerCapabilityError( `Palisade has no native operation for ${tx.TransactionType}. Enable ` + diff --git a/src/custodians/palisade/tx-tracker.ts b/src/custodians/palisade/tx-tracker.ts index 8f31370..9e32b0c 100644 --- a/src/custodians/palisade/tx-tracker.ts +++ b/src/custodians/palisade/tx-tracker.ts @@ -6,12 +6,19 @@ import type { } from '../../domain/index.js' import { IntentPendingError, SimpleXRPLError } from '../../errors.js' import type { components } from '../../generated/palisade.js' +import type { PollSchedule } from '../poll-schedule.js' +import { pollDelayMs } from '../poll-schedule.js' import type { PalisadeHttpClient } from './transport/palisade-http-client.js' type PalisadeTransaction = components['schemas']['transactionsv2Transaction'] -const POLL_INTERVAL_MS = 1500 +/** + * Poll cadence: responsive for the first few seconds, then backing off so an + * hour-long governance wait costs on the order of a hundred requests rather + * than thousands. See {@link pollDelayMs}. + */ +const POLL_SCHEDULE: PollSchedule = { initialMs: 1500, maxMs: 30_000 } const TERMINAL_SUCCESS = 'CONFIRMED' const TERMINAL_FAILURE: ReadonlySet = new Set(['REJECTED', 'FAILED']) @@ -175,29 +182,44 @@ export class PalisadeTxTracker { }, ): Promise { const { timeoutMs, isDone } = options - const attempts = Math.max( - 1, - Math.ceil((timeoutMs ?? this.timeoutMs) / POLL_INTERVAL_MS), - ) + // Deadline-based rather than a fixed attempt count: with a backing-off + // delay, "number of polls" no longer maps to elapsed time, and the caller's + // budget is expressed in time. + const deadline = Date.now() + (timeoutMs ?? this.timeoutMs) let current = submitted - for (let attempt = 0; attempt < attempts; attempt += 1) { + for (let attempt = 0; ; attempt += 1) { if (TERMINAL_FAILURE.has(current.status)) { + // Include whatever context Palisade attached. The bare + // " REJECTED" gives the caller nothing to act on — not which + // operation, not why — and the transaction is only readable again + // through credentials the caller may not have. + const attributes = Object.entries(current.attributes ?? {}) + .map(([key, value]) => `${key}=${value}`) + .join(', ') + const context = [`action=${current.action}`, attributes] + .filter((part) => part !== '') + .join(', ') throw new SimpleXRPLError( - `Palisade transaction ${current.id} ${current.status}`, + `Palisade transaction ${current.id} ${current.status} (${context})`, ) } if (isDone(current)) { return current } - if (attempt + 1 < attempts) { - // eslint-disable-next-line no-await-in-loop -- sequential poll by design - await new Promise((resolve) => { - setTimeout(resolve, POLL_INTERVAL_MS) - }) - // eslint-disable-next-line no-await-in-loop -- sequential poll by design - current = await this.fetch(base, current.id) + const delay = pollDelayMs(attempt, POLL_SCHEDULE) + if (Date.now() + delay >= deadline) { + throw new IntentPendingError( + current.id, + 'palisade-custody', + current.status, + ) } + // eslint-disable-next-line no-await-in-loop -- sequential poll by design + await new Promise((resolve) => { + setTimeout(resolve, delay) + }) + // eslint-disable-next-line no-await-in-loop -- sequential poll by design + current = await this.fetch(base, current.id) } - throw new IntentPendingError(current.id, 'palisade-custody', current.status) } } diff --git a/src/custodians/poll-schedule.ts b/src/custodians/poll-schedule.ts new file mode 100644 index 0000000..75edf76 --- /dev/null +++ b/src/custodians/poll-schedule.ts @@ -0,0 +1,41 @@ +/** + * Delay schedule shared by the custodian polling loops. + * + * Every governed custodian waits the same way: ask for the current state, sleep, + * ask again, until a terminal state or a deadline. At a 60-second budget a flat + * interval is harmless. At the hour-long budget that multi-step operations need + * — a step can sit waiting on a human approval — a flat 1.5s interval becomes + * ~2,400 requests for a single step, and roughly 7,200 for a three-step IOU + * issuance. That is enough to look like abuse to the custodian long before the + * caller's deadline is reached. + * + * Backing off keeps the early polls responsive (a locally-signed transaction + * settles in seconds) while making a long wait cheap: an hour costs on the order + * of a hundred requests instead of thousands. + */ + +/** Growth factor between successive polls. */ +const FACTOR = 2 + +/** Inputs for {@link pollDelayMs}. */ +export interface PollSchedule { + /** Delay before the second poll; the first re-check stays this responsive. */ + readonly initialMs: number + /** Ceiling the delay grows to and then holds. */ + readonly maxMs: number +} + +/** + * The delay to wait before the poll following `attempt`. + * + * @param attempt - Zero-based index of the poll just performed. + * @param schedule - The initial delay and its ceiling. + * @returns `initialMs * 2^attempt`, clamped to `maxMs`. + */ +export function pollDelayMs(attempt: number, schedule: PollSchedule): number { + const { initialMs, maxMs } = schedule + const grown = initialMs * FACTOR ** attempt + // `grown` overflows to Infinity for large attempt counts; Math.min still + // yields maxMs, so the clamp holds without a separate guard. + return Math.min(maxMs, grown) +} diff --git a/src/custodians/ripple/auth/keypair.service.ts b/src/custodians/ripple/auth/keypair.service.ts index c22d676..2a5cf14 100644 --- a/src/custodians/ripple/auth/keypair.service.ts +++ b/src/custodians/ripple/auth/keypair.service.ts @@ -66,14 +66,24 @@ export class KeypairService { * * @param privateKey - The intent-author private key (PEM string or DER buffer). * @returns A service bound to the detected algorithm. - * @throws {@link CustodyAuthError} if the algorithm cannot be determined. + * @throws {@link CustodyAuthError} if the key cannot be parsed, or parses + * but uses an algorithm Custody does not support. */ public static fromPrivateKey(privateKey: string | Buffer): KeypairService { const algorithm = KeypairService.detectKeyType(privateKey) if (algorithm === 'unknown') { + // Distinguish the two ways detection fails. Collapsing both into one + // "unsupported algorithm" message sent at least one CI investigation + // looking at the key's curve when the real problem was that the PEM's + // newlines had been escaped in transit and it never parsed at all. throw new CustodyAuthError( - 'Unsupported or unrecognized private key algorithm. Expected a ' + - 'PEM/DER secp256k1, secp256r1, or ed25519 key.', + tryParsePrivateKey(privateKey) === null + ? 'Could not parse the intent-author private key. Expected PEM or ' + + 'DER contents; check the value is a complete key and that its ' + + 'newlines survived any environment-variable or secret-store ' + + 'round-trip.' + : 'Unsupported private key algorithm. Expected a secp256k1, ' + + 'secp256r1 (prime256v1), or ed25519 key.', ) } return new KeypairService(algorithm) diff --git a/src/custodians/ripple/construction.ts b/src/custodians/ripple/construction.ts index d1dd6c5..bfa7e30 100644 --- a/src/custodians/ripple/construction.ts +++ b/src/custodians/ripple/construction.ts @@ -212,7 +212,51 @@ async function fetchSigningKeySecret( throw new SimpleXRPLError('private_key or user_alias not found in secret') } - return { privateKeyPem: secret.private_key, publicKey: secret.public_key } + // Same escaped-newline hazard as the env var: a PEM stored in a JSON secret + // field commonly arrives with its line breaks escaped. + return { + privateKeyPem: normalizePem(secret.private_key), + publicKey: secret.public_key, + } +} + +/** + * Strip one layer of wrapping single or double quotes. + * + * Secret stores and `.env` parsers routinely hand back a quoted value. A quoted + * PEM matches none of the prefixes {@link resolveSigningKey} tests, so it would + * otherwise fall through to being treated as a file path. + * + * @param value - The raw value. + * @returns `value` without a matched pair of surrounding quotes. + */ +function stripWrappingQuotes(value: string): string { + const trimmed = value.trim() + const quoted = + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + return quoted && trimmed.length >= 2 ? trimmed.slice(1, -1) : trimmed +} + +/** + * Restore real newlines in a PEM whose line breaks were escaped in transit. + * + * A PEM is inherently multi-line, but the places it travels through — GitHub + * Actions secrets, `.env` files, JSON string fields, shell exports — frequently + * turn each newline into a literal backslash-n. `createPrivateKey` then rejects + * the value outright, and because the algorithm can no longer be detected the + * failure surfaced as "unsupported private key algorithm", which points at the + * key's curve rather than at its formatting. This is the single most common way + * a valid Custody signing key fails to load, so normalize it rather than making + * every operator rediscover it. + * + * @param pem - PEM contents, possibly carrying escaped newlines. + * @returns The PEM with `\n` / `\r\n` escape sequences replaced by real + * newlines, and a trailing newline ensured. + */ +function normalizePem(pem: string): string { + const withNewlines = pem.replace(/(?:\\r)?\\n/gu, '\n').trim() + return `${withNewlines}\n` } /** @@ -223,11 +267,12 @@ async function fetchSigningKeySecret( * @returns The resolved private/public key pair. */ async function resolveSigningKey(value: string): Promise { - if (value.startsWith(SECRETS_MANAGER_ARN_PREFIX)) { - return fetchSigningKeySecret(value) + const unquoted = stripWrappingQuotes(value) + if (unquoted.startsWith(SECRETS_MANAGER_ARN_PREFIX)) { + return fetchSigningKeySecret(unquoted) } - if (value.startsWith(PEM_MARKER)) { - return { privateKeyPem: value } + if (unquoted.startsWith(PEM_MARKER)) { + return { privateKeyPem: normalizePem(unquoted) } } // eslint-disable-next-line n/no-sync -- One-time startup config read, not on any request path. return { privateKeyPem: readFileSync(value, 'utf8') } diff --git a/src/custodians/ripple/submission/intent-polling.ts b/src/custodians/ripple/submission/intent-polling.ts index edee5a8..e35926f 100644 --- a/src/custodians/ripple/submission/intent-polling.ts +++ b/src/custodians/ripple/submission/intent-polling.ts @@ -4,13 +4,20 @@ import { IntentValidationError, } from '../../../errors.js' import type { components } from '../../../generated/custody.js' +import type { PollSchedule } from '../../poll-schedule.js' +import { pollDelayMs } from '../../poll-schedule.js' import type { CustodyHttpClient } from '../transport/custody-http-client.js' type TrustedIntent = components['schemas']['Core_TrustedIntent'] type IntentEntity = components['schemas']['Core_IntentEntity'] type IntentStatus = components['schemas']['Core_IntentStatus'] -const POLL_INTERVAL_MS = 1000 +/** + * Poll cadence: responsive at first, then backing off so a long governance wait + * (a multi-step step may wait on a human approval for the best part of an hour) + * costs a manageable number of requests. See {@link pollDelayMs}. + */ +const POLL_SCHEDULE: PollSchedule = { initialMs: 1000, maxMs: 30_000 } const HTTP_NOT_FOUND = 404 /** @@ -115,7 +122,7 @@ export async function pollIntentUntilExecuted( const { client, domainId, intentId, timeoutMs } = options const deadline = Date.now() + timeoutMs let lastStatus: IntentStatus | typeof NOT_YET_VISIBLE = NOT_YET_VISIBLE - for (;;) { + for (let attempt = 0; ; attempt += 1) { let trusted: TrustedIntent | undefined try { // eslint-disable-next-line no-await-in-loop -- Sequential polling is inherent to waiting for a terminal state. @@ -151,10 +158,11 @@ export async function pollIntentUntilExecuted( } } - if (Date.now() >= deadline) { + const delay = pollDelayMs(attempt, POLL_SCHEDULE) + if (Date.now() + delay >= deadline) { throw new IntentPendingError(intentId, 'ripple-custody', lastStatus) } // eslint-disable-next-line no-await-in-loop -- Sequential polling is inherent to waiting for a terminal state. - await sleep(POLL_INTERVAL_MS) + await sleep(delay) } } diff --git a/src/custodians/ripple/submission/transaction-polling.ts b/src/custodians/ripple/submission/transaction-polling.ts index 85efc04..cba790e 100644 --- a/src/custodians/ripple/submission/transaction-polling.ts +++ b/src/custodians/ripple/submission/transaction-polling.ts @@ -1,12 +1,15 @@ import type { OnChainResult } from '../../../domain/index.js' import type { components } from '../../../generated/custody.js' +import type { PollSchedule } from '../../poll-schedule.js' +import { pollDelayMs } from '../../poll-schedule.js' import type { CustodyHttpClient } from '../transport/custody-http-client.js' type ApiTransaction = components['schemas']['Core_ApiTransaction'] type TransactionsCollection = components['schemas']['Core_TransactionsCollection'] -const POLL_INTERVAL_MS = 5000 +/** Poll cadence for ledger confirmation, backing off. See {@link pollDelayMs}. */ +const POLL_SCHEDULE: PollSchedule = { initialMs: 5000, maxMs: 30_000 } /** * Wait for `ms` milliseconds. @@ -67,7 +70,7 @@ export async function pollTransactionOnChain( const { client, domainId, intentId, timeoutMs } = options const deadline = Date.now() + timeoutMs - for (;;) { + for (let attempt = 0; ; attempt += 1) { // eslint-disable-next-line no-await-in-loop -- Sequential polling is inherent to waiting for ledger confirmation. const collection = await client.get( `/v1/domains/${domainId}/transactions`, @@ -81,10 +84,11 @@ export async function pollTransactionOnChain( } } - if (Date.now() >= deadline) { + const delay = pollDelayMs(attempt, POLL_SCHEDULE) + if (Date.now() + delay >= deadline) { return undefined } // eslint-disable-next-line no-await-in-loop -- Sequential polling is inherent to waiting for ledger confirmation. - await sleep(POLL_INTERVAL_MS) + await sleep(delay) } } diff --git a/src/index.ts b/src/index.ts index c758191..c0f8c58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,6 +62,12 @@ export * from './ports/index.js' // seams without depending on `xrpl` directly. export type { SubmitResponse, Transaction, TxResponse } from 'xrpl' +// `Wallet` is re-exported as a value, not just a type: `LocalSigner.create()` +// accepts `Wallet` instances, so without this a caller cannot use the documented +// factory — or generate an ad-hoc keypair — without adding a direct `xrpl` +// dependency for a type this SDK already requires. +export { Wallet } from 'xrpl' + // Read-model helpers (currency decode, credential-free account resolution). export { decodeCurrency, diff --git a/src/orchestration/multi-step.ts b/src/orchestration/multi-step.ts index 3207384..cf7e334 100644 --- a/src/orchestration/multi-step.ts +++ b/src/orchestration/multi-step.ts @@ -3,6 +3,24 @@ import { MultiStepFailureError, SimpleXRPLError } from '../errors.js' import type { SubmissionHost, SubmitRequest } from '../pipeline/index.js' import { submitTransaction } from '../pipeline/index.js' +/** + * How long each step of a multi-step operation may take before the custodian + * poll gives up. + * + * Deliberately far longer than the 60-second single-step default. A step of a + * multi-step operation is not just one transaction — it is a barrier: nothing + * after it runs until it lands. On a governed custodian a step routinely waits + * on a human pressing approve, and the steps can belong to *different* accounts + * (`IOU.issue` sequences issuer → holder → issuer), so an approver may not even + * know the next step is queued behind theirs. At 60 seconds the common outcome + * was that step one timed out and the remaining steps were never submitted at + * all, leaving a half-configured issuer. + * + * An hour is a bound, not a promise: `IntentPendingError` still ends the wait, + * and the intent goes on living custodian-side to be resumed by id. + */ +export const MULTI_STEP_STEP_TIMEOUT_MS = 3_600_000 + /** * Wrap a non-`SimpleXRPLError` so `MultiStepFailureError.failed.error` stays typed. * @@ -45,7 +63,12 @@ export async function runMultiStep( let result: SubmissionResult try { // eslint-disable-next-line no-await-in-loop -- Steps commit sequentially by design; no rollback exists. - result = await submitTransaction(host, step) + result = await submitTransaction(host, { + ...step, + // `??`, not an overwrite: an explicit per-step timeout still wins, so a + // caller (or a future vertical) can opt a single step back out. + timeoutMs: step.timeoutMs ?? MULTI_STEP_STEP_TIMEOUT_MS, + }) } catch (error) { throw new MultiStepFailureError(committed, { step: index, diff --git a/src/verticals/iou.helpers.ts b/src/verticals/iou.helpers.ts index 7829f99..0a16070 100644 --- a/src/verticals/iou.helpers.ts +++ b/src/verticals/iou.helpers.ts @@ -65,9 +65,27 @@ const HOLDER_SEED_ENV = 'XRPL_HOT_WALLET_SEED' * * @param currency - The caller-supplied currency code. * @returns The code to use as the transaction's `currency` field. - * @throws {@link IntentValidationError} if the code doesn't fit in 20 bytes. + * @throws {@link IntentValidationError} if the code is missing/empty, or + * doesn't fit in 20 bytes. */ export function encodeCurrencyCode(currency: string): string { + // Typed `string`, so TypeScript callers cannot reach this — but the SDK ships + // to JavaScript consumers too, and omitting `ticker` used to reach + // `currency.length` and surface as a bare + // "TypeError: Cannot read properties of undefined (reading 'length')", + // naming neither the field nor the method that failed. + // Widened to `unknown` deliberately: the declared type says this cannot be + // undefined, which is exactly why the compiler would otherwise reject the + // check that catches JavaScript callers passing nothing. + const received: unknown = currency + if (typeof received !== 'string' || received === '') { + const shown = + received === undefined ? 'undefined' : JSON.stringify(received) + throw new IntentValidationError( + `ticker is required and must be a non-empty string, but received ` + + `${shown}. Pass the IOU's currency code, e.g. 'USD'.`, + ) + } if ( currency.length === STANDARD_CURRENCY_CODE_LENGTH || HEX_CURRENCY_CODE.test(currency) @@ -398,15 +416,6 @@ export function readIssuanceSeeds(): { return { issuerSeed, holderSeed } } -/** - * Verify the issuer has enabled `asfAllowTrustLineClawback` before allowing a - * clawback, per the API mapping's note that the SDK "verifies canClawback was - * set to true at token creation." - * - * @param host - The client the read runs against. - * @param issuerAddress - The issuer's r-address. - * @throws {@link IntentValidationError} if the flag is not set. - */ /** * Verify the issuer has `asfRequireAuth` set before authorizing a holder. * @@ -441,6 +450,15 @@ export async function assertRequireAuthEnabled( } } +/** + * Verify the issuer has enabled `asfAllowTrustLineClawback` before allowing a + * clawback, per the API mapping's note that the SDK "verifies canClawback was + * set to true at token creation." + * + * @param host - The client the read runs against. + * @param issuerAddress - The issuer's r-address. + * @throws {@link IntentValidationError} if the flag is not set. + */ export async function assertClawbackEnabled( host: SubmissionHost, issuerAddress: string, diff --git a/src/verticals/iou.ts b/src/verticals/iou.ts index 9f01d2d..b043471 100644 --- a/src/verticals/iou.ts +++ b/src/verticals/iou.ts @@ -55,7 +55,7 @@ import type { * The IOU (trust-line currency) vertical, exposed as `client.iou`. Write operations * act as the issuer ({@link IOUWriteOptions.from}, default the primary signer); * reads take an explicit `account` or default to the primary. Callers name - * their own counterparty (`holder`/`destination`) per call. + * their own counterparty (`holder`/`to`) per call. */ export class IOU { private readonly host: SubmissionHost @@ -294,7 +294,7 @@ export class IOU { * @param params - The IOU, destination, and amount. * @param options - Issuer account, fee override, and idempotency key (see * {@link IOUWriteOptions}). - * @returns The submission result, with `{ destination, amount }` as the + * @returns The submission result, with `{ to, amount }` as the * intent output. */ public async transfer( @@ -307,11 +307,7 @@ export class IOU { issuer: issuer.address, value: iouValue(params.amount, 'amount'), } - const transaction = buildIssuedPayment( - issuer.address, - params.destination, - amount, - ) + const transaction = buildIssuedPayment(issuer.address, params.to, amount) const result = await submitTransaction(this.host, { transaction, account: issuer, @@ -319,7 +315,7 @@ export class IOU { idempotencyKey: options?.idempotencyKey, }) return withIntent(result, { - destination: params.destination, + to: params.to, amount: params.amount, }) } diff --git a/src/verticals/iou.types.ts b/src/verticals/iou.types.ts index ed3ac92..5e2a4f7 100644 --- a/src/verticals/iou.types.ts +++ b/src/verticals/iou.types.ts @@ -113,7 +113,7 @@ export interface IOUClawbackIntent { /** Parameters for {@link IOU.transfer}. */ export interface IOUTransferParams extends IOURef { /** The destination r-address. */ - readonly destination: string + readonly to: string /** * The amount to send, as a decimal string (e.g. `'10'`, `'0.25'`). * @@ -129,7 +129,7 @@ export interface IOUTransferParams extends IOURef { /** Output attached to an {@link IOU.transfer} result. */ export interface IOUTransferIntent { /** Destination r-address. */ - readonly destination: string + readonly to: string /** Amount sent. */ readonly amount: string } diff --git a/src/verticals/token.ts b/src/verticals/token.ts index 997a867..501b8ee 100644 --- a/src/verticals/token.ts +++ b/src/verticals/token.ts @@ -254,6 +254,23 @@ export class Token { options?: TokenWriteOptions, ): Promise> { const account = this.host.resolveAccount(options?.from) + // The ledger refuses to destroy an issuance that still has tokens in + // circulation, but it says so as `tecHAS_OBLIGATIONS` — a code that names + // neither the issuance nor the amount outstanding, and reads as an opaque + // failure to anyone who has not memorised the tec codes. Check first so the + // caller is told what is actually holding the destroy up. + const current = await retrieveToken(this.host, { + mptIssuanceId: params.mptIssuanceId, + }) + const outstanding = current.data?.outstandingAmount + if (outstanding !== undefined && outstanding !== '0') { + throw new IntentValidationError( + `MPT issuance ${params.mptIssuanceId} still has ${outstanding} in ` + + 'circulation (base units), so it cannot be destroyed. Have every ' + + 'holder return their balance to the issuer with Token.transfer ' + + 'first, then retry.', + ) + } const tx: MPTokenIssuanceDestroy = { TransactionType: 'MPTokenIssuanceDestroy', Account: account.address, diff --git a/test/integration/iou.test.ts b/test/integration/iou.test.ts index 29c3a96..c5403d6 100644 --- a/test/integration/iou.test.ts +++ b/test/integration/iou.test.ts @@ -96,7 +96,7 @@ describe('IOU vertical (live testnet)', () => { expect(unlocked?.freeze_peer ?? false).toBe(false) await client.iou.transfer( - { ticker: 'USD', destination: holder.classicAddress, amount: '50' }, + { ticker: 'USD', to: holder.classicAddress, amount: '50' }, from, ) // Read the holder's balance back through the SDK. @@ -167,7 +167,7 @@ describe('IOU vertical (live testnet)', () => { const from = { from: issuer.classicAddress } await client.iou.issue({ ticker: 'USD' }) await client.iou.transfer( - { ticker: 'USD', destination: holder.classicAddress, amount: '50' }, + { ticker: 'USD', to: holder.classicAddress, amount: '50' }, from, ) diff --git a/test/unit/construction/construction.test.ts b/test/unit/construction/construction.test.ts index 1a3dc13..a97e6b9 100644 --- a/test/unit/construction/construction.test.ts +++ b/test/unit/construction/construction.test.ts @@ -1,3 +1,4 @@ +import { KeypairService } from '../../../src/custodians/ripple/auth/keypair.service.js' import { resolveFromEnvOptions } from '../../../src/custodians/ripple/construction.js' import { SimpleXRPLError } from '../../../src/errors.js' import { generateTestKey } from '../custody-auth/test-utils.js' @@ -45,6 +46,65 @@ describe('resolveFromEnvOptions', () => { expect(send).not.toHaveBeenCalled() }) + it('restores a PEM whose newlines were escaped in transit', async () => { + // How a multi-line PEM most often arrives from a GitHub Actions secret or a + // .env file. Left alone it fails to parse, and the resulting error blames + // the key's algorithm rather than its formatting. + const escaped = SIGNING_KEY_PEM.replace(/\n/gu, String.raw`\n`) + expect(escaped).not.toBe(SIGNING_KEY_PEM) + + const options = await resolveFromEnvOptions({ + primary: 'rPrimary', + env: envWith(escaped), + }) + + expect(options.auth.signingKey).toBe(SIGNING_KEY_PEM) + expect(KeypairService.detectKeyType(options.auth.signingKey)).toBe( + 'ed25519', + ) + }) + + it('restores a PEM whose newlines were escaped as CRLF', async () => { + const escaped = SIGNING_KEY_PEM.replace(/\n/gu, String.raw`\r\n`) + const options = await resolveFromEnvOptions({ + primary: 'rPrimary', + env: envWith(escaped), + }) + expect(options.auth.signingKey).toBe(SIGNING_KEY_PEM) + }) + + it('strips wrapping quotes so a quoted PEM is not mistaken for a file path', async () => { + const options = await resolveFromEnvOptions({ + primary: 'rPrimary', + env: envWith(`"${SIGNING_KEY_PEM}"`), + }) + expect(options.auth.signingKey).toBe(SIGNING_KEY_PEM) + }) + + it('strips wrapping quotes from a Secrets Manager ARN', async () => { + send.mockResolvedValueOnce({ SecretString: JSON.stringify(SECRET_JSON) }) + const options = await resolveFromEnvOptions({ + primary: 'rPrimary', + env: envWith(`'${SECRET_ARN}'`), + }) + expect(options.auth.signingKey).toBe(SIGNING_KEY_PEM) + expect(send).toHaveBeenCalledTimes(1) + }) + + it('restores escaped newlines in a PEM stored in a Secrets Manager secret', async () => { + send.mockResolvedValueOnce({ + SecretString: JSON.stringify({ + ...SECRET_JSON, + private_key: SIGNING_KEY_PEM.replace(/\n/gu, String.raw`\n`), + }), + }) + const options = await resolveFromEnvOptions({ + primary: 'rPrimary', + env: envWith(SECRET_ARN), + }) + expect(options.auth.signingKey).toBe(SIGNING_KEY_PEM) + }) + it('reads an explicit RIPPLE_CUSTODY_AUTH_CLIENT_ID', async () => { const options = await resolveFromEnvOptions({ primary: 'rPrimary', diff --git a/test/unit/custodians/palisade/mapping.test.ts b/test/unit/custodians/palisade/mapping.test.ts index eb48929..81326bc 100644 --- a/test/unit/custodians/palisade/mapping.test.ts +++ b/test/unit/custodians/palisade/mapping.test.ts @@ -6,6 +6,7 @@ import { } from 'xrpl' import type { AccountSet, Clawback, OfferCreate, Payment, TrustSet } from 'xrpl' +import { toCurrencyAmount } from '../../../../src/custodians/palisade/mapping/currency.js' import { buildRawTransactionBody, PALISADE_NATIVE_TRANSACTORS, @@ -204,31 +205,35 @@ describe('txToNativeSubmit — AccountSet', () => { }) }) -describe('txToNativeSubmit — Clawback / OfferCreate / OfferCancel', () => { - it('maps a Clawback amount (IOU)', () => { +describe('txToNativeSubmit — Clawback (unsupported) / OfferCreate / OfferCancel', () => { + it('does not map Clawback natively', () => { + // Deliberately unsupported: Palisade has no field that carries an IOU + // clawback's holder, and rejects the AccountSet that enables clawback at + // all. Routing it native would submit a request with the holder in a field + // Palisade reads as the issuer. const tx: Clawback = { TransactionType: 'Clawback', Account: 'rIssuer', Amount: { currency: 'USD', issuer: 'rHolder', value: '25' }, } - const { subPath, body } = txToNativeSubmit(tx) - expect(subPath).toBe('xrp/clawback') - expect(body).toEqual({ - amount: { asset: 'USD', issuer: 'rHolder', value: '25' }, - }) - }) - - it('rejects an MPT clawback amount by name', () => { - // MPT reaches toCurrencyAmount through Clawback; the error must name the - // field and the two ways out, since it is a hard capability boundary. - const tx: Clawback = { - TransactionType: 'Clawback', - Account: 'rIssuer', - Amount: { mpt_issuance_id: 'ABCDEF', value: '5' }, - } + expect(PALISADE_NATIVE_TRANSACTORS.has('Clawback')).toBe(false) expect(() => txToNativeSubmit(tx)).toThrow(SignerCapabilityError) expect(() => txToNativeSubmit(tx)).toThrow( - /no native MPT support for Amount.*allowRawSigning/su, + /no native operation for Clawback/u, + ) + }) + + it('rejects an MPT amount by name', () => { + // toCurrencyAmount's MPT branch is a hard capability boundary, so the error + // must name the field and the two ways out. Asserted directly: it used to + // be reached through Clawback, and the transactors that still call it + // (OfferCreate, TrustSet) are typed to exclude MPT amounts outright. + const amount = { mpt_issuance_id: 'ABCDEF', value: '5' } + expect(() => toCurrencyAmount(amount, 'TakerGets')).toThrow( + SignerCapabilityError, + ) + expect(() => toCurrencyAmount(amount, 'TakerGets')).toThrow( + /no native MPT support for TakerGets.*allowRawSigning/su, ) }) @@ -287,18 +292,13 @@ describe('txToNativeSubmit — non-native', () => { ).toThrow(SignerCapabilityError) }) - it('lists exactly the six natively-mapped transactors', () => { + it('lists exactly the five natively-mapped transactors', () => { const asc = (left: string, right: string): number => left.localeCompare(right) expect(Array.from(PALISADE_NATIVE_TRANSACTORS).sort(asc)).toEqual( - [ - 'AccountSet', - 'Clawback', - 'OfferCancel', - 'OfferCreate', - 'Payment', - 'TrustSet', - ].sort(asc), + ['AccountSet', 'OfferCancel', 'OfferCreate', 'Payment', 'TrustSet'].sort( + asc, + ), ) }) }) diff --git a/test/unit/custodians/palisade/palisade-custody.test.ts b/test/unit/custodians/palisade/palisade-custody.test.ts index 7c611dd..859a752 100644 --- a/test/unit/custodians/palisade/palisade-custody.test.ts +++ b/test/unit/custodians/palisade/palisade-custody.test.ts @@ -262,6 +262,27 @@ describe('PalisadeCustody.submitAndWait — native', () => { expect(port.posts).toHaveLength(0) }) + it('carries the action and attributes into a REJECTED error', async () => { + // A bare " REJECTED" leaves the caller nothing to act on, and the + // transaction may only be re-readable through credentials they lack. + const port = fakePort({ + onSubmit: () => ({ + id: 'tx1', + status: 'REJECTED', + action: 'PALISADE_MANAGED', + attributes: { reason: 'policy denied' }, + }), + }) + const custody = await makeCustody(port) + const account = (await custody.listAccounts())[0] + const promise = custody.submitAndWait( + payment, + contextFor(account, ledgerStub()), + ) + await expect(promise).rejects.toThrow(/action=PALISADE_MANAGED/u) + await expect(promise).rejects.toThrow(/reason=policy denied/u) + }) + it('throws when the native submission is REJECTED', async () => { const port = fakePort({ onSubmit: () => ({ id: 'tx1', status: 'REJECTED' }), diff --git a/test/unit/custodians/poll-schedule.test.ts b/test/unit/custodians/poll-schedule.test.ts new file mode 100644 index 0000000..7d0892b --- /dev/null +++ b/test/unit/custodians/poll-schedule.test.ts @@ -0,0 +1,44 @@ +import { pollDelayMs } from '../../../src/custodians/poll-schedule.js' + +const SCHEDULE = { initialMs: 1500, maxMs: 30_000 } + +describe('pollDelayMs', () => { + it('starts at the initial delay and doubles', () => { + expect(pollDelayMs(0, SCHEDULE)).toBe(1500) + expect(pollDelayMs(1, SCHEDULE)).toBe(3000) + expect(pollDelayMs(2, SCHEDULE)).toBe(6000) + expect(pollDelayMs(3, SCHEDULE)).toBe(12_000) + expect(pollDelayMs(4, SCHEDULE)).toBe(24_000) + }) + + it('holds at the ceiling instead of growing without bound', () => { + expect(pollDelayMs(5, SCHEDULE)).toBe(30_000) + expect(pollDelayMs(50, SCHEDULE)).toBe(30_000) + // 2 ** 2000 overflows to Infinity; the clamp must still hold. + expect(pollDelayMs(2000, SCHEDULE)).toBe(30_000) + }) + + it('keeps an hour-long wait to ~100 requests, not thousands', () => { + // The reason this exists: a flat 1.5s interval over the hour a multi-step + // step may wait costs ~2,400 requests per step, ~7,200 for a 3-step IOU + // issuance — enough to read as abuse well before the caller's deadline. + const budgetMs = 3_600_000 + let elapsed = 0 + let polls = 0 + while (elapsed < budgetMs) { + elapsed += pollDelayMs(polls, SCHEDULE) + polls += 1 + } + expect(polls).toBeLessThan(150) + expect(budgetMs / SCHEDULE.initialMs).toBeGreaterThan(2000) + }) + + it('stays responsive early, so a fast transaction is not delayed', () => { + // The first four polls all land inside the first ~22 seconds. + const firstFour = [0, 1, 2, 3].reduce( + (total, attempt) => total + pollDelayMs(attempt, SCHEDULE), + 0, + ) + expect(firstFour).toBeLessThanOrEqual(22_500) + }) +}) diff --git a/test/unit/custody-auth/keypair.service.test.ts b/test/unit/custody-auth/keypair.service.test.ts index 04eff06..aa02b9d 100644 --- a/test/unit/custody-auth/keypair.service.test.ts +++ b/test/unit/custody-auth/keypair.service.test.ts @@ -1,6 +1,7 @@ import { createPublicKey, createVerify, + generateKeyPairSync, verify as cryptoVerify, } from 'node:crypto' @@ -25,9 +26,25 @@ describe('KeypairService.detectKeyType', () => { expect(KeypairService.detectKeyType('not a key')).toBe('unknown') }) - it('fromPrivateKey throws CustodyAuthError on an unrecognized key', () => { + it('fromPrivateKey names formatting, not the curve, when the key will not parse', () => { + // The two failure modes must read differently: an escaped-newline PEM used + // to surface as "unsupported algorithm", which points at the curve and + // hides the real cause. expect(() => KeypairService.fromPrivateKey('garbage')).toThrow( - /Unsupported or unrecognized private key/u, + /Could not parse the intent-author private key/u, + ) + expect(() => KeypairService.fromPrivateKey('garbage')).toThrow( + /newlines survived/u, + ) + }) + + it('fromPrivateKey names the algorithm when the key parses but is unsupported', () => { + const rsa = generateKeyPairSync('rsa', { modulusLength: 2048 }) + .privateKey.export({ type: 'pkcs8', format: 'pem' }) + .toString() + expect(KeypairService.detectKeyType(rsa)).toBe('unknown') + expect(() => KeypairService.fromPrivateKey(rsa)).toThrow( + /Unsupported private key algorithm/u, ) }) }) diff --git a/test/unit/orchestration/multi-step.test.ts b/test/unit/orchestration/multi-step.test.ts index 7c982e7..75f3ca3 100644 --- a/test/unit/orchestration/multi-step.test.ts +++ b/test/unit/orchestration/multi-step.test.ts @@ -1,7 +1,10 @@ import type { Transaction } from 'xrpl' import { MultiStepFailureError, SimpleXRPLError } from '../../../src/index.js' -import { runMultiStep } from '../../../src/orchestration/index.js' +import { + MULTI_STEP_STEP_TIMEOUT_MS, + runMultiStep, +} from '../../../src/orchestration/multi-step.js' import { fakeResult, @@ -26,6 +29,48 @@ describe('runMultiStep', () => { await expect(runMultiStep(host, [])).resolves.toEqual([]) }) + it('gives every step the long multi-step timeout, not the 60s default', async () => { + // Each step is a barrier: nothing after it runs until it lands. On a + // governed custodian that wait includes a human approval, so the + // single-step default would strand the remaining steps unsubmitted. + const first = makeStepCustodian('ripple-custody', testAddress()) + const second = makeStepCustodian('ripple-custody', testAddress()) + first.queue(fakeResult('HASH1')) + second.queue(fakeResult('HASH2')) + const host = makeFakeHost([first.account, second.account]) + + await runMultiStep(host, [ + { + transaction: accountSetTx(first.account.address), + account: first.account, + }, + { + transaction: accountSetTx(second.account.address), + account: second.account, + }, + ]) + + expect(MULTI_STEP_STEP_TIMEOUT_MS).toBe(3_600_000) + expect(first.calls[0].ctx.timeoutMs).toBe(MULTI_STEP_STEP_TIMEOUT_MS) + expect(second.calls[0].ctx.timeoutMs).toBe(MULTI_STEP_STEP_TIMEOUT_MS) + }) + + it('lets an explicit per-step timeout override the multi-step default', async () => { + const only = makeStepCustodian('ripple-custody', testAddress()) + only.queue(fakeResult('HASH1')) + const host = makeFakeHost([only.account]) + + await runMultiStep(host, [ + { + transaction: accountSetTx(only.account.address), + account: only.account, + timeoutMs: 5_000, + }, + ]) + + expect(only.calls[0].ctx.timeoutMs).toBe(5_000) + }) + it('runs every step and returns the results in order', async () => { const first = makeStepCustodian('ripple-custody', testAddress()) const second = makeStepCustodian('ripple-custody', testAddress()) diff --git a/test/unit/token/token.test.ts b/test/unit/token/token.test.ts index b9f757c..d4ab329 100644 --- a/test/unit/token/token.test.ts +++ b/test/unit/token/token.test.ts @@ -253,6 +253,45 @@ describe('Token vertical', () => { }) describe('destroy', () => { + /** + * A client whose `ledger_entry` read reports a given outstanding amount. + * + * @param outstandingAmount - Base-unit amount still in circulation. + * @returns The client and the transactions it builds. + */ + async function clientWithOutstanding( + outstandingAmount: string, + ): Promise { + const txs: Transaction[] = [] + const ledger: LedgerPort = { + async autofill(tx: Transaction): Promise { + txs.push(tx) + return { ...tx, Sequence: 1, Fee: '12', LastLedgerSequence: 100 } + }, + submit: async (): Promise => + ({ result: {} }) as unknown as SubmitResponse, + submitAndWait: async (): Promise => + ({ result: { hash: 'HASH' } }) as unknown as TxResponse, + request: async (): Promise => + ({ + result: { + node: { + Issuer: 'rIssuer', + AssetScale: 2, + OutstandingAmount: outstandingAmount, + Flags: 0, + }, + }, + }) as T, + } + const client = await SimpleXRPL.init({ + xrpldUrl: 'wss://x.invalid', + signers: [LocalSigner.fromSeed(Wallet.generate().seed as string)], + ledger, + }) + return { client, txs } + } + it('builds MPTokenIssuanceDestroy', async () => { const { client, txs } = await tokenClient() await client.token.destroy({ mptIssuanceId: MPT_ID }) @@ -260,6 +299,25 @@ describe('Token vertical', () => { expect(tx.TransactionType).toBe('MPTokenIssuanceDestroy') expect(tx.MPTokenIssuanceID).toBe(MPT_ID) }) + + it('refuses to destroy an issuance with tokens still in circulation', async () => { + // The ledger would reject this as tecHAS_OBLIGATIONS, which names neither + // the issuance nor the amount outstanding. + const { client, txs } = await clientWithOutstanding('10000') + await expect( + client.token.destroy({ mptIssuanceId: MPT_ID }), + ).rejects.toThrow(/still has 10000 in circulation/u) + expect(txs).toHaveLength(0) + }) + + it('destroys when nothing is outstanding', async () => { + const { client, txs } = await clientWithOutstanding('0') + await client.token.destroy({ mptIssuanceId: MPT_ID }) + expect(txs).toHaveLength(1) + expect((txs[0] as MPTokenIssuanceDestroy).TransactionType).toBe( + 'MPTokenIssuanceDestroy', + ) + }) }) describe('transfer', () => { diff --git a/test/unit/verticals/iou.test.ts b/test/unit/verticals/iou.test.ts index fb0b28f..d8134a4 100644 --- a/test/unit/verticals/iou.test.ts +++ b/test/unit/verticals/iou.test.ts @@ -45,7 +45,6 @@ function fakeLedger( txs: Transaction[] } { const txs: Transaction[] = [] - // eslint-disable-next-line no-bitwise -- compose the account's ledger flag bits const flags = (clawbackEnabled ? 0x80000000 : 0) | (requireAuth ? 0x00040000 : 0) const ledger: LedgerPort = { @@ -254,11 +253,32 @@ describe('IOU amounts are validated at the API boundary', () => { // surfaced as an opaque "Decimal precision out of range" at signing time. const imprecise = String(0.1 + 0.2) + it('rejects a missing or empty ticker with a named field, not a TypeError', async () => { + // Typed `string`, so this is unreachable from TypeScript — but JavaScript + // consumers used to get a bare + // "TypeError: Cannot read properties of undefined (reading 'length')" + // from deep inside encodeCurrencyCode, naming neither field nor method. + const { client, txs } = await issuedClient() + const destination = Wallet.generate().classicAddress + const send = async (ticker: unknown): Promise => + client.iou.transfer({ + ticker: ticker as string, + to: destination, + amount: '1', + }) + + await expect(send(undefined)).rejects.toBeInstanceOf(IntentValidationError) + await expect(send(undefined)).rejects.toThrow(/ticker is required/u) + await expect(send('')).rejects.toBeInstanceOf(IntentValidationError) + await expect(send('')).rejects.toThrow(/ticker is required/u) + expect(txs).toHaveLength(0) + }) + it('transfer rejects an over-precise amount before submitting', async () => { const { client, txs } = await issuedClient() const promise = client.iou.transfer({ ticker: 'USD', - destination: Wallet.generate().classicAddress, + to: Wallet.generate().classicAddress, amount: imprecise, }) await expect(promise).rejects.toBeInstanceOf(IntentValidationError) @@ -302,7 +322,7 @@ describe('IOU amounts are validated at the API boundary', () => { const { client, txs } = await issuedClient() await client.iou.transfer({ ticker: 'USD', - destination: Wallet.generate().classicAddress, + to: Wallet.generate().classicAddress, amount: '0.1', }) // '0.1' is not representable as a double; as a string it reaches the ledger @@ -315,7 +335,7 @@ describe('IOU amounts are validated at the API boundary', () => { const precise = '123456789.012345' await client.iou.transfer({ ticker: 'USD', - destination: Wallet.generate().classicAddress, + to: Wallet.generate().classicAddress, amount: precise, }) expect((txs[0] as Payment).Amount).toMatchObject({ value: precise }) @@ -425,7 +445,7 @@ describe('IOU.transfer', () => { const destination = Wallet.generate().classicAddress const result = await client.iou.transfer({ ticker: 'USD', - destination, + to: destination, amount: '50', }) const tx = txs[0] as Payment @@ -437,7 +457,7 @@ describe('IOU.transfer', () => { issuer: issuerAddress, value: '50', }) - expect(result.intent).toEqual({ destination, amount: '50' }) + expect(result.intent).toEqual({ to: destination, amount: '50' }) }) })