A fork of the papi console, pointed at the Quantus chains and patched to accept their post-quantum signature type.
Deployed at https://qapi.blackbeard.observer.
The console is built on polkadot-api,
which shares no code with the polkadot-js stack the Quantus browser extension forks
(quantus/extension, quantus/common, quantus/ui, quantus/wasm). That makes it an
independent implementation of the same wire format — and the only way to find out
whether our stack is right about Quantus or merely self-consistent.
It already is: for the same call, nonce and signature, createV4Tx here produces bytes
identical to quantus/extension's tier-1 harness.
getSignerType in @polkadot-api/signers-common reads the extrinsic's Address and
Signature types out of the metadata — the right thing, and better than polkadot-js,
which hardcodes ExtrinsicSignature: 'MultiSignature' in a type definition file. It then
threw that answer away and required the enum to carry Ecdsa, Ed25519 and Sr25519.
Quantus's DilithiumSignatureScheme carries Dilithium87 and Dilithium65, so every
signing attempt raised Unkown signer before reaching code that would have worked —
createV4Tx is entirely length-agnostic, and a 7 219-byte ML-DSA-87 signature drops
straight in.
patches/@polkadot-api__signers-common.patch drops the names and keeps the structural
check. See quantus/papi-console#1.
pnpm install
pnpm dev
Pushes to main deploy to oolon via .gitea/workflows/deploy.yaml. One-time host
provisioning — DNS, certificate, nginx vhost, webroot — is
script/infra-setup.sh, run by an operator:
./script/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub
Conventions: architecture/ —
deployment-gitea-actions.md, reverse-proxies.md, external-tls.md, public-dns.md.
Upstream's own README, and the licence, are unchanged: this is a fork, not a rewrite.
18 activities
Checked against main and the live site. The chains and the deployment are done; three small items remain.
Met:
networks/quantus.json), plus Localhost for a dev node. Prefix 189 comes from the runtime.99483d9 (deploy.yaml, infra-setup.sh). https://qapi.blackbeard.observer serves the current build.Remaining:
polkadot-api/papi-console upstream remote. Also fix the local origin, which still points at the old quantus/papi-console path rather than blackbeard/qapi.The issue's point about wss-only from a deployed page was never written down, and may not hold: browsers generally allow ws://127.0.0.1 from an https page, so a deployed console may reach a local node.
Checked against main and 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.patch keeps only the type !== "enum" check. The deployed bundle has it, and no longer contains the Ecdsa/Ed25519/Sr25519 list.zkTreeRoot is in the block state.createV4Tx test, not through the console UI.Remaining:
b66b0a6).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.b66b0a6 feat: offer only signers that can sign for Quantus9d7b002 feat: label the blackbeard extension by namead67084 fix: say which block is missing instead of "e is not an object"32d8a6e chore: updated repo paths2c8cefa fix: remove the fork-with options, and name the workflow step for what it does6358367 feat: offer every Quantus endpoint, and drop the chains that are not post-quantum84f6095 fix: decode Quantus block headers, which is what was actually brokenThis 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.
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.
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:
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.
Shipped, because the page had no business failing silently:
Subscribe's fallback defaults to null, which is literally documented as "render null until the subscription exists", and that was the black page;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.
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.
0448d9d fix: say what is happening while connecting, and stop defaulting to a light client40927fa fix: build on the rust runner, because fedora-44's pnpm cannot start93641f2 fix: preflight the sudoers grants by destination, not by command path99483d9 feat: rename to the qapi console, and deploy it to oolon933ffec feat: accept the Quantus signature type, and list Quantus chains93641f2 fix: preflight the sudoers grants by destination, not by command path99483d9 feat: rename to the qapi console, and deploy it to oolonThe 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.
Both checked rather than assumed:
Unkown signer on exactly these inputs, so the check really was the only blocker.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.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.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.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.
balances.transfer_keep_alive signs 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.
933ffec feat: accept the Quantus signature type, and list Quantus chains