The extension has to build a signing payload, assemble an extrinsic and decode
a call well enough to show a user what they are approving. The obvious route was
@polkadot/api's codec. That is closed, and quantus/api#1 carries the tested
evidence:
- @polkadot/types caps fixed arrays at 2048 bytes, and ML-DSA signatures are
[u8;5261] and [u8;7219], so every Quantus extrinsic trips it
- 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
- it *guesses* that signed extensions it does not recognise contribute nothing
to the signed payload
The third is why this is a package rather than a patch. The guess is right
today — the registry says ReversibleTransactionExtension and
WormholeProofRecorderExtension are empty on both halves — and it is right only
by luck. This chain's encoding has changed between runtimes, transactionVersion
has gone 2 -> 3 -> 6 across four upgrades, and when the guess stops holding the
wallet keeps signing: valid signatures over a payload missing bytes the runtime
put there, reported by the chain as BadProof, which is also what it reports for
a wrong key.
So nothing here names a pallet, a call, an extension or a signature scheme.
Every type id is read from metadata the node produced by running
Metadata_metadata against the runtime WASM in a given block's state, the same
oracle blackbeard.observer has been decoding against across four upgrade
boundaries. encode_extensions walks the declared extensions in order and refuses
to build a payload when one that encodes to something has no value supplied —
a wallet that cannot sign is a bug report, one that signs the wrong bytes is a
support case nobody diagnoses.
Proven end to end on Heisenberg at spec 148: a balances.transfer_keep_alive
built entirely here, signed by @quantus/crypto under QUANTUS_EXTRINSIC, included
at block 1050475 and read back from that block — inherent at index 0 included,
which is the block @polkadot/api cannot decode at all.
Two notes carried over from @quantus/crypto, both load-bearing: decode_checked
walks with scale_decode's IgnoreVisitor before scale_value touches the bytes,
because scale_value sizes a Vec from the length prefix before decoding an item
and an aborted allocation leaves no Err to catch; and the build needs binaryen
123, since 105 silently corrupts the output.
Closes #3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
@quantus/codec
Metadata-driven SCALE encode and decode for the Quantus chain, compiled to WASM.
Nothing in this package names a pallet, a call, a signed extension or a signature
scheme. Everything is read from the metadata the node produced by running
Metadata_metadata against the runtime WASM in a given block's state, which
makes the runtime the oracle rather than this package's author.
Why not @polkadot/api
Three reasons, in increasing order of importance — the evidence is on quantus/api#1.
@polkadot/typesrefuses fixed arrays longer than 2048 bytes. ML-DSA signatures are[u8;5261]and[u8;7219], so every Quantus extrinsic trips it.api.rpc.chain.getBlockthrows on every block of this chain, at the timestamp inherent. The extrinsic preamble byte's top two bits are a type tag (0b00bare,0b10signed,0b01general) and the low six are the version; Quantus emits0x84— signed, v4 — and0x05— bare, v5 — in the same block while the metadata declares version 4. polkadot-js reads that byte as a version.- It guesses that signed extensions it does not recognise contribute nothing
to the signed payload, logging
Unknown signed extensions … treating them as no-effect.
The third is why this package exists rather than a patch. The guess is correct
only while every unrecognised extension happens to be zero-sized. This chain's
encoding has already changed between runtimes — transactionVersion has gone
2 → 3 → 6 across four upgrades, each an extrinsic-format change — and when the
guess stops being correct the wallet keeps signing. Those signatures are
cryptographically valid, over a payload missing bytes the runtime put there, and
the chain reports them as BadProof, which is also what it reports for a wrong
key. Silent, remote, and indistinguishable from the one thing it is not.
Here the registry decides. An extension whose declared type encodes to nothing contributes nothing; anything else must be supplied by the caller or no payload is produced at all.
Use
import { Runtime } from '@quantus/codec';
const runtime = Runtime.fromMetadata(await fetchMetadata()); // state_getMetadata
const call = runtime.encodeCall('Balances', 'transfer_keep_alive', {
dest: { Id: '0x…' },
value: '1000000000'
});
const values = runtime.standardExtensions({
blockHash: genesisHash, // immortal era
genesisHash,
nonce,
specVersion,
transactionVersion
});
const payload = runtime.signerPayload(call, values);
// sign `payload` with @quantus/crypto under the QUANTUS_EXTRINSIC context,
// hashing it first with BLAKE2b-256 if it is longer than 256 bytes
const extrinsic = runtime.encodeExtrinsic(
{ Id: accountId },
signature,
runtime.encodeExtra(values),
call
);
standardExtensions fills in the extensions Substrate itself defines. Anything
else this runtime declares as non-empty is refused by name — see above for why
that is the desired behaviour rather than a limitation.
Build
./scripts/build-quantus.sh quantus-codec
Same constraints as @quantus/crypto: a modern toolchain (separate from
wasm-crypto's 2022 nightly), initSync over base64+zlib for the MV3 CSP,
wasm-bindgen's own glue rather than @polkadot/wasm-bridge, and binaryen 123
— version 105 silently corrupts the output. See
quantus/wasm#1 and
#3.