Skip to content

feat: propose Custody XRPL transaction orders from an address, and harden the on-chain wait - #63

Merged
pdp2121 merged 2 commits into
mainfrom
feat/propose-transaction-order
Nov 6, 2026
Merged

pdp2121 merged 2 commits into
mainfrom
feat/propose-transaction-order

Conversation

@pdp2121

@pdp2121 pdp2121 commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

High Level Overview of Change

Propose Custody's own XRPL operation from an address. api.propose takes a full v0_CreateTransactionOrder payload, so callers had to look up the Custody account for an address, generate the order id, and fill in the fee strategy and memos themselves. The new api.proposeTransactionOrder does that:

const { intentId } = await custody.api.proposeTransactionOrder(
  {
    account: 'r...',
    operation: {
      type: 'Payment',
      destination: { type: 'Endpoint', endpointId },
      amount: '1000',
      currency: { type: 'MultiPurposeToken', issuanceId },
    },
  },
  { dryRun: true },
)
  • Account lookup: finds the Custody account holding account in the custodian's domain, with ledgerId to pick one when the address is on several ledgers.
  • Operation and defaults: passes Custody's operation through unchanged, so options the verticals don't model are available, such as a Payment to a Custody Endpoint. Defaults the order to a Priority: Low fee, no memos, and no custom properties. The fee strategy, maximum fee, memos, source tag, and custom properties can all be set, and every api.propose envelope option is accepted.
  • One id: id is the idempotency key. The returned intentId is also the order id that the on-chain transaction is filed under.
  • Dry run first: dryRun: true dry-runs the same order before proposing. If Custody says it would fail, it throws DryRunFailedError with the verdict, and nothing is proposed.
  • Errors: checked against the Custody version like propose. Throws AccountNotFoundError when no account in the domain holds the address.
  • New exports: CustodyTransactionOrder, CustodyTransactionOrderOptions.

Harden the on-chain wait (client.intent.awaitOnChain, custody.pollTransactionOnChain, and the confirmation step of submitAndWait):

  • Picks the right transaction. When Custody resubmits a transaction with a higher fee, the old attempt stays listed under the same order as Replaced. The wait now ignores Replaced attempts and follows the newest surviving one by registeredAt. Before, it read the first listed transaction, so a resubmitted transaction could be reported as dead while its replacement went on to succeed.
  • Stops on failures before the ledger. A transaction Custody Failed (e.g. hint InvalidUserPayload) or Interrupted now ends the wait with that reason, instead of polling to the timeout and reporting "still in flight".
  • Uses its whole time budget, and retries a read that timed out. These are the same fixes the intent wait got in 0.3.0.
  • Behaviour change: if only the replaced attempt is listed so far, the wait keeps polling for the replacement rather than failing at once.

Supporting changes:

  • api.ts reached its line limit, so it was split. Its public types move to api-types.ts (package exports unchanged), and checking a payload against the instance's version becomes VersionGuard.assertPayload.
  • The XRPL-transaction path and the new operation path share one order builder, transactionOrderPayload. The existing tests of the native order's exact shape pass unchanged.
  • The Ripple Custody guide gains an "XRPL transaction orders" section.

Context of Change

These came out of mapping a production Custody integration onto simpleXRPL, call by call. Its core flows are MPT issuance creation, MPT payment to a Custody endpoint, and MPT authorize. They were possible through api.propose, but each repeated the same account lookup and order boilerplate, and the endpoint payment had no higher-level path at all. proposeTransactionOrder makes each flow a single call. The same review found the on-chain wait could misread a resubmitted transaction, and waited out the full timeout on transactions Custody had already failed.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactor (non-breaking change that only restructures code)
  • Tests (You added tests for code that already exists, or your new feature included in this PR)
  • Documentation Updates
  • Release

Test Plan

  • Unit, proposeTransactionOrder:
    • looks up the sender and proposes the operation unchanged, with the default fee, memos, and custom properties;
    • one id for the intent and the order, returned straight away;
    • order fields and envelope options pass through, and id becomes both ids;
    • dryRun: true dry-runs exactly the order it then proposes;
    • a failed dry run throws DryRunFailedError with the verdict and proposes nothing;
    • throws AccountNotFoundError for an unknown sender;
    • is version-checked before anything is proposed.
  • Unit, on-chain wait:
    • ignores a Replaced attempt listed first and follows its replacement;
    • takes the newest attempt whatever the listing order;
    • keeps waiting while only the replaced attempt is listed;
    • stops at once on Custody Failed and Interrupted, with the hint or cause in the message;
    • reads at 0, 5, and 12 s for a 12 s budget, with the last read on the deadline;
    • retries a read that timed out.
  • Contract (live sandbox, non-mutating): proposeTransactionOrder with dryRun: true for an MPT payment to a nonexistent endpoint. Custody parses the endpoint destination and fails the dry run ("Endpoint … does not exist"). The call throws DryRunFailedError and nothing is proposed.

@pdp2121
pdp2121 merged commit 95aff65 into main Nov 6, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant