Skip to content

Latest commit

 

History

History
980 lines (754 loc) · 257 KB

DEVLOG.md

File metadata and controls

980 lines (754 loc) · 257 KB

DEVLOG

What was done, removed, ignored or simulated, dated. This is the source of the README's prior-work note and of the final presentation.

2026-10-24

  • The specification (FIANSO-SPEC.md) is added, then amended with the Ledger documentation feedback requirement (section 7.6). It consolidates the preparation dossier kept in STOOOKEEE/TTT (docs/carbs/). The earlier prototype stays where it is and is not reused.
  • The team device is a Ledger Stax, firmware 1.11 (API_LEVEL 27, the level of the SDK image), side-loading allowed.
  • Upstream imports, unmodified: XRPLF/rippled alphanet at 68bd02b647 (the branch head, unchanged since 2026-10-16) and LedgerHQ/app-xrp master at 7b5185ff. File counts match upstream (2300 and 6763). UPSTREAM.md records them.
  • Workspace skeleton, CI, pull request template, architecture and decision records, README (pull requests #1 to #4). CI covers the TypeScript side only; the daemon and the Ledger app are not built in CI.
  • The scope is widened to a post-quantum root on the device, a quantum exposure check with migration, and community tooling (a Device Management Kit adapter for xrpl-connect, a create-xrp template). The agent payment scene is reserved, not planned.
  • Licence: Apache-2.0 for the code written here.
  • Installed locally and not committed: the Ripple DevRel DevEx capture hook.
  • The daemon of this repository is built from rippled/ (Debug, with the existing Conan cache): xrpld version 3.4.1+DEBUG; the upstream PasskeyListSet suite passes on the imported tree (11 cases, 250 tests, 0 failures). Recipe in docs/BUILDING.md.
  • Not done yet: the Ledger app has not been built or loaded on the Stax; no spike (K1 to K8) had run at that point; the protocol codes had not been re-checked on the imported tree (spike K5 has run since: see "Plan check, spike K5 and review gate 1").
  • Simulated: nothing at that point; the entries below say what is simulated.

N0: test baselines of the imported tree

Run on rippled/ as imported (git diff 516f6a5 HEAD -- rippled/ is empty, tree 3e3f112e), Debug build, Apple clang 17, arm64 macOS, where char is signed. No test was edited, skipped or removed.

./xrpld --unittest --unittest-jobs=6 (from rippled/build): 627.7 s, 280 suites, 5334 cases, 1,285,447 tests, 204 failures, and ValidatorSiteV2 did not complete ("The child process may have crashed"). Every suite with a failure was run again alone (./xrpld --unittest=xrpl.app.<Suite>):

Suite Failures (6 jobs) Alone First failure
AMMBinned 152 152 tecINVARIANT_FAILED instead of tesSUCCESS at rippled/src/test/app/AMMBinned_test.cpp:313; the log says "Invariant failed: a MPToken was created"
AMMCurves 41 41 tecPATH_DRY instead of tesSUCCESS at rippled/src/test/app/AMMCurves_test.cpp:1469
InvariantsVault 2 2 tecLIMIT_EXCEEDED instead of tesSUCCESS at rippled/src/test/app/invariants/InvariantsVault_test.cpp:3035
ValidatorList_Dilithium 2 2 reads a validator list from an absolute path on the upstream author's machine (rippled/src/test/app/ValidatorListDRandom_test.cpp:150-158)
ValidatorListExpiration 2 2 "expires() returned nullopt" and "secp256k1 VL backward compatibility broken" (rippled/src/test/app/ValidatorListExpiration_test.cpp:204, :383)
Delegate 1 1 the delegable-type counter: 71 counted, 60 pinned (rippled/src/test/app/Delegate_test.cpp:2846-2855)
MPToken 1 1 rippled/src/test/app/MPToken_test.cpp:2095, in "MPT Issue Invalid in Transaction"
SignatureVerification 1 0 passes alone
ValidatorSite 1 0 "database is locked" with parallel jobs; passes alone
ValidatorSiteV2 did not complete 0 passes alone (86 cases)

201 failures repeat, in seven suites; the other three suites fail only when several jobs run at once. Of the upstream suites the specification names, PasskeyListSet, AccountDelete and PassKey pass and Delegate fails only its counter test, so for Delegate "green before and after" means "no new failure" (see docs/PLAN-CHECK.md). Suites the next tasks rely on pass: Batch, Beneficiary, Firewall, Sponsor, InvariantsBeneficiary, HashRouter, NetworkOPs, STTx, Feature, ServerDefinitions, LedgerEntry, AccountObjects, TxQMetaInfo, TxQPosNegFlows.

./xrpl_tests (the gtest binary, built with cmake --build . --target xrpl_tests): 1120 tests from 240 suites in 52 s, 1119 passed, 1 failed: VaultTests.BuilderFromSleRoundTrip ("Field 'DealingInterval' may not be explicitly set to default.", rippled/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp:258; sfDealingInterval is SoeDefault in rippled/include/xrpl/protocol/detail/ledger_entries.macro:643), the same when run alone; 3 upstream tests are disabled (DISABLED_ consensus simulations).

Header levelization (rippled's .github/scripts/levelization/generate.py, run on a scratch copy so that its results folder in the tree is left alone): the imported tree has one dependency that the committed results/ordering.txt lacks, test.server > xrpl.rdb, from rippled/src/test/server/Wallet_test.cpp:10. Not ours; left as it is.

These are the base failures. docs/base-failures.txt lists them one failing assertion per line (file, line and message); a later run counts a failure as ours when it adds a line there.

Plan check, spike K5 and review gate 1

  • docs/PLAN-CHECK.md lists, for every task in scope, the files we will touch and the upstream code we mirror, each with the full path:line of the imported tree. Every citation was printed again and read (its section 6).
  • Spike K5, code part done. Every code of spec section 6.1 is free on the imported tree and in the server_definitions answers of a standalone node of this build and of the public AlphaNet, which are identical (hash 34614C84…C3C5). Nothing is renumbered. The device-track branch uses the same numbers for every field the app parses. The encode round trip of K5 waits for packages/core (task W1), once N2 exposes the new types.
  • Deviation: "green before and after" is judged per assertion. Before any change, the upstream Delegate suite fails its delegable-type counter (71 counted, 60 pinned at rippled/src/test/app/Delegate_test.cpp:2846) and MPToken fails its completeness assertion (rippled/src/test/app/MPToken_test.cpp:2095). The spec asks these suites to stay green; they are not green before any change, so a run counts as clean when it adds no line to docs/base-failures.txt. Re-pinning the Delegate counter is not allowed: both session types are NotDelegable and leave it at 71. N2 adds a SigningSessionSet case to the MPToken completeness test, so that our type is covered instead of hidden in a set that is already not empty.
  • Deviation: the device track (the Ledger app, loading it on the Stax, K4, K6, photographs of the screens) belongs to a teammate, the device-track owner (decision D5, partly settled). These goals build the protocol, the client, the device contract and a simulated device, and never touch a device.
  • Deviation, planned: the passkey wallet (an account that signs with its passkey only, after SetRegularKey to the blackhole address and asfDisableMaster) is not in spec section 8.5. It is built with the client, and that spec section is updated in the pull request that adds it.
  • SIMULATED, P5 before any change. With a software WebAuthn authenticator (no device, no browser ceremony), a payment from an account, signed through WebAuthn by a P-256 passkey of its list, was refused by the unchanged tree with invalidTransaction, "fails local checks: Invalid signature." ./xrpld --unittest=PasskeyWebAuthn with the draft test gave: #26 failed: apply (rippled/src/test/app/PasskeyWebAuthn_test.cpp:94): Got telENV_RPC_FAILED (Unit test RPC failure.); Expected tesSUCCESS, #27 failed: PasskeyWebAuthn_test.cpp(96), "1 suite, 2 cases, 65 tests total, 2 failures". The challenge it sent equals the signing data and holds bytes at or above 0x80. char is signed on this machine. N1 records the fix.
  • Review gate 1, round 1 (an independent reviewer, model Opus, read-only, given PLAN.md, the spec and docs/PLAN-CHECK.md): VERDICT: CHANGES REQUIRED. Blocking: the MPToken completeness test would hide our type, and the "green before and after" reading needed this entry. Non-blocking: the activation of a P5 fix had to be argued in both directions and cover a third BAD site; N1 lands as a test pull request, then a fix pull request; rows for W3 and W7; N2 risks (generated code behind the macros, a hand-written jss name, transaction settings); the feature listing of N9 is unverified; the citation command missed short references; two line numbers were off. Applied in docs/PLAN-CHECK.md, in docs/base-failures.txt (the failures per assertion) and in this entry.
  • Review gate 1, round 2: VERDICT: APPROVED. Its non-blocking findings are applied before merging: the SIMULATED labels and the output above; the cost and the rules each caller uses, worded exactly; the WebAuthn condition narrowed to a P-256 SigningPubKey with sfPasskeySignature; every refusal next to an accepted control; the preconditions of the peer test; the suites of the fix pull request and the full run for code in apply.cpp, NetworkOPs.cpp or PeerImp.cpp; raw messages in docs/base-failures.txt; small accuracy fixes. One is applied differently from its wording: the reviewer asked to document that a forced signature verdict is not honoured before the fix; it is honoured instead, with one more private router flag, because ledger replay would otherwise stop reproducing a pre-fix ledger that holds a WebAuthn transaction. Review gate 2 checks it on the fix pull request.
  • Process: the N1 test was written and run once on this machine while round 1 ran, to learn whether a fix was needed. Nothing was committed or opened as a pull request before the verdict.

N1: the WebAuthn challenge comparison (P5)

SIMULATED: every result here comes from a software WebAuthn authenticator (rippled/src/test/jtx/webauthn.h). No device and no browser ceremony was used.

  • Test first. The authenticator and the refusals landed first (test: pull request). Before any change, on this machine (char signed), a valid WebAuthn payment was refused with invalidTransaction, "fails local checks: Invalid signature." (output in "Plan check, spike K5 and review gate 1"). P5 is confirmed.

  • Fix, a separate fix: commit gated by a new amendment, fixPasskeyChallenge (top of features.macro, Supported::Yes, DefaultNo). With it, the challenge is compared with the signing data as two Slices; without it, the old comparison stays. Only that comparison changes.

  • Activation. While the amendment is disabled, a transaction signed through WebAuthn (a P-256 SigningPubKey with sfPasskeySignature) bypasses the signature cache of checkValidity, so no verdict crosses the activation in either direction. NetworkOPs and PeerImp do not mark it BAD either. A verdict forced by forceValidity (cluster relay, ledger replay, signature checks off) still holds: one more private router flag, PRIVATE9 on the unused bit 0x0020, records it. isSignatureVerdictCacheable holds the condition in one place. Cost: one WebAuthn verification per checkValidity call for such a transaction, until the amendment is enabled.

  • Tests (suites PasskeyWebAuthn and PasskeyRelay):

    • a challenge with a byte at or above 0x80, under both amendment states;
    • every refusal next to an accepted control;
    • the same signed blob refused before the amendment and accepted after it;
    • checkValidity with one router and the rules of both eras;
    • a forced verdict on both sides of the fix;
    • the condition itself;
    • NetworkOPs::processTransaction;
    • a copy relayed by an overlay test peer.

    Before the amendment, a signature is refused where char is signed and accepted where it is unsigned. The unsigned branch is UNVERIFIED here, since this machine has signed char.

  • Also in the fix: an API-CHANGELOG.md entry for the submit error condition, and one new levelization dependency, test.overlay > xrpl.core.

  • infra/standalone.cfg (N9) has to enable fixPasskeyChallenge, otherwise, where char is signed as on this machine, every WebAuthn transaction fails on that node. The public AlphaNet serves the definitions of the imported tree (K5) and has no such amendment, so its answer to a WebAuthn transaction may depend on its validators' platforms (UNVERIFIED; spike K2 is not run).

  • Review gate 2 (an independent reviewer, model Opus, read-only, on the fix pull request): VERDICT: APPROVED. Its non-blocking findings are applied before merging: the comment of the guard after a malformed result now says why it matters (a held transaction reaches it under earlier rules); a test pins the narrowing to P-256 (sfPasskeySignature next to a secp256k1 key, and next to no key); the tests check every built transaction before using it, and the relay test checks the balance where char is unsigned; HashRouter.h reserves the bit 0x20 and says when to remove PRIVATE9; the condition states that only the top level carries sfPasskeySignature; the API changelog says that a refused WebAuthn transaction is now answered "Invalid signature." on every submission before the amendment; this line is qualified to platforms where char is signed. The defect came with the upstream commit that added passkeys (XRPLF/rippled b30e9fc369, "featurePasskey").

  • Kept to the facts on purpose: no upstream pull request and no message about this defect (decision D7).

  • Not done at the time of this entry (later entries record what was done since): the Ledger app had not been loaded on the Stax and no spike beyond K5 had run; K5 itself was run (see the plan-check entry above).

  • Simulated in this entry: the software WebAuthn authenticator, as labelled above.

  • Passkey defect P6, verified on the public AlphaNet (FIANSO-SPEC 6.14): a second PasskeyListSet on the same account fails with tefEXCEPTION (A3E31459…, after a first tesSUCCESS 8AC8B117…), so a passkey can be neither rotated nor revoked. Found by reading SetPasskeyList.cpp, then tested with a fresh faucet account and random P-256 keys (throw-away script). Not reported upstream (D7); a decision is needed on whether we carry a fix for W2.

  • Key migration in one Batch does not work: an all-or-nothing Batch with SetRegularKey + asfDisableMaster, master-signed, returns tesSUCCESS (16A5CA91…) but applies neither; the same Batch with asfDefaultRipple applies (828DAF98…). The post-quantum migration stays two clear-signed transactions.

  • Post-quantum root on the Stax, first proof (spike K4 / L2). A throw-away app built from LedgerHQ/app-boilerplate derived an ML-DSA-44 key on the device and signed on the public AlphaNet (network 24100): fresh account rs3drHLeUPmQmnYNmeVGnFwY68xDnA5G8T; SetRegularKey to the device's ML-DSA key (E3D53D69…, master key); AccountSet asfDisableMaster (80D644C8…, master key: the node answers tecNEED_MASTER_KEY when the regular key signs it); a 1 XRP Payment signed with ML-DSA-44 on the Stax and verified with @noble/post-quantum before submission, tesSUCCESS (B55E26D1E387C2D277A9EA8F7AE4AC1F98FEFE6A28796DB51F4EFD30710C698D); the old classical key then gets tefMASTER_DISABLED. The spike screen showed the message size and a SHA-256 prefix, not the decoded transaction (blind), and derived the key from a BIP32 key (not allowed by section 7.7 of the spec); that spike is not part of this repository.

  • The feature is moved into ledger-app/ (src/pq/, instructions 0x10 to 0x12, Stax / Flex / Apex P only): SLIP-21 derivation (label carbs ML-DSA-44), low-RAM ML-DSA, working buffers overlaid on the transaction context (.bss ≈ 25 KB instead of ≈ 31 KB). It builds for Stax and Nano S Plus.

  • Loaded on the Stax and proven on the AlphaNet with the XRP app itself (host side still a local script, not the DMK): fresh account rpxMtodcdjFGpssK5k7D4CixifFBCLS7eV, ML-DSA key account rDX7hJiDtUVUnN2tjRqswtuZcbE88yi9i2 (corrected 2026-10-25: the zero-seed key, whose secret is public, see below); SetRegularKey 0CBD8C74…; asfDisableMaster B9EB8FB4… (master key); 1 XRP Payment signed with ML-DSA-44 on the device, verified with @noble/post-quantum, tesSUCCESS (7654683C8DBBD091941A6BBD369DA25F0E5888B2C5C21A1340DFDCD6C652436B); the classical key then gets tefMASTER_DISABLED. The review screen is still blind (size and SHA-256 prefix).

  • The ML-DSA review is now clear-signed: the device strips STX\0 and runs the transaction through the app's own parser, then shows the same review screen as a classical signature (blind-signing screen only when the parser asks for it; malformed data is refused with 0x6A80). Proven on the AlphaNet: account rGcT27HiGhoJWYRxqg9jcnPboGD4RGuLLm, same device ML-DSA account rDX7hJiDtUVUnN2tjRqswtuZcbE88yi9i2 (the SLIP-21 key is stable across reinstalls; corrected 2026-10-25: that was the symptom of the seed defect, the key was KeyGen_internal of 32 zero bytes on every device, see "the ML-DSA root key was the zero-seed key" below), 1 XRP Payment signed with ML-DSA-44 after review, tesSUCCESS (46F4D08446F73FDF4DA648EB5E050B7BF0C8FA882B35B53B8D90E2BFD6F6F3EE).

  • Friction met with the Ledger SDK and tools is logged in docs/ledger-dx-feedback.md (fifteen entries today).

  • L1, clear signing of SigningSessionSet / SigningSessionDelete in ledger-app/ (not yet on the Stax, no node with the amendment yet). All the SigningSessions numbers the app uses are in src/xrp/signing_session.h (types 130 / 131, SessionKey ACCOUNT 34, SessionCap AMOUNT 47, SessionDuration UINT32 114, PermissionValue UINT32 52, tfAllTransactions 0x00010000); they are free on the public AlphaNet's server_definitions (checked today) and must be checked against the amendment when it lands. Screen: "Open session" / "Close session", account, session key, "Valid for" in minutes, "Allows" (mode A flag or one row per permission), "Max spend", fee, and a final "Note: Cannot change keys, signers or the account". The permission names are generated from rippled/'s macros (ledger-app/tools/gen_session_permissions.py, 83 values). An unknown permission or flag bit, or a duplicate Flags, forces blind signing; a truncated transaction, 11 permissions or a field claiming the "Note" row are refused. MAX_ARRAY_LEN goes from 8 to 10.

  • Tests: 14 vectors encoded with ripple-binary-codec (tests/standalone/testcases/30-… and 31-…, a .json beside each .raw); cmocka 4/4 groups pass, including exact BLIND_SIGN_REQUIRED for the three blind cases; Ragger on the Speculos emulator, 7 clear-sign cases per device with signature check, snapshots for Stax, Flex, Nano S Plus, Nano X and Apex P; builds for the five devices with no warning. Simulated: the device screens are emulator snapshots, not the real Stax.

  • On the real Stax (XRP app with this change and the ML-DSA root of #7, built together locally): a SigningSessionSet in mode C (20 XRP, 10 minutes) for the device account r4MG66fUzV75ZHbSiEAXGHAxANrwQTnGh8 was reviewed and signed with the classical key, signature verified locally with ripple-keypairs; a SigningSessionSet in mode P (Payment, OfferCreate, 5 minutes) for the post-quantum account rDX7hJiDtUVUnN2tjRqswtuZcbE88yi9i2 (corrected 2026-10-25: the zero-seed key, see below) was reviewed through the same screens and signed with the device's ML-DSA-44 key, signature verified with @noble/post-quantum. Not submitted: no node has the amendment yet, so the acceptance case (session opened on the node) is still open. The throw-away "carbs PQ" spike app was removed from the device.

Two account profiles and ML-DSA-44 session keys (D13, D14)

Documentation only: no protocol, client or device code changed.

  • D13 and D14 are applied to the specification (§2, the TL;DR, §6.4, §6.14, case 20 of §6.15, §7.7, §8.2 to §8.5, §10.1, §10.3, §12 and appendix A), to docs/ARCHITECTURE.md, and in ADR 0011. ADRs 0004 and 0008 are marked superseded in part; their text stays.

  • D14, read in code. An ML-DSA-44 session key needs nothing specific in the amendment; §6.14 gives the path:line of each step. Whether the node accepts a transaction single-signed by an ML-DSA-44 key is UNVERIFIED in a test: the only upstream test that signs one asserts a duration and not the verdict (rippled/src/test/app/STTXSigning_test.cpp:46-73). Case 20 settles it in N4.

  • Deviation: case 20 with ML-DSA-44 moves from N8 (stretch) to N4 (MVP), because D14 makes ML-DSA-44 session keys a requirement.

  • Deviation: §7.7 now follows the device track. It said that the app prepends STX\0 before an ML-DSA signature. In the device track's code, now in ledger-app/, the host sends the whole signing data, prefix included, and the app strips the prefix to parse and display (ledger-app/src/pq/pq_root.c:139-151). The instructions 0x10 to 0x12, the 250-byte chunks and the 2 048-byte limit come from the same code (ledger-app/src/pq/pq_root.h:12-23, ledger-app/src/apdu/global.h:71).

  • Deviation: three demo profiles become two (§8.5). The former "Ledger" and "PQ" profiles merge into the Ledger account; the "Face" profile becomes the Mac account.

  • The numbers of §6.1 match the device header, ledger-app/src/xrp/signing_session.h (from the device track, merged into develop):

    §6.1 item Spec signing_session.h
    SigningSessionSet 130 TRANSACTION_SIGNING_SESSION_SET 130 (14)
    SigningSessionDelete 131 TRANSACTION_SIGNING_SESSION_DELETE 131 (15)
    SessionKey ACCOUNT 34 XRP_ACCOUNT_SESSION_KEY 34 (19)
    SessionCap AMOUNT 47 XRP_UINT64_SESSION_CAP 47 (20)
    SessionDuration UINT32 114 XRP_UINT32_SESSION_DURATION 114 (21)
    PermissionValue (reused) UINT32 52 XRP_UINT32_PERMISSION_VALUE 52 (22)
    Permissions (reused) ARRAY 29 XRP_STARRAY_PERMISSIONS 29 (23)
    Permission (reused) OBJECT 15 XRP_STOBJECT_PERMISSION 15 (24)
    tfAllTransactions 0x00010000 TF_ALL_TRANSACTIONS 0x00010000u (27)

    The numbers in brackets are lines of that header. The app does not use SessionSpent (an entry field), the entry type, the keylet or the result codes. Its id 0xFD for the synthetic "Note" row (line 32) is not an XRPL field: VL 253 is free on the public AlphaNet and in the imported tree, whose highest VL field is 54 (sfClientDataJSON, rippled/include/xrpl/protocol/detail/sfields.macro:548).

  • Review gate, round 1 (on #16, #17 and #18 together): its findings on this part are applied.

    • §6.14 also cites where the build asserts the 1 312-byte size, the signing side and the jtx signer.
    • The D14 table now cites the full fee formula and states that no local check limits the size.
    • UNVERIFIED item 1 now says the AlphaNet accepted device signatures. A new item covers @noble/post-quantum signatures, which no node has checked yet.
    • The case 20 refusals name their codes, and three cases are added.
    • §7.7 gains the status words, the rule that the context is cleared when the instruction changes, the key derived on chunk 0, the signature length answer, and the devices the path is built for. The stale LEDGER_ASSERT sentence is gone.

Tickets and sessions, a design (D15)

Documentation only, and a proposal: ADR 0010 is Proposed, and nothing in §6 of the spec changes until the owner adopts an option.

  • D15 in the spec: tickets serve multisig coordination (§2). §12 now says that a session-signed transaction may today consume any ticket of the account, and §15 lists the decision left open.

  • ADR 0010 gives three options, with their fields, flags and result codes, the invariant impact, the tests and what the device would need:

    1. a ticket budget mode;
    2. a session key co-signing as its account;
    3. tickets bound at opening.

    It recommends option 3 and its rule for sessions without a range, and rejects option 2.

  • Every new code an option would need is free on the public AlphaNet (server_definitions hash 34614C84…C3C5, the hash of spike K5): UINT32 115 and 116, ACCOUNT 35, tef -169, and no flag defined for SigningSessionSet. It is free in the imported tree too, with the commands and output in the ADR. Nothing is allocated.

  • Device-track watch: git fetch --prune shows develop holding the three device-track branches (merge 695e6df). Open drafts #7, #10 and #11 are those branches. No number in ledger-app/src/xrp/signing_session.h changes, so there is nothing to adapt.

  • Review gate, round 1, on this part. ADR 0010 is fixed:

    • the line that names TicketCount in the app is corrected;
    • option 1 notes that mode A can also lock tickets, and gives the owner count when a TicketCreate uses a ticket;
    • option 2 lists every code of the signer check;
    • option 3 exempts the own close, rejects an overflowing range, names terPRE_TICKET, says that opening does not check the tickets, and cites the code it would touch;
    • the "if adopted" list is completed, and the ADR gains its Decision and Consequences parts.

Limits and roadmap (D16)

Documentation only.

  • Headline. It stays "quantum-safe by default" (D11). The TL;DR now says so, and the default profile is the Ledger account (D13).
  • No agent scene. D6 and D9 are marked superseded by D16, and §8.8 no longer reserves the agent.
  • §16 "Limits and roadmap" is added to the spec, with a short table in docs/ARCHITECTURE.md. Each limit has its reason, its path:line where it comes from code, what bounds it today and what would lift it. The limits:
    • P-256 sessions on Mac accounts;
    • extractible ML-DSA session keys and large transactions on Ledger accounts;
    • the passkey list that cannot be replaced (P6);
    • the WebAuthn challenge defect (P5);
    • ML-DSA v1 on the device without side-channel countermeasures;
    • Batch cannot migrate keys atomically;
    • 36 KB of device RAM.
  • Batch, read in code. The observation of the device track (an all-or-nothing Batch with asfDisableMaster returns tesSUCCESS and applies nothing) matches the code. An inner transaction has no SigningPubKey (rippled/src/libxrpl/tx/transactors/system/Batch.cpp:279-285), and asfDisableMaster needs the transaction's own key to be the master key (rippled/src/libxrpl/tx/transactors/account/AccountSet.cpp:246-256, :307-313).
  • Stale P5 text updated. §6.14 and §15 still called P5 unverified. They now give the N1 result (SIMULATED, a software authenticator) and the fix. §6.12 adds fixPasskeyChallenge to the standalone features, as infra/README.md already does.
  • Review gate, round 1 (VERDICT: CHANGES REQUIRED, two blocking findings, both applied):
    • Row 6 of §16 no longer says that side-channel attacks need the device in hand. The design claims nothing against them, and timing seen by a compromised host is UNVERIFIED.
    • The intro of §16 no longer says that no classical key can sign. The master key is disabled, as the AlphaNet's tefMASTER_DISABLED shows. The claim holds only while the account has no signer list, delegate or beneficiary key, and the ML-DSA-44 session key is a client policy that neither the ledger nor the device enforces.
    • The other blocking finding, a wrong line in ADR 0010, is fixed in that part.
    • Also fixed: N9 lists six features; the P5 paragraph cites the current lines; the §13 row on P5 records that it happened; README.md says "by default"; docs/PLAN-CHECK.md no longer calls the ML-DSA instructions proposals.
  • Review gate, round 2: VERDICT: APPROVED. It found every round 1 finding resolved and each new citation correct. Its two non-blocking findings were slips of the merge resolution in this entry, a duplicated bullet and two joined bullets; both are fixed before merging. It confirmed that these three pull requests do not change the N2 macro batch: §6.1, §6.2, §6.3, §6.10, §6.13 and appendix A are unchanged. The batch grows only if the owner adopts the ticket range of ADR 0010 before N2 (sfSessionFirstTicket, UINT32 115, and sfTicketCount on SigningSessionSet and the entry).

Client library @carbs/sessions (W1, Q1, Q2: public API)

  • packages/core gets its public API with working code: definitions read from server_definitions (an answer carrying only hash is refused), encodeForSigning (STX\0 ‖ signing fields), encodeForDevice (the same fields without the prefix, SigningPubKey set, no TxnSignature) and encodeTx, low-S strict DER for P-256, a WebCrypto P-256 session key generated non-extractible and fresh per session, ML-DSA-44 through @noble/post-quantum 0.7.1 (pinned), classical keys through ripple-keypairs, passkey signing (PasskeySignature) with a pluggable authenticator, SigningSessionSet / SigningSessionDelete builders that require LastLedgerSequence, a JSON-RPC and WebSocket node client limited to public methods, typed errors for the three tefSESSION_* codes, and the quantum module (checkExposure, buildPqMigration, runPqMigration).
  • Deviation: SIMULATED definitions. The node with the SigningSessions amendment does not exist yet, so @carbs/sessions/testing adds the identifiers of spec section 6.1 to the recorded public AlphaNet server_definitions (hash 34614C84…C3C5). It is the only place where the client writes protocol numbers by hand; a test checks them against every number of ledger-app/src/xrp/signing_session.h. Everything built on them is SIMULATED until the client reads the node's own answer (then a test compares the two).
  • Cross-check with the device track: encodeForDevice reproduces byte for byte the seven valid vectors of ledger-app/tests/standalone/testcases/30-* and 31-* (modes A, P, C, ten permissions, short duration, own close, close by a third party).
  • ripple-binary-codec 2.11.0 leaves PermissionValue without a type when it is built from custom definitions (constructor order, src/enums/xrpl-definitions-base.ts:94, :121, :145); parseDefinitions binds the types again after construction. Logged in docs/ledger-dx-feedback.md.
  • SIMULATED: the passkey tests use a software WebAuthn authenticator (no device, no browser ceremony); the ML-DSA signer is software.

2026-10-24: the web app (GOAL-3, milestone M1)

  • Template. apps/web is the output of create-xrp@0.5.0 (Nuxt), generated outside the repository and copied in unmodified (PR #19, three commits merged with --rebase: import, reformat, wire-in). UPSTREAM.md records the tarball checksum, the gitHead and what the generator does, with dist/index.js line numbers. The wire-in raised pnpm to 10.33.0 (the template demands >= 10.26) in packageManager and the CI together, moved the nested pnpm-workspace.yaml settings to the root, pinned xrpl to 5.3.0 and added a typecheck script.
  • CSP. script-src 'self' without unsafe-inline. Nuxt writes its runtime config as an inline <script> in the SPA shell, so a Nitro plugin moves it into /_carbs/inline/<hash>.js (dev server, Node server and nuxt generate; in the static build the file is written by the prerender:generate hook). Checked over HTTP only; no browser was driven.
  • Five screens (Account, Session, Actions, Attack, Quantum) behind NodePort, DevicePort, WalletPort and ExposurePort. SIMULATED: the node (lib/sim/node.ts, spec 6.5 and 6.13 in order), the Stax (lib/sim/device.ts, the screen lines of spec 7.3 for the session types; the lines for SetRegularKey, AccountSet and Payment are stand-ins until the device track freezes them), the wallet (lib/sim/wallet.ts: no WebAuthn prompt, no WebCrypto) and the exposure check over a simulated history. The badge is visible wherever a result comes from the simulation.
  • Not simulated faithfully (said here so nobody relies on it): granular permissions (mode P takes whole transaction types only), tickets in use, Batch, Firewall, Beneficiary, sponsorship, multi-signing, the 256-ledger window beyond the LastLedgerSequence check, real signatures (keyed digests, keys.ts), real account addresses. The simulated node also reproduces the verified passkey defect P6 (a second PasskeyListSet raises tefEXCEPTION).
  • Deviation from spec 8.5 as written (three demo profiles Face / Ledger / PQ): the Account screen follows the two profiles of ADR 0011 / D13 (Mac account and Ledger account), merged in #16 after this work started. A Mac account can also run the passkey-only recipe (regular key to the blackhole address, master disabled).
  • Bug found by the tests: (Flags | 0x80000000) is negative in JavaScript, so the first envelope no longer matched the mode C whitelist; flags are now normalized to unsigned (>>> 0) in the envelopes and in the simulated node.
  • Exposure rules adjusted in the simulation: a disabled master key and an ML-DSA-44 regular key are reported as seen but not exposed (a post-quantum key on the ledger is no risk; a disabled master no longer authorizes). Whether a disabled master key can still re-enable itself on the real node is UNVERIFIED here.
  • Pending on GOAL-2: packages/core is still empty on develop. Until it exports its builders and loadDefinitions(server_definitions), the screens build transaction JSON in lib/tx-builders.ts and never encode it; once core lands, every transaction the UI builds goes through it with the definitions loaded from the node, never the bundled xrpl.js definitions (they do not know types 130 and 131).

2026-10-24: template leftovers removed (web app)

  • Networks. The template's Testnet and Devnet are gone. apps/web/lib/networks.ts has AlphaNet (default, network id 24100), the local node (NUXT_PUBLIC_LOCAL_NODE_URL, default ws://localhost:6006) and mainnet (read-only, Quantum screen only). The localStorage key is now carbs-network (the old scaffold-xrp-network is never read). Explorer links for transactions use the explorer of the selected network; under the simulated backend no link is shown, since a simulated hash is on no explorer. The mainnet endpoints were written from memory at first; xrplcluster.com answered server_info (read-only) on 2026-10-24, and livenet.xrpl.org is still UNVERIFIED (no request made to it).
  • Removed: the template's payment page and form, header, shell, account card, network selector, the useWallet and useNetworkSelection composables, its payment helpers and their tests, its logo; its README, QUICKSTART and .env.local.example. Branding is carbs with a placeholder mark (components/ui/BrandMark.vue, public/favicon.svg); the credit to scaffold-xrp stays in apps/web/README.md, UPSTREAM.md and the MIT LICENSE.
  • CSP. connect-src is now a list of origins built from the networks and their explorers (lib/csp.ts), no bare scheme and no wildcard (tests). vercel.json is generated from the same policy and tested for drift; _headers is written into the static output.
  • Hosted node. A page served over https only accepts wss:// and https:// node URLs (mixed content), said in the UI next to the field; docs/DEPLOY.md lists it, the passkey / fixPasskeyChallenge caveat for the public AlphaNet, and "use Chrome" for WebHID.
  • Still open: every transaction the UI builds must go through packages/core with the definitions loaded from the node's server_definitions. packages/core landed in #21 and the screens are not wired to it yet (milestone M2, next); until then no transaction is encoded by the web app.

2026-10-24: wallet entry (web app)

  • Three adapters replace the template's Xaman, GemWallet and WalletConnect (and their API keys, environment variables and QUICKSTART text): Ledger Stax (Device Management Kit only; the LedgerAdapter of xrpl-connect, built on ledgerjs, is never imported, and a test scans the sources for it), Passkey (this Mac) and Demo account. They implement the WalletAdapter interface read at apps/web/node_modules/xrpl-connect/index.d.ts:1928 and register in the real WalletManager, which the tests drive (connect, sign, signAndSubmit, signMessage refused).
  • SIMULATED: the node behind them (still lib/sim/node.ts), the Stax (lib/sim/device.ts, so the Ledger adapter is SIMULATED whatever the node), the demo account. The Passkey adapter uses the browser's WebAuthn only when a person presses Connect; in tests it uses the software authenticator of @carbs/sessions (real P-256, no prompt), which the modal labels SIMULATED. No real WebAuthn prompt, no device and no browser automation were used here.
  • Passkey path, tested end to end on the simulated node: the passkey is registered by a master-signed PasskeyListSet, signs through an Authenticator of @carbs/sessions, and the simulated node verifies the assertion like STTx.cpp:455-517 (type webauthn.get, challenge equal to base64url of the signing data, P-256 over authenticatorData ‖ SHA-256(clientDataJSON)); a tampered transaction is refused temBAD_SIGNATURE. The signing data is the canonical JSON of the simulated transaction, not the binary encoding (the simulation does not encode); the live backend will pass the encodeForSigning bytes through signWithPasskey of core. The simulated wallet's passkey is now this real software authenticator instead of a fake.
  • Browsers: WebAuthn needs https or localhost; WebHID needs Chrome; the modal gives the reason when an adapter is unavailable.

Spikes K3 and K7: the post-quantum migration on the AlphaNet, the exposure check on mainnet

SIMULATED: the ML-DSA-44 key of K3 is a software key of @noble/post-quantum 0.7.1 standing in for the device; the classical master keys are software keys too. Client-side signing only, fresh faucet accounts, pnpm --filter @carbs/sessions exec tsx spikes/k3-alphanet.ts. Explorer: https://alphanet.xrpl.org/transactions/<hash>.

  • K3, the base question: yes. A Payment signed by an ML-DSA-44 RegularKey (1 312-byte key, 2 420-byte signature made by @noble/post-quantum) is accepted: 2C8878527C31D1835C3D2DF240CE1A28BFD2CF7F6C32436B48AD14B3E8AD7B38 tesSUCCESS (account rSVRWjk6Ma5L843PitewhF5JQbcJX3nzx, key account r9iGR3sKh5x2qva2Lhb9yzXhyeSem3HN3v). This settles item 4 of the UNVERIFIED list of FIANSO-SPEC.md section 6.14 on the AlphaNet (a signature made by @noble/post-quantum, not on the device, accepted by a node) and item 5 for that key (the client's RIPEMD160(SHA256(pubkey)) is the AccountID the node accepted as RegularKey).
  • The order is enforced by the node. On the same account: the ML-DSA key signing before it is the regular key gets tefBAD_AUTH, "Transaction's public key is not authorized." (F4C84C9BBA141AFB804718DBAFB7E0B930FB1CE5C18B39CF618F06DC905A9B74, not included); SetRegularKey by the master DC3569DB24C41D2BA1890A598245533D160D8C6D217B6B8FD1131701ED6B8165 tesSUCCESS; asfDisableMaster signed by the ML-DSA regular key gets tecNEED_MASTER_KEY (8A7DF265ECEE7D2C811380C7E44807D4816C35737F118C2A290677B30CBA7394, included, fee charged).
  • K3 extended: the migration of section 8.7 holds as specified, run by runPqMigration on a second fresh account rnCQwebvNKsbaiByERfaj9NTNiTtkpENoV (key account rDkSPCGkmTVUGGfXV8v6yNoZ4LN2vqoNNm):
    1. SetRegularKey, master: EEA75218F13CAC191A1EAC2282EE4C4798A4C6B7A07F5DCCDA0C75078F48B0A2 tesSUCCESS;
    2. proof AccountSet (no change), ML-DSA: 4477DF76EEFE0B542FB76EE0307AFEA6DFB0B9136B1461C79C39D72C22D60DFB tesSUCCESS;
    3. guard before step 3: the proof validated, the ML-DSA key derived again from its seed in a separate process (software, SIMULATED; on the device this is the reinstall check), the exposure report reviewed (master exposed by step 1, no other authority);
    4. asfDisableMaster, master: 85B5A3A5989E1873B944817C76B80B408C83E4D0D91EDE79E682DC37C83DA9A1 tesSUCCESS. Afterwards the master gets tefMASTER_DISABLED, "Master key is disabled." (0BABF5ADE0B2EE070DDD497B55BACA5563D9C40711B5A13CBA53FC62C223D8F4, not included) and the ML-DSA key still signs (49AE6A196E3164347D40E5A258655C792E2C89B54DD2E75E26F1674550936C17 tesSUCCESS). checkExposure then answers no-exposure-found, "the master key is exposed but disabled", with the ML-DSA regular key exposed but not quantum-vulnerable.
  • Friction: the AlphaNet RPC proxy answers HTTP 403 (an nginx page) to any account_tx that carries forward; a first K3 run stopped there after its first four transactions (fresh accounts rLtXhBrahFDudPsNFkbVnQN3K79wFxJktp and rB5rTuCkZ6mRX9zSuSn4MkFTRKwdo7F8Tb; tefBAD_AUTH, SetRegularKey D21EA13B…, ML-DSA Payment 5277E0FA…, tecNEED_MASTER_KEY 600AD8A7…, the same results). checkExposure now falls back to newest first and keeps the oldest reveal; the second run used new accounts.

K7, mainnet, read-only (spikes/k7-mainnet.ts, https://xrplcluster.com, rippled 3.4.1, history from ledger 32570; the answers are recorded in packages/core/src/quantum/fixtures/ and replayed by mainnet.test.ts):

  • Known exposed, picked as a sender whose master key signed in ledger 107879672: rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah, exposed, first self-signed transaction 5773874D4E9E6DC8C8756EAC2B30DCBC860113EA20FE5EB29CEFC84025E581FE, found on the first page of an oldest-first search.
  • Known safe, picked as an account created by a payment in that ledger that has sent nothing: rEpekNdzt2izvF2MQb1DHksNprz1KRsJyC, no-exposure-found (its creation is in the searched history, one transaction, the search not cut short). The same answers with a smaller bound give unknown, "not found within 1 pages of 10 transactions".
  • Pages for a busy account: the same exposed account (89 transactions in one ledger) needs more than 25 pages of 200 transactions to reach its end: 5 000 transactions in 537 s, the search cut short. Finding a first reveal is cheap oldest first; proving absence for a busy account is not, so the bound and the unknown answer of rule 6 are what makes the check usable.
  • Friction: the public server rate-limits (tooBusy, "rate limit: units quota (2000 per 10s) exhausted, retry in ~7428ms"); JsonRpcClient now retries tooBusy / slowDown a bounded number of times, waiting what the server says. Mainnet has no PasskeyList or Beneficiary entries and refuses those account_objects filters with invalidParams, "Invalid field 'type'."; the check asks only for entry types the node's own server_definitions lists.
  • Adapted to D13 and D14: createMlDsaSessionKey() gives a Ledger account its fresh ML-DSA-44 session key (page memory, extractable, FIANSO-SPEC.md section 12).

Device contract and packages/ledger

  • docs/device-contract.md writes down what the client sends to the XRP app and what it expects back, every number cited in the device track's code: the classical 0x02 / 0x04 instructions as app-xrp does today (fields without STX\0, the app adds it), the ML-DSA-44 instructions 0x10 / 0x11 / 0x12 of the device track (STX\0 included, at most 2 048 bytes), the status words, the review lines, the byte layout, vectors, and the items left to the device-track owner.
  • packages/ledger gets the client side of that contract (getClassicPublicKey, signClassic, getMlDsaPublicKey, signMlDsa, deviceMlDsaSigner, deviceClassicSigner, dmkTransport over the DMK 1.11 sendApdu), the review-line prediction reviewLines, the device's ML-DSA key derivation (SLIP-21 label carbs ML-DSA-44, checked against the four test vectors of SLIP-0021), and a SIMULATED device that follows the contract APDU by APDU and signs in software.
  • Cross-checks: reviewLines reproduces line for line, order included, ten frozen screen vectors of the device track (SigningSessionSet in the three modes, ten permissions, a short duration, both SigningSessionDelete shapes, SetRegularKey, AccountSet asfDisableMaster, a Payment); ML-DSA signatures returned through the full 0x11 / 0x12 exchange verify with @noble/post-quantum 0.7.1; classical signatures verify with ripple-keypairs.
  • Adapted to the device track: its branch STOOOKEEE/n1-passkey-challenge-fix (not in develop yet) has its own DMK client in packages/ledger/src/device.ts and sign.ts (a LedgerXrp session with a raw exchange). This package adds files beside them and an adapter to that exchange shape (rawTransport, rawExchangeOf) instead of a second connection layer; the two clients send the same APDUs (0x11 chunks of 250 bytes there, 255 here, both accepted by ledger-app/src/pq/pq_root.c:164-181). When that branch lands, src/index.ts and package.json need their exports and dependencies merged.
  • Deviation: docs/ARCHITECTURE.md said packages/ledger imports only types of packages/core; the device commands use its encoder and the BytesSigner shape, so the rule now says it imports packages/core and nothing else of the workspace.
  • SIMULATED: every result of packages/ledger's tests comes from the simulated device; no device was touched. The key-derivation vector for Speculos' default mnemonic is UNVERIFIED on a device.

2026-10-24: live exposure check in the Quantum screen (web app, M2 part 1)

  • Live source, real reads. The Quantum screen has a Data switch, default Live: checkExposure of @carbs/sessions runs against the real node of the chosen network (AlphaNet https://rpc.alphanet.xrpl.org, mainnet https://xrplcluster.com), read-only (JsonRpcClient refuses any method outside its public list; a test asserts that only account_info, account_tx, account_objects and server_definitions are called). The core report is mapped to the screen's report (lib/live/exposure.ts). Simulated histories stay behind the other radio button, badged SIMULATED. The migration panel is still the simulated one in this change.
  • Checked on the real AlphaNet (read-only, from Node, no browser): account rpxMtodcdjFGpssK5k7D4CixifFBCLS7eV (recorded by the device track), history of 4 transactions in ledgers 220005 to 236826 with the creation inside: master key exposed but disabled, regular key ML-DSA-44 (post-quantum), status no-exposure-found. Mainnet: the wiring reached account_tx on the genesis account and was rate-limited (below); no mainnet verdict was obtained.
  • Findings (also in docs/ledger-dx-feedback.md): (1) the AlphaNet RPC answers the CORS preflight with 400, so a browser cannot POST JSON to it; the client sends the body as text/plain, a "simple" request without preflight, which both nodes accept (curl evidence, 2026-10-24); a test pins the header. happy-dom preflights every cross-origin request, so the component tests use a recorded node and the real-node check ran in plain Node. (2) the public mainnet cluster rate-limits account_tx (tooBusy, "retry in ~7042ms"): the clients wait as long as the node asks, up to four times. (3) loadDefinitions refuses a node without the SigningSessions names by default; the exposure check does not need them, so it loads the definitions with requireSessions: false (the session screens will require them).
  • No browser was driven (I do not use the Chrome tools), so the page-level behaviour of the preflight-free request is argued from the specification of simple requests and the curl answers, not observed in a browser.

Passkey wallet and spike K1 on the AlphaNet

  • Deviation, planned: the passkey wallet (an account that signs with its passkeys only) is not in FIANSO-SPEC.md section 8.5; this pull request adds it there. buildPasskeyWallet / runPasskeyWallet in packages/core: PasskeyListSet (master), a proof AccountSet signed by the passkey through WebAuthn, SetRegularKey to rrrrrrrrrrrrrrrrrrrrBZbvji (master), asfDisableMaster (master). The proof step is added to the planned order, for the reason the post-quantum migration has one: on a node that refuses the passkey's signature (P5 without fixPasskeyChallenge), disabling the master key would lock the account for good. The client refuses step 4 unless step 2 was validated.
  • Spike K1, public AlphaNet, fresh accounts, client-side signing (spikes/k1-alphanet.ts): a WebCrypto P-256 key generated non-extractible, set as RegularKey (2A6B9E4052D4DEAB9BEF1CF75D6D1F588A5447528A93DCA99C6635D78F06C971), signs a 1 XRP Payment with a raw ECDSA signature normalized to low-S strict DER: E60C191803F88DDBD9316CA23B90D7FF48EAEE4A304B8E148B325C16E2B7766D tesSUCCESS (account rpC3w3GwyXiZsjosZjduqQBxA9v2e9ZYkp). The outcome of K1 holds: the P-256 session key works; no Ed25519 fallback is needed.
  • SIMULATED, the passkey wallet on the AlphaNet (software WebAuthn authenticator, account rKVYVn9TTrn2L4K1zg6UUfsxEMEJSf39Go): PasskeyListSet 5D4B6E62B6B855C8D9C5E2AC4D1A608787AFA1E7B9A670B5FF4D3A760E3AC3FC tesSUCCESS; the WebAuthn proof is refused before it reaches a ledger, submit answers invalidTransaction, "fails local checks: Invalid signature." (0A32A379…, never included), as P5 predicts on a tree without fixPasskeyChallenge (the AlphaNet's platform is unknown: this says nothing more, as spec section 6.14 notes); the wallet stopped there and the master key stayed enabled. A raw P-256 signature by the same passkey key (no PasskeySignature) is accepted: B4613788A564A3877629FCA869B52C78C5E60764F1993A08AAE8AAD9FD267962 tesSUCCESS. A first run of the same spike (fresh accounts rGpEjaLgmr1LEf4TTA7bafTXckoqJ96Wvh, r97RZuVj7rXK7uWD5pzDbXyLhU2TzgXry8; K1 payment F644F01F… tesSUCCESS) stopped on that refusal because submitBlob threw on the RPC error; it now returns invalidTransaction with the node's error_exception.
  • Still to run on the local node once N2/N9 are merged (step 6 of the goal): the full passkey wallet with fixPasskeyChallenge, then a SigningSessionSet signed by the passkey (W2) and a session-signed Payment.
  • Hybrid note (a passkey as a multisig signer): recorded, nothing built. Multi-signing never reads the passkey list: Transactor::checkMultiSign accepts a signer's key only when it is the signer account's master key or its RegularKey (rippled/src/libxrpl/tx/Transactor.cpp:1187-1340; the only read of keylet::passkeyList is in the single-signature path, :1141-1158), and the Signer inner object has no PasskeySignature field (rippled/src/libxrpl/protocol/InnerObjectFormats.cpp:21-27), so a WebAuthn assertion cannot even be carried. A raw P-256 key can still be a signer when it is the master key or RegularKey of the signer account, since checkMultiSign accepts any key type publicKeyType knows (rippled/src/libxrpl/tx/Transactor.cpp:1254-1258); UNVERIFIED by a test.

2026-10-24: live migration in the Quantum screen (web app, M2 part 2)

SIMULATED: the device. It is the contract-following software device of @carbs/ledger with a random seed made in the page, not a Stax; the page's simulated Stax screen shows each review. Real: the node. Everything below is the public AlphaNet's own answer, signed client-side, never through the node's sign RPC, on a fresh faucet account (createLiveMigration, apps/web/lib/live/migration.ts).

  • What it is. With the Data switch on Live, "Start with a fresh AlphaNet account" creates a master key and an ML-DSA-44 key through the device contract (deviceClassicSigner, deviceMlDsaSigner), funds the account from the faucet (https://faucet.alphanet.xrpl.org/accounts), builds the three steps with buildPqMigration, signs each with signTransaction (NetworkID and the other common fields from fetchCommonFields), submits with submitAndWait (validated result), and shows each hash as a link to the explorer of the AlphaNet. Step 3 goes through disableMasterRefusals: it needs the validated proof, a post-quantum backup and the reviewed authorities. A software device cannot be reinstalled, so the recoverability evidence is a backup: the page shows the simulated device's 64-byte seed and the person must confirm saving it; a test derives the regular key back from that seed.
  • Run once against the real AlphaNet (from Node with the same controller, no browser, 26 s): fresh account rfsQK9XifCgJ1BRYyZnDXBJfcequPJEKaq, exposure before: no-exposure-found;
    1. SetRegularKey to the ML-DSA key, master: 3C0B9A0DC86E03940A17A4BF7BE812998D967F253AB62462F73D81036E0D3039 tesSUCCESS;
    2. proof AccountSet, ML-DSA: D073C5166244802D0517A10306F3E241D37099A6FAC54BA15B1749E28EB61806 tesSUCCESS;
    3. asfDisableMaster first refused by the app ("the ML-DSA key is not proven recoverable and no post-quantum backup is given; the authorities that survive asfDisableMaster were not acknowledged"), then, after the seed backup and the review, master: B6E91FDBF765456633FB85BC32CEEBE31593DD4433CA58D62F688D933F5DB856 tesSUCCESS. Exposure after: no-exposure-found (master exposed but disabled, regular key ML-DSA-44). Explorer: https://alphanet.xrpl.org/transactions/<hash>; the account page path /accounts/<address> is UNVERIFIED (the explorer is a single-page app that answers 200 to any path).
  • Tests (121 in the web app): the controller against a test double of the AlphaNet that decodes the real blobs (lib/live/fake-alphanet.ts: order enforced with the node's own codes, no signature check, a TEST DOUBLE), and the Quantum screen on both sources.
  • Not done: no browser was driven, so the faucet's CORS answer (the preflight returns 200 and reflects the origin, checked with curl) and the explorer links are not observed in a page. Mainnet is never written to.

2026-10-24: the live backend for Session, Actions and Attack (web app, M3 groundwork)

Not verified on the amendment node: it does not exist on develop yet (the amendment's definitions are in an open pull request). What is built and what it was tested against:

  • Built (apps/web/lib/live/): createLiveBackend asks a node on its public port for its server_definitions and refuses one without the SigningSessions names; LiveNode (NodePort on @carbs/sessions: ledger for the close time, account_info, account_objects with the signing_session type, fetchCommonFields, encodeTx, submitAndWait); createLiveBuilders (the node's own shapes, flags and permission objects from the definitions, never typed by hand); LiveWallet (a secp256k1 master key, the passkey through signWithPasskey with the browser's WebAuthn or the software authenticator, a non-extractible P-256 or an ML-DSA-44 session key, fresh per session); createLiveDevice (the contract-following software double of @carbs/ledger, SIMULATED). lib/boot.ts picks the backend at page load (NUXT_PUBLIC_BACKEND or the saved choice); if the node cannot be reached, answers too slowly (8 s) or lacks the amendment, the page stays on the simulation and the header says why. A page served over https never tries a ws:// node.
  • Tested (149 tests in the web app): the whole demo store on the live backend over a test double of the node that decodes the real blobs and serves core's SIMULATED session definitions: account setup for the three profiles, a mode C session opened and closed, session-signed payments counted against the cap, the node's refusals for the attacks, expiry on a live node without a simulated clock, a passkey signing the binary signing data, a rejected device. The double applies a handful of the session rules with the node's codes (lib/live/fake-alphanet.ts); it is not the amendment.
  • Unverified until a node carries the amendment: the shape of signing_session objects in account_objects (Expiration, Permissions, SessionCap, SessionSpent, Flags), the ledger answer used for the close time, whether the standalone node serves account_objects for the type name signing_session, and every real result code. Nothing was submitted to any node for this change.
  • Refactors on the way: builders, common fields and counterparties come from the backend (TxBuilders, NodePort.commonFields, Backend.counterparties); the context keeps sim next to the active backend. The local node's dev faucet (NUXT_PUBLIC_LOCAL_FAUCET_URL, default http://localhost:3005/accounts) is part of the Content-Security-Policy; the script that starts it is milestone M3 proper.

2026-10-24: the SigningSessions amendment (GOAL-1)

N2: definitions of the SigningSessions amendment and minimal transactors

  • One macro batch, minimal transactors, one build. SigningSessions (Supported::Yes, DefaultNo) at the top of features.macro, above fixPasskeyChallenge; the fields SessionKey ACCOUNT 34, SessionCap AMOUNT 47, SessionSpent AMOUNT 48, SessionDuration UINT32 114; ltSIGNING_SESSION 0x009A (signing_session); SigningSessionSet 130 and SigningSessionDelete 131; tfAllTransactions and lsfAllTransactions 0x00010000; tefSESSION_EXPIRED, tefSESSION_LIMIT, tefSESSION_TX_NOT_ALLOWED at the end of TEFcodes (-172, -171, -170) with their TER.cpp strings; keylet::signingSession in namespace 'z'; kMaxSessionDuration, kSessionFeeMultiple, kSessionMaxLedgerWindow in Protocol.h; jss::session_key; the ledger_entry parser; the AccountDelete deleter. Every number equals spec 6.1 and ledger-app/src/xrp/signing_session.h (130, 131, 34, 47, 114, 52, 29, 15, 0x00010000); SessionSpent (48) is not parsed by the app, which never signs it.
  • No field for D13 to D16 or ADR 0010. D13, D14 and D16 need no field (spec 6.14: an ML-DSA-44 session key goes through generic code). ADR 0010 is still Proposed and nothing of it is adopted (D15), so sfSessionFirstTicket and the ticket budget fields are not added.
  • What the transactors do in N2. SigningSessionSet: checkExtraFeatures on PermissionDelegationV1_1, the flag mask, tecDUPLICATE for an existing (account, key), the sponsor-aware checkReserve (tecINSUFFICIENT_RESERVE), the entry with Expiration = parentCloseTime + SessionDuration and SessionSpent = 0 in mode C, the owner directory and owner count. Its preflight validation is N3. SigningSessionDelete: the owner closes without Owner; with Owner, any account closes an expired session (tecNO_PERMISSION while it is live); Owner == Account is temMALFORMED; a missing session is tecNO_ENTRY. Deviation from the order of spec 10.1: these closes are N7's; they are here so that the transactor on develop, which the other goals now use, never lets a stranger close a live session. The own close by the session key needs the session path (N4) and stays in N7.
  • Decisions. Reserve sponsorship: not supported (spec 6.10). SigningSessionSet is not in isReserveSponsorAllowed (rippled/src/libxrpl/ledger/helpers/SponsorHelpers.cpp:28-61), so preflight1 refuses a reserve sponsor with temINVALID_FLAG (rippled/src/libxrpl/tx/Transactor.cpp:216-224), tested; ltSIGNING_SESSION is not in isLedgerEntrySupportedBySponsorship (:268-286). Firewall: Allow, the default, as DelegateSet and SignerListSet (rippled/include/xrpl/protocol/detail/transactions.macro:837-840, :173-174): opening or closing a session moves no value, and every session-signed transaction still meets checkFirewall (spec 6.5). Both types keep the default NotDelegable, so the Delegate counter stays at 71.
  • Deviation from docs/PLAN-CHECK.md, N2 bullet: the two TRANSACTION entries name only .amendment. The macro header asks to name only the settings that differ from the defaults (rippled/include/xrpl/protocol/detail/transactions.macro:28-29); the firewall and privilege decisions are pinned by the test transaction settings instead. The four fields are appended together at the end of sfields.macro, not after the highest code of each type.
  • Tests first. SigningSessions (cases 1, 7, 18 without the own close, 19, 24 for RPC and sponsorship, the reserve code, flags, settings, an MPT SessionCap refused by the local checks) and the jtx helpers test/jtx/session.h were written before the definitions; they did not compile without them. jtx and not gtest: every case goes through submit, preflight, preclaim and Transactor::operator(), which need an Application (docs/PLAN-CHECK.md finding 9). Two upstream suites get a case for the new type, nothing removed or weakened: LedgerEntry (testSigningSession, the malformed-field sweep of runLedgerEntryTest, as testDelegate) and MPToken (testMPTInvalidInTx covers SigningSessionSet.SessionCap, finding 3).
  • Generated code. code_gen rewrote 21 unrelated files and created 14 (the drift of finding 8: AccountRoot, MPToken, MPTokenIssuance, PasskeyList, Repo, Vault, CouponScheduleCreate, PasskeyListSet, RepoAccept, RepoCancel, RepoClose, RepoCreate, RepoDefault, VaultCreate, VaultSet and their tests changed; Beneficiary, TokenIssuance, BeneficiarySet, TokenConvert, TokenIssuanceCreate, TokenIssuanceDestroy, TokenIssuanceSet and their tests new). All of them were restored or removed; only the six SigningSession* files are committed.
  • Levelization (scratch mirror): no new line; the only difference is the known base drift test.server > xrpl.rdb.
  • Results (rippled/build, Debug; the run before review round 1; after it, the suite has 10 cases and 284 tests, below). ./xrpld --unittest=SigningSessions: 9 cases, 268 tests, 0 failures. ./xrpld --unittest=SigningSessions,Delegate,PasskeyListSet,AccountDelete,PassKey,ServerDefinitions,PasskeyWebAuthn,Apply,MPToken,Feature,LedgerEntry,AccountObjects --unittest-jobs=6: two failures, both in docs/base-failures.txt — Delegate counts 71 delegable types against 60 pinned (unchanged by N2: both new types are non-delegable) and MPToken fails its completeness assertion, now at line 2105 since ten lines were inserted above rippled/src/test/app/MPToken_test.cpp:2095. Printed once with a temporary log line, then removed: the set it asserts empty holds FirewallSetMaxFee and OfferCreateMinQuantity, the base pair; SigningSessionSetSessionCap left it. ./xrpl_tests: 1135 tests, 1134 passed, the one failure is the base VaultTests.BuilderFromSleRoundTrip; the 15 generated SigningSession* tests pass.
  • Test that first failed for a reason of its own: the tecINSUFFICIENT_RESERVE case. A SigningSessionSet refused for its reserve in the open ledger was applied again when the ledger closed, after a payment had covered it (the open ledger retries a tec). The test now closes the ledger after the refusal.
  • Device-track watch. A draft pull request of the device-track owner (#29, branch STOOOKEEE/n2-signing-sessions) implements mode C and adds two protections not in the spec: SessionMaxPerTx (AMOUNT 49), SessionWitness (ACCOUNT 35) with WitnessSignature (OBJECT 43), and tefSESSION_NO_WITNESS; its description says the owner must confirm them. The branch STOOOKEEE/l1-session-clear-signing adds 49 and 35 to the device's signing_session.h as optional rows. Every number #29 gives to an item of spec 6.1 equals N2's (130, 131, ACCOUNT 34, AMOUNT 47 and 48, UINT32 114, 0x009A, the three tefSESSION_* codes in the same order), so the device's header on develop and N2 agree. N2 follows the spec and does not add the two protections; codes 35, 43, 49 and the tef value after tefSESSION_TX_NOT_ALLOWED stay free, so they can be appended without renumbering if the owner adopts them.
  • Review gate 2, round 1: VERDICT: CHANGES REQUIRED (Plan agent, model opus, read-only). Blocking, all applied: (1) the stranger's close "at Expiration exactly" was never at it, since close times move in 10-second steps and a 25-second duration ends between two of them; the test now uses 30 seconds, is refused at every ledger before Expiration and asserts parentCloseTime == Expiration before the close that succeeds; (2) the reserve test did not prove that the reserve is checked on the balance before the fee; it now funds one drop short of the reserve of one more object (tecINSUFFICIENT_RESERVE), then exactly that reserve, which succeeds although the balance after the fee is below it; (3) four comments named the ValidSigningSession check, which does not exist before N6; they now say there is no transaction-specific invariant yet. Non-blocking: the MPT SessionCap case promised by docs/PLAN-CHECK.md finding 3 is added; blank lines between the macro entries; a citation off by one line fixed (transactions.macro:28-29). Left for later, as the reviewer allows: before N3, SigningSessionSet stores any shape, including a duration that makes the 32-bit Expiration wrap (no authority follows from a session until N4, and N3 lands first); a fee-only sponsored SigningSessionSet is accepted and untested (N8).
  • Mutation check of the two strengthened tests (temporary edits, reverted): the stranger's close with <= in place of < fails at rippled/src/test/app/SigningSessions_test.cpp:271 (tecNO_PERMISSION at Expiration); the reserve checked on the balance after the fee in place of preFeeBalance_ fails at :216 (tecINSUFFICIENT_RESERVE). After the revert: ./xrpld --unittest=SigningSessions, 10 cases, 284 tests, 0 failures.
  • Review gate 2, round 2: VERDICT: APPROVED. All three blocking findings resolved (the cited lines :271 and :216 checked against the assertions); one new non-blocking note, applied: the earlier result above is marked as the run before round 1. Comparison suites again on the final tree, --unittest-jobs=6: 13 suites, 294 cases, 87171 tests, 2 failures, the same two base failures (Delegate 71 vs 60, MPToken_test.cpp:2105).

N9: the standalone node and its ledger closer

  • The device-track owner's commit, cherry-picked alone. infra/standalone.cfg, infra/closer.ts and infra/compose.yaml come unchanged from ebdd0ce (branch STOOOKEEE/n1-passkey-challenge-fix, draft #22), with its author; the other commits of that branch stay out (its P5 fix repeats #13; the test wallet and the DMK signer are the owner's to bring). Our commit adds SigningSessions to [amendments] and updates infra/README.md.
  • Deviation from spec 6.12 ([features]): the configuration enables the amendments through [amendments], whose entries are voted up and enabled in the genesis ledger of --start; the owner's commit says [features] does not seed the genesis ledger. Checked here only in the direction that matters: with [amendments], the feature command reports all six enabled (below). That [features] alone would not is the owner's observation, UNVERIFIED here.
  • compose.yaml builds a wallet service from apps/passkey-wallet, which is not on develop (it comes with the owner's test-wallet commit); infra/README.md says so. The node and closer services do not depend on it.
  • Checked on the node built from N2 (#32), with a copy of infra/standalone.cfg whose paths and ports point at a scratch directory, the closer node infra/closer.ts against its admin WebSocket, and a script that signs on the node's admin port with secp256k1 keys of fresh accounts (local standalone node only; no P-256 key goes through the node's signing):
    • server_definitions on the public port: SigningSessionSet 130, SigningSessionDelete 131, SigningSession 154 (0x009A), SessionDuration UInt32 114, SessionCap Amount 47, SessionSpent Amount 48, SessionKey AccountID 34, tefSESSION_EXPIRED -172, tefSESSION_LIMIT -171, tefSESSION_TX_NOT_ALLOWED -170.
    • feature: Passkey, Quantum, SigningSessions, BatchV1_1, PermissionDelegationV1_1, fixPasskeyChallenge all enabled: true. fixBatchV1_2 was not needed for anything run here; it stays UNVERIFIED as a need.
    • The closer logged closer: connected to ws://127.0.0.1:16007, closing every 3000 ms and ledgers advanced.
    • A session opened and closed in mode A (Flags 65536) and in mode C (SessionCap 50000000, SessionSpent 0): SigningSessionSet tesSUCCESS, the entry listed by account_objects type signing_session, SigningSessionDelete tesSUCCESS, no session left.
    • ledger_accept on the public port: forbidden, "Bad credentials.".

N3, N4, N4b: validation of SigningSessionSet and the session path (modes A and P)

  • Tests first. The new cases of SigningSessions were written and run against the N2 tree before any of this code: 21 cases, 804 tests, 92 failures (every validation case answered tesSUCCESS or tecDUPLICATE, every session-signed transaction tefBAD_AUTH). After the code: 28 cases, 1720 tests, 0 failures.
  • N3, SigningSessionSet validation (spec 6.3; cases 2 to 5, case 6 without the session part). Preflight: exactly one mode, else temMALFORMED; SessionKey == Account temMALFORMED; a duration of 0 or above 600 temBAD_EXPIRATION; Permissions empty temARRAY_EMPTY, above 10 temARRAY_TOO_LARGE, a duplicate, a non-delegable value or an excluded type temMALFORMED (as DelegateSet::preflight, rippled/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp:25-48); a SessionCap that is not a positive XRP amount temBAD_AMOUNT. Preclaim: a SessionKey that is the RegularKey, the AccountID of a key of the passkey list (read as checkSingleSign reads it, rippled/src/libxrpl/tx/Transactor.cpp:1220-1236) or the beneficiary (:1238-1256) is tecNO_PERMISSION. With Delegate, the type being non-delegable, preflight answers temINVALID (tested). The wrap of a 32-bit Expiration that review gate 2 noted for N2 is no longer reachable: the duration is at most 600.
  • N4, the predicate. isSessionSigned and signingSessionOf live in one header, rippled/include/xrpl/tx/SigningSession.h; signingSessionOf is the predicate returning the session's keylet, which applySteps.cpp passes to the hook and Transactor.cpp reads for the accounting, and isSessionSigned is its boolean form (a test asserts both agree, N10). The invariant (N6) uses the same shape test without the existence check (sessionKeyletOf), because an own close erases the session it must still recognise: a deviation in form from spec 6.4 ("one function"), not in what the three parts decide. A transaction is session-signed when SigningSessions is enabled (no ledger read before), it carries no Delegate, Sponsor, SponsorSignature or CounterpartySignature and is not a Batch inner transaction, it is single-signed with a key of a known type and has no Signers, and a session exists for (Account, the signer). The checks of Signers and of the Batch inner flag are defensive: a multi-signed transaction and a Batch inner transaction have an empty SigningPubKey, so the signer test already refuses them (so a mutation of either would go unnoticed; neither is in the mutation check).
  • N4, the hook. In invokePreclaim, Transactor::invokeCheckSession<T> replaces T::checkSign when the predicate holds, between invokeCheckPermission<T> and checkFirewall (spec 6.5); it returns NotTEC. Its rules, in order: (1) eligibility, tefSESSION_TX_NOT_ALLOWED: the own close (a SigningSessionDelete without Owner whose SessionKey is the signer) is always eligible; mode A takes a delegable type not in kSessionExcluded; mode P runs the sequence of Transactor::checkPermission (rippled/src/libxrpl/tx/Transactor.cpp:391-419) with the session entry, then T::checkGranularSemantics, every refusal mapped to the tef; mode C signs nothing yet (N5); (2) LastLedgerSequence present and at most 256 ledgers ahead, tefSESSION_TX_NOT_ALLOWED; (3) expiry, tefSESSION_EXPIRED, except the own close; (4) the fee ceiling, tefSESSION_LIMIT, the own close included. What the replaced Transactor::checkSign did and the hook must redo: the pseudo-account refusal (tefBAD_AUTH, as rippled/src/libxrpl/tx/Transactor.cpp:964-974), and the unknown key type, which is part of the predicate (such a transaction is not session-signed and meets the ordinary tefBAD_AUTH).
  • Deviation, the fee ceiling: the hook calls T::calculateBaseFee directly, as invokeCalculateBaseFee does inside the same withTxnType dispatch, instead of the free calculateBaseFee and its Expected; an exception there leaves invokePreclaim and is caught by preclaim (rippled/src/libxrpl/tx/applySteps.cpp:438-441), which answers tefEXCEPTION, a tef as well; that catch takes std::exception only, where invokeCalculateBaseFee also catches .... AMMCreate and LedgerStateFix, whose required fee is the owner reserve increment, pass the ceiling (tested; LedgerStateFix then fails for its own reason, tecFAILED_PROCESSING).
  • Deviation, where kSessionExcluded lives: in SigningSession.h beside the predicate, not in rippled/include/xrpl/protocol/Protocol.h with the numeric constants: Protocol.h does not see TxType.
  • Types that check their own signature (Batch and LoanSet override checkSign) are never authorized by a session: the hook refuses a type whose checkSign differs from Transactor::checkSign, and case 9b asserts that no eligible type has one (both are non-delegable today, so the refusal is defence in depth). FirewallSet, FirewallDelete and WithdrawPreauth check a counterparty signature in preclaim, which still runs after the session path; they are out of reach because they are not delegable.
  • Case 9b pins the 68 types a session signs in mode A and lists in mode P (the 71 delegable types minus the three excluded). A new delegable type fails the test until it is added there or to kSessionExcluded. The test also lists every type in mode P and expects tesSUCCESS for exactly those 68.
  • Mode C answers tefSESSION_TX_NOT_ALLOWED to everything but the own close until N5 brings its whitelist and its accounting together: the cap check without the counter would let a session spend without bound. The test mode C before its accounting pins this and changes in N5.
  • Case 20 (D14). Session keys of the four key types sign a payment and close their session: P-256 (raw ECDSA, no PasskeySignature), Ed25519, secp256k1 and ML-DSA-44. The Ledger account: master disabled, RegularKey an ML-DSA-44 key (1 312 bytes); the disabled master cannot open a session (tefMASTER_DISABLED), a session for the RegularKey itself is tecNO_PERMISSION, the RegularKey opens one; the entry's SessionKey is calcAccountID of the session key; the ML-DSA-44 session key pays, is refused for another account (tefBAD_AUTH), and closes its own session; a corrupted ML-DSA-44 signature is refused by submit (invalidTransaction). A 1 312-byte SigningPubKey that is not a key is typed ML-DSA-44 by its size and refused by submit (invalidTransaction). This settles items 1 and 2 of the UNVERIFIED list of spec 6.14 on our node (jtx keys, not @noble/post-quantum: items 4 to 6 stay open). Low-S strict DER for P-256 is the client's duty (the node accepts high-S, spec 6.14): not claimed here.
  • Case 14 and 15. A session key used for another account, with Delegate, with Signers, with a fee Sponsor, with a CounterpartySignature (a FirewallSet update) or as a Batch inner signer falls to the ordinary signature check and gets tefBAD_AUTH. Deviation from case 14 as written: the multi-signed shape gets tefBAD_SIGNATURE, not tefBAD_AUTH: a signer that is not in the account's signer list is refused by checkMultiSign with that code, an existing rule this amendment does not touch.
  • Case 17 and 17b. A session key later set as RegularKey stays bounded (SetRegularKey signed by it tefSESSION_TX_NOT_ALLOWED) and has full authority once the owner deletes the session with its master key. LastLedgerSequence missing or at seq + 257: tefSESSION_TX_NOT_ALLOWED; at seq + 256: applied.
  • Not here: the pseudo-account refusal of the hook has no test yet (a session entry for a pseudo-account needs injected state; N8, case 24). The fee-only sponsored SigningSessionSet noted by gate 2 stays for N8.
  • Mutation check (one temporary edit at a time, rebuilt, run, reverted; script output kept in the pull request): the predicate without the Sponsor and SponsorSignature test (1 failure), without the CounterpartySignature test (1), without the Delegate test (1); expiry at > instead of >= (4); the fee ceiling one base fee higher (23); the window one ledger longer (1); SubscriptionSet out of kSessionExcluded (34); checkGranularSemantics skipped (3); the RegularKey check of SigningSessionSet removed (the suite aborts). After the revert: 0 failures.
  • Neighbouring suites (--unittest-jobs=6): SigningSessions, Delegate, PasskeyListSet, AccountDelete, PassKey, ServerDefinitions, PasskeyWebAuthn, Apply, Batch, Firewall, Beneficiary, MultiSign, Sponsor, TxQ, Feature, MPToken, LedgerEntry, SetRegularKey, AccountSet and every Invariants* suite: 34 suites, 618 cases, 133273 tests, 4 failures, all in docs/base-failures.txt (Delegate 71 vs 60, MPToken_test.cpp:2105, InvariantsVault_test.cpp:3035 twice). Levelization (scratch mirror): no new line.
  • Review gate 2, round 1: VERDICT: CHANGES REQUIRED. Blocking, applied: a mode-P session could list a granular permission of a non-delegable type and sign with it. Permission::isDelegable accepts a granular value when its type's amendment is enabled, even for a non-delegable type (rippled/src/libxrpl/protocol/Permissions.cpp:235-244), so AccountDomainSet, AccountTransferRateSet and the other AccountSet granular permissions passed, and a session could change an issuer's TransferRate. SigningSessionSet now refuses any permission, transaction-level or granular, whose type mode A could not sign (temMALFORMED), and the hook checks the same eligibility at use under the ledger's rules. Tests: AccountDomainSet and AccountTransferRateSet refused; case 9b lists every granular permission and pins the seven a session may hold (TrustlineAuthorize, TrustlineFreeze, TrustlineUnfreeze, PaymentMint, PaymentBurn, MPTokenIssuanceLock, MPTokenIssuanceUnlock). Non-blocking, applied: the comment and this entry no longer count the firewall types among those that override checkSign; the exception path of the fee ceiling is described as it is; the predicate is evaluated once (invokePreclaim passes the session's keylet to the hook); trace logs on the tecNO_PERMISSION and tecDUPLICATE refusals; the whitespace change clang-format made to upstream checkSingleSign is reverted (only our block is formatted); and the tests the reviewer found missing: SponsorshipSet and SubscriptionSet signed by a mode-A session (tefSESSION_TX_NOT_ALLOWED), the close of another key's live session, a session-signed Batch with an inner SetRegularKey (tefSESSION_TX_NOT_ALLOWED), the session key as the counterparty signer of a firewall (tefBAD_AUTH), a pre-funded fee sponsor without SponsorSignature (tefBAD_AUTH), the documented limitation of 17b (with a fee sponsor or as a Batch inner signer, a key that is both the RegularKey and a session key acts with the RegularKey: applied), and a P-256 session key signing through WebAuthn (PasskeySignature, software authenticator, SIMULATED): the session still bounds it. Result: 28 cases, 1844 tests, 0 failures. Mutations of the new checks: the granular refusal removed (16 failures), the Sponsor exclusion alone removed (1 failure). Not covered by a test: the eligibility check of mode P at use, reachable only if a type stops being delegable under a live session.
  • Review gate 2, round 2: VERDICT: CHANGES REQUIRED, for one documentation finding: the pull request body still said "SIMULATED: nothing" after the WebAuthn test was added (hard rule 7); every code finding of round 1 was found fixed, and the reviewer wrote "Once the PR body is fixed, I have nothing left to raise." The body now labels the test SIMULATED. Deviation from PLAN.md section 3 (at most two rounds per gate): a third, confirmation-only round checked that body edit and nothing else: VERDICT: APPROVED. Neighbouring suites after the fix: 34 suites, 618 cases, 133397 tests, the same 4 base failures.

2026-10-24: pnpm demo, and the live backend against a node with the amendment (web app, M3)

Real: the node. It is the standalone node of infra/standalone.cfg started by pnpm demo on this machine, with a build from the protocol track made at the N2 stage (the macros and the minimal transactors; its server_definitions serve SigningSessionSet 130, SigningSessionDelete 131 and the three tefSESSION_* codes). SIMULATED: the Stax (the software double of the device contract) and, for the passkey, the software authenticator of @carbs/sessions (real P-256, no prompt). No browser, no device and no real WebAuthn prompt were used; the run is the web app's own live backend driven from Node.

  • pnpm demo (scripts/, package @carbs/demo): rewrites infra/standalone.cfg for this machine (data in a temporary directory, loopback only), starts xrpld (XRPLD_BIN, default rippled/build/xrpld), infra/closer.ts, a dev faucet on 127.0.0.1:3005 that holds the genesis key (GENESIS_SEED from the environment, never written; the browser never sees it; only the page's own origins may call it) and the web app with NUXT_PUBLIC_BACKEND=live. Tested: the configuration rewrite, the ports read from the file, the destination parser, and the faucet over HTTP (the payment it signs is decoded: Payment, 100 XRP, from the genesis account; origins, preflight, bad body, other paths).
  • What worked on the real node (one fresh account per profile, each funded by the faucet):
    • the older build without the amendment: the live boot refuses it with the exact missing names and the page stays on the simulation;
    • Demo account: funded, then SigningSessionSet in mode C (20 XRP, 600 s) tesSUCCESS; the signing_session object the node returns (Account, SessionKey, Expiration, SessionCap, SessionSpent) maps correctly in LiveNode, which settles the UNVERIFIED shape noted in the entry above;
    • Mac account: master-signed PasskeyListSet tesSUCCESS (the node accepts the Passkeys object shape built from the definitions), the passkey_list object read back, then a session opened with a WebAuthn assertion over the binary signing data tesSUCCESS (fixPasskeyChallenge is enabled by the configuration);
    • Ledger account: SetRegularKey (master), proof AccountSet (ML-DSA), asfDisableMaster (master), all tesSUCCESS, then a mode A session opened by an ML-DSA-44 signature tesSUCCESS.
  • What failed, and why: every session-signed transaction (actions, attacks, the session key's own close) got tefBAD_AUTH: the authorization hook of the amendment (N3/N4) is not in that build. So Actions and Attack cannot be checked on the real node yet.
  • Bug found by this run (fixed): account_objects reads the validated ledger, so right after funding it answers actNotFound, which aborted account setup. LiveNode now waits for the account to be validated before returning from the faucet and treats actNotFound on account_objects as an empty list; the test double raises it too.
  • Addresses of the accounts of this run are throw-away standalone accounts and are not recorded.

Switch to the node: the passkey wallet and W2 on our standalone node

  • The client reads the node now. N2 and N9 are merged (develop 0882800). The server_definitions of our standalone node (hash F3F8D90EA7D8C770C528567852D1EE2AEC8F1F6F248BA6995C9CCB7E42CB18D9) are recorded in packages/core/src/fixtures/standalone-server-definitions.json; a test shows that every SigningSessions identifier of the SIMULATED definitions equals the node's (types 130 / 131, fields 34 / 47 / 48 / 114, entry 0x009A, tfAllTransactions / lsfAllTransactions 0x00010000, tefSESSION_EXPIRED −172, tefSESSION_LIMIT −171, tefSESSION_TX_NOT_ALLOWED −170). The encoding and device tests use the node's definitions; encodeForDevice still reproduces the device track's seven vectors byte for byte. The SIMULATED definitions stay exported for code written against them.
  • The node: xrpld 3.4.1+DEBUG, a copy of the binary built in GOAL-1's worktree (whose head was 0882800, with N3 work not yet committed), started standalone (-a --start) on infra/standalone.cfg with local ports and paths; feature reports SigningSessions, fixPasskeyChallenge, Passkey, Quantum, BatchV1_1 and PermissionDelegationV1_1 enabled; infra/closer.ts closes a ledger every 3 s. The genesis seed came from an environment variable. Run: spikes/k1-local.ts.
  • SIMULATED passkey wallet on our node (software WebAuthn authenticator), account rJ1mTefJFYYJgv9fcCVZxN4PRHVvkhhY6Y: PasskeyListSet (master) F9194EED619B7F3E1CCB214134950B7DDDB1025FC8F624C9F20384860077A968 tesSUCCESS; the WebAuthn proof 5EE08F891163968E207FF26DE48E7AA4D0AE094B1F92D3102430828FE347FB0A tesSUCCESS (with fixPasskeyChallenge; the public AlphaNet refused the same shape, see "Passkey wallet and spike K1"); SetRegularKey to rrrrrrrrrrrrrrrrrrrrBZbvji 57163143E76EA0B9480E905E67C34149A193E6B1F2CB97918EC0C123B4CB4C77 tesSUCCESS; asfDisableMaster 3CAC1409CB97DAD4F2170D5E76461B0DFC85040B0CD1DB1963234D779C0EC7A9 tesSUCCESS; the master key then gets tefMASTER_DISABLED, "Master key is disabled.".
  • W2, a session opened by the passkey: SigningSessionSet in mode C (20 XRP, 600 s), signed through WebAuthn by the passkey of that account: B78CB7C811CDB1866EB90366866EAF7C4CCB3A2165D0D429A5F954E2FE25BACC tesSUCCESS; the entry is found at the index the client computes (signingSessionIndex, SHA512Half(0x007A ‖ Account ‖ SessionKey)) with SessionCap 20000000, SessionSpent 0, Expiration 846197110.
  • Ledger account (D13, D14; SIMULATED software ML-DSA keys): SetRegularKey to an ML-DSA-44 key F770A9D1…, proof 4F0535B0…, asfDisableMaster B33BF7C5…, all tesSUCCESS; then a SigningSessionSet (mode A) signed by the ML-DSA-44 regular key 8B82D785DF23A51CB9AC97834919EECDCED1BE6B5B82982DA70CFDB3DABCEF0C tesSUCCESS, whose session key is a fresh ML-DSA-44 key.
  • Not yet: a session-signed transaction. The session path is N3 / N4 / N4b, not merged; until then the node authorizes a session key like any other key, so every transaction it signs gets tefBAD_AUTH, "Transaction's public key is not authorized." (the P-256 Payment 5AD00254…, the ML-DSA-44 Payment B1F50D8D…; also the SetRegularKey, PasskeyListSet, over-cap Payment and own close attempts). The spike runs again once N4 is merged.
  • Device-track watch: the device track's draft #29 (based on its own branch, not develop) adds SessionMaxPerTx (AMOUNT 49), SessionWitness (ACCOUNT 35) and tefSESSION_NO_WITNESS, a design that is not in the spec; GOAL-1 left those codes free. The client looks every identifier up by name, so nothing changes here unless the owner adopts it.

2026-10-24: hygiene pass (web app)

  • Production Node server checked over HTTP (nuxt build, then node .output/server/index.mjs): the Content-Security-Policy and the other headers are sent, the page holds no executable inline script, /_carbs/inline/<hash>.js is served as text/javascript, and the meta fallback is present. The dev server and the static build were checked earlier. No browser was driven.
  • Wording and paths: the import recipe in UPSTREAM.md no longer carries an absolute path, and an attack title that said "deadline" now says "last ledger" (it is LastLedgerSequence).

Transactions found by account and sequence

  • FIANSO-SPEC.md section 8.3 asks the client to identify a transaction by (Account, Sequence), not by hash, while P-256 malleability exists (the node does not enforce low-S, so a relay can re-encode a P-256 signature and change the id). submitAndWait waited by hash only; with WaitOptions.identity (set by submitSignedAndWait, which the migration and the passkey wallet now use) it also looks the transaction up in account_tx once the account's validated sequence has moved past it (or by TicketSequence), and returns the id the ledger holds. Tested with a recorded answer whose id differs from ours.

2026-10-24: independent review of the web app, and what it changed

An independent read-only review (a separate agent, no build, no run) of apps/web found the defects below; each was checked against the code before it was fixed, and each has a test (lib/review-fixes.test.ts and the component tests).

  • High: the live migration could show the seed of an earlier run as the backup of the current key (the seed was kept after an exposure check that could fail). The seed is now tied to the run that made it.
  • Results and state: a failed refresh after a submit lost the node's answer (and, for a session opening, the session key); the answer is now kept. A slow poll could overwrite a newer refresh with an older state (the cap-overflow attack then built a one-drop payment): refreshes carry a sequence number and only the latest writes; the poll never throws and skips while an action runs. A preliminary tes or tec that never validated was reported as final: NodeResult.final says whether it is the node's last word, and the view says "not validated". A setup that stopped half-way left the other screens believing an account existed: they wait for ready.
  • Labels: the faucet and the passkey were called SIMULATED on a live backend, and a Ledger account signed by the software device had no badge on a live node; the labels now follow what is actually simulated (the device, the authenticator) and what is not.
  • Wallet adapters on the live backend: they returned the simulation's hash and address and signed the passkey's challenge in the simulation's form; they now use the node's transaction hash, the signer's real address and the live wallet's passkey. Tested against the node double.
  • Explorer link: a live result linked to the explorer of the network selected in the header; it now links to the explorer of the node it was sent to.
  • Keyboard: the device screen takes the focus, closes on Escape, keeps Tab inside, and makes the page behind it inert; two reviews are queued instead of the second replacing the first and leaving its caller waiting; the wallet dialog takes the focus and gives it back.
  • Smaller: a fresh Ledger account now gets a fresh simulated device (the second one reused the first's address); the victim account is funded only for the attack that needs it; the Actions and Attack logs keep 50 entries; the Open button says why it is disabled; a missing lsfDisableMaster in the definitions is an error instead of "not disabled".
  • Simulated node, order of checks (spec 6.5): tem checks come before the account lookup, and the minimum fee is checked after the signature, so a stranger's key gets tefBAD_AUTH rather than telINSUF_FEE_P.
  • Not fixed here, belongs to packages/core: the WebSocket client has no request timeout, and while it connects each concurrent caller opens its own socket (the extra ones leak). The web app guards against a node that does not answer at boot (8 s) but not mid-session.
  • Documented: the Node server keeps the externalized config script in the memory of its process, so a multi-instance or serverless deployment of it needs the static output instead (DEPLOY.md).

2026-10-24: Actions and Attack on a real node with the authorization hook (web app, M3b, modes A and P)

Real: the node. The standalone node of pnpm demo on the protocol track's build of the branch of pull request #45 (Authorize session-signed transactions in modes A and P, merged into develop as 5690796 afterwards; the binary is that branch's build, not a build of develop). SIMULATED: only the software authenticator of the Mac account. Driven from Node through the web app's live backend (no browser); one fresh Mac account per mode, funded by the dev faucet, a session of 600 s.

  • Mode A and mode P (P with the six action types): the session opened tesSUCCESS, and every action tesSUCCESS signed by the P-256 session key with no prompt: payment, offer, trust line, ticket, NFT mint, check. The session's own close tesSUCCESS.
  • Every refusal on the Attack screen is the node's own code, none charged a fee: SetRegularKey, PasskeyListSet, AccountSet and a second SigningSessionSet all tefSESSION_TX_NOT_ALLOWED; a fee of 500 000 drops tefSESSION_LIMIT; a transaction without LastLedgerSequence tefSESSION_TX_NOT_ALLOWED; the session key signing for another account tefBAD_AUTH.
  • Mode C is not in that build: every payment of a mode C session got tefSESSION_TX_NOT_ALLOWED (and so did the ticket and the cap overflow). The six-payment cap scenario and the spent bar are therefore not yet verified on the real node; they wait for N5.
  • Not run on the real node: the attack after expiry (the screen waits for the real Expiration, 600 s).
  • Finding fixed by this run: the first run showed tecNO_DST_INSUF_XRP, tecNO_ISSUER, tecNO_DST for the payment, offer, trust line and check, because the payee did not exist on a real ledger and 3.5 XRP is below the reserve that creates an account. The store now funds the payee (not a session-signed transaction) before the first action.
  • Divergent design noticed (device-track watch): the owner's draft pull request #29 and the branch STOOOKEEE/n2-signing-sessions carry a different mode C (a per-payment cap SessionMaxPerTx, a SessionWitness and WitnessSignature, tefSESSION_NO_WITNESS) that is not in the specification or in the protocol track's build. The web app builds only the modes of the specification; if the design is adopted, SessionParams and the Session screen need a per-payment cap and a witness field.

Step 6 completed on our node: a session-signed payment (K1), refusals, a passkey-opened session (W2)

Node: a copy of the binary built from PR #45's head c73de94, whose rippled/ is identical to develop 5690796 (git diff c73de94 5690796 -- rippled/ is empty), standalone on infra/standalone.cfg with local ports, infra/closer.ts every 3 s, definitions hash F3F8D90E… (identical codes to the SIMULATED ones). Fresh accounts funded by the genesis account, whose seed came from an environment variable. SIMULATED: the passkey is a software WebAuthn authenticator, the ML-DSA-44 keys are software keys. spikes/k1-local.ts.

  • Passkey wallet, account rEM66HTuk56Y94RWUqQhrayRRPzBoQ4hUG: PasskeyListSet FD8B7CF3…, WebAuthn proof C7E31133…, SetRegularKey to the blackhole A00D4B47…, asfDisableMaster 1A63E160…, all tesSUCCESS; the master key then gets tefMASTER_DISABLED.
  • W2, mode A, opened by the passkey: SigningSessionSet signed through WebAuthn 24F768E3A590A6792FF937FD8C31F8F9A0BE6F03632D339E37B8AF30D1AFBFEC tesSUCCESS.
  • K1 on our node: a 1 XRP Payment signed by the non-extractible WebCrypto P-256 session key (low-S strict DER, LastLedgerSequence set) 1CCDC51464024A739DB5EC0760BD60C9A09AC3236FA679102B5DC88FD2CE2BA1 tesSUCCESS.
  • Refusals, the node's own codes, each "The signing session does not allow this transaction." and never included: SetRegularKey tefSESSION_TX_NOT_ALLOWED (F8203D47…), PasskeyListSet tefSESSION_TX_NOT_ALLOWED (5D5AF229…), AccountSet tefSESSION_TX_NOT_ALLOWED (44331E82…), a SigningSessionSet signed by the session key tefSESSION_TX_NOT_ALLOWED (BC82EA1E…).
  • Own close by the session key: SigningSessionDelete AE7B020AC6125E0614A65A9665F45731891FA8F71B2E2098BDC794BF0049D17A tesSUCCESS.
  • Mode C (20 XRP, opened by the passkey, 859983AA… tesSUCCESS, entry SessionSpent 0): the session-signed 1 XRP Payment gets tefSESSION_TX_NOT_ALLOWED (61867F38…), as does the 25 XRP one, and SessionSpent stays 0: mode C is N5, not merged yet. Its own close succeeds (8386C6D7…).
  • Ledger account (D13, D14): SetRegularKey to an ML-DSA-44 key F0702305…, proof 84658D9E…, asfDisableMaster 50F3FB85…; a SigningSessionSet signed by that ML-DSA-44 regular key 61FDFF1E…; a 1 XRP Payment signed by a fresh ML-DSA-44 session key 238EA325B3B6052238B8926A80029EBFE30DFE6F8DCC8CBCF895E5A37234C254 tesSUCCESS; the classical master tefMASTER_DISABLED. This settles on our node item 1 of the UNVERIFIED list of FIANSO-SPEC.md section 6.14 for a client-made key (a Ledger account opens a session with its ML-DSA-44 key and an ML-DSA-44 session key signs).

N5, N6, N7: mode C accounting, the ValidSigningSession invariant, the closes

  • Tests first. The mode-C cases were written and run before the code: 30 cases, 1981 tests, 21 failures (every mode-C payment answered tefSESSION_TX_NOT_ALLOWED). The new suite InvariantsSigningSession was run before the invariant existed: 6 cases, 795 tests, 96 failures. After the code: SigningSessions 31 cases, 2001 tests, 0 failures; InvariantsSigningSession 6 cases, 839 tests, 0 failures.
  • N5, eligibility in mode C (spec 6.5 rule 1, case 12): isSessionCapPayment in rippled/include/xrpl/tx/SigningSession.h, a closed whitelist: a Payment of XRP whose only flag is tfFullyCanonicalSig and whose fields, besides the common transaction fields (TxFormats::getCommonFields), are only Destination, DestinationTag and Amount. An IOU amount, SendMax, Paths, DeliverMin, tfPartialPayment, tfSponsorCreatedAccount, InvoiceID and every other type answer tefSESSION_TX_NOT_ALLOWED. tfNoRippleDirect and tfLimitQuality on an XRP payment are refused earlier by Payment's own preflight (temBAD_SEND_XRP_NO_DIRECT and temBAD_SEND_XRP_LIMIT, rippled/src/libxrpl/tx/transactors/payment/Payment.cpp:249, :242), so they are not in the test of the whitelist; SendMax, Paths, DeliverMin and the partial flag are tested on an XRP amount paid with an IOU, a shape Payment accepts.
  • N5, the cap (rule 5, case 11 and 13): Fee + Amount ≤ SessionCap − SessionSpent, except for the own close, tefSESSION_LIMIT; a remaining cap of zero refuses even a fee.
  • N5, chargeSession (spec 6.6): the predicate's keylet and the own-close shape are read at the top of Transactor::operator(), before apply(). Exactly two call sites: before the first checkInvariants and after the canApply block, and right after the fee-claim reset that follows a tecINVARIANT_FAILED. The fee claimed is added to SessionSpent, and the Amount too on tesSUCCESS, for a session with a cap only; the own close is exempt by shape, and a session that no longer exists is skipped. Case 23 on a real transaction for each path: tecNO_DST_INSUF_XRP from preclaim and tecUNFUNDED_PAYMENT from doApply count the fee only; the reset path is reached by writing into the open ledger a destination balance above the XRP supply, so the first invariant pass fails on XRPBalanceChecks and the reset pass succeeds: tecINVARIANT_FAILED, the fee counted again. Case 13: a mode-C payment by ticket applies and lowers the owner count by one.
  • N6, the invariant (spec 6.7, case 21): ValidSigningSession, rippled/include/xrpl/tx/invariants/SigningSessionInvariant.h and rippled/src/libxrpl/tx/invariants/SigningSessionInvariant.cpp, in the InvariantChecks tuple, whose every member runs in both passes (rippled/src/libxrpl/tx/invariants/InvariantRunner.cpp:46-77). visitEntry keeps the before and after states of sessions, account roots and the entries of the account's authority (signer list, passkey list, delegate entries, beneficiary, firewall). The transaction is session-signed when the session its shape names (sessionKeyletOf, the predicate without the existence test) existed before it, so an own close that erased its session is still recognised. One test per rule, on a mutated view, modelled on InvariantsBeneficiary_test; plus a valid mode-C payment that passes. Deviation: rule 1's "the owner count changes by exactly one" allows one less on the account when the transaction consumes a ticket (a SigningSessionSet by ticket adds the session and removes the ticket). Test open and close by ticket; without the allowance it fails (4 failures, a mutation reverted).
  • N7 (case 18): the third-party and owner closes came with N2, the own close with N4. Here, in mode C: the own close is exempt from the cap (it succeeds with nothing left), not from the fee ceiling. AccountDelete removes sessions (case 19, since N2).
  • Mutation check (one temporary edit at a time on SigningSessions and InvariantsSigningSession, reverted): first call site of chargeSession removed (15 failures), second call site removed (3), cap rule removed (9), whitelist flag test removed (4), whitelist field test removed (6), XRP-only test removed (1), the amount counted on a tec too (4), invariant rule 4 outflow check removed (2), rule 5 removed (28), rule 3 "outside its session" removed (2), rule 1 owner count removed (8). After the revert: 0 failures.
  • Neighbouring suites (--unittest-jobs=6): the 20 of N4 plus InvariantsSigningSession, Ticket and Payment: 37 suites, 647 cases, 136545 tests, 4 failures, the base ones (Delegate 71 vs 60, MPToken_test.cpp:2105, InvariantsVault_test.cpp:3035 twice). Levelization: no new line.
  • Review gate 2: VERDICT: APPROVED in round 1 (Plan agent, model opus, read-only): no path lets a mode-C session move more XRP than its cap; the transaction queue may hold several session transactions against the same SessionSpent, but each is checked again when applied (rippled/src/xrpld/app/misc/detail/TxQ.cpp:342, :632), so the extra ones end in tefSESSION_LIMIT. Nine non-blocking findings; applied before merging: (1) rule 1 skipped the owner-count check when a ticket brought the expected change to zero, so a SigningSessionSet by ticket that forgot increaseOwnerCount would pass; the actual change is now compared in every case (test rule 1: a creation by ticket; the old behaviour fails it, 4 failures, mutation reverted); (3) the tuple position does not matter, reworded; (4) the code of tfLimitQuality corrected; (5) the comment of checkSession names the cap; (6) a valid own close in mode C added to the invariant suite; (7) the anti-drain loop of spec 10.1 row N5 is now the test mode C anti-drain loop: a key that only sends fee-burning tecNO_DST_INSUF_XRP payments is stopped by tefSESSION_LIMIT after ten, and the account loses exactly ten fees, under the cap; (9) mutated-view tests for a change of Permissions, of Account, of SponsoringAccountCount, and an own close that moves SessionSpent. Left as recorded: (2) the invariant reads the named session for every single-signed transaction once the amendment is on (written in the XLS, N10); (8) a fee cut short by the reset when the balance is short is counted at its real amount by construction (chargeSession takes the fee reset returns) but has no test, since a jtx account always holds its reserve. After these: SigningSessions and InvariantsSigningSession, 41 cases, 3077 tests, 0 failures.

2026-10-24: mode C, expiry and the cap on a real node (web app, M3b complete)

Real: the node, the standalone node of pnpm demo on the protocol track's build of develop at #49 (mode C accounting and its invariant), including the earlier hook. SIMULATED: only the software authenticator of the Mac account. Driven from Node through the web app's live backend (no browser); fresh accounts funded by the dev faucet.

  • Mode C, 20 XRP cap, 600 s (the six-payment scenario of the specification): the session opened tesSUCCESS with SessionCap 20 000 000 and SessionSpent 0 read back from the node. Payments of 3.5 XRP, fees included in the count: 1 to 5 tesSUCCESS, SessionSpent 3 500 010, 7 000 020, 10 500 030, 14 000 040, 17 500 050 (the fee is 10 drops); the sixth tefSESSION_LIMIT, no fee charged, SessionSpent unchanged (the acceptance of task W6). A ticket and an offer in mode C tefSESSION_TX_NOT_ALLOWED.
  • Attacks in mode C, all the node's own codes, none charged: SetRegularKey, PasskeyListSet, AccountSet, a second SigningSessionSet and a missing LastLedgerSequence tefSESSION_TX_NOT_ALLOWED; the cap overflow and the 500 000-drop fee tefSESSION_LIMIT; another account tefBAD_AUTH. The session's own close tesSUCCESS.
  • Expiry: a mode A session of 6 s: once the ledger's close time passed its Expiration, a ticket and the "after expiry" attack got tefSESSION_EXPIRED, no fee, and the session key's own close was still tesSUCCESS. (The first action already met the expiry, because funding the payee took longer than the session.)
  • Regression: mode A payment and mint tesSUCCESS; mode P with Payment and TicketCreate listed: payment tesSUCCESS, a mint tefSESSION_TX_NOT_ALLOWED.
  • So the Session, Actions and Attack screens are now checked against a node with the amendment in modes A, P and C. Not run: the screens in a browser (only the stores behind them, through the live backend); the Ledger account's sessions on this build (device-contract double, run earlier on the N2-stage build); the build is the protocol track's, not one of develop made by me.

N8: the cross-cutting cases (16, 20, 22, 24)

  • Tests only: no production code changes; every rule they exercise landed in N2 to N7. Run before this entry: ./xrpld --unittest=SigningSessions, 36 cases, 2249 tests, 0 failures; InvariantsSigningSession unchanged.
  • Case 16, Firewall (enabled through testableAmendments, spec 6.11): a session-signed payment to a destination the firewall does not authorize is tefFIREWALL_BLOCK in mode A and in mode C, and counts nothing; a payment to the preauthorized backup applies and is counted. A firewall set beforehand does not stop the opening (FirewallAction::Allow, the N2 decision).
  • Case 20: done in N4 (P-256, Ed25519, secp256k1, ML-DSA-44, and a P-256 key signing through WebAuthn, SIMULATED).
  • Case 22, the transaction queue: with the queue's minimum ledger size at 3, the open ledger is filled past the size the queue expects (read from TxQ::getMetrics), so a session-signed payment at the base fee is terQUEUED. When its session expires at the next close, the queue drops it; when the owner deletes the session by ticket (with a fee above the escalated one) while it waits, the queue drops it too. Either way the payment never applies and claims no fee. A mutation that never expires a session fails the test (6 failures, reverted).
  • Case 24: ledger_entry and account_objects since N2. The pseudo-account refusal of the session path (tefBAD_AUTH): an account root marked as a pseudo-account (sfAMMID) is written into the open ledger next to an existing session, since a pseudo-account never opens one; without the refusal the payment would be authorized (mutation: 1 failure, reverted). The sponsorship decision: a fee sponsor may pay for an opening (spfSponsorFee with its SponsorSignature), the reserve stays the account's; a reserve sponsor is refused (N2).
  • Self-review against spec 6.15 (no review gate for N8, PLAN.md Amendment A): cases 16, 20, 22 and 24 each have a test asserting the exact result code; no case of 1 to 24 is left without one: 1, 7, 18, 19 and 24 (N2); 2 to 6, 8, 9, 9b, 10, 13, 14, 15, 17, 17b and 20 (N3, N4, N4b); 11, 12, 13, 18, 21 and 23 (N5 to N7); 16 and 22 (N8). Limits stated where they are: the short-balance reset of N5 has no test, the at-use eligibility check of mode P has no test (DEVLOG, N3/N4 and N5 entries).
  • Device-track watch: STOOOKEEE/n2-signing-sessions now carries six more commits: modes A and P, its own ValidSigningSession, a test-wallet panel and an agent demo on a session. It is a parallel implementation in the same files, not merged here. Every number it gives to an item of spec 6.1 still equals develop; its extras (SessionMaxPerTx AMOUNT 49, SessionWitness ACCOUNT 35, WitnessSignature OBJECT 43, tefSESSION_NO_WITNESS) remain the owner's decision, and their codes remain free on develop.

N10: the XLS, and the full runs before the last pull request

  • docs/XLS-Sessions.md, written from the merged implementation (develop at f889b6a): identifiers, the entry, both transactions, the predicate, the authorization rules in their order, the accounting, the invariant, RPC, security considerations and test coverage. Every path:line it cites was printed and checked against the tree. It also records the read cost the N5 review noted: once the amendment is enabled, each single-signed transaction reads one session keylet in preclaim, at the top of Transactor::operator() and in the invariant.

  • Full ./xrpld --unittest --unittest-jobs=6 on develop at f889b6a: 706.8 s, 284 suites, 5436 cases, 1,292,631 tests, 203 failures, of which one is ValidatorSiteV2, which did not complete ("The child process may have crashed"), as in N0. The 202 others, per suite in the full run: AMMBinned 152, AMMCurves 41, InvariantsVault 2, ValidatorListExpiration 2, ValidatorList_Dilithium 2, Delegate 1, MPToken 1, SignatureVerification 1 (its timing assertion, only under parallel jobs). The N0 baseline: 280 suites, 1,285,447 tests, 204 failures. Every suite with a failure was run again alone and compared with docs/base-failures.txt per assertion (file and line). AMMBinned (152), AMMCurves (41), InvariantsVault (2), ValidatorListExpiration (2) and SignatureVerification (0 alone, the timing assertion that fails only under parallel jobs) match line for line. Delegate (1, "Expected: 60 Actual: 71") and ValidatorList_Dilithium (2, "Could not find vl.json") are the same messages; the comparison script did not read their location. MPToken (1) is the base assertion, moved from line 2095 to 2105 by the ten lines N2 added above it. No new failure. The four suites more than N0 are SigningSessions and InvariantsSigningSession (this goal) and PasskeyWebAuthn and PasskeyRelay (N1); all four pass. LedgerEntry and MPToken gained cases in N2 and add no failure.

  • Full ./xrpl_tests: 1135 tests from 243 suites, 1134 passed; the one failure is the base VaultTests.BuilderFromSleRoundTrip.

  • The node, again, on the final binary: with a copy of infra/standalone.cfg (its [amendments] identical, ports and paths moved to a scratch directory) and node infra/closer.ts, feature lists the six amendments enabled. In mode A and in mode C:

    • SigningSessionSet tesSUCCESS;
    • a Payment signed by the session key tesSUCCESS, and in mode C SessionSpent 1000010 (1 XRP and the fee);
    • an AccountSet signed by the session key tefSESSION_TX_NOT_ALLOWED;
    • the own close tesSUCCESS, no session left.

    The session key signs through the node's sign in offline mode (a secp256k1 key, local standalone node only), because the node's sign-and-submit refuses a key that is neither the master nor the regular key.

  • What went wrong on the way: the first rerun of this check answered tefBAD_AUTH to every session-signed transaction. A node started for the N9 check, on the N2 binary without a session path, was still running and held the ports, and the cleanup pattern had never matched its command line. The node was stopped and the check repeated; nothing in the code changed. A temporary probe used to find this was reverted before the rebuild.

  • Final review gate, round 1: VERDICT: CHANGES REQUIRED (Plan agent, model opus, read-only), one blocking finding: the XLS gave tefBAD_AUTH for a session key used as a multi-signer, where the code and the test give tefBAD_SIGNATURE; fixed in §6. Non-blocking, applied: the deviation of case 14 for the multi-signer recorded (above, N4 entry); isSessionSigned, required in the shared header but not called by production code, is now covered by a test (the session-signed predicate) that checks it agrees with signingSessionOf, and the N4 entry says which function each part calls and why the invariant uses sessionKeyletOf; the XLS now lists the pseudo-account refusal as rule 0 and the checkSign-override refusal first inside eligibility, says that AccountDelete's owner count is not checked, that the one-mode and SessionSpent checks apply at creation, that the own close never moves SessionSpent, and that SigningSessionDelete also needs PermissionDelegationV1_1; this entry names every failure of the full run. Left: infra/Dockerfile (stretch). After the fixes: SigningSessions and InvariantsSigningSession, 46 cases, 3296 tests, 0 failures. The body of #45 no longer says that a mode-P TicketCreate session "cannot burn the balance": a refused transaction claims no fee, but each TicketCreate it signs may pay up to the fee ceiling (XLS §11).

  • Final review gate, round 2: VERDICT: APPROVED. Every round 1 finding found fixed (the Dockerfile left as recorded). Three non-blocking notes, applied before merging: the pull request body now says this PR adds a test; the XLS says that authorization and accounting ask the predicate and the invariant its shape test; the predicate test adds a transaction that passes the shape test but has no session (isSessionSigned and signingSessionOf both false). After them: SigningSessions and InvariantsSigningSession, 46 cases, 3298 tests, 0 failures.

  • The full runs again on the final develop (0bcb324), after #53 merged; its only rippled/ change since f889b6a is the 43-line predicate test. ./xrpld --unittest --unittest-jobs=6: 594.8 s, 287 suites, 5556 cases, 1,356,135 tests, 205 failures: AMMBinned 152, AMMCurves 41, InvariantsVault 2, ValidatorListExpiration 2, ValidatorList_Dilithium 2, Delegate 1, MPToken 1, SignatureVerification 1, ValidatorSite 1 ("database is locked"), ServerInfo 2. Every one was run again alone and compared per assertion with docs/base-failures.txt: the first eight match the baseline (MPToken at line 2105, moved by N2); SignatureVerification, ValidatorSite and ServerInfo pass alone (0 failures), so they fail only under parallel jobs. ServerInfo (rippled/src/test/rpc/ServerInfo_test.cpp:139, :150, the port protocol checks) is new to that list and is unchanged since main; docs/base-failures.txt now names it in its header. ./xrpl_tests: 1135 tests, 1134 passed, the base VaultTests.BuilderFromSleRoundTrip. No new failure.

2026-10-24: final audit, round 1 (CHANGES REQUIRED), and what changed

Final review gate (an independent reviewer, model Opus, read-only, on develop at 0bcb324): VERDICT: CHANGES REQUIRED, one blocking finding and several should-fix. Applied here:

  • Blocking, fixed: the web app typed tfFullyCanonicalSig by hand and sent it on the live path. The flag now comes from the backend (CommonFields.Flags): the simulated node adds it, a live node needs none and none is sent. The hand-typed flag values are confined to the simulation (lib/sim, lib/tx-builders.ts, lib/constants.ts), and a test fails if any other source mentions them.
  • SIMULATED labelling: the setup steps of a Ledger account signed on the software double of the device are badged on the Account screen even when the node is live (they were badged only on the Session screen).
  • Documentation made true: apps/web/README.md describes the two backends; docs/ARCHITECTURE.md no longer says packages/ledger depends on the Device Management Kit (it reaches it through the DmkApduSender interface only, and no DMK or WebHID path is in the repository) nor that the real-device end-to-end test exists (it does not); the root README marks the Device Management Kit adapter and the create-xrp template as planned, not built (tasks E1 and E2), and lists what is not done. docs/ledger-dx-feedback.md had a duplicated table (my conflict resolutions) and three unfinished sections: deduplicated and written from its own log.
  • Deviation from the specification, recorded (case 13): the spec says a mode P session limited to TicketCreate "cannot burn the balance". The fee ceiling bounds the fee of each session-signed transaction, not their number, so modes A and P can pay fees up to the ceiling on every transaction they sign during their 600 seconds; only mode C counts fees against a total. docs/XLS-Sessions.md section 11 now says so.
  • Deviation, recorded: the web app has a third profile, a throw-away "Demo account" (master key only), beside the two of D13 (Mac account, Ledger account). It exists for people without a passkey or a device; it is badged SIMULATED.
  • Real-node runs are now reproducible: pnpm --filter web live-run (apps/web/manual/live-run.manual.test.ts) runs, against pnpm demo --no-web, the flows the DEVLOG records. Its printed output on the protocol track's latest build (develop at #53) is below. The earlier entries record the same flows from throw-away drivers; the transaction hashes of those runs are not recorded for the web app's own runs (the hashes of the AlphaNet runs are).
  • CSP: the local node's HTTP port (the admin RPC) is no longer in connect-src; the app uses its public WebSocket only.
  • Not fixed here, belongs to the protocol track: the node checks of the N9 and N10 entries used scripts that are not committed; infra/Dockerfile and the apps/passkey-wallet that infra/compose.yaml builds are not on develop. Commit hygiene: a squash subject says (#32) for pull request #33, three merge: commits use a type outside the list, and several squash bodies have lines over 72 columns; history is not rewritten.

Summary of the printed output of pnpm --filter web live-run (three tests, 108 s; lines grouped), each account funded by the dev faucet, the Mac account's passkey the software authenticator (SIMULATED):

[A] open: tesSUCCESS
[A] action payment, offer, trust, ticket, mint, check: tesSUCCESS (six times)
[A] attack regular-key, passkey, disable-master, open-session: tefSESSION_TX_NOT_ALLOWED, fee charged: false
[A] attack fee-burn: tefSESSION_LIMIT fee charged: false
[A] attack no-last-ledger: tefSESSION_TX_NOT_ALLOWED fee charged: false
[A] attack other-account: tefBAD_AUTH fee charged: false
[A] own close: tesSUCCESS
[P] (the same, with the six types listed): identical results
[C] open: tesSUCCESS
[C] payment 1..5: tesSUCCESS, spent 3500010, 7000020, 10500030, 14000040, 17500050
[C] payment 6: tefSESSION_LIMIT, spent 17500050
[C] ticket: tefSESSION_TX_NOT_ALLOWED
[C] cap overflow: tefSESSION_LIMIT
[expiry] after expiry: tefSESSION_EXPIRED
[expiry] own close: tesSUCCESS

A fresh review gate on N3, N4 and N4b, as merged

  • A fresh review gate on N3, N4 and N4b as merged (round 1 of at most two, Plan agent, model opus, read-only), run because the gate of #45 needed a third round: VERDICT: CHANGES REQUIRED, one blocking finding. Spec 6.14 item 3 (the fee ceiling applies to an ML-DSA-44 session transaction as to a P-256 one) had no test, and the N4 entry did not say so. Now settled: the key-type test of case 20 sends a payment at the ceiling and one drop above it for P-256, Ed25519, secp256k1 and ML-DSA-44 session keys (tesSUCCESS, then tefSESSION_LIMIT). Non-blocking, applied: a test of the rule order of spec 6.5 when two rules fail together (not eligible and expired: not allowed; no LastLedgerSequence and expired: not allowed; expired and over the ceiling: expired; the own close, expired and over the ceiling: the ceiling), which fails when the expiry and ceiling rules are swapped (a mutation, reverted); the expiry test now asserts parentCloseTime == Expiration before its refusal. Recorded: spec 6.15 row 13 says "above calculateBaseFee × 1000" while rule 4 of spec 6.5 says calculateBaseFee + 1000 × base; the code, the tests and the XLS follow rule 4. The Transactor.cpp citations of the N3/N4 entry are lines at 5690796; on later commits the passkey and beneficiary checks moved (to :1250-1268 and :1271-1288 at 0bcb324). After: SigningSessions and InvariantsSigningSession, 47 cases, 3359 tests, 0 failures.
  • Round 2: VERDICT: APPROVED. Its words: "PR #56 fixes the blocking finding, and the non-blocking findings are either fixed or recorded, so this round approves." It checked that only the test file and the DEVLOG change, that the ceiling test uses the formula of rule 4, that the rule-order test fails when expiry moves ahead of rule 1, 2 or 4, and that the expiry boundary holds with 10-second close times and a 30-second duration. This gate on N3, N4 and N4b was approved within the two rounds PLAN.md section 3 allows.

2026-10-24: final audit, round 2

Final review gate, round 2 (an independent reviewer, model Opus, read-only, on develop at aea1dd0): VERDICT: APPROVED. No blocking finding. It confirmed the round-1 blocking item fixed in the code (the live path takes flags from server_definitions), and that no as any, secret, third-party script or removed upstream test is in the diff. Its should-fix and notes, applied in the next pull request (documentation, one committed symlink and the driver's assertions only; no source of the app or the protocol changed):

  • apps/web/dist, a symlink to a local scratch path, was tracked; removed, and the ignore rule now matches files as well as directories.
  • The root README and docs/ARCHITECTURE.md said more than was run: the live stores were run from Node against a real node, the screens never in a browser, the browser passkey path never against a real authenticator. Both now say so; WebHID and explorer wording in apps/web/README.md and docs/DEPLOY.md corrected.
  • The live-run driver now asserts the node's codes (it only printed them), and a stray separator row of the feedback table is gone.
  • Listed, not changed: FIANSO-SPEC.md (the owner's reference) still says in its UNVERIFIED list, item 4, that no node has accepted an @noble/post-quantum signature; the step-6 entry above records one accepted (238EA325…), so the owner may update the line. rippled/src/test/app/MPToken_test.cpp gained a SigningSessionSet case required by that suite's exhaustiveness check (nothing removed or weakened); its commit does not carry the justification PLAN 1.8 asks for, and it is given here. infra/compose.yaml builds apps/passkey-wallet/Dockerfile, which is not on develop (already recorded).

2026-10-25: per-payment cap, witness and Batch, ported onto develop

What. The two protections of the first implementation (STOOOKEEE/n2-signing-sessions) on top of develop's sessions: SessionMaxPerTx (AMOUNT 49, mode C only, tefSESSION_LIMIT above it) and SessionWitness (ACCOUNT 35) with the co-signature WitnessSignature (OBJECT 43, prefix WTS\0, tefSESSION_NO_WITNESS; the own close is exempt). @carbs/sessions opens sessions with both and co-signs as the witness. A new suite SigningSessionsBatch and buildBatch.

Why. Develop is the reviewed base; the protections were the only protocol work that existed only on the other branch. Numbers were free on develop, so the shared wire format does not change. Sofiane still has to accept both protections (they are not in the spec).

Path. Merging the two branches was set aside: about twenty rippled files conflict and several clean merges would define things twice. SessionMaxPerTx keeps temMALFORMED without a cap and temBAD_AMOUNT for a bad amount, like develop's SessionCap.

Batch. The AlphaNet observation (a key migration in one Batch returns tesSUCCESS and applies nothing) is explained and pinned in a test: an inner transaction has no signature, so the inner asfDisableMaster fails with tecNEED_MASTER_KEY and, under tfAllOrNothing, every inner change is discarded while the outer Batch keeps its tesSUCCESS. A session key cannot sign an outer Batch (tefSESSION_TX_NOT_ALLOWED, Batch overrides checkSign) nor be a BatchSigner (tefBAD_AUTH).

Tests (Mac, Debug): SigningSessions with SigningSessionsBatch 47 cases / 2681 checks, InvariantsSigningSession 9 / 1079, STTx, Batch, PasskeyWebAuthn, PasskeyRelay, AccountDelete 0 failures; Delegate and MPToken keep their base failure (docs/base-failures.txt). @carbs/sessions 99 tests.

Bugs & fix. The first witness test disabled the witness's master key with its regular key: tecNEED_MASTER_KEY; only the master key may set asfDisableMaster.

2026-10-24: audit 24.10, section A5 (session screen copy)

  • The session note no longer claims more than the node enforces (audit finding A5, confirmed). "Cannot change keys, signers or the account" became "Cannot change keys or signers": AccountSet is not delegable, but its granular permissions are (AccountDomainSet, rippled/include/xrpl/protocol/detail/permissions.macro:29), so a mode P session can change the account. Changed in the app (ledger-app/src/xrp/signing_session.h), the frozen screen vectors (30-signing-session-set/01 to 05 and 12), the client's prediction (packages/ledger/src/screen.ts), the web app's SIMULATED device, docs/device-contract.md and the screen table of FIANSO-SPEC.md 7.3. The DEVLOG entry of L1 above quotes the old text and is left as it was written.
  • Mode C says what it allows. A capped session's review gains a synthetic row Allows; XRP payments only, fees included, before the Note, what the node enforces (rippled/src/libxrpl/tx/Transactor.cpp:1108-1112, :1131-1142). It is the parser's own row like the Note (field id 0xFC, refused when it appears in the signed data: new vector 13-spoofed-scope, which went to blind signing before and is now refused). RAM: the field array has a fixed size (MAX_FIELD_COUNT) and the text is a constant in flash, so no RAM is added.
  • What ran: the app's unit tests natively (recipe in docs/BUILDING.md on the branch of the review-lock pull request): with the vectors updated and the parser unchanged, test_tx failed ("Cannot change keys, signers or the account" != "Cannot change keys or signers", and 13-spoofed-scope answered blind signing); after the change test_printers 4/4, test_swap 2/2, test_tx 4/4. packages/ledger: the five session vectors failed before the screen.ts change and pass after; a new SIMULATED-device test in apps/web/lib/sim/device.test.ts failed before and passes after.
  • What did not run: the app was not built for any device and the Ragger snapshots were not regenerated (no Speculos or builder image here). These snapshot folders now show the old note, and the mode C ones lack the new row; they need regeneration with --golden_run: ledger-app/tests/standalone/snapshots/{stax,flex,apex_p,nanosp,nanox}/30-signing-session-set/{01-mode-all,02-mode-permissions,03-mode-cap,04-ten-permissions,05-short-duration,12-cap-per-tx-and-witness}/. Nothing was shown on the Stax.

2026-10-24: audit 24.10, section A (client and web nits)

  • signWithSessionKey needs the current ledger. currentLedger was optional, and without it the LastLedgerSequence window (kSessionMaxLedgerWindow) was not checked although the node refuses a transaction outside it. It is now required, and a missing or non-integer value is refused at run time too (for JavaScript callers). The one caller outside the tests (packages/core/spikes/k1-local.ts) already passed it.
  • Exposure report of a session key. A key that signed for the account and is none of its standing authorities was reported as "not one of its current authorities"; for the key of a live signing session that is wrong. The reason now says that such a key, while its session lives, holds an authority bounded by that session (mode, cap, expiry). Only the text changed; the finding is still reported and not counted.
  • A vacuous assertion in packages/ledger/src/ledger.test.ts compared the ML-DSA seed with itself. It now recomputes SLIP-0021 by hand with @noble/hashes (master node from "Symmetric key seed", child for 0x00 ‖ "carbs ML-DSA-44", last 32 bytes) and compares; a mutant taking the first 32 bytes fails it.
  • CSP form-action 'self' (it does not fall back to default-src): in the header, the meta fallback, vercel.json and docs/DEPLOY.md.
  • Wording. The Ledger profile of the Account screen said "Every own-authority transaction is signed on the device" also on the simulation; it now says "signed by the simulated Stax (software keys, no device involved)" when the device is simulated. The Mac profile never claimed device signing, and apps/web/pages/index.vue makes no "signed on the device" claim (its headline is decision D16, left as it is).
  • Tests: each change has a test that failed before it (the session refusal, also as a type error; the exposure reason; the CSP directive; the Account wording) and passes after; pnpm format:check, pnpm typecheck and pnpm test pass. Nothing was run in a browser.

2026-10-24: audit 24.10, section A5 (pnpm demo and its faucet)

  • The genesis seed reached every child process (audit finding A5, confirmed). scripts/src/demo.ts spawned xrpld, the closer and the Nuxt dev server with { ...process.env, ...env }, so each of them inherited GENESIS_SEED; none needs it, because the faucet that signs with it runs in the demo process. Children now get childEnv(process.env, env) (scripts/src/child-env.ts), which drops GENESIS_SEED even when a caller passes it again, and the demo removes it from its own environment once the signer is built.
  • The faucet's only gate was the Origin header. Any local process can send the page's origin. Chosen of the two options of the audit: a per-origin cap (10 fundings per origin per minute, then 429 with Retry-After), because it bounds the drain whoever calls, needs no change in the web app, and a per-run token handed to the web app would sit in its public runtime config, readable by any local process that can load the page. Not done: the token.
  • Tests: scripts/src/demo.test.ts gains a childEnv case (it failed before: the module did not exist) and a cap case with an injected clock (it failed before: the third request got 200); 7 of 7 pass. pnpm demo itself was not run (no node was started for this change).

2026-10-24: audit 24.10, section A4 (the client's blind prediction)

  • The simulated device predicted a clear review where the app goes blind (audit finding A4, confirmed). reviewLines in packages/ledger/src/screen.ts printed an unknown flag as Flags; 196608 and an unknown permission by its value, both as clear, and listed eleven permissions as clear; the app asks for blind signing (ledger-app/src/xrp/flags.c, has_unknown_flags; session_permissions.c returns no name) or refuses. Now blind for any Flags bit other than tfFullyCanonicalSig and the session's tfAllTransactions (both read from server_definitions), for a permission value outside the app's name table, and above kPermissionMaxSize; 0x80010000 reads Allows; All transactions and 0x80000000 alone shows nothing, as on the app. The app's permission names are mirrored in kDevicePermissionNames, and a test reads session_permissions.c and checks that both lists are equal.
  • Tests (SIMULATED device logic only, no device): six new cases in packages/ledger/src/ledger.test.ts (vectors 07 and 11 from their bytes, 06 from a decoded form since the codec throws on its bytes, the canonical-flag cases, other types, the name-table check) all failed before the change and pass after. Left as they were, and written in docs/device-contract.md: 08-duplicate-flags (the decoded form keeps one Flags, so the client still predicts clear), 09 and 10 (they do not decode; the simulated device refuses them).

2026-10-24: audit 24.10, section A (the ML-DSA path of the app)

  • Review lock on the ML-DSA path (audit finding A1, confirmed). While the review of transaction A was on screen, INS 0x11 kept accepting chunks, so a host could replace the buffer with B and the approval of A signed B; a switch to another instruction during the review zeroed tmp_ctx and the approval signed an empty message. The classical path refuses both (ledger-app/src/apdu/messages/sign_transaction.c:273-285, and sign_transaction() outside PENDING_REVIEW). Now: a reviewing flag and the SHA-256 of the reviewed bytes in the PQ context; every 0x11 chunk during the review gets 0x6A80; the approval signs only bytes with the reviewed hash and signs nothing after an instruction change. INS 0x10 needs no check of its own: entry.c resets the context, and so cancels the review, before any other instruction runs.
  • Blind-signing setting (audit finding A2, confirmed). The ML-DSA path showed the blind review even with blind signing disabled; it now shows the classical error screen and answers 0x6985, as review_transaction() does (ledger-app/src/transaction/transaction.c:203-205).
  • First C tests of the 0x10-0x12 path (audit finding A3). ledger-app/unit-tests/src/test_pq_root.c compiles the real entry.c, global.c and pq_root.c with the real parser; only the SDK is replaced by recording doubles (unit-tests/include_pq/). 16 cases: public key chunks, chunk reassembly, prefix, unparsable data, the 2 048-byte limit, bad P1/P2, rejection, a stale signature after a new first chunk, the interleaved cases of A1, the hash check, the blind-signing setting. Before the fixes, the four lock cases and the blind-disabled case failed; after, 16 of 16 pass. Two mutants of the existing code (no signature reset on a new first chunk; a limit one byte too large) are caught.
  • What ran: the four unit-test binaries of ledger-app/unit-tests, natively on macOS (Apple clang 17, arm64, cmocka 1.1.7 built from its tarball into a scratch prefix, Homebrew OpenSSL 3), the first run of these unit tests on this repository: test_printers 4/4, test_swap 2/2, test_tx 4/4, test_pq_root 16/16. Recipe and the two local workarounds in docs/BUILDING.md.
  • What did not run: the app was not built for the Stax (no ledger-app-builder image and no SDK on this machine; Docker was not running), so the change is compiled only against the test doubles of the SDK headers, not against the real lcx_mldsa.h, io.h or cx.h. No Speculos or Ragger run, no device. The real SDK calls used are the ones the app already uses elsewhere (cx_sha256_init and cx_hash_no_throw in ledger-app/src/xrp/xrp_helpers.c:130-131, ui_error_blind_signing in ledger-app/src/transaction/transaction.c:205). Left as before, as on the classical path: when an approval signs nothing (a review cancelled by an instruction change, or bytes that changed), the status page that follows still says "signed" (ledger-app/src/ui/transaction/review_menu_nbgl.c:63-72).

2026-10-25: audit 24.10, section B1 (decision D4: CredentialCreate and DIDSet excluded from sessions)

From the read-only audit of develop at ce2c7cb, section B item 1.

  • Decision (owner, D4): exclude CredentialCreate and DIDSet. Both are delegable (rippled/include/xrpl/protocol/detail/transactions.macro:645, :769) and grant lasting rights outside the account's own authority: a credential is an attestation about a third party that other accounts' deposit preauthorizations and permissioned domains accept; a DID is the account's lasting identity document. Neither is bounded by an amount fixed at signing, and both outlive the session.
  • Change: kSessionExcluded (rippled/include/xrpl/tx/SigningSession.h) holds five types; spec 6.8 and D4, docs/XLS-Sessions.md sections 3 and 11, and the SIMULATED type table of the web app (apps/web/lib/sim/types.ts) follow.
  • Tests first: mode A signing both (tefSESSION_TX_NOT_ALLOWED, and neither object created); mode P listing either (temMALFORMED); a new case mode P exclusions at use that writes a mode-P session listing Payment, CredentialCreate and DIDSet into the open ledger (no transaction can create one) and signs all three: Payment succeeds, the two others tefSESSION_TX_NOT_ALLOWED. Case 9b moves both names out of the eligible list. Before the fix, ./xrpld --unittest=SigningSessions: 39 cases, 2384 tests, 15 failures (9b "new eligible type" twice, mode-P creation 2 + its cascade, mode A 4, at use 2, among others). After: ./xrpld --unittest=SigningSessions,InvariantsSigningSession: 48 cases, 3399 tests, 0 failures. ./xrpld --unittest=Delegate,Credentials,DID,PermissionedDomains,DepositAuth: 126 cases, 9299 tests, 1 failure, the Delegate counter that fails on the import as well (71 counted, 60 pinned; N0 above); the count does not move, since both types stay delegable. pnpm format:check, pnpm typecheck, pnpm test green (web app: 26 files, 189 tests).
  • This also covers, for these two types, the at-use eligibility check of mode P that the N3/N4 entry listed as untested.

2026-10-25: guardrail review of the session path (F1 to F5)

From the read-only review of the session guardrails on develop at 98f358b (findings F1 to F6; F6, the durable effects of TrustSet, PermissionedDomainSet and OracleSet in modes A and P, is a design question left to the owner).

  • F1 (medium), the session key could delay its own revocation. The queue replaced a queued transaction by any transaction on the same SeqProxy paying 25 % more, whoever signed it, and the open ledger did the same when the new fee cleared escalation (TxQ::tryDirectApply removes the queued entry it replaces). The key could therefore outbid the owner's SigningSessionDelete waiting in the queue, or use up the tickets the owner would revoke with. Change: sessionDisplacesOther (rippled/src/xrpld/app/misc/detail/TxQ.cpp) refuses with telCAN_NOT_QUEUE_FEE a session-signed transaction that would replace a queued one its session key did not sign, in TxQ::apply and, before any direct apply, in tryDirectApply (which then falls through to the queue's refusal). And a session-signed transaction other than the own close may no longer use a ticket (tefSESSION_TX_NOT_ALLOWED, rule 1 of checkSession): that is the rule ADR 0010 gives for a session without a ticket range, applied alone, so the owner can always revoke by ticket and multi-signed transactions keep their tickets. Case 13 said "a mode-C payment using TicketSequence applies"; testModeCAccounting now asserts the refusal, that no fee is counted and that the ticket stays. No product flow used a ticket under a session: the agent and the web app use Sequence, and the web app's "ticket" action is a TicketCreate, which is unaffected. Spec 6.5, case 13, section 12 and open item 5, and docs/XLS-Sessions.md follow; ADR 0010 stays Proposed for its options.
  • F2, WitnessSignature before the amendment. preflight1 now answers temDISABLED to any transaction carrying it while SigningSessions is disabled, as for the sponsor fields.
  • F3, the own close kept the 1000× fee margin. It escapes expiry, cap and witness and is never counted, so past the cap it could cost up to 10 010 drops. Its ceiling is now the base fee of its type, no margin (Transactor::checkSession, rule 4): past the cap, the account loses at most one base fee more. Interaction with #76 (open, the exception-safe base fee): both touch the same three lines; #76 replaces baseFee() with *required, this change only adds margin (0 for the own close), so the merged line is fee > *required + margin.
  • F4, the invariant saw only the XRP outflow. Rule 4 now also fails on a mode-C session transaction other than the own close that is not a session cap payment (an IOU payment, any other type), and on one that counts more than SessionMaxPerTx beyond its fee (the fee argument of finalize was unused). Rule 5 also fails when a session-signed transaction creates a sponsorship, subscription, credential or DID entry.
  • F5, documented only. The LastLedgerSequence window bounds the distance to the current ledger, not the session: a post-dated transaction refused now can apply later, and 256 ledgers can outlast 600 s. The comments of kSessionMaxLedgerWindow and of rule 2 say so; the defence is a fresh key per session.
  • Tests: SigningSessions: the witness co-signature with the amendment off; the own close above its base fee refused in mode A and in mode C with the cap used up (the account loses the cap and one base fee); a new case tickets and session-signed transactions (a payment and a TicketCreate by ticket refused, the own close by ticket applies); the queue: the owner's queued close is displaced neither by a session payment at twice the base fee nor at the ceiling, the session is deleted at the next close and the payment never applies. InvariantsSigningSession: an IOU payment and an AccountSet in mode C; SessionMaxPerTx one drop over the bound, and at the bound passing; one creation of each of the four types. Mutations (reverted): without the direct-apply guard, 4 failures; without the queue guard, 5.
  • Runs (Mac, Debug, --unittest-jobs=6): SigningSessions,SigningSessionsBatch,InvariantsSigningSession,TxQ,Batch,STTx,PasskeyWebAuthn: 8 suites, 167 cases, 16 253 tests, 0 failures. TxQ,Batch,STTx,PasskeyWebAuthn,Delegate,MPToken: 7 suites, 283 cases, 59 637 tests, 2 failures, the base ones (Delegate "New delegable transaction detected", MPToken_test.cpp:2115 txWithAmounts.empty(), listed at :2095 in docs/base-failures.txt on the imported tree).
  • Not changed: the simulated node of the web app (apps/web/lib/sim/) still models the own close with the 1000× margin and does not simulate tickets or the queue.

2026-10-25: SessionMaxTxCount, a bound on how many transactions a session signs

  • What: SigningSessionSet takes an optional SessionMaxTxCount (UINT32 115), from 1 to kMaxSessionTxCount = 100 000 (temMALFORMED otherwise), in every mode. The SigningSession entry stores it with SessionTxCount (UINT32 116) at 0. Transactor::checkSession (rule 6) refuses a session-signed transaction with tefSESSION_LIMIT once SessionTxCount >= SessionMaxTxCount, except the own close; chargeSession adds one for every session-signed transaction applied, tes or tec, in modes A, P and C, the own close excluded, including after the fee-claim reset.
  • Why: the device profiles (agent, game, trading, streaming, minting) bound the number of actions as well as the amounts; modes A and P had no bound but the duration.
  • Path: counting in chargeSession, where SessionSpent is already charged, keeps one place for the accounting and inherits its two calls (before the first invariant pass and after the reset), so a transaction counts exactly once whatever its path. A separate counter per mode was rejected: the field is mode-independent.
  • Invariant ValidSigningSession: SessionMaxTxCount is immutable; at creation SessionTxCount is present exactly with the bound, at 0, and the bound is in range; afterwards the counter neither appears nor disappears, moves by exactly one in a transaction its session signs (the own close excepted) and by zero otherwise, never exceeds the bound, and a session-signed transaction that keeps a counting session must count.
  • Generated wrappers: SigningSession.h, SigningSessionSet.h and their two gtest files regenerated with cmake/scripts/codegen into a temporary directory and copied alone (the other generated files differ from the generator on develop and were left untouched).
  • Tests: SigningSessions: a new case SessionMaxTxCount (range, all three modes open with the bound, a tec in mode A counts; mode C: a tes and a tec count, the third payment tefSESSION_LIMIT with no fee, the own close passes after the limit; mode P OfferCreate: a tecKILLED and a tes count, the third refused, the own close passes); mode C accounting now also checks the count through the tecINVARIANT_FAILED reset (3 after two tec, a refused ticket payment and the reset). InvariantsSigningSession: a new case with the bound changed, three bad creations, the counter removed, +2 in a session payment, +1 in an AccountSet and in the own close, a decrease, a count past the bound, a session payment that does not count, and the valid +1.
  • Runs (Mac, Debug): SigningSessions,SigningSessionsBatch,InvariantsSigningSession,TxQPosNegFlows,TxQMetaInfo,Batch,STTx (--unittest-jobs=6): 7 suites, 160 cases, 16 428 tests, 0 failures. Delegate: 27 cases, 6093 tests, 1 failure, MPToken: 150 cases, 41 475 tests, 1 failure, the base ones. xrpl_tests --gtest_filter='*SigningSession*': 15 tests passed.
  • Not changed: FIANSO-SPEC.md, docs/XLS-Sessions.md and the simulated node of the web app do not describe the bound yet.

2026-10-25: audit 24.10, section B4 (the README states how the bounds are measured)

The README said "at most 10 minutes" without saying on which clock, and did not say that the own close escapes the cap. Checked in the code before writing: Expiration is the parent close time plus SessionDuration (rippled/src/libxrpl/tx/transactors/session/SigningSessionSet.cpp:218-219), expiry compares the parent close time of the current ledger with it (isSessionExpired, rippled/src/libxrpl/tx/SigningSession.cpp), and the own close skips expiry and the cap (Transactor::checkSession rules 3 and 5) and is not charged to SessionSpent (Transactor::chargeSession). The audit's "may pay up to base fee × 1000" no longer holds since #79: rule 4 now gives the own close no margin, so it pays at most the base fee its type requires; the README says that. One clause each added. Documentation only, nothing run.

2026-10-25: audit 24.10, section D2 (session key types gated by their amendment)

From the read-only audit of develop at ce2c7cb, section D item 2. Verified first: nothing in rippled/src or rippled/include reads featurePasskey except the PasskeyListSet definition (rippled/include/xrpl/protocol/detail/transactions.macro:1357), and nothing reads featureQuantum at all (grep -rn "featurePasskey\|featureQuantum"). The feature names are Passkey and Quantum (rippled/include/xrpl/protocol/detail/features.macro:161, :156).

  • Change: Transactor::checkSession refuses a session-signed transaction with tefSESSION_TX_NOT_ALLOWED, the own close included, when its SigningPubKey is P-256 and Passkey is not enabled, or ML-DSA-44 and Quantum is not enabled. It sits with the pseudo-account refusal, before rule 1 of spec 6.5, as part of the key-type check the hook redoes in place of Transactor::checkSign.
  • Deviation from the audit's wording: the audit asked for the check in SigningSessionSet preflight or preclaim as well. That is not possible: the opening carries SessionKey, an AccountID (spec 6.3), not a public key, so its key type is unknown until the key signs. Such a session can still be removed by the owner, or by anyone after expiry.
  • Not changed (upstream, out of scope): the imported tree accepts P-256 and ML-DSA-44 signatures from a master key, a RegularKey or a signer whatever these amendments say; this change only covers the session path.
  • Test first, new case session key types need their amendment: for P-256 without Passkey and ML-DSA-44 without Quantum, a payment and the own close are refused and the master-signed close works; with the amendment, both succeed. Before the change, ./xrpld --unittest=SigningSessions: 41 cases, 2601 tests, 8 failures (tesSUCCESS where tefSESSION_TX_NOT_ALLOWED was expected, and the cascade). After: ./xrpld --unittest=SigningSessions,InvariantsSigningSession,SigningSessionsBatch: 57 cases, 3880 tests, 0 failures.
  • After merging develop at 620774f (#64 and #79), rebuilt: the three suites, 62 cases, 4304 tests, 0 failures; with the gate removed again, SigningSessions fails the same 8 assertions.
  • After merging develop at e27a154 (#82, #83), rebuilt: the three suites, 64 cases, 4756 tests, 0 failures.

2026-10-25: audit 24.10, section B3 (SigningSessionDelete is a queue blocker)

From the read-only audit of develop at ce2c7cb, section B item 3: SigningSessionSet and SigningSessionDelete used ConsequencesFactoryType::Normal, while SetRegularKey and SetPasskeyList are Blocker (rippled/include/xrpl/tx/transactors/account/SetRegularKey.h:17, SetPasskeyList.h:16).

  • SigningSessionDelete becomes a blocker. The transaction queue holds a blocker alone in an account's queue (rippled/src/xrpld/app/misc/detail/TxQ.cpp:661-675, checks at :865-882 and :913-928). Once a close applies, what its session key signed loses its authority and would leave the queue without claiming a fee, which is exactly the case blockers exist for. Direct application is unaffected (TxQ::tryDirectApply, TxQ.cpp:1687, runs before the blocker checks), so the owner can still revoke at once by paying the open ledger's fee at the account's sequence or with a ticket; the existing case 22 (a close by ticket while a session payment waits) still passes unchanged. Trade-off, the same as for SetRegularKey: a session key that keeps transactions queued for the account makes the owner pay the open-ledger fee to close it, and a queued own close blocks the account's queue until it applies or is replaced.
  • SigningSessionSet stays Normal, on purpose. An opening grants a new key authority and removes none, and spec 6.3 refuses a session for a key that already has authority (RegularKey, passkey, beneficiary; the master key is refused by SessionKey == Account), so no transaction already queued changes validity; a transaction signed by the new key fails preclaim, and is not queued, until the opening applies. The test asserts it: a queued opening and a payment queued behind it both queue.
  • Test first, new case transaction queue: the close is a blocker. First version, before the change: ./xrpld --unittest=SigningSessions: 41 cases, 2605 tests, 7 failures (a session-signed payment behind a queued close got terQUEUED, a close behind a queued payment got terQUEUED, with their cascades). While writing it, one assertion showed that the node keeps a refused local transaction and applies it on a later open ledger (a refused master-signed close came back two ledgers later as tecNO_ENTRY and took 10 drops); the refused transactions now end with the current ledger (LastLedgerSequence). Final version against the old header (stashed and rebuilt): 4 failures (telCAN_NOT_QUEUE instead of telCAN_NOT_QUEUE_BLOCKED twice and telCAN_NOT_QUEUE_BLOCKS twice). With the change: ./xrpld --unittest=SigningSessions,InvariantsSigningSession,SigningSessionsBatch,TxQ: 96 cases, 11147 tests, 0 failures.
  • After merging develop at 620774f (with #79, which changes the queue for session-signed transactions and gives the own close no fee margin), rebuilt: the four suites, 101 cases, 11571 tests, 0 failures; with the header set back to Normal, SigningSessions fails the same 4 assertions. #79 does not make the blocker unnecessary: it stops a session transaction from displacing another key's queued transaction, not a session transaction from queuing behind a close.
  • After merging develop at e27a154 (#82, #83), rebuilt: the four suites, 103 cases, 12023 tests, 0 failures.

2026-10-25: audit 24.10, section B2 (the private router flag leaves the public range)

HashRouterFlags::PRIVATE9, added by the fixPasskeyChallenge pull request (#13) for forced signature verdicts (kSfSigforced, rippled/src/libxrpl/tx/apply.cpp:70), sat at 0x0020, in the public range (BAD to TRUSTED are 0x02 to 0x10), because the 16-bit private range 0x0100-0x8000 was full. A public flag later given 0x20 upstream would collide with it. This is our own commit's defect, not an upstream one.

  • Change: HashRouterFlags is now std::uint32_t and PRIVATE9 is 0x10000. Every use was checked: the type is only combined with its own operators (HashRouter.h), stored in HashRouter::Entry, and passed by value (HashRouter.cpp, PeerImp); nothing serializes it, casts it to 16 bits or masks a byte of it (grep -rn HashRouterFlags rippled/src rippled/include). The entry grows by two bytes.
  • Build cost, measured before starting: 27 translation units depend on HashRouter.h (ninja -t deps), so the change is cheap to rebuild.
  • Test first (gtest HashRouterFlags, new file): every private flag is one bit at or above 0x0100, the public ones below it, and no two share a bit. Before: Expected: (bits) >= (kFirstPrivateBit), actual: 32 vs 256. After: 2 tests passed. ./xrpld --unittest=HashRouter,PasskeyWebAuthn,PasskeyRelay,Batch,TxQ,NetworkOPs,SigningSessions: 149 cases, 14612 tests, 0 failures.
  • After merging develop at 620774f, rebuilt: the same suites, 150 cases, 14752 tests, 0 failures; PRIVATE9 set back to 0x0020 fails the gtest again (actual: 32 vs 256). Rebuilding after a change of HashRouter.h alone took 9 s for xrpl_tests and 27 s for both binaries.
  • After merging develop at e27a154 (#82, #83), rebuilt: the same suites, 151 cases, 14842 tests, 0 failures; gtest 2 passed.

2026-10-25: audit 24.10, two session paths the DEVLOG listed as untested

The N3/N4 and N5 entries above, and the N8 self-review, say that two paths had no test: the eligibility check of mode P at use, and a reset that charges less than the fee because the balance is short. Both now have one. The code was already right; each test was proven to bite by a mutation (one at a time, rebuilt, run, reverted).

  • Mode P eligibility at use (mode P eligibility at use): two mode-P sessions are rewritten in the open ledger (no transaction can create them, SigningSessionSet refuses both lists) to list, besides Payment, the non-delegable AccountSet at the transaction level and its granular permission AccountDomainSet. Payments still sign; an AccountSet (asfRequireDest) and an AccountSet that only sets Domain are refused (tefSESSION_TX_NOT_ALLOWED) and change nothing. Mutation: the isSessionEligibleType check of the mode-P branch of Transactor::checkSession removed: 2 failures, both transactions tesSUCCESS.
  • Fee cut short by the reset (mode C accounting, fee cut short): the reset only cuts the fee in a closed ledger (in an open ledger checkFee returns terINSUF_FEE_B, rippled/src/libxrpl/tx/Transactor.cpp, the end of checkFee, and reset asserts balance >= fee), so the test builds the next ledger and applies by hand, as upstream Regression_test.cpp:100-130 does. Alice's balance is set to 4 drops, a mode-C session-signed payment with a 10-drop fee ends in tecINSUFF_FEE, and SessionSpent grows by 4 drops, the amount actually taken; the balance is 0. Mutation: the first chargeSession call given the declared Fee instead of the fee reset returned: 1 failure (SessionSpent 10). The invariant alone does not catch that mutation (counting more than the outflow satisfies rule 4); this test does.
  • Runs: ./xrpld --unittest=SigningSessions,InvariantsSigningSession,SigningSessionsBatch: 58 cases, 3826 tests, 0 failures. The at-use test for the two D4 types (CredentialCreate, DIDSet) is in the D4 pull request.
  • After merging develop at 620774f (#64, #79), rebuilt: the three suites, 63 cases, 4250 tests, 0 failures. Mutations again: chargeSession given the declared fee, 1 failure; the at-use check removed, 4 failures (this branch's two AccountSet refusals, and #64's CredentialCreate and DIDSet at use, which #79's invariant now stops as tecINVARIANT_FAILED).

2026-10-25: audit 24.10, section B (the session fee ceiling uses the exception-safe base fee)

From the audit task list (section B, minor): Transactor::invokeCheckSession passed T::calculateBaseFee straight to checkSession, while the fee check of invokePreclaim uses xrpl::calculateBaseFee (rippled/src/libxrpl/tx/applySteps.cpp:247-275, :446-449), which catches an exception and returns tefEXCEPTION.

  • Change: rule 4 of checkSession takes the required fee from xrpl::calculateBaseFee and returns its error as a NotTEC (temUNKNOWN or tefEXCEPTION; anything else would be tefINTERNAL), as spec 6.5 rule 4 asks; the baseFee callback parameter is gone. isSessionExpired now calls hasExpired(view, Expiration, ExpiryComparison::Inclusive) (rippled/src/libxrpl/ledger/View.cpp:49-63): Inclusive is parentCloseTime >= Expiration, the comparison the function made by hand, so the semantics are identical.
  • No failing test is possible, said plainly. No session-eligible transactor's calculateBaseFee throws (the overrides are EscrowFinish, the owner-reserve types and the confidential MPT multipliers), and the two types with their own checkSign (Batch, LoanSet) stop at rule 1. Before the change a throw would also have ended in tefEXCEPTION, through the catch of preclaim (applySteps.cpp:432-441), with a fatal log; the result code does not change, only the path. The expiry change is covered by the existing boundary tests: the mutation ExpiryComparison::Exclusive fails 7 assertions of SigningSessions (expiry, rule order, delete after expiry, reverted).
  • Runs: ./xrpld --unittest=SigningSessions,InvariantsSigningSession,SigningSessionsBatch: 56 cases, 3760 tests, 0 failures.
  • After merging develop at 620774f: #79 rewrote rule 4 (the own close gets no margin) on the same lines; the merge keeps both: the required fee from xrpl::calculateBaseFee, and no margin for the own close. Rebuilt: the three suites, 61 cases, 4184 tests, 0 failures; the Exclusive mutation again fails 7 assertions.
  • After merging develop at e27a154 (#82, #83), rebuilt: the three suites, 63 cases, 4636 tests, 0 failures.

2026-10-25: audit 24.10, section D1 (P-256 signatures: strict DER, low S, ignored signature fields)

Upstream defects of the imported tree, fixed in their own commits (one defect each, no amendment code mixed in), on one branch. Verified in our tree before any change: parseDERSignature (rippled/src/libxrpl/protocol/PublicKey.cpp:335-376 at 4a3a774) skips the sequence length byte, never checks for bytes after S, accepts non-minimal and negative integers, has no low-S rule, and can read one byte past the buffer; verifySigObject (rippled/src/libxrpl/protocol/STTx.cpp) only reads PasskeySignature next to a P-256 key and, on that path, ignores TxnSignature. The audit's wording "over-read when S ends the buffer" is slightly off: the read past the end happens when the buffer ends at the tag of S (the length byte of S is then read from beyond it).

  • Over-read (fix: Stop the P-256 DER parser reading past the signature, no amendment). The outcome was already a rejection for every input (the bounds check after the read fails whatever byte is read), so no signature changes validity. Test first: a gtest places such a signature against a PROT_NONE page; before the fix the gtest binary dies (exit 138, SIGBUS), after it passes.
  • Signing side (fix: Produce low-S P-256 signatures, no amendment): sign() returned high-S P-256 signatures about half the time, which a strict verifier must refuse; it now writes n - s. Every earlier signature still verifies. Test first: 64 signatures, 28 high before, 0 after.
  • Strict DER and low S (fix: Require strict DER and a low S for P-256 signatures, new amendment fixP256StrictDer, Supported::Yes, DefaultNo, at the top of features.macro, enabled in infra/standalone.cfg). Strict parse, as ecdsaCanonicality does for secp256k1: the sequence length equal to the signature's, minimal integers, nothing after S, 0 < R < n, 0 < S <= n/2. The rule is passed from STTx::checkSign(Rules const&) through the single, multi, WebAuthn and BatchSigner paths, as fixPasskeyChallenge is. Consensus safety, judged: the change refuses signatures that verified before, so it needs an amendment; activation is made safe as for fixPasskeyChallenge: until it is enabled, a transaction with a P-256 key anywhere bypasses the signature cache, and NetworkOPs and PeerImp do not mark it bad (isSignatureVerdictCacheable). Tests first (the plumbing built with the strict flag ignored): gtest 4 of 8 failing (high S, trailing byte, wrong total length, non-minimal integers accepted under the strict flag); P256StrictDer 20 failures, all in the "with the fix" cases (each altered form verified under a new transaction ID); after the parser, 0.
  • PasskeySignature that no P-256 key uses (fix: Refuse a PasskeySignature that no P-256 key uses, same amendment): next to a secp256k1 key or next to Signers it was ignored, so anyone could add one and re-hash the transaction. Test first: with the fix, both variants still verified (Valid); after, SigBad; without the fix they verify and keep no verdict.
  • TxnSignature next to a PasskeySignature (fix: Refuse a TxnSignature next to a PasskeySignature, same amendment): found while writing the previous test; same class of defect. Test first: verified under a new ID with the fix; after, refused.
  • One amendment for the three validity changes, said plainly: all three are P-256 signature-shape malleability fixes and share the activation work; splitting them into three amendments is possible if a reviewer prefers.
  • Client: the passkey wallet (apps/passkey-wallet) sent the authenticator's DER as it came; it now goes through canonicalizeDer from @carbs/sessions (the web app's passkey path already did). Without it, about half of its passkey payments would be refused on our node.
  • Runs: ./xrpl_tests: 1142 tests, 1 failure, the base VaultTests.BuilderFromSleRoundTrip (N0). ./xrpld --unittest=P256StrictDer,PasskeyWebAuthn,PasskeyRelay,PassKey,MultiSign,Batch,STTx,SigningSessions,SigningSessionsBatch,InvariantsSigningSession,Delegate,Sponsor,Firewall,AccountDelete,PasskeyListSet,ServerDefinitions,Feature: 267 cases, 34304 tests, 1 failure, the base Delegate counter. Full ./xrpld --unittest --unittest-jobs=6 on this branch: 640.9 s, 1,293,550 tests, 203 failures, the same suites and counts as the recorded baseline (AMMBinned 152, AMMCurves 41, InvariantsVault 2, ValidatorListExpiration 2, ValidatorList_Dilithium 2, Delegate 1, MPToken 1, SignatureVerification 1, and ValidatorSiteV2 not completing); SignatureVerification passes alone, and the two validator-list suites fail alone exactly as docs/base-failures.txt lists. WebAuthn results use the software authenticator: SIMULATED.
  • After merging develop at 67af145 (#64, #79, #82), rebuilt: P256Signature 8 passed; P256StrictDer,PasskeyWebAuthn,PasskeyRelay,PassKey,MultiSign,Batch,STTx,SigningSessions,SigningSessionsBatch,InvariantsSigningSession: 167 cases, 10780 tests, 0 failures. Mutation (the strict parser never selected): gtest 4 failures, P256StrictDer 19 failures, reverted.

2026-10-24: front end follows the XRPL Commons art direction

Owner request: the web app follows the XRPL Commons look (an XRPL showcase app with no stance of its own), with one exception: whenever a Ledger is involved the whole app turns into Ledger mode. Branch fianso/frontend-da (the audited final pull request keeps its state).

  • Sources read. xrpl-commons.org: the homepage and its Webflow stylesheet (colours, --navy #000637, --white #f5f4e7, --blue-violet #791aff, --dodger-blue #1aa4ff, --yellow #faff1a; Clash Display and Plus Jakarta Sans; pill buttons at 100rem, cards at 1 to 1.25 rem with 2 px navy borders). Figma file "Présentations" (the link the owner gave points at the page "HISTORY 25/26", a canvas of every deck): the XRPL Commons material is in page "26/27", the deck Wave of Innovation <> DeVinci Blockchain (9499:49) and its Source · WOI assets frame (9481:1146). That deck is dark navy (#05192f), Montserrat, with violet (#6b6bf2) and cyan (#27c4e8) offset layers behind photos; it is the XRP Australia and London delegation proposal, not a design system, so the site is the reference and only the offset-layer motif is taken from it.
  • Assumption, unverified. Ledger's exact purple: the Ledger homepage gave no usable token and a web search found none (a third-party spec lists black, white and an orange-red #ff5300). Ledger mode uses #bbb0ff as the accent over a violet silk (#6a4cf0); both are tokens (--ledger-violet and the color prop in layouts/default.vue).
  • Ledger mode (lib/ledger-mode.ts): on for a Ledger account, the Ledger Stax wallet connecting or connected, a real Stax connected or connecting, or a device review; only colours change. The dark theme that followed the system is gone: the app is light unless a Ledger is involved.
  • Components from React Bits, ported to Vue (no React in the app): RubberSegment (top bar, with an empty state for the landing page), ThoughtLine (replaces the busy line; shows the setup steps of an account as they happen, then "Done in 39.6s"), Silk. They run on the motion package (12.38.0). Icons are inline SVG, no icon package.
  • Fonts are self-hosted in public/fonts (Clash Display copied from the site's stylesheet under the Fontshare licence, Plus Jakarta Sans from @fontsource, OFL); font-src 'self' is unchanged.
  • Checked in Chrome against the dev server: Commons look on the Account screen, Ledger mode switching on during a simulated Ledger account creation (silk, device review, steps, settled line), navigation through the segmented control. pnpm --filter web generate and check-static pass. Not checked: a real Stax in Ledger mode, other browsers, the Safari rendering of backdrop-filter and WebGL2.

2026-10-25: the wallet stage pages follow the XRPL Commons art direction

  • What: demo.html, game.html, dex.html, stream.html and mint.html (apps/passkey-wallet) leave their own dark style for the look of the web app: cream, navy ink, violet, Clash Display and Plus Jakarta Sans, 2 px navy borders, pill buttons. The column where the Ledger Stax is involved (the Stax column of the demo, the owner column of each use case) and the footer's Device layer are in Ledger mode (dark violet). The 1440x900 single screen and the large type stay.
  • One token file: src/theme/stage.css imports apps/web/assets/tokens.css and holds no value of its own; the wallet's vite.config.ts uses apps/web/public as its publicDir for the fonts, and the wallet Dockerfile copies both. The Ledger-mode block of the tokens now also matches [data-theme="ledger"] on any element (the web app keeps setting it on <html>). A copy of the token file was rejected: two files would drift.
  • Semantic colours map to the tokens: paid --color-ok, ledger refusal --color-danger, device refusal --color-accent (violet), waiting --color-warn (amber), expiry --color-highlight; a prompt injection is a yellow --color-tag marker. Blue text uses --color-highlight-text, the brand blue mixed with the ink, because the brand blue on cream is too light at projector distance. In Ledger mode the violet is close to the muted ink, so a lit Device layer also gets a violet wash.
  • Checked: headless Chrome at 1440x900 against the Vite dev server, before and after, empty state, plus the demo with one timeline event of every tone injected in the page. Pages fit without scrolling as before (the mint page scrolled before and still does; the game's centre column already clipped its tx grid).

2026-10-25: web app, no double submission; the old classical key refused on screen

  • Bug: a double click on migration step 3 sent the same AccountSet twice: tesSUCCESS, then tefPAST_SEQ for the second copy (same Sequence). Every signing button ran its action once per click.
  • Change: guarded (apps/passkey-wallet/src/dom.ts), already the click wrapper of the test page, now runs one action at a time: the pressed button (or the form's submitter) is disabled from the click until the action and the page refresh end, and clicks in between are ignored. It also takes the submit event itself (preventDefault), so the three forms pass it instead of calling a fresh wrapper per submit (which had no memory of the running one). The mint and stream pages' own wrappers now build on it; the game's auto-clicker start goes through it without its click so the same button can still stop it. Agent approvals ignore a second decision on a request already being decided.
  • Path: the button is re-enabled before the refresh, which then sets its real state; restoring it afterwards would undo the refresh (step 3 must stay off once done). Refreshes that re-enable it mid-flight are harmless: the running flag drops the click.
  • Ledger Stax section: « signer en ML-DSA-44 » is never chosen by hand (checked and disabled once migrated). Once migrated, « Essayer avec l'ancienne clé classique » signs a 1 XRP payment with the classical key on the Stax; tefMASTER_DISABLED is logged green (logOutcome), anything else red.

2026-10-25: SigningSessionDelete flag mask, spec and XLS in line with develop, full unit run

  • What: SigningSessionDelete declares getFlagsMask, returning tfUniversalMask (the pattern of FirewallDelete): every bit but the universal flags is temINVALID_FLAG in preflight, as FIANSO-SPEC 6.3 asks. The flags test now tries each of the 30 non-universal bits on a close, then closes with tfFullyCanonicalSig. FIANSO-SPEC.md (6.1 to 6.7, 6.9, 6.10, 6.13, 6.15, appendix A) and docs/XLS-Sessions.md now describe develop: SessionMaxPerTx, SessionWitness with WitnessSignature (WTS\0) and tefSESSION_NO_WITNESS, SessionMaxTxCount / SessionTxCount, the hardening of #79 (no queue displacement, no ticket but the own close, the own close fee ceiling, the new invariant checks) and the Batch interplay; the XLS line references are refreshed, and the test plan gains cases 25 to 28.
  • Why: the behaviour was already the default of Transactor::getFlagsMask; declaring it keeps the rule visible next to SigningSessionSet's. The two documents still described the three-code design of N2 and called the witness and the per-payment cap "awaiting decision".
  • Path: a mask refusing every bit, universal ones included, was rejected: TxFlags.h asserts that transaction masks allow tfInnerBatchTxn, and the owner revokes sessions inside a Batch (SigningSessionsBatch, "revoke three sessions in one Batch").
  • Full run (./xrpld --unittest --unittest-jobs=6, Mac, Debug, branch STOOOKEEE/d-rippled-finish on e27a154): 889.7 s, 288 suites, 5581 cases, 1,357,960 tests, 222 failures: AMMBinned 152, AMMCurves 41, InvariantsVault 2, ValidatorListExpiration 2, ValidatorList_Dilithium 2, Delegate 1 (60 vs 71, now at Delegate_test.cpp:2854), MPToken 1 (:2115), all in docs/base-failures.txt; SignatureVerification 1 and ValidatorSiteV2 1, which pass alone (the second hit test_fetch1, a file another suite left in the working directory under parallel jobs; once it was removed, ValidatorSite,ValidatorSiteV2 gave 0 failures); and SHAMapStore 19, new to the list.
  • SHAMapStore, not caused by sessions: it fails alone too, 19 failures each time (SHAMapStore_test.cpp:1443, :1620-1642, "online_delete rotation has been unable to make progress for 8 ledgers"), with this branch's binary, with the binary of carbs-dev (196ad5c) and with the main checkout's binary built on 2026-10-24 at 19:24, before 0bcb324 (20:39) and therefore before #56, #59, #64, #79 and #82. The 0bcb324 full run passed it, and neither the test nor SHAMapStoreImp has changed since the import (516f6a5): code older than a tree that passed it now fails it, so the cause is the host or the date, not the session code. Load is not it either: the alone runs failed at a load average near 4. The online-delete health check reads LedgerMaster::getValidatedLedgerAge, which compares the validated close time with the time keeper; the exact factor is not identified. docs/base-failures.txt names it in its header, like ServerInfo.
  • Rebased on develop after #87 (the session key-type gate, SigningSessionDelete as a queue blocker, the exception-safe fee ceiling, strict DER for P-256), which the two documents now also describe. Full run again on the rebased tree: 675.1 s, 286 suites, 5469 cases, 1,294,829 tests, 222 failures: the same base failures (Delegate 60 vs 71, MPToken_test.cpp:2115), SignatureVerification 1, SHAMapStore 19 as above, and ValidatorSiteV2, which did not complete ("The child process may have crashed"), as in N0. ValidatorSiteV2,ValidatorSite,SignatureVerification alone: 3 suites, 173 cases, 8815 tests, 0 failures. SigningSessions,SigningSessionsBatch,InvariantsSigningSession,P256StrictDer: 4 suites, 78 cases, 5412 tests, 0 failures. No new failure from the session code.

2026-10-25: a WitnessSignature only where a witness is needed (review of #59)

  • What: an independent review of the merged witness extension (#59) found that WitnessSignature, a common field of every transaction and not a signing field, was verified by STTx::checkSign against any key, but checked against the ledger only for a session-signed transaction whose session has a SessionWitness, the own close excepted. Once SigningSessions is enabled, anyone relaying a valid transaction (master-signed, RegularKey-signed, signed under a session without a witness, or an own close) could add a WitnessSignature made with a throwaway key, or strip one from an own close, and change the transaction's ID while it stays valid: the malleability class fixP256StrictDer closes for P-256, opened by our own extension. Now a transaction that needs no witness takes no WitnessSignature, with tefBAD_SIGNATURE ("A signature is provided for a non-signer."): invokePreclaim refuses one on a transaction that is not session-signed, and rule 7 of Transactor::checkSession on a session without a witness and on the own close, even co-signed by the witness. No new amendment: SigningSessions is unreleased, and a WitnessSignature is temDISABLED before it.
  • Witness key types: a witness key of a type whose amendment is off (P-256 without Passkey, ML-DSA-44 without Quantum) no longer co-signs (isSessionWitnessed, tefSESSION_NO_WITNESS), as for a session key since #87. The XLS now also says that outside sessions the imported tree gates no key type (master keys and RegularKeys), an upstream behaviour left as it is.
  • Not changed, documented: a witness account left with a SignerList only (master key disabled, no RegularKey) can co-sign nothing, so its session signs only its own close (fail-closed; the test shows it). The temDISABLED test the review asked for already existed (SigningSessions "amendment", #79).
  • Run (Mac, Debug, this branch on 469843a): the two new cases fail without the code change, 16 failures (stray signatures accepted with tesSUCCESS; P-256 and ML-DSA-44 witness keys co-signing with their amendment off), with the tests kept and the three code files reverted. With the change: SigningSessions, SigningSessionsBatch, InvariantsSigningSession, P256StrictDer, PasskeyWebAuthn, Batch, TxQPosNegFlows, TxQMetaInfo, STTx: 9 suites, 186 cases, 17,763 tests, 0 failures. xrpl_tests --gtest_filter='*SigningSession*:*Witness*:P256Signature*:HashRouterFlags*': 25 passed. clang-format 22.1.8 and pnpm format:check clean.
  • After the review gate of #92 (approve with nits): the refusal of a stray WitnessSignature on a transaction that is not session-signed now comes after its signature check, so a key without authority keeps tefBAD_AUTH (the wallet's "attack after close" button, which co-signs, still shows that code); a Batch inner transaction carrying one fails the whole Batch in Batch::preflight (temBAD_SIGNATURE, before any fee; gated on SigningSessions, before which the inner preflight already answers temDISABLED); signAsWitness (packages/core) refuses to co-sign a session close; the wallet explains tefBAD_SIGNATURE. New cases: multi-signed, by a delegate, a Batch outer and inner, and a co-signed payment by the key after its session is closed (tefBAD_AUTH). The XLS line references are refreshed, "not signed by a session key" became "not session-signed", and the older sentence "no node verifies one before the amendment" now says that no transaction carrying one is applied (its signature is still verified at relay and submit). The XLS notes that a co-signature can be stripped only if the same key later signs without a witness, which a fresh key per session rules out, and that without fixP256StrictDer a P-256 session or witness signature stays re-encodable. Mutation run: the check moved back before authorization and the Batch refusal removed give 5 failures. Then SigningSessions,SigningSessionsBatch,InvariantsSigningSession,P256StrictDer,PasskeyWebAuthn,Batch,TxQPosNegFlows,TxQMetaInfo,STTx,Delegate,MultiSign: 11 suites, 235 cases, 24,750 tests, 1 failure, the base Delegate one (60 vs 71). pnpm test: core 102, scripts 7, ledger 57, web 229, all passing.

2026-10-25: fixP256StrictDer on every P-256 path; verify() makes each caller choose (review of #78)

  • What: an independent review of #78 (merged in #87) found two P-256 signatures that fixP256StrictDer did not reach, because they are checked in preflight with verify(), outside STTx::checkSign, and verify() defaulted its strictness to false: a payment channel claim (PaymentChannelClaim.cpp:98) and a cross-chain attestation (XChainAttestations.cpp:79, called by attestationPreflight). A channel key or an attestation key may be P-256, and Signature there is not a signing field, so a relay could still re-hash such a transaction once the amendment is on. Both now pass rules.enabled(fixP256StrictDer), and channel_verify answers as the claim would be checked under the open ledger's rules.
  • No default: the four-argument verify() has no default any more. A three-argument form remains for keys that are not P-256 (the upstream tests use it with secp256k1, Ed25519 and ML-DSA keys, unchanged) and refuses a P-256 key, so a caller that does not say which rules apply cannot accept a P-256 signature in either form. Manifests (Sign.cpp) and publisher lists (ValidatorList.cpp), which have no ledger rules (they accept a P-256 key like any other), pass false explicitly: behaviour unchanged.
  • Also: sign() stops with logicError (which terminates the process, as the other failure branches of sign() do) when the low-S normalization cannot run (BN_new or BN_rshift1 failing), instead of silently emitting a possibly high S. The comments that named only fixPasskeyChallenge (NetworkOPs.cpp, PeerImp.cpp, apply.cpp, HashRouter.h) name both amendments.
  • Not fixed, documented: PasskeySignature.PasskeyID is neither signed nor checked against the ledger (only SetPasskeyList reads it), so a relay can change it and a passkey-signed transaction's ID, on the master, RegularKey, passkey list and session paths alike. Only the passkey list path has a ledger record to bind it to; a full fix is a protocol decision for the Passkey amendment (tie it to the passkey list entry, or sign it). Stated in the spec, the API changelog and the limits table of docs/ARCHITECTURE.md, to report upstream.
  • Run (Mac, Debug, this branch on 469843a): failing first, with the four sites made lax again (claim, channel_verify, attestation, the three-argument verify()), 7 failures in P256StrictDer and 1 gtest failure. With the change: P256StrictDer,PayChan,PayChanToken,XChain,PasskeyWebAuthn,PasskeyRelay,PassKey,MultiSign,Batch,STTx,SigningSessions,SigningSessionsBatch,Manifest,ValidatorList,SecretKey,PublicKey,Seed: 21 suites, 519 cases, 124,612 tests, 4 failures, the base ones of docs/base-failures.txt (ValidatorListExpiration 2, ValidatorList_Dilithium 2). xrpl_tests --gtest_filter='P256Signature*:HashRouterFlags*:*SigningSession*': 27 passed. New: claim and channel_verify with and without the fix, the attestation's two forms, a P-256 session witness's high-S form (regression: the witness role already had the flag), R = n refused, and the three-argument verify() refusing P-256. clang-format 22.1.8 clean.
  • After the review gate of #93 (approve with nits): a transaction-level test of the attestation (XChainAddClaimAttestation with a high-S P-256 signature: temXCHAIN_BAD_PROOF with the fix, tecNO_ENTRY at the bridge lookup without it), since the direct AttestationBase::verify test did not cover the preflight wiring (mutation: false at XChainBridge.cpp gives 1 failure); channel_verify reads the current ledger's rules as submit does; the comments of apply.cpp name both amendments; the PasskeyID item moved to a "Known issues" section of the API changelog, and the limits row says what bounds it today (submitSignedAndWait finds a transaction by account and sequence; the apps still poll by hash) and what would fix it (bind it to the passkey list entry's ID, else empty, under fixP256StrictDer). On the HashRouter: a tem from preflight can be marked BAD (NetworkOPs.cpp), and that stays safe across the activation because any signature the strict form accepts, the lax form accepts too. Run: P256StrictDer,PayChan,PayChanToken,XChain,PasskeyWebAuthn,PasskeyRelay,PassKey,MultiSign,Batch,STTx,SigningSessions,SigningSessionsBatch,Manifest,SecretKey,PublicKey,Seed: 18 suites, 495 cases, 116,335 tests, 0 failures; gtests 27 passed.

2026-10-25: only a close by the account itself blocks the queue; the fee escalation sentence (review of #70 and #81)

  • What: the review of #70 (merged in #87) noted that SigningSessionDelete was a queue blocker for every close, including a third party's cleanup of an expired session (Owner present), which removes no authority from the cleaner's own account and only kept the cleaner's next transactions out of its queue. SigningSessionDelete now uses a custom consequences factory: a blocker without Owner (the owner's close, or the own close by the session key), an ordinary transaction with Owner. TxQ behaviour only, local to each node: no consensus change.
  • Docs: the XLS, the spec and the README said that "a close that pays the open ledger's fee still applies at once". That holds for the owner's close; the own close signed by the session key pays at most the base fee of its type (rule 4, since #79), so while fees are escalated it can only wait in the queue. All three now say so, and the API changelog describes the blocker. The XLS lists the unpriced verification of a WitnessSignature (a multi-signer adds one base fee each) as an open question.
  • Run (Mac, Debug, this branch on 469843a): failing first, with the test kept and the two code files reverted, SigningSessions 2 failures (carol's payment behind her cleanup telCAN_NOT_QUEUE_BLOCKED instead of terQUEUED). With the change: SigningSessions,SigningSessionsBatch,TxQPosNegFlows,TxQMetaInfo: 4 suites, 93 cases, 10,654 tests, 0 failures. clang-format 22.1.8 and pnpm format:check clean.
  • After the review gate of #94 (approve with nits, no must-fix): the README, the XLS and the spec no longer say the own close "waits in the queue": it can at best be queued, alone in its account's queue, and the owner's close applies at once at the account's sequence or with a ticket. Two more cases: a cleanup queues behind the cleaner's own payment (the other direction of the blocker rule), and an own close under fee escalation is queued and blocks what follows (the case behind the doc sentence). The API changelog notes that auth_change and auth_change_queued read false for a cleanup; SigningSessionDelete.cpp includes applySteps.h; the XLS open question on the witness fee cites the precedents (CounterpartySignature priced by LoanSet and FirewallSet, SponsorSignature free). SigningSessions: 49 cases, 3,586 tests, 0 failures.

2026-10-25: a P-256 session or witness key needs fixP256StrictDer too

  • What: the review gate of #92 suggested it and it was left out at first: a P-256 key signs under a session, as the session key or as the witness, only when both Passkey and fixP256StrictDer are enabled (isSessionKeyTypeEnabled, shared by Transactor::checkSession and isSessionWitnessed). Before, on a ledger with SigningSessions and Passkey but without the strict DER fix, a P-256 session or witness signature could be re-encoded by anyone (high S, loose DER) and the transaction's ID changed. Both are unreleased amendments of this fork, proposed together, so no behaviour on any network changes.
  • Run (Mac, Debug, on 6b189db): failing first, with the old gates kept, SigningSessions 5 failures (a P-256 session key and witness accepted with fixP256StrictDer off). With the change, the P256StrictDer case "session witness, without the fix" now expects tefSESSION_NO_WITNESS (the witness is refused before the form of its signature matters); SigningSessions,SigningSessionsBatch,P256StrictDer,PasskeyWebAuthn,InvariantsSigningSession,Batch,STTx: 7 suites, 154 cases, 11,016 tests, 0 failures. clang-format 22.1.8 and pnpm format:check clean.
  • After the review gate of #95 (approve with nits): three line references the new code moved are fixed (SigningSession.cpp:159, SigningSession.h:26 and :23-27); isSessionKeyTypeEnabled is a switch over the four key types with no default, so a new type must be decided there; SigningSession.cpp includes KeyType.h; the spec and the changelog say "unless both Passkey and fixP256StrictDer are enabled"; the XLS says the session key gives up its session only when its type may sign, the owner or anyone after expiry deleting it otherwise. New case: an Ed25519 witness co-signs without the P-256 fix. Run: 7 suites, 154 cases, 11,049 tests, 0 failures.

2026-10-25: the transaction parser fuzzed natively under ASan and UBSan, no crash

  • What: the Ledger app's fuzzer (ledger-app/fuzzing/, libFuzzer under ClusterFuzzLite) needs Docker, the Ledger SDK and a clang with libFuzzer, none of which this Mac has (Docker was not running; Apple clang ships no libFuzzer). ledger-app/fuzzing/native/run.sh builds the same entry point (fuzzing/src/fuzz_tx.c: parse_tx, then format the title and value of every field) with the parser and the session policy sources of unit-tests/ against their SDK test doubles, under AddressSanitizer and UndefinedBehaviorSanitizer with no recovery, and feeds it random mutations (bit flips, interesting bytes, insertions, deletions, truncation, splicing, swaps, duplicated chunks) of the 102 ClusterFuzzLite seeds and the 159 test vectors of tests/standalone/testcases, the signing session, Batch, policy and Agent ID ones included. It is not coverage-guided, and says so; it does not replace a ClusterFuzzLite run.
  • Run (Mac, Apple clang 17, arm64): 900 s, 448,495,487 executions, no crash, no sanitizer report. A 30 s measuring run found that 6.3% of mutated inputs parse completely, with 8.2 fields each formatted, so about 28 million mutated transactions went through the whole parse and display path; the rest exercise the refusal paths.
  • Not done: a coverage-guided campaign (ClusterFuzzLite in Docker), and fuzzing of the APDU layer and the ML-DSA chunking (pq_root.c), which this entry point does not reach.

2026-10-25: the full rippled suite on the final tree, and the wallet's end-to-end script on the final node

  • Full suite (Mac, Debug, ./xrpld --unittest --unittest-jobs=8 on 6b189db, develop and main after #94): 522 s, 286 suites, 5,411 cases, 1,315,625 tests, 204 failures: AMMBinned 152, AMMCurves 41, InvariantsVault 2, ValidatorListExpiration 2, ValidatorList_Dilithium 2, Delegate 1 and MPToken 1, all in docs/base-failures.txt; SignatureVerification 1 and ValidatorSiteV2 1, and ValidatorSite did not complete ("The child process may have crashed"). ValidatorSiteV2,ValidatorSite,SignatureVerification alone: 3 suites, 173 cases, 8,815 tests, 0 failures. SHAMapStore passed in this run. #95 (P-256 session keys need fixP256StrictDer) came later and ran its neighbouring suites (7 suites, 0 failures).
  • The wallet's end-to-end script (apps/passkey-wallet/scripts/session-demo.ts) against a local standalone node built from the final tree with #95 (infra/standalone.cfg, the closer, the well-known standalone genesis seed): 38 checks, all as expected. Among them: mode C with a 50 XRP cap and 20 XRP per payment (tefSESSION_LIMIT for 25 XRP and for the remainder), the session key closing its own session (tesSUCCESS) then refused (tefBAD_AUTH), a witness session (tefSESSION_NO_WITNESS without or with a stranger's co-signature, tesSUCCESS with the witness's), modes A and P refusing SetRegularKey and AccountSet (tefSESSION_TX_NOT_ALLOWED), a session opened and revoked inside Batches, and the all-or-nothing key migration Batch that applies nothing. It drives the wallet's own session code, not the browser pages.

2026-10-25: web app, an Apps screen for the team's live apps

  • What: a sixth screen, Apps (/apps, after Attack in the top bar, and on the landing page), with one card per page of the passkey wallet's server: demo.html (the AI agent), game.html, dex.html, stream.html, mint.html and index.html (the wallet itself). A card says what the app shows, the session it opens (mode, Stax profile, and the limits as the Stax review lists them: Valid for, Max transactions, Max spend, Max per payment, Allows), what the ledger refuses and with which code, what the Stax refuses before it signs (0x6986), and links to ${NUXT_PUBLIC_APPS_URL}/<page> in a new tab (rel="noopener noreferrer"). The default host is the team's wallet server, https://debian-docker.tail8b5861.ts.net:9449; a value that is not a plain http(s) URL shows no link. The data is one typed module, apps/web/lib/live-apps.ts.
  • Why: the live apps exist only on the team's node and server; the front did not say they exist, nor what each one bounds and how.
  • Numbers, as the apps' sources set them (in apps/passkey-wallet): agent: mode C, 20 XRP, 5 XRP per payment, 180 s (scripts/agent/agent.ts defaults; the run sheet sets 120 s); game: C, 5 XRP, 0.1 XRP, 300 transactions, 600 s, profile game (src/apps/game/rules.ts); trading: P (OfferCreate, OfferCancel), 200 transactions, 600 s, profile trading (src/apps/dex/rules.ts); streaming: C, 1 XRP, 0.01 XRP, 600 transactions, 600 s, profile streaming (src/apps/stream/config.ts); minting: P (NFTokenMint), 50 transactions, 600 s, profile minting (src/apps/mint/mint-session.ts); wallet: the form's defaults, C, 50 XRP, 20 XRP, 300 s (index.html).
  • Found while reading: the device has an agent profile (50 XRP, 5 XRP, 3 min, 20 transactions, #88), but the agent's opening on develop (approvalTx, src/agent-approvals.ts) names no profile and sets no SessionMaxTxCount, so the Stax checks it against its default session policy (the run sheet's 50 XRP, 5 XRP per payment, 3 min) and its review has no Profile or Max transactions row. The card says so; the wallet was not changed. The wallet page's sessions name no profile either.
  • Checked: lib/live-apps.test.ts reads the wallet's sources (as packages/ledger reads session_permissions.c) and fails when a number on a card drifts from them (mutation: two limits changed, 4 failures); each use case's opening passes the client's mirror of the device's profile check (sessionPolicyRefusal, policy on) and each device probe on its card is refused with Profile <name>:, and passes with the policy off; the agent's budget passes the run sheet's policy and its first, 500 XRP budget is refused; the Stax rows equal the client's prediction (reviewLines); every ledger code is a name of rippled/src/libxrpl/protocol/TER.cpp; connect-src does not list the team's host. AppsScreen.test.ts: the links, the rows, the codes, the note, no link for an unusable URL, no request sent. In Chrome against the dev server at 1440 px, and with mobile emulation at 390, 375 and 320 px: no horizontal overflow. With six screens the top bar's labels touched at 390 px, so below Tailwind's sm breakpoint they use the small type. The six pages answered HTTP 200 with their titles at the default host from this machine (on the team's tailnet), and their bundles carry the same session rules (read from the minified JavaScript).
  • Not checked: no app was used from this screen: nothing was signed and no session was opened. The Stax rows come from the device's code, its frozen vectors (game, trading) and the client's prediction, not from a real Stax; the device's refusals need its session policy on. The profile checks above are the client's SIMULATED mirror of the device, not the device.

2026-10-25: web app, every address and hash links to its network's explorer

  • What: an account address or a transaction hash shown in the web app opens the explorer of the network it lives on, in a new tab (rel="noopener noreferrer"): the account page (its transactions) or the transaction page. One helper, explorerLink (apps/web/lib/explorer.ts, next to explorerTxUrl and explorerAccountUrl of lib/networks.ts), and one component, ExplorerLink (with LinkedText for the addresses a sentence names), used by the result view, the Account screen (address, regular key), the Session screen (session key), the wallet entry, and the Quantum screen (the checked account, which the report now shows, the transaction that first revealed a key, the addresses in the reasons, the coverage and the surviving authorities, the migration's account and steps). The text keeps its font and its shortened form; the link is underlined in the accent with an arrow (.explorer-link, CSS only, no icon package).
  • Rules: only a real classic address (checksum) or a 64-hex hash on a network with a public explorer is linked: AlphaNet and mainnet. A SIMULATED value, a value on the local node (no public explorer) and a transaction no validated ledger holds (a refusal, or an answer that never validated) stay plain text, as before; the SIMULATED badges are unchanged. A transaction's link uses the explorer of the node it was sent to: NodePort.explorerUrl(hash) became explorerNetwork() (the live node's network, none for the simulation).
  • AlphaNet explorer: https://alphanet.xrpl.org answers 403 "Forbidden" to a browser (checked with curl and a browser's headers; the team found it in Chrome), so the AlphaNet explorer is now the XRPL Explorer in custom-network mode, https://custom.xrpl.org/alphanet.xrpl.org, whose account page showed "Custom: alphanet.xrpl.org" and the account's balance in Chrome. wss://alphanet.xrpl.org, the RPC and the faucet do not change. kAlphaNetExplorerTxUrl of @carbs/sessions, printed by the spikes, follows. Mainnet stays https://livenet.xrpl.org. The public AlphaNet keeps only recent history (server_info gave complete_ledgers 240005-253753 on 2026-10-25), so the link of an older transaction opens the explorer's "not found" page; account pages always work. The DEVLOG entries above keep the old URL as it was written.
  • Content-Security-Policy: networkOrigins put each network's explorer in connect-src, although the page never fetches one; with the new base that would have added https://custom.xrpl.org. Links are navigation, which connect-src does not govern, so the explorers left it: https://alphanet.xrpl.org and https://livenet.xrpl.org are gone from the policy and nothing was added. vercel.json was rewritten with UPDATE_HOST_FILES=1, and docs/DEPLOY.md carries the same policy (checked against buildCsp).
  • Checked: lib/explorer.test.ts (both explorers, no link for the local node or without a network, no link for a malformed or wrong-kind value, sentences cut around real addresses and hashes only), ExplorerLink.test.ts (a real AlphaNet hash renders a link with the custom explorer's URL in a new tab; a SIMULATED one, the local node's and a fake address render plain text; a sentence's address and hash link on mainnet, nothing in a SIMULATED sentence), ResultView.test.ts (the node's own explorer, not the header's; plain text for a SIMULATED hash, a refusal and the local node), QuantumScreen.test.ts (the live report links the account and the revealing transaction on AlphaNet and on mainnet; the simulated report links nothing; the live migration's links use the new base), networks.test.ts (the explorers are not in connect-src). pnpm --filter web generate and check-static pass.
  • Not checked: the Account, Session, Actions and Attack screens run on the simulation or the local node, so their links never appear today; they are wired and tested through the result view only. A transaction page was not opened on the custom explorer for a transaction older than the AlphaNet's history. The device modal still shows the Stax's rows as text, as the device does.

2026-10-25: the header shows the wordmark only

  • What: the violet placeholder mark next to the carbs wordmark is gone from the header (components/ui/BrandMark.vue removed): the wordmark is the logo of the app. The square logo of media/brand/ (the "c" of the wordmark) serves only as the favicon and for the submission, as the owner asked. pnpm --filter web typecheck and test (268) pass.

2026-10-25: rotating the RegularKey on every payment, in a Batch

  • What: an account whose master key is disabled pays only through an all-or-nothing Batch signed by its current RegularKey K_n, carrying [Payment, SetRegularKey -> K_{n+1}]. Inner transactions carry no signature, so a payment reveals only K_n, the key it retires; the ledger stores K_{n+1} as an AccountID (a hash), so the public key in force is never on the ledger. SetRegularKey has no master-key check (unlike asfDisableMaster), so it works as an inner transaction.
  • rippled: KeyRotationBatch (rippled/src/test/app/KeyRotationBatch_test.cpp): three rotations, each paying bob and installing the next key (the outer's SigningPubKey is K_n and its blob does not hold K_{n+1}'s public key), the retired key tefBAD_AUTH and the master tefMASTER_DISABLED after each; a Batch whose payment fails (tecNO_DST_INSUF_XRP) leaves the RegularKey unchanged (only the outer fee and sequence consumed); a rotation to a key never revealed stores its AccountID only (the account root holds no copy of its public key). 3 cases, 154 tests, 0 failures; Batch and SigningSessionsBatch still pass.
  • Live: pnpm --filter @carbs/passkey-wallet rotation-demo (GENESIS_SEED, XRPLD_WS, default ws://127.0.0.1:16006). Keys come from one 32-byte secret (ROTATION_SECRET, random by default): key n is the secp256k1 family seed whose entropy is the first 16 bytes of SHA-512("carbs-rotation" | secret | n), index 0 being the master; chosen over BIP32 (not a dependency) and over deriveKeypair's accountIndex because each key stays a plain family seed the existing signWithSeed signs with. On the Mac standalone node: 5 payments, every inner tesSUCCESS, K1–K5 then refused (tefBAD_AUTH), the master too (tefMASTER_DISABLED); account_tx shows each of K1–K5 exactly once, in the Batch installing the next key, and K6 (in force) never.
  • Limit: the master public key is revealed by the two setup transactions (SetRegularKey, then asfDisableMaster, which needs the master's own signature and cannot be an inner transaction); it is disabled afterwards.

2026-10-25: cleanup, the template leftovers, dead code and duplicated constants

  • How it was found: pnpm dlx knip@5 with the passkey wallet's HTML entry points declared (a first run without them flagged every stage page's sources as unused, a false positive), --include-entry-exports for @carbs/sessions; tsc --noUnusedLocals on each package and vue-tsc --noUnusedLocals --noUnusedParameters on the web app (clean, except one unused type import in the passkey wallet's pq-migration.ts, left alone because that code is being changed elsewhere); a grep for every candidate over the repository; the files of the create-xrp import (0508c48) that #24 did not replace.
  • Removed: apps/web/.scaffold-xrp.json (the generator's state, read by nothing); apps/web/.gitignore (Next.js, Turbo and Rust/Bedrock entries; what still applies, .vercel/, *.pem, the debug logs and the apps/web/dist symbolic link that nuxt generate writes, is now in the root .gitignore, whose dist/ became dist so it matches a link); the root's !.env.local.example (the file went in #24); the web app's type-check script (a copy of typecheck); the stream -> stream-browserify alias (the package is not installed); packages/core/.prettierignore (prettier reads the root file, which has the same rule); isAddress (lib/sim/keys.ts) and knownResultCodes (lib/result-codes.ts), with no caller; the --backdrop token, never read (the Ledger theme's silk is the .ledger-backdrop element).
  • One source for the protocol constants: the web app's copies of the session limits, the LastLedgerSequence offset, the default duration and the blackhole address are gone; the simulated node, the Session screen and the demo store import them from @carbs/sessions. The passkey wallet's two browser-only copies of the Ripple epoch offset use kRippleEpochOffset. Values are unchanged.
  • Left in place, on purpose: the passkey wallet's own batch.ts, hex.ts, rpc.ts and the agent script's epoch constant duplicate @carbs/sessions, but they also run under Node's type stripping (server/, scripts/), which cannot load @carbs/sessions (ERR_MODULE_NOT_FOUND on its ./x.js imports, checked with Node 22.20); the public exports of @carbs/sessions that no package calls yet (accountIdHex, isSessionResultCode, signerAddress, assembleSigned) are the library's API; FIANSO-SPEC-v1.md, which no file links to, says it is kept for its evidence; the template's Buffer, events and process stand-ins, which would need each wallet of xrpl-connect checked in a browser before going.
  • Checked: pnpm format:check, pnpm typecheck, pnpm test (core 102, ledger 57, web 268 and 5, scripts 7, passkey wallet 5), pnpm --filter web generate and check-static (clean), and the passkey wallet's vite build. Nothing was run on a device or a node: no behaviour changed.

2026-10-25: the hosted web app talks to the team's node

  • What: the wallet server answers CORS for its faucet (/api/faucet) to the origins in WEB_ORIGINS (default https://carbs-kappa.vercel.app): a preflight from a listed origin gets 204 with access-control-allow-origin, any other 403; the wallet's own pages stay same-origin. The hosted web app is built with NUXT_PUBLIC_LOCAL_NODE_URL=wss://debian-docker.tail8b5861.ts.net:9448 and NUXT_PUBLIC_LOCAL_FAUCET_URL=https://debian-docker.tail8b5861.ts.net:9449/api/faucet, so "Live (local node)" reaches the team's node from a browser on the team's tailnet; elsewhere it falls back to the simulation with the reason shown. Both endpoints are on the team's server only (no public exposure).
  • Checked: the server locally: preflight from the listed origin 204 with the CORS headers, from another origin 403, a POST from the listed origin carries the header. The live run against the team's node needs the server redeployed with this change.

2026-10-25: Ledger's XRP signer kit, imported and extended

  • What: Ledger's official @ledgerhq/device-signer-kit-xrp (0.3.0, LedgerHQ/device-sdk-ts develop at 8ab7cdc) is imported unmodified into packages/signer-kit-xrp (its tree object equals upstream's; UPSTREAM.md), made to build and test here with tooling changes only (@carbs/device-signer-kit-xrp; upstream's Vitest 3.2.6 and Prettier 3.5.3; three shared configs of Ledger's monorepo copied byte for byte; an esbuild and tsc build run by pnpm install), then extended in its own style: getPqPublicKey (0x10), signTransactionPq (0x11 then 0x12), getSessionPolicy (0x08, a typed model), the session refusals 0x6815 and 0x6986 in its error mapping, and JSDoc and README on the STX\0 prefix the app adds itself on the classical path. packages/ledger now runs every command through the kit: on a real Ledger through SignerXrp and dmk.executeDeviceAction (skipOpenApp: true), on the SIMULATED device and test doubles through the kit's exported device action factories over exchange. Its public API is unchanged, with getAppConfig (real device only) and LedgerDevice.signer added. packages/signer-kit-xrp/UPSTREAM-PR.md drafts an upstream pull request; nothing was sent.
  • Why: stop building next to Ledger's kit and extend it instead, so that the kit carries what this app needs and the extension can be offered upstream.
  • Run (Mac, Node 22.20): the kit's 106 upstream tests pass unedited, 205 with ours (30 files, Vitest 3.2.6); on the root Vitest 4, 4 upstream tests fail ("is not a constructor", a DX log row), hence the pin. @carbs/ledger 68 (the 57 existing ones unchanged, on the kit path, and 11 that drive a real SignerXrp on a DeviceManagementKit whose executeDeviceAction runs each action against the simulated device), the web app 269, @carbs/sessions 102, scripts 7. pnpm format:check, pnpm typecheck, pnpm --filter web generate and check-static pass (CSP unchanged, no third-party script). Six DX log rows.
  • Not done: nothing ran on a real device: the kit path (OpenApp skipped, executeDeviceAction, the PQ commands in 255-byte chunks) was not tried on the Stax, and the classical signature through the kit neither. The passkey wallet's Dockerfile was updated (it copies the kit) but not built. ESLint was not run on the kit (its config is private to Ledger's monorepo). The kit exports no factory for getAppConfig, so app configuration is not available on the simulated device.
  • After the review gate of #112 (approve with nits, blocked only by a conflict with develop): develop merged again (both DEVLOG entries kept). 0x6815 and 0x6986 leave the kit's shared upstream map: a separate XRP_SESSION_APP_ERRORS (spreading the upstream one) is used by the ML-DSA sign command only, so the upstream classical command reports them as an unknown device exchange error carrying the status word, which packages/ledger still turns into the same DeviceError. The JSDoc of the new SignerXrp methods names no fork. GetSessionPolicyCommand refuses a profile with more permissions than the app stores (SESSION_PROFILE_MAX_PERMISSIONS, 2). The length checks of getMlDsaPublicKey and signMlDsa that the kit made unreachable are gone: a malformed answer (a short chunk, a wrong announced length) now surfaces as the kit's InvalidResponseFormatError, a DMK error object that describeLedgerError prints, no longer as a DeviceContractError. The web app's device ports take an XrpTransport. The kit README says that lib/ is missing after pnpm install --ignore-scripts; UPSTREAM-PR.md lists ESLint not run and the interface methods as breaking for implementers.

2026-10-25: post-quantum passkeys in Ledger's Security Key app (ML-DSA-44, COSE -48)

  • Why: the owner's Ledger developer contacts pointed to the FIDO2 "Security Key" app. With ML-DSA-44 credentials a Ledger is a post-quantum WebAuthn authenticator whose passkeys can sign XRPL transactions through the passkey path (a parallel task makes rippled verify a PasskeySignature whose SigningPubKey is the raw 1,312-byte ML-DSA-44 key and whose Signature is the raw 2,420-byte signature over authenticatorData || SHA-256(clientDataJSON)).
  • Import: LedgerHQ/app-security-key main at 1fce46957b5d155cc2d5d47d2cea6840e50b3b23 (app 1.7.8) into security-key-app/, unmodified, in its own commit; tree 9e26a7d5… equal to upstream's (UPSTREAM.md).
  • Change (security-key-app/CARBS.md): getInfo lists -48 (the algorithms member, over USB CTAPHID); makeCredential accepts -48 and returns a COSE AKP key {1: 7, 3: -48, -1: bstr(1312)} generated in place; getAssertion signs authData || clientDataHash with pure ML-DSA-44, empty context. Key pair derived, never stored: xi = SHA256((3 << 128 | nonce) || privateKeySeed), the app's ECDSA construction with its own role, then MLDSA_internal_keygen(xi), the SDK function ledger-app/src/pq uses. Low-RAM ML-DSA (HAVE_MLDSA_OPTIMIZATION), Stax, Flex and Apex P only. Attestation unchanged (packed, ES256). No screen added.
  • RAM on the Stax: .bss 11,094 → 14,830 B (the CTAP2 response buffer 1,032 → 3,445 and the authData buffer 500 → 1,822), stack 25,760 → 22,024 B. No buffer for keys or signatures: the secret key is on the stack for one call, the signature is computed at the end of the response buffer and moved into place by the CBOR encoder. Static estimate of the deepest ML-DSA path (-fstack-usage plus the call graph of app.asm): 12,680 B from get_assertion_confirm, callers and indirect calls not counted. CUSTOM_IO_APDU_BUFFER_SIZE stays 1,031: a CTAPHID reply is sent from the app's buffer, so ML-DSA-44 is offered only over USB CTAPHID and refused over U2F_MSG and NFC, whose replies are copied into the IO buffer.
  • Built in ledger-app-builder-lite (image 3561f39cb806) for Stax, Flex, Apex P (with ML-DSA) and Nano X, Nano S Plus (without). Stax bin/app.hex sha256 edefd8a5871db827…, application hash 8d68de4438860be1… (after the review gate below).
  • Tests: seven new LedgerUT suites (COSE AKP bytes, derivation chain and wiping, the role-3 seed, the decoded registration and assertion responses with the signature intact behind the credential ID, transports, getInfo); three mutations caught. 17 of 18 suites pass; upstream test_client_pin crashes under ASan at exit on the unmodified import too. The SDK's ML-DSA is faked in them.
  • Host side: security-key-app/carbs/mldsa_passkey_test.py (python-fido2 1.2.0, dilithium-py 1.5.1) runs getInfo, makeCredential with -48, getAssertion, verifies the ML-DSA-44 signature on the host and prints the PasskeySignature shape; verify_noble.mjs cross-checks with @noble/post-quantum 0.7.1; load_stax.sh sideloads with ledgerblue (its --offline output is byte-identical to make's app.apdu). SIMULATED: the script's selftest uses a software ML-DSA-44 authenticator; dilithium-py and noble agree on its signature and on KeyGen_internal(seed).
  • After the review gate (APPROVE-WITH-NITS on #109): over U2F_MSG and NFC an ML-DSA-44 credential of an allowList is skipped as unusable, so an ES256 credential later in the list signs instead of the request failing; the signature buffer is wiped when ML-DSA signing fails (the SDK leaves rejected candidates in it); cx_mldsa_internal.h is included instead of a hand-written prototype; the makeCredential response and the user-entity tail of the assertion return an error on an encoder fault instead of a truncated reply. Four tests added, three more mutations caught. Documented, not changed: a side-loaded build attests with the app's public TEST key under Ledger's AAGUID (the Stax test certificate fails openssl verify against the production CA); no keepalive during an ML-DSA computation; U2F authenticate of an ML-DSA credential ID signs with a role-0 ECDSA key no RP holds (upstream behaviour). The transport check itself is mocked in every suite: only the logic after it is tested. The host script now fails on an attestation other than packed ES256 and says whether the certificate is the test or the production one. .bss and stack unchanged.
  • Not run on a device: no Stax was connected; nothing of this was loaded or shown on a real device. Signing time, stack headroom on the device, browsers with -48 and a node accepting the result are open.

2026-10-25: first post-quantum passkey on a real Ledger Stax

  • What ran, on the owner's Stax (USB, Security Key app of #109 side-loaded with security-key-app/carbs/load_stax.sh, application hash 8d68de44…, the same as the build): security-key-app/carbs/mldsa_passkey_test.py make (python-fido2 1.2.0, dilithium-py 1.5.1). getInfo lists algorithms -7, -8, -47 and -48. makeCredential with alg -48, approved on the device: a 64-byte credential ID and a 1,312-byte ML-DSA-44 public key in the AKP COSE key; the packed ES256 attestation verifies (the app's TEST attestation key, public in its repository: not proof of a genuine Ledger). getAssertion, approved on the device: the 2,420-byte ML-DSA-44 signature over authenticatorData || SHA-256(clientDataJSON) verifies on the host (pure ML-DSA-44, empty context). Both device operations, approvals included, took 24 s in total. The challenge was the script's test string, not yet an XRPL transaction.
  • Fixed: load_stax.sh expanded an empty array under set -u, which macOS's bash 3.2 refuses ("unbound variable"); it now uses ${extra[@]+…}.
  • Next: the same assertion over an XRPL transaction's signing data, submitted to a node with PasskeyMLDSA (#113).

2026-10-25: post-quantum passkeys, the PasskeyMLDSA amendment

  • What: a new amendment, PasskeyMLDSA (Supported::Yes, DefaultNo, effective only with Passkey and Quantum: isPasskeyMLDSAEnabled), enabled in infra/standalone.cfg. PasskeyListSet accepts ML-DSA-44 public keys (1 312 bytes, raw) next to P-256 ones; a PasskeySignature next to an ML-DSA-44 SigningPubKey is checked as a P-256 one is (webauthn.get, the challenge equal to the signing data as bytes, whatever fixPasskeyChallenge says) with a pure ML-DSA-44 signature, empty context, over authenticatorData ‖ SHA-256(clientDataJSON) (COSE -48). A TxnSignature next to it is always refused. Such a signature authorizes only through a passkey list entry that holds the key under the same PasskeyID (Transactor::checkMLDSAPasskey; tefBAD_AUTH otherwise, also for a master key, RegularKey or session key signing through WebAuthn), so a relay cannot change its PasskeyID and the transaction's ID, which it still can on the P-256 path. Until activation, no signature verdict on such a transaction is cached (isSignatureVerdictCacheable). The jtx WebAuthn authenticator signs with ML-DSA-44 keys too, and can alter AuthenticatorData. Spec: docs/XLS-PQ-Passkeys.md.
  • Why: a passkey has full authority, and in the alphanet it is always P-256 with its public key on the ledger from registration: on any account that has one, it is a quantum backdoor that moving the other keys to ML-DSA-44 does not close.
  • Prior art read first (read only, nothing posted): XLS-83d and XLS-84d (Ripple, 2024, closed as stale), XLS-0103 and the alphanet Quantum amendment (Transia-RnD/rippled#27), the alphanet Passkey amendment (Transia-RnD/rippled#29) and its open review comments. The key and signature format is the one of Quantum and RFC 9964; the PasskeyID binding follows XLS-84d's lookup of the credential in the list; Quantum is required explicitly because the imported tree does not gate ordinary ML-DSA-44 signatures on it.
  • SIMULATED: every WebAuthn result comes from the software authenticator (rippled/src/test/jtx/webauthn.h): no device, no browser. Whether any browser or authenticator creates an alg: -48 credential is UNVERIFIED.
  • Run (Mac, Debug, arm64, on 00755b0):
    • Failing first, with the amendment defined and the tests written but no behaviour: PasskeyMLDSA 16 cases, 801 tests, 61 failures (lists refused with temMALFORMED, WebAuthn payments refused with "Invalid signature.", and the second submission of a refused blob answered "Transaction has bad signature.", a cached verdict).
    • Mutations: without the cache rule of isSignatureVerdictCacheable, 12 failures; with the PasskeyID comparison removed, 3; without the list-only rule in checkSign, 5; without the session-path refusal, 2.
    • With the change: PasskeyMLDSA 17 cases, 865 tests, 0 failures. PasskeyMLDSA,PasskeyWebAuthn,PasskeyListSet,PassKey,PasskeyRelay,P256StrictDer,SigningSessions,SigningSessionsBatch,STTx,Batch,InvariantsSigningSession,KeyRotationBatch,MultiSign,Delegate,Feature,ServerDefinitions,TxQPosNegFlows,TxQMetaInfo: 18 suites, 289 cases, 32,206 tests, 1 failure, the base Delegate counter. Full suite (--unittest-jobs=8): 288 suites, 5,468 cases, 1,318,716 tests, 203 failures: the 201 that repeat in docs/base-failures.txt (AMMBinned 152, AMMCurves 41, InvariantsVault 2, ValidatorListExpiration 2, ValidatorList_Dilithium 2, Delegate 1, MPToken 1), SignatureVerification 1, and ValidatorSite, which did not complete (counted as one); ValidatorSite,SignatureVerification alone: 87 cases, 0 failures. xrpl_tests: 1146 passed, 1 failed, the base VaultTests.BuilderFromSleRoundTrip. clang-format 22.1.8 and pnpm format:check clean.
  • Not done: the TypeScript client (another agent implements it against docs/XLS-PQ-Passkeys.md §6); a relay test over the overlay for this case (the condition is the one PeerImp already reads, covered by PasskeyRelay for P-256); P6 (the list can be neither replaced nor removed), PasskeyListSet being Delegable and its missing reserve check, all Passkey defects that ML-DSA-44 passkeys inherit; binding the P-256 PasskeyID.
  • After the review gate of #113 (approve with nits, no hole found):
    • isPasskeyMLDSAEnabled also requires fixP256StrictDer: before it no verdict on a transaction with a PasskeySignature is ever cached, so every ML-DSA-44 passkey transaction would have been verified again on every checkValidity call. The TxnSignature refusal next to an ML-DSA-44 passkey is now the fixP256StrictDer rule. The runs without fixP256StrictDer are gone; the cache test now crosses fixP256StrictDer with PasskeyMLDSA already voted.

    • A fixed vector from an independent implementation: key, AuthenticatorData, ClientDataJSON and signature made by @noble/post-quantum 0.7.1 over the signing data that ripple-binary-codec 2.11.0 encodes (generator packages/core/spikes/passkey-mldsa-vector.ts, hex committed in the test). The node's signing data equals the codec's, the node verifies the assertion as it is, refuses it without the amendment, and refuses it with one bit changed in the signature (first and last byte) or in AuthenticatorData.

    • The binding on the other top-level paths: a Delegate signing with its own ML-DSA-44 passkey, an outer Batch signed by the account's passkey, a sponsored transaction with the account's passkey at the top level; another PasskeyID gives tefBAD_AUTH in each, and the delegate cannot use the delegator's passkey. A key that is already a session key when added to the list signs through WebAuthn only once the session is deleted.

    • Not changed, documented: no cap on the number of ML-DSA-44 entries (docs/XLS-PQ-Passkeys.md §8). The getSignature comment says it is not gated and off the consensus path.

    • Run: PasskeyMLDSA 18 cases, 957 tests, 0 failures. Mutations, each restored afterwards:

      Gate removed Failures Cases that catch it
      the verdict-cache rule (isSignatureVerdictCacheable) 12 testSignatureBeforeAmendment, testActivation, testCacheableCondition, testCacheAcrossActivation, testNetworkOPsDoesNotMarkBad
      the PasskeyID comparison (checkMLDSAPasskey) 8 testPasskeyIdBound, testOtherSigningPaths
      the list-only rule (Transactor::checkSign) 10 testPasskeyIdBound, testOtherSigningPaths
      the session-path refusal (checkSession) 4 testSession, testSessionKeyAddedToList
      the byte comparison of the challenge on the ML-DSA-44 path 2 testChallengeAsBytes
      the amendment gate of PasskeyListSet 9 testListSet
      the dependencies in isPasskeyMLDSAEnabled (PasskeyMLDSA alone) 45 testListSet, testSignatureBeforeAmendment, testCacheableCondition, testCacheAcrossActivation

      Neighbouring suites (the list above plus Sponsor*): 21 suites, 347 cases, 41,421 tests, 1 failure, the base Delegate counter. xrpl_tests: 1146 passed, the base VaultTests failure. clang-format 22.1.8, pnpm format:check and the @carbs/sessions typecheck clean.

2026-10-25: web app, one Node choice in the header

  • Why: the owner found the header of the hosted site confusing: a Backend select (Simulated / Live (local node)), a separate Network select (AlphaNet / Local node), a URL field with a Use button, warnings, and a SIMULATED badge that stayed when "Local node" was picked in the Network select, because that select never changed the backend. On the https site the live backend also refused ws://localhost:6006 as mixed content, although browsers allow the loopback.
  • What: one select, Node: Simulated (default), Local node (on your machine), Team node (the team's server) (components/ui/NodePicker.vue, lib/node-choice.ts, saved as carbs-node; the former carbs-backend and carbs-network keys are ignored, so every visitor starts on the simulation again). Picking a live node opens a dialog (NodeDialog.vue): for the local node, the command (XRPLD_BIN=/path/to/xrpld GENESIS_SEED=… pnpm demo --no-web, plus --web-origin <page origin> when the page is not the local dev server), a link to docs/BUILDING.md, the URLs (ws://localhost:6006, faucet http://localhost:3005/accounts), that the defaults need no typing, a collapsed custom URL; for the team node, its preset (NUXT_PUBLIC_TEAM_NODE_URL, NUXT_PUBLIC_TEAM_FAUCET_URL) and a note on the tailnet and on browsers whose own secure DNS cannot resolve *.ts.net. Connect asks the node for its definitions (SigningSessions names required) and its last validated ledger (lib/node-probe.ts) and shows "Connected · ledger N" or the exact reason (timeout after 8 s, cannot connect to …, does not serve SigningSessions), with Retry; on success the choice is saved and the page reloads on that node. A saved node that does not answer at a later load leaves the page on the simulation, the header says why once (12 s) and the saved choice is forgotten. The Quantum screen keeps its own AlphaNet / Mainnet choice. NUXT_PUBLIC_BACKEND now takes local or team (live still means local); pnpm demo sets local.
  • URL rule: checkNodeUrl accepts ws:// and http:// on localhost, 127.0.0.1 and [::1] from an https page and still refuses them on any other host (127.0.0.2, localhost.example.org, a LAN or tailnet name).
  • CSP: the team node's WebSocket and faucet origins (wss://debian-docker.tail8b5861.ts.net:9448, https://debian-docker.tail8b5861.ts.net:9449) are in connect-src, built from lib/networks.ts; no wildcard, no bare scheme. vercel.json rewritten with UPDATE_HOST_FILES=1, docs/DEPLOY.md carries the same policy. The hosted build no longer needs NUXT_PUBLIC_LOCAL_* pointed at the team's server (previous entry): Local node and Team node are separate now. The Apps links are still navigation only; their origin is in the policy because of the team faucet.
  • Dev faucet: pnpm demo --web-origin <origin> (repeatable, a plain http(s) origin only) adds a page the faucet answers, and the faucet answers Chrome's Private Network Access preflight (access-control-allow-private-network: true) for listed origins only. parseArgs moved to scripts/src/args.ts so it is tested.
  • SIMULATED badge: audited on every screen. The header badge shows only while the simulation is in effect (HeaderStatus.vue). The screens already keyed their badges on the backend in effect and the device; one wrong claim was fixed: the wallet entry called the Demo account's account SIMULATED on a live node, where it is a real funded account (only its key is thrown away); it is now SIMULATED on the simulation only. The wallet's reported network reads "Simulated node (SIMULATED)" on the simulation instead of "AlphaNet (SIMULATED)".
  • Found in the browser: the header's backdrop-filter made the wallet dialog (and would have made the node dialog) a fixed box as small as the header bar, so "Connect a wallet" showed only its title row. The blur now sits on a ::before layer behind the bar, and the bar is above the page (z-index), so the dialogs cover the whole page again.
  • Checked: pnpm format:check, pnpm typecheck, pnpm test (web 37 files and 288 tests, scripts 11), pnpm --filter web generate and check-static (clean). Unit tests: networks.test.ts (loopback accepted from https, other plain hosts refused, team presets and their connect-src origins), network-store.test.ts, node-choice.test.ts, node-probe.test.ts (ledger index, missing amendment, unreachable, timeout, the socket closed after), boot.test.ts (local, team, refusals, ws://localhost from https), the faucet's --web-origin and preflight. Component tests (NodePicker.test.ts): one select with three choices and no URL field, the local dialog's command and URLs, connect then save then reload, the failure reason then Retry, the team dialog's preset and note, custom URL checked before any request, Escape gives the focus back, going back to the simulation, the live URL reopening its dialog, the refusal shown once then gone, the header badge on the simulation, after a refusal and not on a live node. In Chrome against the static build served on http://127.0.0.1:4173 (an http page, on the team's tailnet): Team node > Connect answered, the page reloaded on the team node (ledger 2048, no SIMULATED badge in the header or on the Account screen, the Stax device picker still badged); the wallet entry showed SIMULATED for the Stax only and none for the passkey and the demo account; Local node > Connect with no node running said "Could not connect: cannot connect to ws://localhost:6006"; a saved Local node with no node gave the header notice and the badge, and cleared carbs-node.
  • Not checked: the hosted https page against ws://localhost:6006 in a real browser (the rule is unit-tested; Chrome's local-network permission prompt was not seen), the dev faucet called from the hosted origin with --web-origin against a running node, the team faucet from the hosted origin, and any account created or transaction sent on the team node from this screen. Clicks on the top bar's segmented control sent by the browser automation did not navigate (the cards did); not investigated, and not changed here.
  • After the review gate: the timeouts left sockets connecting. WebSocketClient (packages/core/src/node.ts) kept the socket only once it opened, so close() did nothing while it connected, and the page load never closed a refused client. The client now keeps the socket from its creation, shares one connection among the requests that wait for it, fails it on close(), and closes a socket that opens after close(); chooseBackend closes the client when it refuses the node. Proved with the real WebSocketClient against a server that accepts TCP and never answers the handshake (packages/core/src/node.test.ts, apps/web/lib/node-sockets.test.ts: the TCP connection ends after the probe's and the page load's timeout) and a socket double that opens late; with the closing removed, those tests fail. Also: the simulated wallet reports a network simulated with no URL instead of the local node's (xrpl-connect checks the id, api.test.ts caught it); the wallet server answers the Private Network Access preflight for its listed origins only (server/faucet-cors.ts, tested), since a browser on the public site reaches it on a tailnet address (100.64.0.0/10), not checked from the hosted site; defaultNetwork (unused) is gone; the loopback rule is stated for Chrome and Firefox (Safari not checked); docs/DEPLOY.md warns that NUXT_PUBLIC_BACKEND=team makes each load wait up to 8 s while the team node is down. In Chrome, the Demo account still connects on the simulation.

2026-10-25: the client side of post-quantum passkeys (PasskeyMLDSA)

  • What: the client side of PR #113, which verifies ML-DSA-44 WebAuthn passkeys (COSE -48) in rippled under PasskeyMLDSA.

    • @carbs/sessions: keys/pq-passkey.ts covers registration (browserCreateMlDsaPasskey, mlDsaPasskeyFromAttestation over a new CBOR subset, cbor.ts) and signing (signWithMlDsaPasskey, a raw 2,420-byte PasskeySignature.Signature, with the node's stateless checks run first in mlDsaAssertionProblem). It also has SoftwareMlDsaAuthenticator (SIMULATED). signWithPasskey routes a 1,312-byte credential to the new path, and P-256 is unchanged. buildPasskeyListSet now refuses a key that is neither P-256 nor ML-DSA-44, and an empty credential id. browserAuthenticator takes optional transports and a timeout.
    • Passkey wallet: a section, "Passkey post-quantique (Ledger Stax)", creates a -48 credential on a cross-platform authenticator, registers it, pays, opens a session and locks the account. It lists each node answer. scripts/pq-passkey-demo.ts runs the same flow, from the same module (src/pq-webauthn.ts), with the software authenticator.
  • Simulated: every WebAuthn assertion in the tests and in the live run below comes from the software authenticator, with no device and no browser ceremony. The node's answers are real.

  • Unit tests (cbor.test.ts, keys/pq-passkey.test.ts, 17 new cases), covering:

    • the RFC 8949 vectors;
    • the AKP header bytes A3 01 07 03 38 2F 20 59 05 20;
    • packed (a synthetic ES256 statement, the shape PR #109's Stax returns) and none attestations;
    • a challenge equal to STX\0 ‖ fields (1.4 KB, base64url without padding);
    • the signature verified with @noble/post-quantum, failing with a context, over the bare signing data, or with another key;
    • a PasskeyID equal to the listed id;
    • nine assertions the client refuses before the node would.

    Four mutations (the alg check, the rawId check, the challenge comparison, the PasskeyID) each failed one test.

  • Live run (Mac, standalone): the xrpld built in the fianso/pq-passkey worktree (head 48ca687, xrpld version 3.4.1+DEBUG), with a temporary copy of that branch's infra/standalone.cfg (paths moved to a scratch directory), infra/closer.ts, and the standalone genesis seed. feature gave PasskeyMLDSA enabled: true. pnpm --filter @carbs/passkey-wallet pq-passkey-demo returned 14 node answers, all as expected:

    Step Node answer
    PasskeyListSet (master key) tesSUCCESS; account_objects holds the 1,312-byte key
    Payment signed by the passkey tesSUCCESS (see below)
    PasskeyID of another credential tefBAD_AUTH ("Transaction's public key is not authorized.")
    One signature bit flipped invalidTransaction ("fails local checks: Invalid signature.")
    Valid assertion of an unregistered ML-DSA-44 passkey tefBAD_AUTH
    SigningSessionSet mode C (passkey) tesSUCCESS
    Payment by the P-256 session key tesSUCCESS
    SigningSessionDelete (passkey) tesSUCCESS
    SetRegularKey to the black hole, then asfDisableMaster tesSUCCESS each
    Payment signed by the master key tefMASTER_DISABLED
    Payment signed by the passkey again tesSUCCESS

    The validated payment holds a SigningPubKey of 1,312 bytes, no TxnSignature, a 2,420-byte Signature, and a 1,395-byte challenge of type webauthn.get. This is the first time a node of ours accepted a WebAuthn ML-DSA-44 assertion built by @noble/post-quantum (an open point of docs/XLS-PQ-Passkeys.md §6).

  • Not done when this entry was written: nothing ran with the Stax or in a browser. The next entry records the Stax run; what it proves and what it leaves open is listed there.

2026-10-25: a real Ledger Stax signs XRPL transactions with a post-quantum passkey

  • What ran: apps/passkey-wallet/scripts/stax-pq-passkey.ts against a local standalone node built from #113 (2fe8bd8's tree; PasskeyMLDSA, Passkey, Quantum, fixP256StrictDer enabled), with the owner's Stax running the Security Key app of #109 (application hash 8d68de44…). The credential is the one made on the device earlier (mldsa_passkey_test.py make: 1,312-byte ML-DSA-44 key, COSE AKP alg -48). Each assertion is a real CTAP2 getAssertion approved on the device, with the XRPL signing data as challenge; python-fido2 stands in for the browser (the Chrome ceremony is still untested). python-fido2, not the Device Management Kit: the Security Key app speaks FIDO2 (CTAP2 over CTAPHID), which is not Ledger app access; the DMK drives Ledger apps over their APDUs, not FIDO.
  • Node answers: PasskeyListSet with the Stax key (master key) tesSUCCESS (DE46B0D8…F3EC); a 5 XRP Payment signed by the Stax passkey tesSUCCESS (D7AE42A4…6319), on the ledger with a 1,312-byte SigningPubKey, no TxnSignature and a 2,420-byte PasskeySignature.Signature, challenge 1,395 bytes; a mode C SigningSessionSet signed by the Stax passkey tesSUCCESS (E4BBB04F…2B90), challenge 1,403 bytes.
  • Timings on the device: 6.2 s and 4.2 s per assertion, approval included.
  • What this proves: real CTAP2 ML-DSA-44 assertions from the Stax are accepted by a node with PasskeyMLDSA (registration, payment, session opening), and the client's own check (mlDsaAssertionProblem, which signWithMlDsaPasskey runs before returning) passed on those real signatures.
  • What it does not prove:
    • the browser ceremony: Chrome forwarding alg: -48 to the Stax over USB, accepting the AKP credential, and passing a 1.4 KB challenge (the wallet section is still untested in a browser);
    • the TypeScript registration parser (mlDsaPasskeyFromAttestation, the CBOR and COSE code) has never read real device bytes: the Stax credential came from mldsa_passkey_test.py, which python-fido2 parsed. mldsa_passkey_test.py make --dump-attestation packages/core/src/keys/fixtures/stax-attestation.json now records the device's attestationObject and rawId, and keys/pq-passkey.test.ts parses that file when it exists (skipped, with a message, until it is recorded).

After the review gate of #116

  • Error labels in the wallet section: "Cette étape n'a rien envoyé au nœud" only for errors thrown before the step's submit (the page's own checks, a DOMException from WebAuthn or WebCrypto, the library's KeyError, EncodeError and session builder errors). Any other error (the faucet, the connection, submitAndWait while it polls) reads "État inconnu : … vérifiez le hash ou le compte sur le nœud". NotSupportedError and NotAllowedError from WebAuthn are shown as "-48 non pris en charge par ce navigateur ou cet authentificateur, ou cérémonie annulée ou expirée", with the raw name and message.
  • Master seed in clear: the section says "Nœud de test uniquement, graine en clair" and refuses to create an account unless the node is localhost, 127.0.0.1, [::1] or a host listed in VITE_TEST_NODE_HOSTS. Locking asks for confirmation; forgetting an unlocked account warns that the seed is lost, and says the PasskeyID and key can be read back from account_objects.
  • Library:
    • verify: false and passkeyId moved to signWithMlDsaPasskeyForTests (@carbs/sessions/testing); signWithMlDsaPasskey always checks and always writes the credential's own id.
    • CBOR tags are refused; non-canonical lengths are read as written, documented.
    • Credential ids above 1,023 bytes are refused (WebAuthn's limit); a CBOR error in the extensions is a KeyError.
    • browserCreateMlDsaPasskey takes excludeCredentialIds.
    • The mlDsaAssertionProblem docstring says it is a mirror of the node's checks, not the node.
  • Tests added: the depth limit (16 accepted, 17 refused), huge declared lengths (2^53 − 1 and 2^32 − 1 arrays, maps and strings refused in under 100 ms), non-canonical lengths read, tags refused; the UP-flag refusal, empty and 1,024-byte credential ids (1,023 accepted), unreadable extensions and COSE key as KeyError; the public signWithMlDsaPasskey writes the credential's own id; the fixture test above.
  • SIMULATED interop check of the dump: dump_attestation fed with a python-fido2 encoding of a -48 registration (dilithium-py key, synthetic packed statement, no device) wrote a file that the fixture test parsed, with the same key as python-fido2's reading; the temporary fixture was then deleted.
  • stax-pq-passkey.ts: each expected result name is checked with defs.resultCode, and its temporary directory is deleted at the end.

2026-10-25: the TypeScript registration parser reads a real Stax registration

  • Recorded on the owner's Stax: mldsa_passkey_test.py make --dump-attestation packages/core/src/keys/fixtures/stax-attestation.json (a fresh ML-DSA-44 credential, approved on the device; the device's authData kept byte for byte, fmt and attStmt re-encoded by python-fido2 as a browser re-encodes them). The fixture holds only public data: the 64-byte credential id, the 2,060-byte attestationObject and python-fido2's reading of the 1,312-byte key.
  • Result: keys/pq-passkey.test.ts now runs the fixture test instead of skipping it: parseAttestationObject reads fmt: packed, and mlDsaPasskeyFromAttestation returns the same key and credential id as python-fido2 (core: 126 tests, none skipped). This closes the "parser never read device bytes" gap of the entry above; the Chrome ceremony is still untested.
  • Fixed: --dump-attestation now creates the file's directory; the first recording on the device failed after the approval because fixtures/ did not exist yet (one approval lost).

2026-10-25: PasskeyListSet is no longer delegable (fixPasskeyListNotDelegable)

  • Defect (P7, open review comment on Transia-RnD/rippled#29, not reported upstream by us): PasskeyListSet is Delegable (rippled/include/xrpl/protocol/detail/transactions.macro:1354-1358). An account that grants a delegate the PasskeyListSet permission lets it add its own key to the account's passkey list, and a passkey has full authority over the account.
  • Failing first (Mac, Debug, the amendment declared but with no effect yet): new cases PasskeyListSet "a delegate cannot register its own passkey for the account" (P-256) and PasskeyMLDSA "a delegate cannot register its own ML-DSA-44 passkey for the account". alice grants bob PasskeyListSet; bob submits PasskeyListSet for alice with Delegate and his own key; then a Payment from alice signed by that key, raw and through WebAuthn (software authenticator, SIMULATED). On the old rules every step is tesSUCCESS and carol receives 200 XRP from alice. The half that expects the fix failed: PasskeyListSet 15 failures (DelegateSet and the delegated PasskeyListSet tesSUCCESS where temMALFORMED and temINVALID were expected, the payments tesSUCCESS where tefBAD_AUTH was expected, and alice's own list then tefEXCEPTION because of P6), PasskeyMLDSA 6 failures.
  • Fix: fixPasskeyListNotDelegable (Supported::Yes, DefaultNo, ID 2A769EFF52FBAC63FA14CD4AC3AEB250B8085B82EA5B49E4804758F8FAD3C988, added to infra/standalone.cfg, 122 amendments). Permission::isDelegable answers false for PasskeyListSet once it is enabled (rippled/src/libxrpl/protocol/Permissions.cpp:250-254). Upstream refuses non-delegable types in two places and both follow: DelegateSet granting the permission temMALFORMED (rippled/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp:42-43) and a transaction with Delegate temINVALID in preflight1 (rippled/src/libxrpl/tx/Transactor.cpp:258-260). There is no granular permission for the type (rippled/include/xrpl/protocol/detail/permissions.macro). The macro keeps Delegable so the rules before activation do not change; a test enables the amendment over a Delegate entry granted before it: the delegated PasskeyListSet is refused, the entry's Payment permission still works.
  • Signing sessions: kSessionExcluded already lists PasskeyListSet (rippled/include/xrpl/tx/SigningSession.h:26-32), so no session could sign it before the fix either. Case 9b asserted that every excluded type is delegable; it now expects PasskeyListSet delegable only without the fix, and checks that it is still excluded. That is the one change to our own amendment's tests, in the same commit because the fix changes what it asserts.
  • Results: PasskeyListSet 12 cases, 384 tests; PassKey 3 cases, 65 tests; PasskeyWebAuthn 9 cases, 277 tests; PasskeyMLDSA 19 cases, 1,038 tests; SigningSessions with SigningSessionsBatch 56 cases, 3,955 tests; all 0 failures. Delegate 27 cases, 6,093 tests, 1 failure, the base counter (71 counted, 60 pinned), unchanged because the counter reads the macro at compile time. Feature, AmendmentTable, ServerDefinitions: 0 failures. Mutation: the check in isDelegable disabled, 22 failures (PasskeyListSet 15, PasskeyMLDSA 6, SigningSessions 1). clang-format 22.1.8 on the changed lines and pnpm format:check clean.
  • Not done: the reserve check of SetPasskeyList and P6 stay open; no upstream pull request or comment (D7).

2026-10-25: the ML-DSA root key was the zero-seed key (SLIP-21 seed fix)

  • Defect. derive_keypair (ledger-app/src/pq/pq_root.c) passed a zeroed 64-byte buffer to os_derive_bip32_with_seed_no_throw(HDW_SLIP21, …) and used bytes 32 to 64 as the ML-DSA seed. In HDW_SLIP21 mode the OS writes only the 32-byte SLIP-21 key, at the start of the buffer (Speculos src/bolos/os_bip32.c, hdw_slip21: memcpy(private_key, node + 32, 32)), so bytes 32 to 64 stayed zero and every device, whatever its 24 words, derived ML-DSA-44.KeyGen_internal(32 zero bytes). The unit-test double filled all 64 bytes, which hid it.
  • What it means for the AlphaNet proofs. The real Stax's ML-DSA account rDX7hJiDtUVUnN2tjRqswtuZcbE88yi9i2 is exactly the address of @noble/post-quantum 0.7.1 ml_dsa44.keygen(new Uint8Array(32)) (SHA-256 of the public key EB4E7302…08E1). The accounts rGcT27HiGhoJWYRxqg9jcnPboGD4RGuLLm and rpxMtodcdjFGpssK5k7D4CixifFBCLS7eV (and the session reviewed for rDX7h… above) therefore use a key whose secret anyone can compute: they are test accounts with no value, and must not be read as protected by a hardware-held secret. The signatures were really made on the Stax and checked with @noble/post-quantum, so they still prove the review and signing path. The earlier observation "the SLIP-21 key is stable across reinstalls" was the symptom: a constant key is stable too. The spec's recoverability check (§7.7, step 3: derived twice, including after a reinstall) cannot catch a constant key; two different seeds must give two different keys, which is now tested on Speculos. The spike account rs3drH… came from another app and derivation and is not covered here. The entries above are kept, with a correction marker.
  • Failing first (cmocka, ledger-app-builder-lite + libssl-dev): the double now writes exactly 32 bytes at the start of the buffer, as the OS does, and the keygen double makes the public key a function of the seed. On the old code test_public_key_chunks, test_key_is_derived_from_the_slip21_key ("1312 bytes … the same" as the zero-seed key) and test_zero_slip21_output_is_refused (0x9000 instead of 0x6F00) fail: 19 of 22 pass.
  • Fix. The seed is the first 32 bytes the OS writes (the buffer handed to the OS stays 64 bytes, as raw_privkey[static 64] in os_seed.h asks); an all-zero output is refused with 0x6F00 for INS 0x10 and for signing; the buffer is wiped, and the key buffers too on failure. The SLIP-21 label is aligned(4) (the OS takes it as const unsigned int *), and the SDK's cx_mldsa_internal.h replaces the hand-written prototype of MLDSA_internal_keygen (a test double of the header joins unit-tests/include_pq/). Result: test_pq_root 22 of 22, and test_printers, test_swap, test_tx, test_policy unchanged.
  • Speculos (simulated device, Stax model): with the old build, the default mnemonic and abandon … art both return the zero-seed key EB4E7302…; with the fixed build they return DDDC0737… (rsr2Kobu5srdxbFTFhWnmTR5PPDZoZtTUi, the vector of docs/device-contract.md §4, now verified on Speculos) and 2CCB8F5A… (rL9z2woZeC6vBBQeRRVLd11soKg5CBJRDr), as slip21.ts and @noble/post-quantum compute them. ledger-app/carbs/pq_key_check.py --speculos gave the same answer.
  • Built for the Stax in ledger-app-builder-lite (image 3561f39cb806), no new warning: bin/app.hex sha256 d6ba36dddc01861c…, application hash 6988de51e15297c8… (this commit alone; the PR's final hashes are in its description).
  • Not done: nothing was loaded on the Stax. The owner loads it (ledger-app/carbs/load_stax.sh, which runs the ledgerblue.loadApp command make load prints) and runs ledger-app/carbs/pq_key_check.py, which must not answer "ZERO-SEED KEY". The new key is a new account: nothing on the AlphaNet moves to it automatically.

2026-10-25: the demo and pitch videos

  • What was made: a demo video (3:01, 16:9, no sound) and a pitch video (2:52, vertical, narrated, word-synced captions), written as code with Remotion outside this repository and published on YouTube (demo, pitch); the README links them. Both use the web app's tokens and fonts, and the Ledger look on every Ledger scene.
  • Real: every code line on screen, with its line number (85 lines in 9 excerpts, each compared with b4c5b76, no mismatch); the live-run output, copied from the 24 October entry; the AlphaNet ML-DSA-44 payments; the numbers, from the README and this log.
  • Corrected after #120: a first render of the demo, made before #120, read "SLIP-21 from the 24 words: the same key after a reinstall". The entry above shows that the old build derived the zero-seed key on every device, so that stability was the symptom. The demo was rendered again: the line now reads "seed from the 24 words (fixed in #120)", the AlphaNet panel says the payments were signed with the old build's zero-seed test key (they prove the review and signing path, not a protected secret), and the pq_root.c excerpt shows its new lines (150 to 168, 135 to 153 before #120).
  • Emulated or synthetic: every Stax screen is a Ragger snapshot on the Speculos emulator, captioned "Speculos emulator, same app code"; no footage of the real Stax is used. The narration is text-to-speech (fish.audio, model Drama 3) from a voice cloned from Sofiane's; each take was transcribed with whisper and checked against the script before it was kept.

2026-10-25: audit of the ML-DSA path, the items after the seed fix

  • An approval after an instruction change signed bytes the user never saw (defence in depth). Whether an ML-DSA review was pending lived in tmp_ctx (PQ->reviewing). After another instruction, tmp_ctx belongs to that instruction, and the classical INS 0x04 buffers up to MAX_RAW_TX raw bytes over all of it without parsing them. Bytes laid out as a pending review of other signing data (flag, message, length, its SHA-256) were signed with ML-DSA when the user approved the transaction still on screen. Failing first: test_stale_approval_ignores_a_forged_review (signatures 1 instead of 0). What a host could get this way is limited: the first bytes of the signed message are the classical context's curve and path, never STX\0, so the signature is not a valid XRPL transaction signature; it is still a signature over bytes nobody reviewed. Fix: the pending flag lives outside tmp_ctx (pq_review_pending), and reset_transaction_context() clears it (pq_cancel_review), which is how an instruction change cancels the review.
  • The stale approval wrote into the other instruction's memory and said "Transaction signed". It now reads and writes nothing, answers nothing, and calls review_menu_request_cancelled(), so the status page shows Request cancelled (review_menu_nbgl.c). Failing first: test_stale_approval_writes_nothing (2 bytes of tmp_ctx changed). The status page itself is not unit-tested (the review screens are doubles in test_pq_root) and was not shown on Speculos or on the Stax.
  • INS 0x12 could return an earlier signature. A new request that started with P1 = 1, or had a refused P1/P2, left the previous signature readable. Every INS 0x11 that is not refused for a review on screen now wipes it, as do the reject, mismatch and error paths; a next chunk with nothing before it is refused with 0x6A80, as on the classical path. Failing first: test_new_request_drops_the_old_signature (0x9000 instead of 0x6982) and test_next_chunk_needs_a_first_chunk (no answer instead of 0x6A80).
  • "Profile: " was shown with the policy off. The row claimed limits that nobody checked (session_policy_check returns true at once when the policy is off). session_policy_check_transaction now removes it unless the policy is on and the session fits its profile, on both paths. This reverses the earlier choice recorded in test_profile_vectors ("the review still names the profile"), whose assertion is changed accordingly; failing first: test_profile_vectors (row at index 4 instead of absent). The Ragger test with the policy off gets its own snapshots (04-profile-game-policy-off, 07-profile-trading-policy-off), recorded on Speculos (Stax model) only: the Profile row is gone from the third page. Flex, Apex P, Nano X and Nano S Plus snapshots of that test were not recorded and must be golden-run before the suite passes there.
  • Nits: the SDK's status_words.h (SWO_*) instead of hand-copied status words (a test double of the header joins include_pq/, and test_pq_root asserts the wire values as numbers); the ../ includes of pq_root.c and entry.c replaced by plain names (two include directories added for the test).
  • Results: cmocka in ledger-app-builder-lite + libssl-dev: test_pq_root 26/26, test_policy 23/23, test_printers, test_swap, test_tx unchanged. Ragger on Speculos, Stax only: the whole standalone suite, 144 passed, 41 skipped (the skips are the suite's own device conditions). Built for the Stax and the Nano S Plus with no new warning; Stax bin/app.hex sha256 0bad479b266bc13a…, application hash 03035f570409ec61….
  • Not done: nothing loaded on the Stax; the classical path keeps its upstream status page after a cancelled review (it still says "signed" there, sign_transaction.c); no Ragger run on the other four devices.

2026-10-25: the hosted web app sets up a Ledger account on the team's node, with the real Stax

  • What ran (recorded after the fact from the owner's notes and device photos, around 14:40 ET): the hosted site (carbs-kappa.vercel.app, build of 1df4df7), Node set to Team node, Account > Ledger account > Ledger Stax (USB, WebHID in Chrome), the owner's Stax running the XRP app before the seed fix (#120). The device's account r4MG66fUzV75ZHbSiEAXGHAxANrwQTnGh8 was funded by the team faucet, then SetRegularKey (C27FAAC491…), the ML-DSA-44 proof (4EC5199E19…) and asfDisableMaster (56B4038B7C…), each signed on the Stax and tesSUCCESS.
  • The key is the zero-seed one: with the pre-#120 build, the regular key is rDX7hJiDtUVUnN2tjRqswtuZcbE88yi9i2, the zero-seed key of the entry "the ML-DSA root key was the zero-seed key". So this run proves the web app's flow on the device, not a protected secret.
  • The owner's policy refused a session on the device: opening a session with no maximum per payment, the Stax showed its policy refusal, "No max per payment (limit XRP 5)" (the profile requires one of at most 5 XRP). The status word was not recorded.
  • A second run failed at SetRegularKey with tefMASTER_DISABLED, and the store left the other screens on "No account yet". This is the reuse defect fixed after the hackathon (next entry).
  • Reset, made possible by the defect itself: to run the flow again before the jury, the account was reset on the team's node by signing in software with the zero-seed ML-DSA-44 key, AccountSet ClearFlag 4 (D3793583…3997), then SetRegularKey with no key (F8BEAF4E…4742). Anyone could have done the same, which is why the fixed app derives the key from the device's SLIP-21 key. After the fixed app is loaded, a reset needs the device.

2026-10-25: after the hackathon: result, the maintained copy, and the demo remarks fixed

  • Result: carbs won the Ledger Protocol Innovation track ($4,000). The judged version is STOOOKEEE/carbs at the tag submission (ce42905); this copy, gamween/carbs, carries everything after it.
  • Demo remarks fixed (polish/app-fixes, 8 commits):
    • the device status words the UI showed raw get a sentence (0x650F: open the XRP app);
    • infra/standalone.cfg sets mainnet's reserves (1 XRP, 0.2 XRP per object), and the Account screen shows what the node reports;
    • a Ledger account the device already set up is recognised and reused (a second run used to fail at SetRegularKey with tefMASTER_DISABLED), with the non-secret profile kept per node across reloads;
    • the wallet dialog connects a real Stax over WebHID, and the SIMULATED badge stays on the simulated Stax only;
    • the front names the five amendments, from a list a test checks against features.macro and the cfg.
  • Full post-quantum profiles: the P-256 Mac account is gone from the web app. The Mac account now seals an ML-DSA-44 key in the browser, opened by Touch ID through the WebAuthn PRF extension (the vault of #106, moved to @carbs/sessions). That key becomes the RegularKey, and the master key is disabled after an ML-DSA-44 proof, only once the sealed key reads back from storage. Session keys are ML-DSA-44. Apple's platform authenticator cannot create an alg: -48 passkey, hence PRF. The node and @carbs/sessions keep their P-256 code.
  • Upstream (Ledger only, after the owner's go): LedgerHQ/device-sdk-ts#2052 and LedgerHQ/app-xrp#101, documentation only. The P-256 PasskeyID binding in rippled is not pursued: the base defect stays documented.
  • Checked: pnpm format:check, pnpm typecheck, pnpm test: core 132, signer kit 205, ledger 74, web 329 (plus 5 node tests), passkey wallet 7, scripts 12. The web build passes.
  • Not checked:
    • the wallet dialog's USB entry, the 0x650F message and the reuse of a set-up account ran against software doubles only, not on the Stax;
    • PRF with a real Touch ID was never run;
    • the team node keeps 10 XRP and 2 XRP until it restarts from a fresh ledger with the new cfg;
    • pnpm --filter web lint reports 3 errors in files these commits did not touch.

2026-10-26: the device blind-signs or refuses a Batch with a row outside its inner transactions

  • Found while preparing the upstream Batch pull request. That version's review checks were run against this app's parser on 226 vectors. Eight malformed Batches still clear-signed here:

    • an inner Payment without an Account;
    • fields put straight into RawTransactions, outside any RawTransaction object. An Amount of 10 XRP between the two inner transactions was shown as Amount [1], as if it belonged to the first one;
    • NFTokenOffers on the outer Batch;
    • a stray RawTransactions array placed before the top-level TransactionType. Its objects were not counted, but their fields were shown as rows of the real inner transactions: a second Amount [2] of 10 XRP.

    rippled rejects all eight, but the screen must never mislabel a row.

  • Fix (check_batch, check_batch_inner_field, handle_array_field):

    • every inner transaction names its Account;
    • every row is an outer field, an outer memo or a field of one inner transaction;
    • a RawTransactions array read before the type counts as the Batch's one array, so a second one stops the parse.

    The first two cases go to blind signing. The third is refused, like any Batch whose parsing stops early (it might hold a session).

  • Tests:

    • 32-batch/07-inner-without-account, 08-field-outside-raw-transaction and 09-outer-nftoken-offers are in batch_blind_testcases;
    • 10-raw-transactions-before-type is in session_never_blind_testcases;
    • 08 and 10 are spliced by hand and have no .json.

    Without the change, test_batch_blind_required and test_session_never_blind fail. With it, 5/5 suites pass. A native parse of the 226 vectors, before and after the change, differs on 17 malformed Batches only. The review of every valid transaction is unchanged.

  • Ragger on Speculos (Stax): 144 passed, 45 skipped, no snapshot changed. The four new vectors are skipped there, like the other Batch vectors that must not clear-sign.

  • Not done: the other four devices; not loaded on the Stax (the reload of the fixed app is still pending).