@quantus/codec: encode and decode against the runtime's own metadata, not a hand-written codec #3

Closed
opened 2026-09-15 11:02:32 +00:00 by grenade · 0 comments
Owner

Sibling package to @quantus/crypto (#1), same shape: a Rust crate compiled to WASM with a thin TypeScript surface, published to the Gitea registry.

Why

The extension needs to build a signing payload, build a signed extrinsic, and decode a call well enough to show a user what they are about to approve. The obvious route was @polkadot/api's codec. That is closed — see quantus/api#1 for the tested evidence. Three things in summary:

  • @polkadot/types caps fixed arrays at 2048; Quantus signatures are [u8;5261] and [u8;7219].
  • api.rpc.chain.getBlock throws on every block of this chain, at the timestamp inherent, because it reads the extrinsic preamble byte as a version when the top two bits are a type tag.
  • Most importantly, polkadot-js logs Unknown signed extensions ReversibleTransactionExtension, WormholeProofRecorderExtension found, treating them as no-effect and guesses zero bytes for their extra and their implicit.

That last one is the reason this package exists rather than a patch. The guess is right today — both extensions are PhantomData with type Implicit = () — and it is right only by luck. This chain's encoding has already changed between runtimes, and transactionVersion has gone 2 → 3 → 6 across four upgrades; each is an extrinsic-format change. A signer that assumes an extension contributes nothing produces a cryptographically valid signature over the wrong bytes, which the chain reports as BadProof — indistinguishable from a wrong key, and it will happen on an upgrade rather than at build time.

The metadata declares every one of those types. Reading them is not more work than guessing; it is the same work done correctly.

The oracle

state_getMetadata takes a block hash and makes the node run Metadata_metadata against the runtime code in that block's state, so the runtime WASM describes itself and the node executes it for us. blackbeard.observer has been doing this across four upgrade boundaries on Heisenberg (v126, v131, v136, v148, each a different blob) — crates/blackbeard-core/src/runtime.rs is the reference implementation and carries the hard-won notes, including the preamble-byte one above.

Surface

Metadata in, bytes out. Nothing in this package names a pallet, a call or an extension.

  • loadMetadata(bytes) — SCALE-decode prefixed metadata; v14, erroring loudly on anything else rather than mis-reading it
  • encodeCall(pallet, call, args) — by name, against the registry
  • encodeSignerPayload({ call, era, nonce, tip, specVersion, transactionVersion, genesisHash, blockHash, … }) — walks extrinsic.signed_extensions in order, encoding each extension's ty into the extra and each additional_signed into the implicit, from values supplied by identifier. An extension whose declared type is non-empty and for which no value was supplied is an error, not a zero-length write. That rule is the whole point.
  • encodeExtrinsic({ address, signature, extra, call }) — preamble 0x84, MultiAddress::Id, fixed-size signature with no compact length prefix
  • decodeExtrinsic(bytes) / decodeCall(bytes) — for the approval screen, and for the tier-1 harness to read back what it submitted

Acceptance

The BadProof in quantus/api#1 resolves: a balances.transfer_keep_alive built entirely through this package, signed by @quantus/crypto under QUANTUS_EXTRINSIC, is accepted by Heisenberg and the recipient's balance moves. That is quantus/extension#7 tier 1, and it is the first end-to-end proof that the fork can spend.

Secondary: decodeExtrinsic reads a block that @polkadot/api cannot — the timestamp inherent at index 0 — which is the regression test for the preamble byte.

Notes

  • Same build constraints as #1: separate crate from wasm-crypto (toolchain), initSync over base64+zlib for the MV3 CSP, wasm-bindgen's own glue, binaryen 123 (105 silently corrupts the output).
  • Metadata blobs are large. Cache by spec_version — one fetch per runtime version, ever — as blackbeard-data does; decoding then survives the node pruning the block's state.
  • Depends on #2's golden vectors only for the crypto half; this package's own correctness is pinned by decoding real Heisenberg blocks.
Sibling package to `@quantus/crypto` (#1), same shape: a Rust crate compiled to WASM with a thin TypeScript surface, published to the Gitea registry. ## Why The extension needs to build a signing payload, build a signed extrinsic, and decode a call well enough to show a user what they are about to approve. The obvious route was `@polkadot/api`'s codec. That is closed — see quantus/api#1 for the tested evidence. Three things in summary: - `@polkadot/types` caps fixed arrays at 2048; Quantus signatures are `[u8;5261]` and `[u8;7219]`. - `api.rpc.chain.getBlock` throws on **every block of this chain**, at the timestamp inherent, because it reads the extrinsic preamble byte as a version when the top two bits are a type tag. - Most importantly, polkadot-js logs `Unknown signed extensions ReversibleTransactionExtension, WormholeProofRecorderExtension found, treating them as no-effect` and **guesses zero bytes** for their `extra` and their implicit. That last one is the reason this package exists rather than a patch. The guess is right today — both extensions are `PhantomData` with `type Implicit = ()` — and it is right only by luck. This chain's encoding has already changed between runtimes, and `transactionVersion` has gone 2 → 3 → 6 across four upgrades; each is an extrinsic-format change. A signer that assumes an extension contributes nothing produces a cryptographically valid signature over the wrong bytes, which the chain reports as `BadProof` — indistinguishable from a wrong key, and it will happen on an upgrade rather than at build time. The metadata *declares* every one of those types. Reading them is not more work than guessing; it is the same work done correctly. ## The oracle `state_getMetadata` takes a block hash and makes the node run `Metadata_metadata` against the runtime code in *that block's* state, so the runtime WASM describes itself and the node executes it for us. `blackbeard.observer` has been doing this across four upgrade boundaries on Heisenberg (v126, v131, v136, v148, each a different blob) — `crates/blackbeard-core/src/runtime.rs` is the reference implementation and carries the hard-won notes, including the preamble-byte one above. ## Surface Metadata in, bytes out. Nothing in this package names a pallet, a call or an extension. - `loadMetadata(bytes)` — SCALE-decode prefixed metadata; v14, erroring loudly on anything else rather than mis-reading it - `encodeCall(pallet, call, args)` — by name, against the registry - `encodeSignerPayload({ call, era, nonce, tip, specVersion, transactionVersion, genesisHash, blockHash, … })` — walks `extrinsic.signed_extensions` **in order**, encoding each extension's `ty` into the extra and each `additional_signed` into the implicit, from values supplied by identifier. An extension whose declared type is non-empty and for which no value was supplied is an **error**, not a zero-length write. That rule is the whole point. - `encodeExtrinsic({ address, signature, extra, call })` — preamble `0x84`, `MultiAddress::Id`, fixed-size signature with no compact length prefix - `decodeExtrinsic(bytes)` / `decodeCall(bytes)` — for the approval screen, and for the tier-1 harness to read back what it submitted ## Acceptance The BadProof in quantus/api#1 resolves: a `balances.transfer_keep_alive` built entirely through this package, signed by `@quantus/crypto` under `QUANTUS_EXTRINSIC`, is accepted by Heisenberg and the recipient's balance moves. That is quantus/extension#7 tier 1, and it is the first end-to-end proof that the fork can spend. Secondary: `decodeExtrinsic` reads a block that `@polkadot/api` cannot — the timestamp inherent at index 0 — which is the regression test for the preamble byte. ## Notes - Same build constraints as #1: separate crate from `wasm-crypto` (toolchain), `initSync` over base64+zlib for the MV3 CSP, wasm-bindgen's own glue, binaryen 123 (105 silently corrupts the output). - Metadata blobs are large. Cache by `spec_version` — one fetch per runtime version, ever — as `blackbeard-data` does; decoding then survives the node pruning the block's state. - Depends on #2's golden vectors only for the crypto half; this package's own correctness is pinned by decoding real Heisenberg blocks.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/wasm#3