Stop rejecting the Quantus signature type by variant name #1
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?
The one thing standing between an unmodified papi console and signing real Quantus extrinsics. Context: quantus/extension#1.
papi already does the right thing, then undoes it
getSignerTypein@polkadot-api/signers-commonpulls the extrinsic'sAddressandSignaturetypes out of the metadata — exactly the runtime-as-oracle approach blackbeard.observer settled on, and better than polkadot-js, which hardcodesExtrinsicSignature: 'MultiSignature'in a type definition file.Then it throws the result away and checks names:
Quantus's
DilithiumSignatureSchemehasDilithium87andDilithium65, so this raisesUnkown signerand nothing downstream ever runs.What is downstream is already correct
createV4Txis entirely length-agnostic — it concatenates and compact-prefixes, and never asserts a signature size:A 5261-byte
[variant ‖ sig ‖ pk]from the extension drops straight in.addressPrefixis[id.idx], theMultiAddress::Idvariant index read from metadata, which is also correct. papi's other constraints — extrinsic v4,txExtVersion === 0— Quantus already satisfies.So this is a check that fires before code that would have worked.
The fix
Widen the condition, do not special-case Quantus. The name whitelist exists only to separate the Ethereum shape (
AccountId20+[u8;65]) from the Substrate shape, and the Ethereum arm already has its own structural test one line above. Anenumsignature type over anAccountId32-style address is enough to take the Substrate path; the variant names are not load-bearing, and asserting on them is precisely the hand-written-decoder trap — the runtime already said what the type is.Keep the Ethereum detection as-is.
Where the patch lives
@polkadot-api/signers-commonis MIT, published from thepolkadot-api/polkadot-apimonorepo, and depended on directly here (^0.3.1). Use pnpmpatchedDependencies— this repo is already on pnpm, the change is a couple of lines, and apatches/entry is far cheaper than forking a monorepo for it. Forkingpolkadot-api/polkadot-apiis the escalation if the patch surface ever grows beyond this; say so on this issue if it does.Acceptance
balances.transfer_keep_alivesigns via the extension and is included in a blockPatched, and it produces byte-identical output to the tier-1 harness
The analysis in this issue held. The patch drops the three names and keeps
signature.type !== "enum"; everything downstream was already correct.Same call, same nonce, same signature, fed to
createV4Txand compared against whatquantus/extension'sscripts/tier1/submit.mjsbuilds. That is the corroboration this repo exists for. polkadot-api shares no code with the five polkadot-js forks, so until now our stack had only ever agreed with itself.The patch is load-bearing, and narrow
Both checked rather than assumed:
Unkown signeron exactly these inputs, so the check really was the only blocker.signingTypeundefined the signature passes through verbatim; withsigningType: 'Sr25519'it still gets its01variant byte prepended. The patch widened a check without removing behaviour.Two details worth recording that the issue did not have
createV4TxprependssigningTypeId[signingType]when a signing type is named. For the extension pathsigningTypeisundefined—from-pjs-account.jspasses it only when mocking — so ourwithType: truesignature, which already carries theDilithiumSignatureSchemevariant byte, lands verbatim. Had that not been the case there would be a second variant byte and nothing would verify.getPublicKeyisAccountId().enc(address), i.e. an SS58 decode. On Quantus that yields the Poseidon2 account id, which is exactly whatMultiAddress::Idwants. It happens to be right for the right reason rather than by luck.PJS does not support this signed-extensionfor anything it does not recognise unless both halves are empty. Quantus'sReversibleTransactionExtensionandWormholeProofRecorderExtensionare empty on both, so they pass — but note that this is the same assumption polkadot-js makes, and it will break on the same runtime upgrade. Worth an entry on #2.Where the patch lives
pnpm patch, keyed on the package name rather than a version. There are fifteen resolved copies of@polkadot-api/signers-commonin the tree — nested underpjs-signer,ledger-signer,raw-tx-creator,polkadot-apiitself and@paraspell/sdk— and the version-pinned key only reached one of them. 0.3.0 and 0.3.1 ship an identicalv4.js, so one patch covers both.Acceptance
balances.transfer_keep_alivesigns via the extension and is included in a blockThe two unchecked items need a browser with the extension installed. I could not get there — driving Firefox from this tooling failed outright this time (
Navigation timed out), and the extension's own UI is a privileged context it cannot script regardless. The console builds and the dev server serves, so what is untested is the running page, not the code path this issue is about: that one is tested directly, against real Heisenberg metadata, and it agrees with an independent implementation byte for byte.Correction: the signature patch was not "the one thing standing between an unmodified papi console and signing real Quantus extrinsics"
This issue opens with that claim and I repeated it when the patch landed. It is wrong, and the deployed console is the evidence: it renders a bare black page against Quantus and never finishes loading. The signature patch is necessary and does work — the byte-identical result stands — but it is nowhere near sufficient, because papi cannot read a Quantus block header at all, which happens long before any signing.
Reproduced headlessly (no browser) against
wss://rpc1-mainnet.quantus.com:It retries forever, so
runtime$never emits, so nothing renders.The Quantus header is not a Substrate header
chain/primitives/header/src/lib.rsdefinesqp_header::Header, and the runtime uses it (pub type Header = qp_header::Header<BlockNumber, BlakeTwo256>). It differs fromsp_runtime::generic::Headerin two ways:parent_hashH256H256number#[codec(compact)]u32, no compactstate_rootH256H256extrinsics_rootH256H256zk_tree_rootH256, Quantus-onlydigestDigestDigestThe
zk_tree_rootis deliberate — the source says it is placed beforedigest"to ensure a fixed offset in the header preimage… prevents miners from manipulating the digest to shift the ZK root's position".Verified against a real header (block 53845 on mainnet), decoding by hand at Quantus offsets:
Reading
numberas a Compact consumes 2 bytes where 4 were written, so everything after it shifts and the digestVector<Variant>eventually reads a byte that is not a known variant index — henceinnerDecoder is not a function. The error is three layers away from its cause, which is why it looked like a signature problem.And the block hash is Poseidon, over a felt-aligned preimage
This is the harder half.
getHasherFromHeaderin@polkadot-api/observable-clientidentifies a chain's hasher by search:On Quantus no such
hexists. Neither blake2b-256 nor keccak-256 nor sha256 of the encoded header reproduces the block hash, and they cannot: the header doc says the block hash is "computed using Poseidon for ZK circuit compatibility", over a felt-aligned preimage the header builds itself (primitives/header/src/lib.rs, around the// a felt aligned pre-image for poseidon hashingcomment). So it is not "hash the SCALE bytes with a different function" — the preimage is a different construction entirely.That means supporting Quantus in papi needs, at minimum:
qp-poseidon-coreand on our side already exists compiled to WASM as@quantus/crypto.Both sit below papi's chainHead bootstrap, in code that has no notion of which chain it is talking to.
blockHeaderis a module-level codec insubstrate-bindingsshared by every chain, so a patch cannot simply redefine it without breaking Polkadot in the same console.What I have done, and not done
Shipped, because the page had no business failing silently:
Subscribe'sfallbackdefaults tonull, which is literally documented as "render null until the subscription exists", and that was the black page;defaultSelectedChainusedLIGHT_CLIENT_ENDPOINTunconditionally, routing through smoldot, which needs a chain spec the Quantus networks do not have (hasChainSpecs: false). A second, independent failure that would have bitten even with a working header codec.Not done, and not attempted: the header codec and the Poseidon hasher. That is a real piece of work with a design question in front of it — whether to patch
substrate-bindingswith a layout that round-trips to disambiguate, or to carry a Quantus-aware fork of papi's chainHead bootstrap — and it should not be decided inside a bug comment. Raised as #4.What this means for quantus/extension#7 tier 3
The cross-implementation check still stands:
createV4Txassembles byte-identical output to the tier-1 harness, and that was tested directly against real metadata rather than through the console. What does not stand is "the console connects to a Quantus node and lists extension accounts" — it cannot connect at all yet, and that is two checkboxes on that issue which I should not have expected to fall out of this patch.Checked against
mainand the live site. The patch and the header codec are deployed, but two acceptance items have no proof yet.Met:
patches/@polkadot-api__signers-common.patchkeeps only thetype !== "enum"check. The deployed bundle has it, and no longer contains the Ecdsa/Ed25519/Sr25519 list.zkTreeRootis in the block state.createV4Txtest, not through the console UI.Remaining:
b66b0a6).transfer_keep_alivethrough the console, sign it in blackbeard's popup, and see it included in a block. This is also the extension-popup end-to-end check still owed under quantus/extension#4 and #7.