From 62d160f4cba4e7493a3089eb6b5889dcd8a069f8 Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Fri, 21 Aug 2026 12:09:54 -0400 Subject: [PATCH 1/8] fix: unblock lint on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `eslint . --max-warnings 0` has been failing since 2e7cf1f, which also prevented format:check and build from running in CI at all. Two warnings, one of them a real defect: - assertClawbackEnabled had no JSDoc because its docstring was orphaned above assertRequireAuthEnabled's own docstring — the latter was added later and inserted below the existing block. Move the existing prose down to the function it documents rather than writing a new one. - Drop a redundant `no-bitwise` disable in the IOU tests. no-bitwise is already off for all of test/** (eslint.config.mjs), so the directive was reported as unused. --- src/verticals/iou.helpers.ts | 18 +++++++++--------- test/unit/verticals/iou.test.ts | 1 - 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/verticals/iou.helpers.ts b/src/verticals/iou.helpers.ts index 7829f99..3198511 100644 --- a/src/verticals/iou.helpers.ts +++ b/src/verticals/iou.helpers.ts @@ -398,15 +398,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 +432,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/test/unit/verticals/iou.test.ts b/test/unit/verticals/iou.test.ts index fb0b28f..7b30777 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 = { From 23f182f0fbae481f24d18ef88a22dae1164a45e3 Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Fri, 21 Aug 2026 12:10:50 -0400 Subject: [PATCH 2/8] fix: tolerate escaped newlines in the Custody signing key The contract-tests job fails both Ripple Custody sandbox tests with "Unsupported or unrecognized private key algorithm", which points at the key's curve. The key is fine: re-parsing a known-good prime256v1 PEM with its newlines replaced by literal backslash-n reproduces the failure exactly, so the value never parsed and the algorithm could not be detected. A PEM is inherently multi-line but travels through GitHub Actions secrets, .env files, JSON secret fields and shell exports, all of which routinely escape its newlines. Normalize rather than making every operator rediscover this: - resolveSigningKey strips one layer of wrapping quotes (an otherwise valid quoted PEM matches no prefix and gets treated as a file path) and restores escaped newlines. - Apply the same normalization to a PEM read from Secrets Manager; a key in a JSON string field has the identical hazard. - Split KeypairService.fromPrivateKey's error in two, so "could not parse" no longer reports as an unsupported algorithm. Conflating them is what sent this investigation at the curve. Note this makes the SDK resilient; it does not fix the CI secret, which still needs re-provisioning with its newlines intact. --- src/custodians/ripple/auth/keypair.service.ts | 16 ++++- src/custodians/ripple/construction.ts | 55 +++++++++++++++-- test/unit/construction/construction.test.ts | 60 +++++++++++++++++++ .../unit/custody-auth/keypair.service.test.ts | 21 ++++++- 4 files changed, 142 insertions(+), 10 deletions(-) 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/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/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, ) }) }) From da45acbe38aa3bb7123c28822eb0bd82319e8bd6 Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Fri, 21 Aug 2026 17:06:05 -0400 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20unblock=20the=20demo=20suite=20?= =?UTF-8?q?=E2=80=94=20Wallet=20export,=20destroy=20preflight,=20lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects surfaced by exercising the public API from outside the package. - Export `Wallet` as a value from the public surface. `LocalSigner.create()` accepts `Wallet` instances, so a caller could not use the documented factory (or generate an ad-hoc keypair) without adding a direct `xrpl` dependency for a type this SDK already requires. Importing it from the package entry point failed outright. - Preflight `Token.destroy()` against the issuance's outstanding amount. The ledger already refuses this as `tecHAS_OBLIGATIONS`, but that code names neither the issuance nor the amount, so the caller is left to guess that a holder still has a balance. - ESLint now ignores `demos/`. `.gitignore:37` explicitly invites that directory for local scratch scripts, but it belongs to no tsconfig project, so the type-aware parser errored on every file in it — creating the directory the repo suggests broke `npm run lint` outright. - Document in .env.example that both testnet seeds are required, and point at a fallback faucet. A drained account surfaces as an unrelated-looking submission failure rather than "out of funds". Not addressed here: `Token.list` returns raw base units while `Token.transfer` takes scaled display units, so a transfer of 100 at assetScale 2 reads back as 10000. Fixing that is an API semantics decision, not a bug fix. --- .env.example | 4 +++ eslint.config.mjs | 15 +++++++-- src/index.ts | 6 ++++ src/verticals/token.ts | 17 ++++++++++ test/unit/token/token.test.ts | 58 +++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 2 deletions(-) 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/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/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/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/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', () => { From 9c3b8ac226477eefd55968edd08f54475bc238c7 Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Fri, 21 Aug 2026 14:47:39 -0400 Subject: [PATCH 4/8] fix: name the field when a ticker is missing, instead of a TypeError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Omitting `ticker` reached `currency.length` inside encodeCurrencyCode and surfaced as a bare "TypeError: Cannot read properties of undefined (reading 'length')" — naming neither the field, the method, nor the vertical. Found by calling the built package the way a JavaScript consumer would. TypeScript callers cannot reach this, since `ticker` is typed `string`. But the SDK publishes CJS + ESM for a Node audience that includes plain JS, and every other validation path here already fails with a named IntentValidationError (see iouValue, and Token.issue's XLS-89 report). The guard widens the value to `unknown` first: the declared type says it cannot be undefined, which is precisely why the compiler would otherwise reject the check that catches the callers who pass nothing. --- src/verticals/iou.helpers.ts | 20 +++++++++++++++++++- test/unit/verticals/iou.test.ts | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/verticals/iou.helpers.ts b/src/verticals/iou.helpers.ts index 3198511..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) diff --git a/test/unit/verticals/iou.test.ts b/test/unit/verticals/iou.test.ts index 7b30777..21ed886 100644 --- a/test/unit/verticals/iou.test.ts +++ b/test/unit/verticals/iou.test.ts @@ -253,6 +253,27 @@ 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, + 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({ From b5925aa866514f51c2b0c82f6201855cf063548e Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Fri, 21 Aug 2026 17:06:05 -0400 Subject: [PATCH 5/8] refactor: align iou.transfer on `to`, matching the other transfer verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three value-transfer verbs disagreed on what to call the recipient: xrp.transfer and token.transfer take `to`, while iou.transfer took `destination`. Because the parameter shapes also differ, reaching for the wrong one surfaces as an unrelated TypeError rather than an unknown-property error. Rename IOUTransferParams.destination and IOUTransferIntent.destination to `to`. All three transfer verbs now agree. `destination` is deliberately kept where it is not a transfer — Account.fund, Account.activate and the credential verbs — so the split is now meaningful rather than accidental: `to` moves value, `destination` names an account being set up. Breaking, and free to do now: the package is 0.0.0 and unreleased. Verified against live testnet — test/integration/iou.test.ts passes (5 tests, 148s), covering transfer, offers and holder authorization. --- src/verticals/iou.ts | 12 ++++-------- src/verticals/iou.types.ts | 4 ++-- test/integration/iou.test.ts | 4 ++-- test/unit/verticals/iou.test.ts | 12 ++++++------ 4 files changed, 14 insertions(+), 18 deletions(-) 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/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/verticals/iou.test.ts b/test/unit/verticals/iou.test.ts index 21ed886..d8134a4 100644 --- a/test/unit/verticals/iou.test.ts +++ b/test/unit/verticals/iou.test.ts @@ -263,7 +263,7 @@ describe('IOU amounts are validated at the API boundary', () => { const send = async (ticker: unknown): Promise => client.iou.transfer({ ticker: ticker as string, - destination, + to: destination, amount: '1', }) @@ -278,7 +278,7 @@ describe('IOU amounts are validated at the API boundary', () => { 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) @@ -322,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 @@ -335,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 }) @@ -445,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 @@ -457,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' }) }) }) From 91213abcdbbf19b50ff00ebeafe8cbe338822350 Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Fri, 21 Aug 2026 15:42:14 -0400 Subject: [PATCH 6/8] fix: say what Palisade rejected, not just that it did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Palisade transaction REJECTED` gave the caller nothing to act on — not the operation, not a reason — and re-reading the transaction needs credentials the caller may not have (GetTransaction returns 403 for the configured sandbox credentials). Include the action and any attributes Palisade attached. On the sandbox this turns a bare REJECTED into `... REJECTED (action=PALISADE_MANAGED)`, which at least identifies the operation class. Found while testing IOU clawback: Palisade rejects the AccountSet that enables asfAllowTrustLineClawback / asfRequireAuth, so clawback cannot be enabled on a Palisade-held issuer at all. That is separate from, and compounds, the Amount.issuer/holder mapping problem already noted. --- src/custodians/palisade/tx-tracker.ts | 12 ++++++++++- .../palisade/palisade-custody.test.ts | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/custodians/palisade/tx-tracker.ts b/src/custodians/palisade/tx-tracker.ts index 8f31370..22ddf05 100644 --- a/src/custodians/palisade/tx-tracker.ts +++ b/src/custodians/palisade/tx-tracker.ts @@ -182,8 +182,18 @@ export class PalisadeTxTracker { let current = submitted for (let attempt = 0; attempt < attempts; 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)) { 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' }), From 222e21b6400ed6c19d098bfe81b45c70d6052c14 Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Fri, 21 Aug 2026 17:06:05 -0400 Subject: [PATCH 7/8] feat: give multi-step operations an hour per step, and back off while polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A step of a multi-step operation is not one transaction among many, it is a barrier: nothing after it runs until it lands. On a governed custodian that wait includes a human approval, and consecutive steps can belong to *different* accounts (IOU.issue sequences issuer -> holder -> issuer), so an approver may not know the next step is queued behind theirs. At the 60s single-step default the common outcome was step one timing out and the rest never being submitted, leaving a half-configured issuer. runMultiStep now defaults each step to MULTI_STEP_STEP_TIMEOUT_MS (1 hour). It applies with `??`, so an explicit per-step timeout still wins. The plumbing already existed — SubmitRequest.timeoutMs reaches both governed custodians — it simply was never set. The timeout alone would have been a bad trade. Palisade derived its poll count as timeoutMs / 1500ms and never slowed down, so an hour meant ~2,400 requests per step and ~7,200 for a three-step issuance: a rate-limiting problem swapped in for a timeout one. All three polling loops now share a backing-off schedule (initial interval, doubling, capped at 30s), which keeps the first seconds responsive and costs ~100 requests for a full hour. Palisade's loop also moves from a fixed attempt count to a deadline, since with a growing delay "number of polls" no longer tracks elapsed time, and the caller's budget is expressed in time. Verified against live testnet: test/integration/iou.test.ts passes (5 tests, 165s), covering the multi-step IOU issuance path. --- src/custodians/palisade/tx-tracker.ts | 40 ++++++++++------ src/custodians/poll-schedule.ts | 41 ++++++++++++++++ .../ripple/submission/intent-polling.ts | 16 +++++-- .../ripple/submission/transaction-polling.ts | 12 +++-- src/orchestration/multi-step.ts | 25 +++++++++- test/unit/custodians/poll-schedule.test.ts | 44 +++++++++++++++++ test/unit/orchestration/multi-step.test.ts | 47 ++++++++++++++++++- 7 files changed, 201 insertions(+), 24 deletions(-) create mode 100644 src/custodians/poll-schedule.ts create mode 100644 test/unit/custodians/poll-schedule.test.ts diff --git a/src/custodians/palisade/tx-tracker.ts b/src/custodians/palisade/tx-tracker.ts index 22ddf05..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,12 +182,12 @@ 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 @@ -199,15 +206,20 @@ export class PalisadeTxTracker { 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/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/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/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/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()) From 5d113f7f9d2e4b53015ae38df9c38eb3d1b774c9 Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Fri, 21 Aug 2026 17:06:05 -0400 Subject: [PATCH 8/8] fix: stop routing Clawback natively on Palisade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 at all. asfAllowTrustLineClawback must be set before the issuer owns any trust line, and Palisade rejects that AccountSet (REJECTED action=PALISADE_MANAGED, no reason exposed through the API), reproduced against a genuinely fresh issuer wallet. Dropping the transactor from PALISADE_NATIVE_TRANSACTORS routes Clawback to raw signing where 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. mapClawback is deleted rather than left dead. Tests: the MPT-rejection case reached toCurrencyAmount through Clawback, so it now asserts that boundary directly — the transactors that still call it (OfferCreate, TrustSet) are typed to exclude MPT amounts. Note the comment marking the omission deliberately avoids quoting 'Clawback': scripts/gen-connector-routing.mjs scrapes quoted strings out of that Set literal, comments included, and a quoted mention put the transactor straight back into the generated routing table. Re-enable only once Palisade confirms both a field that carries an IOU clawback's holder, and that the enabling AccountSet is accepted. --- docs/api-md/_media/connector-routing.md | 4 +- docs/api-md/classes/IOU.md | 32 ++++---- docs/api-md/classes/IntentInspector.md | 46 ++++++++++-- docs/api-md/classes/LocalSigner.md | 18 ++--- docs/api-md/classes/RippleCustody.md | 75 ++++++++++++++++--- docs/api-md/classes/SimpleXRPLClient.md | 58 ++++++++------ docs/api-md/classes/Token.md | 20 ++--- docs/api-md/functions/txToNativeSubmit.md | 2 +- .../api-md/functions/validateTokenMetadata.md | 2 +- docs/api-md/globals.md | 1 + docs/api-md/interfaces/IOUTransferIntent.md | 4 +- docs/api-md/interfaces/IOUTransferParams.md | 20 ++--- docs/api-md/interfaces/NativeSubmit.md | 6 +- docs/api-md/interfaces/NetworkInfo.md | 6 +- docs/api-md/interfaces/OnChainResult.md | 27 +++++++ docs/api-md/interfaces/SubmissionHost.md | 26 +++++++ docs/connector-routing.md | 4 +- docs/palisade-api-coverage.md | 2 +- src/custodians/palisade/mapping/clawback.ts | 21 ------ .../palisade/mapping/submit-operations.ts | 33 +++++++- test/unit/custodians/palisade/mapping.test.ts | 54 ++++++------- 21 files changed, 311 insertions(+), 150 deletions(-) create mode 100644 docs/api-md/interfaces/OnChainResult.md delete mode 100644 src/custodians/palisade/mapping/clawback.ts 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/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/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, + ), ) }) })