End to end: three tiers of proof, ending in a real transfer in a real block #7

Open
opened 2026-09-10 10:15:38 +00:00 by grenade · 5 comments
Owner

Part of #1. This issue is the definition of done — everything else is closed when the last tier passes.

Why this needs its own issue

Every other issue can be closed by a passing unit test while the whole remains broken. The failure modes that matter only appear end to end:

  • a signature valid under the wrong FIPS-204 context — cryptographically correct, rejected by the chain, indistinguishable locally (#4)
  • a signed extrinsic whose signature field is framed wrongly — a compact length prefix where the runtime expects a fixed array re-frames every byte after it (quantus/api#1)
  • a signed extension tuple that disagrees with the runtime's TxExtension order, which decodes into something plausible and fails at dispatch

None of those show up until a real node either accepts the extrinsic or does not.

Tier 1 — a node script, no browser

Mnemonic → derive → build payload → sign with the forked keyring → author_submitExtrinsic over WS. No extension, no dapp, no build step.

This is the fastest loop and it exists to answer one question fast: is a rejection a crypto bug or a plumbing bug? Once this submits successfully, every later failure is downstream of the crypto, which removes most of the search space. It also closes out quantus/wasm#2's vectors with something the chain has actually accepted rather than something we asserted.

Runnable before the extension builds at all.

Tier 2 — a small harness dapp

A single-page app, of the order of 200 lines: enable(), list accounts, signRaw, and build-sign-submit a transfer. Lives in this repo, next to the existing test-bundle.html.

The point is not the UI. It is that the page must display the payload hex, the returned signature hex, and the assembled extrinsic hex. When a node rejects something, that display is the debugger, and it is exactly what a full-featured app hides. A harness that only shows a success toast is worth very little here.

This is the everyday loop while working through #3, #4 and #6.

Tier 3 — quantus/papi-console

A real console, and — because polkadot-api shares no code with our five polkadot-js forks — an independent implementation. If papi and our stack both accept the same signature, that is corroboration; if only ours does, we have agreed with ourselves.

It is also much closer to working than polkadot-js/apps: papi reads the extrinsic's Address and Signature types from metadata and assembles length-agnostically, so its only blocker is a variant-name whitelist — quantus/papi-console#1, a two-line pnpm patch, with no dependency on quantus/api#1.

Not polkadot-js/apps, yet

It consumes @polkadot/api + ui-keyring + react-*, so it is blocked on the entire fork chain including quantus/api#1 — which makes it a genuine integration test of that chain, and useless until it is finished. Its payoff is breadth: Developer → Extrinsics reaches every pallet without writing any UI. Worth doing after tier 3 passes.

Note the hosted polkadot.js.org/apps will not substitute: it takes a custom WS endpoint happily, but runs stock libraries, so Quantus signing fails there.

Getting a funded account

No faucet needed. The dev genesis endows three accounts derived from trivial seeds — primitives/dilithium-crypto/src/pair.rs:

pub fn crystal_alice()   -> Dilithium87Pair { Dilithium87Pair::from_seed_slice(&[0u8; 32]) }
pub fn dilithium_bob()   -> [1u8; 32]
pub fn crystal_charlie() -> [2u8; 32]

So a local dev node plus "import from hex seed 0x0000…00" is a funded account immediately. Two caveats, both of which make this worth wiring early in #3:

  • they are ML-DSA-87, the legacy scheme, so this exercises the 87 path rather than the 65 path new accounts use
  • it is the non-HD path — a raw 32-byte seed straight into keygen, no derivation applied — which is a distinct code path from the mnemonic one

seedValidate already accepts a 256-bit hex seed, so the entry point exists.

The run, and the cases that actually prove it

  1. Local Quantus node; record its spec version — the signing context switches at 148.
  2. Create or import an account, fund it.
  3. Submit balances.transfer_keep_alive, approve in the popup.
  4. Assert: included in a block, ExtrinsicSuccess, recipient's balance moved.

Then the negatives, which are the ones that prove the plumbing rather than the happy path:

  • the same flow with the signing context forced to empty is rejected
  • a utility.batch_all — pushes the signing payload past 256 bytes so the blake2 branch in ExtrinsicPayload.sign is actually exercised
  • an ML-DSA-87 (legacy) account signs and is accepted
  • an ML-DSA-65 account signs and is accepted
  • an account exported to JSON, re-imported, and used to sign (quantus/common#3, quantus/ui#1)
  • the extrinsic hex from tier 1, tier 2 and tier 3 is identical for the same call and nonce

That last one is the cheapest cross-check available and it catches framing bugs that all three would otherwise submit happily.

Capture what it cost

Record wall-clock time to sign in the popup. ML-DSA signing is fast, but the MV3 service worker has to cold-start and parse an inlined wasm blob first (quantus/wasm#1); if that is slow enough to look broken, it is a finding worth having before users report it.

Part of #1. This issue *is* the definition of done — everything else is closed when the last tier passes. ## Why this needs its own issue Every other issue can be closed by a passing unit test while the whole remains broken. The failure modes that matter only appear end to end: - a signature valid under the wrong FIPS-204 context — cryptographically correct, rejected by the chain, indistinguishable locally (#4) - a signed extrinsic whose signature field is framed wrongly — a compact length prefix where the runtime expects a fixed array re-frames every byte after it (quantus/api#1) - a signed extension tuple that disagrees with the runtime's `TxExtension` order, which decodes into something plausible and fails at dispatch None of those show up until a real node either accepts the extrinsic or does not. ## Tier 1 — a node script, no browser Mnemonic → derive → build payload → sign with the forked keyring → `author_submitExtrinsic` over WS. No extension, no dapp, no build step. This is the fastest loop and it exists to answer one question fast: **is a rejection a crypto bug or a plumbing bug?** Once this submits successfully, every later failure is downstream of the crypto, which removes most of the search space. It also closes out quantus/wasm#2's vectors with something the chain has actually accepted rather than something we asserted. Runnable before the extension builds at all. ## Tier 2 — a small harness dapp A single-page app, of the order of 200 lines: `enable()`, list accounts, `signRaw`, and build-sign-submit a transfer. Lives in this repo, next to the existing `test-bundle.html`. The point is not the UI. It is that the page must **display the payload hex, the returned signature hex, and the assembled extrinsic hex**. When a node rejects something, that display is the debugger, and it is exactly what a full-featured app hides. A harness that only shows a success toast is worth very little here. This is the everyday loop while working through #3, #4 and #6. ## Tier 3 — [quantus/papi-console](../../../papi-console) A real console, and — because polkadot-api shares no code with our five polkadot-js forks — an **independent implementation**. If papi and our stack both accept the same signature, that is corroboration; if only ours does, we have agreed with ourselves. It is also much closer to working than polkadot-js/apps: papi reads the extrinsic's `Address` and `Signature` types from metadata and assembles length-agnostically, so its only blocker is a variant-name whitelist — quantus/papi-console#1, a two-line pnpm patch, with no dependency on quantus/api#1. ## Not polkadot-js/apps, yet It consumes `@polkadot/api` + `ui-keyring` + `react-*`, so it is blocked on the entire fork chain including quantus/api#1 — which makes it a genuine integration test of that chain, and useless until it is finished. Its payoff is breadth: Developer → Extrinsics reaches every pallet without writing any UI. Worth doing after tier 3 passes. Note the hosted polkadot.js.org/apps will not substitute: it takes a custom WS endpoint happily, but runs stock libraries, so Quantus signing fails there. ## Getting a funded account No faucet needed. The dev genesis endows three accounts derived from trivial seeds — `primitives/dilithium-crypto/src/pair.rs`: ```rust pub fn crystal_alice() -> Dilithium87Pair { Dilithium87Pair::from_seed_slice(&[0u8; 32]) } pub fn dilithium_bob() -> …[1u8; 32] pub fn crystal_charlie() -> …[2u8; 32] ``` So a local dev node plus "import from hex seed `0x0000…00`" is a funded account immediately. Two caveats, both of which make this worth wiring early in #3: - they are **ML-DSA-87**, the legacy scheme, so this exercises the 87 path rather than the 65 path new accounts use - it is the **non-HD** path — a raw 32-byte seed straight into keygen, no derivation applied — which is a distinct code path from the mnemonic one `seedValidate` already accepts a 256-bit hex seed, so the entry point exists. ## The run, and the cases that actually prove it 1. Local Quantus node; **record its spec version** — the signing context switches at 148. 2. Create or import an account, fund it. 3. Submit `balances.transfer_keep_alive`, approve in the popup. 4. Assert: included in a block, `ExtrinsicSuccess`, recipient's balance moved. Then the negatives, which are the ones that prove the plumbing rather than the happy path: - [ ] the same flow with the signing context forced to empty is **rejected** - [ ] a `utility.batch_all` — pushes the signing payload past 256 bytes so the blake2 branch in `ExtrinsicPayload.sign` is actually exercised - [ ] an ML-DSA-87 (legacy) account signs and is accepted - [ ] an ML-DSA-65 account signs and is accepted - [ ] an account exported to JSON, re-imported, and used to sign (quantus/common#3, quantus/ui#1) - [ ] the extrinsic hex from tier 1, tier 2 and tier 3 is **identical** for the same call and nonce That last one is the cheapest cross-check available and it catches framing bugs that all three would otherwise submit happily. ## Capture what it cost Record wall-clock time to sign in the popup. ML-DSA signing is fast, but the MV3 service worker has to cold-start and parse an inlined wasm blob first (quantus/wasm#1); if that is slow enough to look broken, it is a finding worth having before users report it.
grenade changed title from End to end: a dapp-initiated transfer, signed in the extension, included in a block to End to end: three tiers of proof, ending in a real transfer in a real block 2026-09-10 10:39:15 +00:00
Author
Owner

Tier 1 passes. The fork can spend.

scripts/tier1/submit.mjs, against Heisenberg (wss://a1-heisenberg.quantus.cat):

signer      qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7
chain       quantus-runtime spec 148 tx 6
genesis     0xa5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f
extrinsic   v4
nonce       2761
call        0x020300300bb607…02286bee
extra       0x00252b0000
payload     117 bytes -> signing 117
context     QUANTUS_EXTRINSIC
signature   7220 bytes, variant 0x00
extrinsic   7300 bytes
round trip  v4 signed=true {"Balances":{"transfer_keep_alive":{…}}}
accepted    0xd25bb081e4a87da6a2af79f038f6991e914d732322393096128894c0d644e806
included    block 1050475 index 1 (0xa9bfb6b910fa263b65cd00ea4a5dbfd85a3cec450bde6acb5210ac24db1eca71)
decoded     {"Balances":{"transfer_keep_alive":{"dest":{"Id":"0x300bb607…"},"value":"1000000000"}}}
signature   {"Dilithium87":{"bytes":"0xbb2123ac224cbbc91245dabef4535e9d5…
nonce       2761 -> 2762

An ML-DSA-87 key created by the forked keyring from a seed, signing a
balances.transfer_keep_alive under the QUANTUS_EXTRINSIC FIPS 204 context,
accepted by a real node and included in a real block. The chain's Verify impl
checks hash_bytes(public) == signer before it checks the signature, so
inclusion also confirms the Poseidon2 account derivation agrees with the runtime.

It only worked once @polkadot/api was out of the path

The first run through @polkadot/api was rejected:

1010: Invalid Transaction: Transaction has a bad signature

and getting there had already needed a local patch to lift @polkadot/types'
2048-byte cap on fixed arrays. The full account is on quantus/api#1; the
conclusion is that its codec can neither read nor write this chain, and the
replacement is quantus/wasm#3@quantus/codec, which drives every byte from
metadata the node produced by running Metadata_metadata against the runtime
WASM in a given block's state.

The harness now uses WsProvider purely as a JSON-RPC transport. No type in it
is decoded by polkadot-js.

What the harness proves beyond the transfer

  • Round trip before submission. The extrinsic is decoded by the same runtime
    that built it, so a bug in the codec is distinguishable from a disagreement
    with the chain before the node is involved.
  • Reading the block back. Index 0 of the containing block is a timestamp
    inherent whose preamble byte is 0x05 — bare, version 5 — next to this
    signed extrinsic's 0x84, signed, version 4, while the metadata declares
    version 4. Three numbers, all correct. @polkadot/api reads that byte as a
    version and therefore cannot decode any block of this chain at all.
  • No extension is assumed away. The harness fails loudly if the runtime
    declares a non-empty signed extension it has no value for, rather than writing
    zero bytes and signing a short payload.

Not yet covered, and deliberately

  • Mortal eras. This uses an immortal era, so CheckMortality's implicit is
    the genesis hash and there is no birth block to agree with the node about.
    Mortality is a hardening step and wants its own issue.
  • Storage reads. The harness gets its nonce from system_accountNextIndex
    rather than reading System::Account, because @quantus/codec does not do
    storage keys yet. The extension will need that for balances — also its own
    issue.
  • Fee estimation, for the same reason.

I did not isolate which part of polkadot-js's payload was wrong, because that
path is closed either way; the difference is noted on quantus/api#1.

Tier 1 closed. Tier 2 (the extension's own background signing, exercised through
the UI) is next, and #6 is now unblocked.

## Tier 1 passes. The fork can spend. `scripts/tier1/submit.mjs`, against Heisenberg (`wss://a1-heisenberg.quantus.cat`): ``` signer qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7 chain quantus-runtime spec 148 tx 6 genesis 0xa5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f extrinsic v4 nonce 2761 call 0x020300300bb607…02286bee extra 0x00252b0000 payload 117 bytes -> signing 117 context QUANTUS_EXTRINSIC signature 7220 bytes, variant 0x00 extrinsic 7300 bytes round trip v4 signed=true {"Balances":{"transfer_keep_alive":{…}}} accepted 0xd25bb081e4a87da6a2af79f038f6991e914d732322393096128894c0d644e806 included block 1050475 index 1 (0xa9bfb6b910fa263b65cd00ea4a5dbfd85a3cec450bde6acb5210ac24db1eca71) decoded {"Balances":{"transfer_keep_alive":{"dest":{"Id":"0x300bb607…"},"value":"1000000000"}}} signature {"Dilithium87":{"bytes":"0xbb2123ac224cbbc91245dabef4535e9d5… nonce 2761 -> 2762 ``` An ML-DSA-87 key created by the forked keyring from a seed, signing a `balances.transfer_keep_alive` under the `QUANTUS_EXTRINSIC` FIPS 204 context, accepted by a real node and included in a real block. The chain's `Verify` impl checks `hash_bytes(public) == signer` before it checks the signature, so inclusion also confirms the Poseidon2 account derivation agrees with the runtime. ### It only worked once `@polkadot/api` was out of the path The first run through `@polkadot/api` was rejected: ``` 1010: Invalid Transaction: Transaction has a bad signature ``` and getting there had already needed a local patch to lift `@polkadot/types`' 2048-byte cap on fixed arrays. The full account is on quantus/api#1; the conclusion is that its codec can neither read nor write this chain, and the replacement is quantus/wasm#3 — `@quantus/codec`, which drives every byte from metadata the node produced by running `Metadata_metadata` against the runtime WASM in a given block's state. The harness now uses `WsProvider` purely as a JSON-RPC transport. No type in it is decoded by polkadot-js. ### What the harness proves beyond the transfer - **Round trip before submission.** The extrinsic is decoded by the same runtime that built it, so a bug in the codec is distinguishable from a disagreement with the chain before the node is involved. - **Reading the block back.** Index 0 of the containing block is a timestamp inherent whose preamble byte is `0x05` — bare, version **5** — next to this signed extrinsic's `0x84`, signed, version 4, while the metadata declares version 4. Three numbers, all correct. `@polkadot/api` reads that byte as a version and therefore cannot decode any block of this chain at all. - **No extension is assumed away.** The harness fails loudly if the runtime declares a non-empty signed extension it has no value for, rather than writing zero bytes and signing a short payload. ### Not yet covered, and deliberately - **Mortal eras.** This uses an immortal era, so `CheckMortality`'s implicit is the genesis hash and there is no birth block to agree with the node about. Mortality is a hardening step and wants its own issue. - **Storage reads.** The harness gets its nonce from `system_accountNextIndex` rather than reading `System::Account`, because `@quantus/codec` does not do storage keys yet. The extension will need that for balances — also its own issue. - **Fee estimation**, for the same reason. I did not isolate which part of polkadot-js's payload was wrong, because that path is closed either way; the difference is noted on quantus/api#1. Tier 1 closed. Tier 2 (the extension's own background signing, exercised through the UI) is next, and #6 is now unblocked.
Author
Owner

Tier 2: built, and the extension's own signing path is now on @quantus/codec

Two pieces landed.

The extension signs its own payload now

Tier 1 proved the crypto in a script that used the forked keyring directly. The extension itself was still calling registry.createType('ExtrinsicPayload', …).sign(pair), which cannot work here for either of its halves — @polkadot/types cannot describe this chain, and ExtrinsicPayload.sign has nowhere to put a FIPS 204 context. So:

  • RequestExtrinsicSign takes a Runtime instead of a polkadot-js Registry.
  • metadataExpand builds a Runtime from rawMetadata rather than calling registry.setMetadata, which throws on this chain's metadata.
  • Extrinsic.tsx decodes the call through the runtime. Without it the approval screen falls back to raw hex for every transaction — the failure mode where somebody approves bytes nobody read to them.
  • A dapp's era arrives already SCALE-encoded; it is round-tripped against the runtime's own Era type rather than appended on trust (@quantus/codec 0.2.0).

A chain whose metadata the extension does not have is now one it refuses to sign an extrinsic for. That is a deliberate product change. Without the runtime's own description of its signed extensions, building a payload means guessing, and a signature over the wrong bytes comes back from a node as BadProof — which is also what it says about a wrong key, so the user sees a failure nobody can diagnose. The refusal rejects the dapp's promise as well as the popup's, so a page is told rather than left waiting forever.

Re-verified after the rewire: included at Heisenberg block 1050581, nonce 2762 → 2763.

The harness

scripts/tier2/yarn tier2 bundles it, the README has the run steps and the dev-account seeds. It prints the call, the extra, the payload that was signed, the signature that came back, the assembled extrinsic and the round trip decoded back out of it. Nothing in it uses @polkadot/api; the WebSocket is a JSON-RPC client that decodes nothing.

It also exercises metadata.provide, which is no longer optional given the refusal above.

What is verified, and what is not

Verified in a browser: the page loads under Firefox with the extension installed, and @quantus/codec parses Heisenberg's 101,493-byte metadata client side and reports extrinsic v4 with all twelve signed extensions, ReversibleTransactionExtension and WormholeProofRecorderExtension included.

Not verified: the injected signing round trip. Driving the extension's own UI needs a privileged browsing context that the available tooling cannot script — the first-run Welcome screen is as far as it gets — so importing an account and approving a request still wants a human at a keyboard. The background half is covered by Extension.spec.ts, which runs the full pub(extrinsic.sign)pri(signing.approve.password) path against real metadata, so what is untested is specifically the page → content script → background bridge and the popup, neither of which this work changed much.

Test changes worth knowing about

Upstream's custom user extension block was five variations on "does this agree with @polkadot/api". That comparison is meaningless now, and the userExtensions mechanism it exercised — a dapp declaring in JavaScript what an unrecognised signed extension contributes to the payload — is gone. Its absence is the point: the runtime declares its extensions and the wallet reads them, so there is nothing for a dapp to assert and no way for it to be believed. Replaced with tests against real Heisenberg metadata, committed as a fixture.

Two things that turned up while doing it:

  • The test harness was calling keyring.loadAll({ store }) with no type, so every account it made was sr25519 — it had been testing the one keypair type this fork does not target. It now passes type: 'dilithium65' as background.ts does.
  • The derivation specs' expected address, 5FP3TT3E…, is ed25519 — what upstream's harness was silently getting from the keyring default. They now say so, and there is a new test that an ML-DSA parent cannot derive at all.

84 tests pass, lint clean, the extension builds.

Still open on this issue

  • the same flow with the signing context forced to empty is rejected
  • a utility.batch_all pushing the payload past 256 bytes, so the BLAKE2b branch is exercised
  • an ML-DSA-87 (legacy) account signs and is accepted — tier 1, crystal_alice
  • an ML-DSA-65 account signs and is accepted
  • an account exported to JSON, re-imported, and used to sign
  • extrinsic hex identical across tiers 1, 2 and 3

Tier 3 (quantus/papi-console) is untouched, and #11 (mortal eras) and quantus/wasm#4 (storage reads, for balances) came out of tier 1.

## Tier 2: built, and the extension's own signing path is now on `@quantus/codec` Two pieces landed. ### The extension signs its own payload now Tier 1 proved the crypto in a script that used the forked keyring directly. The extension itself was still calling `registry.createType('ExtrinsicPayload', …).sign(pair)`, which cannot work here for either of its halves — `@polkadot/types` cannot describe this chain, and `ExtrinsicPayload.sign` has nowhere to put a FIPS 204 context. So: - `RequestExtrinsicSign` takes a `Runtime` instead of a polkadot-js `Registry`. - `metadataExpand` builds a `Runtime` from `rawMetadata` rather than calling `registry.setMetadata`, which throws on this chain's metadata. - `Extrinsic.tsx` decodes the call through the runtime. Without it the approval screen falls back to raw hex for **every** transaction — the failure mode where somebody approves bytes nobody read to them. - A dapp's `era` arrives already SCALE-encoded; it is round-tripped against the runtime's own `Era` type rather than appended on trust (`@quantus/codec` 0.2.0). **A chain whose metadata the extension does not have is now one it refuses to sign an extrinsic for.** That is a deliberate product change. Without the runtime's own description of its signed extensions, building a payload means guessing, and a signature over the wrong bytes comes back from a node as `BadProof` — which is also what it says about a wrong key, so the user sees a failure nobody can diagnose. The refusal rejects the dapp's promise as well as the popup's, so a page is told rather than left waiting forever. Re-verified after the rewire: included at Heisenberg block 1050581, nonce 2762 → 2763. ### The harness `scripts/tier2/` — `yarn tier2` bundles it, the README has the run steps and the dev-account seeds. It prints the call, the `extra`, the payload that was signed, the signature that came back, the assembled extrinsic and the round trip decoded back out of it. Nothing in it uses `@polkadot/api`; the WebSocket is a JSON-RPC client that decodes nothing. It also exercises `metadata.provide`, which is no longer optional given the refusal above. ### What is verified, and what is not Verified in a browser: the page loads under Firefox with the extension installed, and `@quantus/codec` parses Heisenberg's 101,493-byte metadata **client side** and reports extrinsic v4 with all twelve signed extensions, `ReversibleTransactionExtension` and `WormholeProofRecorderExtension` included. **Not verified: the injected signing round trip.** Driving the extension's own UI needs a privileged browsing context that the available tooling cannot script — the first-run Welcome screen is as far as it gets — so importing an account and approving a request still wants a human at a keyboard. The background half is covered by `Extension.spec.ts`, which runs the full `pub(extrinsic.sign)` → `pri(signing.approve.password)` path against real metadata, so what is untested is specifically the page → content script → background bridge and the popup, neither of which this work changed much. ### Test changes worth knowing about Upstream's `custom user extension` block was five variations on "does this agree with `@polkadot/api`". That comparison is meaningless now, and the `userExtensions` mechanism it exercised — a dapp declaring **in JavaScript** what an unrecognised signed extension contributes to the payload — is gone. Its absence is the point: the runtime declares its extensions and the wallet reads them, so there is nothing for a dapp to assert and no way for it to be believed. Replaced with tests against real Heisenberg metadata, committed as a fixture. Two things that turned up while doing it: - The test harness was calling `keyring.loadAll({ store })` with no type, so every account it made was **sr25519** — it had been testing the one keypair type this fork does not target. It now passes `type: 'dilithium65'` as `background.ts` does. - The derivation specs' expected address, `5FP3TT3E…`, is **ed25519** — what upstream's harness was silently getting from the keyring default. They now say so, and there is a new test that an ML-DSA parent cannot derive at all. 84 tests pass, lint clean, the extension builds. ### Still open on this issue - [ ] the same flow with the signing context forced to empty is rejected - [ ] a `utility.batch_all` pushing the payload past 256 bytes, so the BLAKE2b branch is exercised - [x] an ML-DSA-87 (legacy) account signs and is accepted — tier 1, crystal_alice - [ ] an ML-DSA-65 account signs and is accepted - [ ] an account exported to JSON, re-imported, and used to sign - [ ] extrinsic hex identical across tiers 1, 2 and 3 Tier 3 (quantus/papi-console) is untouched, and #11 (mortal eras) and quantus/wasm#4 (storage reads, for balances) came out of tier 1.
Author
Owner

The negative cases pass

scripts/tier1/cases.mjs, against Heisenberg at spec 148:

PASS  the empty signing context is rejected  — 1010: Invalid Transaction: Transaction has a bad signature
PASS  a payload over 256 bytes is hashed before signing  — 279 bytes -> blake2 32
PASS  a batch_all over 256 bytes dispatches  — block 1050692, holds 50000000000000
PASS  an ML-DSA-65 account signs and is accepted  — block 1050693, moved 1000000000
PASS  an account exported to JSON and re-imported still signs  — block 1050694, address preserved=true, moved 1000000000

The first is the one worth having. Every other failure mode here surfaces as a decode error somewhere; a signature under the wrong FIPS 204 context is well-formed, verifies against its own key, and is refused only by the runtime — so nothing local can tell it from a correct one. It is now pinned against a real node.

Checklist

  • the same flow with the signing context forced to empty is rejected
  • a utility.batch_all — pushes the signing payload past 256 bytes so the blake2 branch is actually exercised
  • an ML-DSA-87 (legacy) account signs and is accepted
  • an ML-DSA-65 account signs and is accepted
  • an account exported to JSON, re-imported, and used to sign
  • the extrinsic hex from tier 1, tier 2 and tier 3 is identical for the same call and nonce

The last one needs tier 3.

A mistake worth recording, because the chain was never wrong

The first run of this matrix reported two batch_all calls as reverted. They had not reverted — both had funded their target with exactly the 50 HEI they were asked for, and the accounts were sitting there holding it while the run printed holds 0.

system_accountNextIndex counts pending pool transactions. It advances the moment a node accepts a transaction, before it is in a block and before it has dispatched. Waiting on it and then reading a balance shows the state from before the dispatch. It is the right call for picking a nonce to sign with and the wrong one for "has this happened yet".

submitAndInclude now looks for the extrinsic's own bytes in the blocks that arrive, which also exercises the decoder against real chain data — every Quantus block opens with a bare-v5 timestamp inherent that @polkadot/api cannot read at all.

Two things follow from it that are worth stating separately, because they are different claims:

  • Included ≠ dispatched. batch_all is atomic, and a reverted dispatch still advances the nonce and still appears in a block. Only the balance settles it.
  • Fees are not the constraint here. payment_queryInfo puts a 5,341-byte ML-DSA-65 extrinsic at a partialFee of 8,196,927,000 — about 0.008 HEI. The Inability to pay some fees in the first run was the funding transfer not having landed yet, not the fee being large.

Supporting work

@quantus/codec gained storage addressing for this (quantus/wasm#4, now closed): storageTarget resolves a pallet and item to twox128(prefix) ‖ twox128(item) plus each map key hashed by the hasher the entry declares, and carries the Default/Optional distinction — an account nobody has funded reads as a zero balance through the first and as an error through the second. Nothing in it knows that System::Account is a Blake2_128Concat map over an AccountId32.

It also gained nested calls: a multi-field variant with named fields only accepted a positional array, so Utility.batch_all could not be encoded at all. That is what the batch_all case above is really testing on the codec side.

submit.mjs moved onto the same shared chain.mjs and now asserts the recipient's balance moved rather than printing it, which it could not do before storage reads existed.

Also of note for the runtime's own surface: this chain has Utility.batch_all but not Utility.batch or Utility.force_batch. Narrower than vanilla Substrate, and the sort of thing a hand-written decoder would never find out.

## The negative cases pass `scripts/tier1/cases.mjs`, against Heisenberg at spec 148: ``` PASS the empty signing context is rejected — 1010: Invalid Transaction: Transaction has a bad signature PASS a payload over 256 bytes is hashed before signing — 279 bytes -> blake2 32 PASS a batch_all over 256 bytes dispatches — block 1050692, holds 50000000000000 PASS an ML-DSA-65 account signs and is accepted — block 1050693, moved 1000000000 PASS an account exported to JSON and re-imported still signs — block 1050694, address preserved=true, moved 1000000000 ``` The first is the one worth having. Every other failure mode here surfaces as a decode error somewhere; a signature under the wrong FIPS 204 context is well-formed, verifies against its own key, and is refused only by the runtime — so nothing local can tell it from a correct one. It is now pinned against a real node. ### Checklist - [x] the same flow with the signing context forced to empty is **rejected** - [x] a `utility.batch_all` — pushes the signing payload past 256 bytes so the blake2 branch is actually exercised - [x] an ML-DSA-87 (legacy) account signs and is accepted - [x] an ML-DSA-65 account signs and is accepted - [x] an account exported to JSON, re-imported, and used to sign - [ ] the extrinsic hex from tier 1, tier 2 and tier 3 is identical for the same call and nonce The last one needs tier 3. ### A mistake worth recording, because the chain was never wrong The first run of this matrix reported two `batch_all` calls as reverted. They had not reverted — both had funded their target with exactly the 50 HEI they were asked for, and the accounts were sitting there holding it while the run printed `holds 0`. **`system_accountNextIndex` counts pending pool transactions.** It advances the moment a node *accepts* a transaction, before it is in a block and before it has dispatched. Waiting on it and then reading a balance shows the state from before the dispatch. It is the right call for picking a nonce to sign with and the wrong one for "has this happened yet". `submitAndInclude` now looks for the extrinsic's own bytes in the blocks that arrive, which also exercises the decoder against real chain data — every Quantus block opens with a bare-v5 timestamp inherent that `@polkadot/api` cannot read at all. Two things follow from it that are worth stating separately, because they are different claims: - **Included ≠ dispatched.** `batch_all` is atomic, and a reverted dispatch still advances the nonce and still appears in a block. Only the balance settles it. - **Fees are not the constraint here.** `payment_queryInfo` puts a 5,341-byte ML-DSA-65 extrinsic at a `partialFee` of 8,196,927,000 — about 0.008 HEI. The `Inability to pay some fees` in the first run was the funding transfer not having landed yet, not the fee being large. ### Supporting work `@quantus/codec` gained storage addressing for this (quantus/wasm#4, now closed): `storageTarget` resolves a pallet and item to `twox128(prefix) ‖ twox128(item)` plus each map key hashed by the hasher the **entry declares**, and carries the `Default`/`Optional` distinction — an account nobody has funded reads as a zero balance through the first and as an error through the second. Nothing in it knows that `System::Account` is a `Blake2_128Concat` map over an `AccountId32`. It also gained nested calls: a multi-field variant with named fields only accepted a positional array, so `Utility.batch_all` could not be encoded at all. That is what the `batch_all` case above is really testing on the codec side. `submit.mjs` moved onto the same shared `chain.mjs` and now **asserts** the recipient's balance moved rather than printing it, which it could not do before storage reads existed. Also of note for the runtime's own surface: this chain has `Utility.batch_all` but **not** `Utility.batch` or `Utility.force_batch`. Narrower than vanilla Substrate, and the sort of thing a hand-written decoder would never find out.
Author
Owner

The cross-tier comparison passes, and it is the strongest evidence here

papi assembled 7300 bytes
IDENTICAL to the tier-1 harness

createV4Tx from the patched papi console, fed the same call, nonce and signature as scripts/tier1/submit.mjs, produces the same bytes. quantus/papi-console#1 has the detail.

This matters more than the other checks on this issue. Everything else so far has been our stack agreeing with itself: @quantus/codec builds the payload, @quantus/crypto signs it, and both are ours. polkadot-api shares no code with the five polkadot-js forks — it reads the extrinsic's Address and Signature types out of the metadata and assembles length-agnostically, by an entirely separate implementation. Two independent readings of the same runtime producing the same 7 300 bytes is the corroboration this tier was designed to get.

Checklist

  • the same flow with the signing context forced to empty is rejected
  • a utility.batch_all pushing the payload past 256 bytes
  • an ML-DSA-87 (legacy) account signs and is accepted
  • an ML-DSA-65 account signs and is accepted
  • an account exported to JSON, re-imported, and used to sign
  • the extrinsic hex from tier 1 and tier 3 is identical for the same call and nonce

Tier 2's copy of that comparison is not done: the harness builds its extrinsic with the same @quantus/codec call tier 1 uses, so comparing them would be comparing a function with itself. What tier 2 adds is the extension's signature rather than a keyring's, and that is what remains unrun.

What is left on this issue, honestly

Everything that needs a browser with the extension installed:

  • tier 2: enable(), list accounts, signRaw, and build-sign-submit through the popup
  • tier 3: the console connecting, listing extension accounts, and signing a transfer

I have not managed either. Firefox can install the extension, but its own UI is a privileged browsing context this tooling cannot script — it gets as far as the first-run Welcome screen and no further — and the last attempt failed outright with a navigation timeout. Both harnesses build and serve; what is untested is a human clicking through them.

Everything that can be verified without a browser has been, against a real node: signing, payload construction, context separation, the BLAKE2b branch, both ML-DSA schemes, JSON round-trip, balance reads, and now an independent implementation agreeing on the wire format byte for byte.

## The cross-tier comparison passes, and it is the strongest evidence here ``` papi assembled 7300 bytes IDENTICAL to the tier-1 harness ``` `createV4Tx` from the patched papi console, fed the same call, nonce and signature as `scripts/tier1/submit.mjs`, produces the same bytes. quantus/papi-console#1 has the detail. This matters more than the other checks on this issue. Everything else so far has been our stack agreeing with itself: `@quantus/codec` builds the payload, `@quantus/crypto` signs it, and both are ours. **polkadot-api shares no code with the five polkadot-js forks** — it reads the extrinsic's `Address` and `Signature` types out of the metadata and assembles length-agnostically, by an entirely separate implementation. Two independent readings of the same runtime producing the same 7 300 bytes is the corroboration this tier was designed to get. ### Checklist - [x] the same flow with the signing context forced to empty is **rejected** - [x] a `utility.batch_all` pushing the payload past 256 bytes - [x] an ML-DSA-87 (legacy) account signs and is accepted - [x] an ML-DSA-65 account signs and is accepted - [x] an account exported to JSON, re-imported, and used to sign - [x] the extrinsic hex from tier 1 and tier 3 is identical for the same call and nonce Tier 2's copy of that comparison is not done: the harness builds its extrinsic with the same `@quantus/codec` call tier 1 uses, so comparing them would be comparing a function with itself. What tier 2 adds is the **extension's** signature rather than a keyring's, and that is what remains unrun. ### What is left on this issue, honestly Everything that needs a browser with the extension installed: - tier 2: `enable()`, list accounts, `signRaw`, and build-sign-submit through the popup - tier 3: the console connecting, listing extension accounts, and signing a transfer I have not managed either. Firefox can install the extension, but its own UI is a privileged browsing context this tooling cannot script — it gets as far as the first-run Welcome screen and no further — and the last attempt failed outright with a navigation timeout. Both harnesses build and serve; what is untested is a human clicking through them. Everything that can be verified without a browser has been, against a real node: signing, payload construction, context separation, the BLAKE2b branch, both ML-DSA schemes, JSON round-trip, balance reads, and now an independent implementation agreeing on the wire format byte for byte.
Author
Owner

Correction on tier 3

I reported the cross-tier hex comparison as passing and implied tier 3 was in hand. The comparison is real — createV4Tx assembles byte-identical output to scripts/tier1/submit.mjs, tested directly against Heisenberg metadata — but I tested the assembly function, not the console, and the console does not work against Quantus at all.

Deployed at https://qapi.blackbeard.observer, it renders a bare black page and never finishes loading. Two causes, neither of them the signature:

  1. papi cannot decode a Quantus block header. qp_header::Header encodes number as a plain u32 where Substrate uses #[codec(compact)], and carries an extra zk_tree_root: H256 before the digest. Reading the number as a Compact consumes 2 bytes where 4 were written, everything after shifts, and the digest vector dies on an unknown variant index — innerDecoder is not a function, retried forever.
  2. The block hash is Poseidon over a felt-aligned preimage. papi identifies a chain's hasher by searching for one where h(rawHeader) === blockHash. No standard hasher satisfies that here, and the preimage is a different construction rather than a different hash of the same bytes.

Both are on quantus/papi-console#1 with the byte-level evidence, and the work is quantus/papi-console#4.

So the two tier-3 boxes on this issue — the console connecting and listing extension accounts, and signing a transfer through it — are further away than when I wrote that comment, not closer. What survives is the corroboration itself, which came from calling papi's assembly directly rather than through a browser, and which I should have described that way rather than as "tier 3".

The checklist item "the extrinsic hex from tier 1, tier 2 and tier 3 is identical" stays ticked on the strength of that direct comparison; the tier-3 acceptance criteria on quantus/papi-console#1 do not.

## Correction on tier 3 I reported the cross-tier hex comparison as passing and implied tier 3 was in hand. The comparison **is** real — `createV4Tx` assembles byte-identical output to `scripts/tier1/submit.mjs`, tested directly against Heisenberg metadata — but I tested the assembly function, not the console, and the console does not work against Quantus at all. Deployed at https://qapi.blackbeard.observer, it renders a bare black page and never finishes loading. Two causes, neither of them the signature: 1. **papi cannot decode a Quantus block header.** `qp_header::Header` encodes `number` as a plain `u32` where Substrate uses `#[codec(compact)]`, and carries an extra `zk_tree_root: H256` before the digest. Reading the number as a Compact consumes 2 bytes where 4 were written, everything after shifts, and the digest vector dies on an unknown variant index — `innerDecoder is not a function`, retried forever. 2. **The block hash is Poseidon over a felt-aligned preimage.** papi identifies a chain's hasher by searching for one where `h(rawHeader) === blockHash`. No standard hasher satisfies that here, and the preimage is a different construction rather than a different hash of the same bytes. Both are on quantus/papi-console#1 with the byte-level evidence, and the work is quantus/papi-console#4. So the two tier-3 boxes on this issue — the console connecting and listing extension accounts, and signing a transfer through it — are **further away than when I wrote that comment**, not closer. What survives is the corroboration itself, which came from calling papi's assembly directly rather than through a browser, and which I should have described that way rather than as "tier 3". The checklist item **"the extrinsic hex from tier 1, tier 2 and tier 3 is identical"** stays ticked on the strength of that direct comparison; the tier-3 acceptance criteria on quantus/papi-console#1 do not.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/extension#7