End to end: three tiers of proof, ending in a real transfer in a real block #7
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.