Stop rejecting the Quantus signature type by variant name #1

Open
opened 2026-09-10 10:37:49 +00:00 by grenade · 3 comments
Owner

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

getSignerType in @polkadot-api/signers-common pulls the extrinsic's Address and Signature types out of the metadata — exactly the runtime-as-oracle approach blackbeard.observer settled on, and better than polkadot-js, which hardcodes ExtrinsicSignature: 'MultiSignature' in a type definition file.

Then it throws the result away and checks names:

if (signature.type !== "enum" || ["Ecdsa", "Ed25519", "Sr25519"].some((x) => !(x in signature.value)))
  throw unkownSignerType();

Quantus's DilithiumSignatureScheme has Dilithium87 and Dilithium65, so this raises Unkown signer and nothing downstream ever runs.

What is downstream is already correct

createV4Tx is entirely length-agnostic — it concatenates and compact-prefixes, and never asserts a signature size:

const preResult = mergeUint8([
  extrinsicFormat.enc({ version: 4, type: "signed" }),
  new Uint8Array([...addressPrefix, ...publicKey]),
  signed,          // ← whatever the extension returned, verbatim
  ...extra,
  callData
]);
return mergeUint8([compact.enc(preResult.length), preResult]);

A 5261-byte [variant ‖ sig ‖ pk] from the extension drops straight in. addressPrefix is [id.idx], the MultiAddress::Id variant 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. An enum signature type over an AccountId32-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-common is MIT, published from the polkadot-api/polkadot-api monorepo, and depended on directly here (^0.3.1). Use pnpm patchedDependencies — this repo is already on pnpm, the change is a couple of lines, and a patches/ entry is far cheaper than forking a monorepo for it. Forking polkadot-api/polkadot-api is the escalation if the patch surface ever grows beyond this; say so on this issue if it does.

Acceptance

  • the console connects to a Quantus node and lists extension accounts
  • balances.transfer_keep_alive signs via the extension and is included in a block
  • the assembled extrinsic hex matches what the harness in quantus/extension#7 produces for the same call
  • a non-Quantus chain (any Substrate testnet) still signs normally through the patched code
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 `getSignerType` in `@polkadot-api/signers-common` pulls the extrinsic's `Address` and `Signature` types **out of the metadata** — exactly the runtime-as-oracle approach blackbeard.observer settled on, and better than polkadot-js, which hardcodes `ExtrinsicSignature: 'MultiSignature'` in a type definition file. Then it throws the result away and checks names: ```js if (signature.type !== "enum" || ["Ecdsa", "Ed25519", "Sr25519"].some((x) => !(x in signature.value))) throw unkownSignerType(); ``` Quantus's `DilithiumSignatureScheme` has `Dilithium87` and `Dilithium65`, so this raises `Unkown signer` and nothing downstream ever runs. ## What is downstream is already correct `createV4Tx` is entirely length-agnostic — it concatenates and compact-prefixes, and never asserts a signature size: ```js const preResult = mergeUint8([ extrinsicFormat.enc({ version: 4, type: "signed" }), new Uint8Array([...addressPrefix, ...publicKey]), signed, // ← whatever the extension returned, verbatim ...extra, callData ]); return mergeUint8([compact.enc(preResult.length), preResult]); ``` A 5261-byte `[variant ‖ sig ‖ pk]` from the extension drops straight in. `addressPrefix` is `[id.idx]`, the `MultiAddress::Id` variant 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. An `enum` signature type over an `AccountId32`-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-common` is MIT, published from the `polkadot-api/polkadot-api` monorepo, and depended on directly here (`^0.3.1`). **Use pnpm `patchedDependencies`** — this repo is already on pnpm, the change is a couple of lines, and a `patches/` entry is far cheaper than forking a monorepo for it. Forking `polkadot-api/polkadot-api` is the escalation if the patch surface ever grows beyond this; say so on this issue if it does. ## Acceptance - [ ] the console connects to a Quantus node and lists extension accounts - [ ] `balances.transfer_keep_alive` signs via the extension and is included in a block - [ ] the assembled extrinsic hex matches what the harness in quantus/extension#7 produces for the same call - [ ] a non-Quantus chain (any Substrate testnet) still signs normally through the patched code
Author
Owner

Patched, 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.

papi assembled 7300 bytes
IDENTICAL to the tier-1 harness

Same call, same nonce, same signature, fed to createV4Tx and compared against what quantus/extension's scripts/tier1/submit.mjs builds. 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:

  • The unpatched copy still sitting in the pnpm store raises Unkown signer on exactly these inputs, so the check really was the only blocker.
  • Polkadot metadata still takes the Substrate path. With signingType undefined the signature passes through verbatim; with signingType: 'Sr25519' it still gets its 01 variant byte prepended. The patch widened a check without removing behaviour.

Two details worth recording that the issue did not have

  • createV4Tx prepends signingTypeId[signingType] when a signing type is named. For the extension path signingType is undefinedfrom-pjs-account.js passes it only when mocking — so our withType: true signature, which already carries the DilithiumSignatureScheme variant byte, lands verbatim. Had that not been the case there would be a second variant byte and nothing would verify.
  • getPublicKey is AccountId().enc(address), i.e. an SS58 decode. On Quantus that yields the Poseidon2 account id, which is exactly what MultiAddress::Id wants. It happens to be right for the right reason rather than by luck.
  • papi throws PJS does not support this signed-extension for anything it does not recognise unless both halves are empty. Quantus's ReversibleTransactionExtension and WormholeProofRecorderExtension are 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-common in the tree — nested under pjs-signer, ledger-signer, raw-tx-creator, polkadot-api itself and @paraspell/sdk — and the version-pinned key only reached one of them. 0.3.0 and 0.3.1 ship an identical v4.js, so one patch covers both.

Acceptance

  • the console connects to a Quantus node and lists extension accounts
  • balances.transfer_keep_alive signs via the extension and is included in a block
  • the assembled extrinsic hex matches what the harness in quantus/extension#7 produces for the same call
  • a non-Quantus chain (Polkadot) still signs normally through the patched code

The 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.

## Patched, 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. ``` papi assembled 7300 bytes IDENTICAL to the tier-1 harness ``` Same call, same nonce, same signature, fed to `createV4Tx` and compared against what `quantus/extension`'s `scripts/tier1/submit.mjs` builds. **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: - The **unpatched** copy still sitting in the pnpm store raises `Unkown signer` on exactly these inputs, so the check really was the only blocker. - **Polkadot metadata still takes the Substrate path.** With `signingType` undefined the signature passes through verbatim; with `signingType: 'Sr25519'` it still gets its `01` variant byte prepended. The patch widened a check without removing behaviour. ### Two details worth recording that the issue did not have - `createV4Tx` prepends `signingTypeId[signingType]` when a signing type is named. For the extension path `signingType` is `undefined` — `from-pjs-account.js` passes it only when mocking — so our `withType: true` signature, which already carries the `DilithiumSignatureScheme` variant byte, lands verbatim. Had that not been the case there would be a second variant byte and nothing would verify. - `getPublicKey` is `AccountId().enc(address)`, i.e. an SS58 decode. On Quantus that yields the Poseidon2 **account id**, which is exactly what `MultiAddress::Id` wants. It happens to be right for the right reason rather than by luck. - papi throws `PJS does not support this signed-extension` for anything it does not recognise **unless both halves are empty**. Quantus's `ReversibleTransactionExtension` and `WormholeProofRecorderExtension` are 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-common` in the tree — nested under `pjs-signer`, `ledger-signer`, `raw-tx-creator`, `polkadot-api` itself and `@paraspell/sdk` — and the version-pinned key only reached one of them. 0.3.0 and 0.3.1 ship an identical `v4.js`, so one patch covers both. ### Acceptance - [ ] the console connects to a Quantus node and lists extension accounts - [ ] `balances.transfer_keep_alive` signs via the extension and is included in a block - [x] the assembled extrinsic hex matches what the harness in quantus/extension#7 produces for the same call - [x] a non-Quantus chain (Polkadot) still signs normally through the patched code The 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.
Author
Owner

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:

ChainHead subfollow request failed, retrying… TypeError: innerDecoder is not a function
    at scale-ts/dist/scale-ts.mjs:285
    at substrate-bindings/dist/codecs/scale/Variant.js:13
90.0s TIMED OUT — runtime$ never emitted a runtime

It retries forever, so runtime$ never emits, so nothing renders.

The Quantus header is not a Substrate header

chain/primitives/header/src/lib.rs defines qp_header::Header, and the runtime uses it (pub type Header = qp_header::Header<BlockNumber, BlakeTwo256>). It differs from sp_runtime::generic::Header in two ways:

field Substrate Quantus
parent_hash H256 H256
number #[codec(compact)] plain u32, no compact
state_root H256 H256
extrinsics_root H256 H256
zk_tree_root H256, Quantus-only
digest Digest Digest

The zk_tree_root is deliberate — the source says it is placed before digest "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:

total 242 bytes
  parent_hash     0824bbcd…111909     MATCH against chain_getHeader
  number (u32 LE) 53845
  state_root      cf3eea70…6b1601     MATCH
  extrinsics_root 7e3fb6b0…ee2a54     MATCH
  zk_tree_root    120b63fd…3f5fa7
  digest: 2 items — variant 6 engine "pow_" 32 bytes, variant 5 engine "pow_" 64 bytes
consumed exactly 242 bytes — layout confirmed

Reading number as a Compact consumes 2 bytes where 4 were written, so everything after it shifts and the digest Vector<Variant> eventually reads a byte that is not a known variant index — hence innerDecoder 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. getHasherFromHeader in @polkadot-api/observable-client identifies a chain's hasher by search:

hashers.find((h) => toHex(h(fromHex(header))) === blockHash) || (() => { throw new Error("Unsupported hasher") })

On Quantus no such h exists. 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 hashing comment). 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:

  1. a header codec for the Quantus layout, and
  2. a Quantus block-hash function — Poseidon over the felt-aligned preimage, which lives in qp-poseidon-core and 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. blockHeader is a module-level codec in substrate-bindings shared 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:

  • a boot splash naming the chain and endpoint, escalating at 8s and 25s — Subscribe's fallback defaults to null, which is literally documented as "render null until the subscription exists", and that was the black page;
  • the default endpoint is no longer the light client. defaultSelectedChain used LIGHT_CLIENT_ENDPOINT unconditionally, 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-bindings with 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: createV4Tx assembles 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.

## 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`: ``` ChainHead subfollow request failed, retrying… TypeError: innerDecoder is not a function at scale-ts/dist/scale-ts.mjs:285 at substrate-bindings/dist/codecs/scale/Variant.js:13 90.0s TIMED OUT — runtime$ never emitted a runtime ``` It retries forever, so `runtime$` never emits, so nothing renders. ### The Quantus header is not a Substrate header `chain/primitives/header/src/lib.rs` defines `qp_header::Header`, and the runtime uses it (`pub type Header = qp_header::Header<BlockNumber, BlakeTwo256>`). It differs from `sp_runtime::generic::Header` in two ways: | field | Substrate | Quantus | | --- | --- | --- | | `parent_hash` | `H256` | `H256` | | `number` | **`#[codec(compact)]`** | **plain `u32`, no compact** | | `state_root` | `H256` | `H256` | | `extrinsics_root` | `H256` | `H256` | | `zk_tree_root` | — | **`H256`, Quantus-only** | | `digest` | `Digest` | `Digest` | The `zk_tree_root` is deliberate — the source says it is placed before `digest` "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: ``` total 242 bytes parent_hash 0824bbcd…111909 MATCH against chain_getHeader number (u32 LE) 53845 state_root cf3eea70…6b1601 MATCH extrinsics_root 7e3fb6b0…ee2a54 MATCH zk_tree_root 120b63fd…3f5fa7 digest: 2 items — variant 6 engine "pow_" 32 bytes, variant 5 engine "pow_" 64 bytes consumed exactly 242 bytes — layout confirmed ``` Reading `number` as a Compact consumes 2 bytes where 4 were written, so everything after it shifts and the digest `Vector<Variant>` eventually reads a byte that is not a known variant index — hence `innerDecoder 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. `getHasherFromHeader` in `@polkadot-api/observable-client` identifies a chain's hasher by **search**: ```js hashers.find((h) => toHex(h(fromHex(header))) === blockHash) || (() => { throw new Error("Unsupported hasher") }) ``` On Quantus no such `h` exists. 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 hashing` comment). 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: 1. a header codec for the Quantus layout, and 2. a Quantus block-hash function — Poseidon over the felt-aligned preimage, which lives in `qp-poseidon-core` and 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. `blockHeader` is a module-level codec in `substrate-bindings` shared 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: - a boot splash naming the chain and endpoint, escalating at 8s and 25s — `Subscribe`'s `fallback` defaults to `null`, which is literally documented as "render null until the subscription exists", and that was the black page; - the default endpoint is no longer the **light client**. `defaultSelectedChain` used `LIGHT_CLIENT_ENDPOINT` unconditionally, 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-bindings` with 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**: `createV4Tx` assembles 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.
Author
Owner

Checked against main and the live site. The patch and the header codec are deployed, but two acceptance items have no proof yet.

Met:

  • Signature patch is live: patches/@polkadot-api__signers-common.patch keeps only the type !== "enum" check. The deployed bundle has it, and no longer contains the Ecdsa/Ed25519/Sr25519 list.
  • Quantus header codec is live: zkTreeRoot is in the block state.
  • Extrinsic bytes: identical to the quantus/extension#7 tier-1 harness (7300 bytes), shown by a direct createV4Tx test, not through the console UI.
  • Non-Quantus chains: they still sign, checked in code against Polkadot metadata. Every non-Quantus chain has since been removed from the UI, so this now matters only for Localhost and Custom.

Remaining:

  1. List extension accounts: in a browser with blackbeard, show its accounts in the console on a Quantus chain. Only the Account Providers panel has been checked (b66b0a6).
  2. Sign and include a transfer: submit a transfer_keep_alive through 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.
Checked against `main` and the live site. The patch and the header codec are deployed, but two acceptance items have no proof yet. **Met:** - **Signature patch is live:** `patches/@polkadot-api__signers-common.patch` keeps only the `type !== "enum"` check. The deployed bundle has it, and no longer contains the Ecdsa/Ed25519/Sr25519 list. - **Quantus header codec is live:** `zkTreeRoot` is in the block state. - **Extrinsic bytes:** identical to the quantus/extension#7 tier-1 harness (7300 bytes), shown by a direct `createV4Tx` test, not through the console UI. - **Non-Quantus chains:** they still sign, checked in code against Polkadot metadata. Every non-Quantus chain has since been removed from the UI, so this now matters only for Localhost and Custom. **Remaining:** 1. **List extension accounts:** in a browser with blackbeard, show its accounts in the console on a Quantus chain. Only the Account Providers panel has been checked (`b66b0a6`). 2. **Sign and include a transfer:** submit a `transfer_keep_alive` through 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.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: blackbeard/qapi#1