Skip to content

Unblock CI and the demo suite: lint, custody key parsing, multi-step timeout, Palisade clawback - #35

Merged
pdp2121 merged 8 commits into
mainfrom
fix-lint-and-custody-key-parsing
Aug 25, 2026
Merged

Unblock CI and the demo suite: lint, custody key parsing, multi-step timeout, Palisade clawback#35
pdp2121 merged 8 commits into
mainfrom
fix-lint-and-custody-key-parsing

Conversation

@cybele-ripple

@cybele-ripple cybele-ripple commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

No description provided.

`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.
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.
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.
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.
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.
`Palisade transaction <id> 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.
… polling

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.
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.
@pdp2121
pdp2121 merged commit 01e4340 into main Aug 25, 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.

2 participants