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.
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:
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
Local Quantus node; record its spec version — the signing context switches at 148.
Create or import an account, fund it.
Submit balances.transfer_keep_alive, approve in the popup.
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
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 block2026-09-10 10:39:15 +00:00
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.
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.
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 notUtility.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.
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.
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:
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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:
TxExtensionorder, which decodes into something plausible and fails at dispatchNone 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_submitExtrinsicover 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 existingtest-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
AddressandSignaturetypes 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: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:seedValidatealready accepts a 256-bit hex seed, so the entry point exists.The run, and the cases that actually prove it
balances.transfer_keep_alive, approve in the popup.ExtrinsicSuccess, recipient's balance moved.Then the negatives, which are the ones that prove the plumbing rather than the happy path:
utility.batch_all— pushes the signing payload past 256 bytes so the blake2 branch inExtrinsicPayload.signis actually exercisedThat 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.
End to end: a dapp-initiated transfer, signed in the extension, included in a blockto End to end: three tiers of proof, ending in a real transfer in a real blockTier 1 passes. The fork can spend.
scripts/tier1/submit.mjs, against Heisenberg (wss://a1-heisenberg.quantus.cat):An ML-DSA-87 key created by the forked keyring from a seed, signing a
balances.transfer_keep_aliveunder theQUANTUS_EXTRINSICFIPS 204 context,accepted by a real node and included in a real block. The chain's
Verifyimplchecks
hash_bytes(public) == signerbefore it checks the signature, soinclusion also confirms the Poseidon2 account derivation agrees with the runtime.
It only worked once
@polkadot/apiwas out of the pathThe first run through
@polkadot/apiwas rejected: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 frommetadata the node produced by running
Metadata_metadataagainst the runtimeWASM in a given block's state.
The harness now uses
WsProviderpurely as a JSON-RPC transport. No type in itis decoded by polkadot-js.
What the harness proves beyond the transfer
that built it, so a bug in the codec is distinguishable from a disagreement
with the chain before the node is involved.
inherent whose preamble byte is
0x05— bare, version 5 — next to thissigned extrinsic's
0x84, signed, version 4, while the metadata declaresversion 4. Three numbers, all correct.
@polkadot/apireads that byte as aversion and therefore cannot decode any block of this chain at all.
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
CheckMortality's implicit isthe genesis hash and there is no birth block to agree with the node about.
Mortality is a hardening step and wants its own issue.
system_accountNextIndexrather than reading
System::Account, because@quantus/codecdoes not dostorage keys yet. The extension will need that for balances — also its own
issue.
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 2: built, and the extension's own signing path is now on
@quantus/codecTwo 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/typescannot describe this chain, andExtrinsicPayload.signhas nowhere to put a FIPS 204 context. So:RequestExtrinsicSigntakes aRuntimeinstead of a polkadot-jsRegistry.metadataExpandbuilds aRuntimefromrawMetadatarather than callingregistry.setMetadata, which throws on this chain's metadata.Extrinsic.tsxdecodes 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.eraarrives already SCALE-encoded; it is round-tripped against the runtime's ownEratype rather than appended on trust (@quantus/codec0.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 tier2bundles it, the README has the run steps and the dev-account seeds. It prints the call, theextra, 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/codecparses Heisenberg's 101,493-byte metadata client side and reports extrinsic v4 with all twelve signed extensions,ReversibleTransactionExtensionandWormholeProofRecorderExtensionincluded.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 fullpub(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 extensionblock was five variations on "does this agree with@polkadot/api". That comparison is meaningless now, and theuserExtensionsmechanism 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:
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 passestype: 'dilithium65'asbackground.tsdoes.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
utility.batch_allpushing the payload past 256 bytes, so the BLAKE2b branch is exercisedTier 3 (quantus/papi-console) is untouched, and #11 (mortal eras) and quantus/wasm#4 (storage reads, for balances) came out of tier 1.
The negative cases pass
scripts/tier1/cases.mjs, against Heisenberg at spec 148: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
utility.batch_all— pushes the signing payload past 256 bytes so the blake2 branch is actually exercisedThe last one needs tier 3.
A mistake worth recording, because the chain was never wrong
The first run of this matrix reported two
batch_allcalls 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 printedholds 0.system_accountNextIndexcounts 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".submitAndIncludenow 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/apicannot read at all.Two things follow from it that are worth stating separately, because they are different claims:
batch_allis atomic, and a reverted dispatch still advances the nonce and still appears in a block. Only the balance settles it.payment_queryInfoputs a 5,341-byte ML-DSA-65 extrinsic at apartialFeeof 8,196,927,000 — about 0.008 HEI. TheInability to pay some feesin the first run was the funding transfer not having landed yet, not the fee being large.Supporting work
@quantus/codecgained storage addressing for this (quantus/wasm#4, now closed):storageTargetresolves a pallet and item totwox128(prefix) ‖ twox128(item)plus each map key hashed by the hasher the entry declares, and carries theDefault/Optionaldistinction — an account nobody has funded reads as a zero balance through the first and as an error through the second. Nothing in it knows thatSystem::Accountis aBlake2_128Concatmap over anAccountId32.It also gained nested calls: a multi-field variant with named fields only accepted a positional array, so
Utility.batch_allcould not be encoded at all. That is what thebatch_allcase above is really testing on the codec side.submit.mjsmoved onto the same sharedchain.mjsand 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_allbut notUtility.batchorUtility.force_batch. Narrower than vanilla Substrate, and the sort of thing a hand-written decoder would never find out.The cross-tier comparison passes, and it is the strongest evidence here
createV4Txfrom the patched papi console, fed the same call, nonce and signature asscripts/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/codecbuilds the payload,@quantus/cryptosigns it, and both are ours. polkadot-api shares no code with the five polkadot-js forks — it reads the extrinsic'sAddressandSignaturetypes 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
utility.batch_allpushing the payload past 256 bytesTier 2's copy of that comparison is not done: the harness builds its extrinsic with the same
@quantus/codeccall 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:
enable(), list accounts,signRaw, and build-sign-submit through the popupI 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.
Correction on tier 3
I reported the cross-tier hex comparison as passing and implied tier 3 was in hand. The comparison is real —
createV4Txassembles byte-identical output toscripts/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:
qp_header::Headerencodesnumberas a plainu32where Substrate uses#[codec(compact)], and carries an extrazk_tree_root: H256before 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.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.