A post-quantum browser wallet for the Quantus Network, forked from the polkadot{.js} extension. It keeps upstream's shape — accounts in a popup, a signer injected into dapps — with the cryptography, encoding and account model Quantus actually needs:
DilithiumSignatureScheme), derived as quantus-cli and the mobile wallet derive them.Wormhole::UsedNullifiers so no node learns which ones are yours. Sending from a wormhole address
is not in the extension yet (quantus/extension#13).Work is tracked at git.lair.cafe/quantus/extension/issues; #1 is the epic.
There is no store listing. Build from source (below), or use the zips a build writes to the repo root.
about:debugging#/runtime/this-firefox → Load Temporary Add-on… → master-ff-build.zip.
Temporary add-ons are unloaded on restart; a permanent install of an unsigned add-on needs Developer
Edition or Nightly with xpinstall.signatures.required set to false.master-chrome-build.zip into a folder, then chrome://extensions
→ Developer mode → Load unpacked → that folder. Not packages/extension/build, which holds
whichever browser was built last.Everything the extension asks the network is a setting, and each can be pointed at your own infrastructure or turned off:
yarn install
yarn build # both browsers; writes master-chrome-build.zip and master-ff-build.zip
yarn test # background specs (the upstream React specs are not run: see quantus/extension#14)
yarn lint
The Quantus forks it builds against (@polkadot/util-crypto, @polkadot/keyring, @polkadot/ui-keyring,
@polkadot/networks) and @quantus/crypto / @quantus/codec come from the Gitea package registry;
see .yarnrc.yml. scripts/tier1 submits real extrinsics against a node, and scripts/tier2 is a
dapp harness for exercising the injected signer in a browser.
The packages keep their upstream @polkadot/extension-* names:
Use @polkadot/extension-dapp, or any library that enumerates
window.injectedWeb3; this extension appears there as blackbeard. Signing requests are encoded and
shown using the chain's own metadata, so a dapp does not need to supply any.
The extension injection interfaces are generic, i.e. it is designed to allow any extension developer to easily inject extensions (that conforms to a specific interface) and at the same time, it allows for any dapp developer to easily enable the interfaces from multiple extensions at the same time. It is not an all-or-nothing approach, but rather it is an ecosystem where the user can choose which extensions fit their style best.
From a dapp developer perspective, the only work needed is to include the @polkadot/extension-dapp package and call the appropriate enabling function to retrieve all the extensions and their associated interfaces.
From an extension developer perspective, the only work required is to enable the extension via the razor-thin @polkadot/extension-inject wrapper. Any dapp using the above interfaces will have access to the extension via this interface.
When there is more than one extension, each will populate an entry via the injection interface and each will be made available to the dapp. The Injected interface, as returned via enable, contains the following information for any compliant extension -
interface Injected {
// the interface for Accounts, as detailed below
readonly accounts: Accounts;
// the standard Signer interface for the API, as detailed below
readonly signer: Signer;
// not injected as of yet, subscribable provider for polkadot-js API injection,
// this can be passed to the API itself upon construction in the dapp
// readonly provider?: Provider
}
interface Account = {
// ss-58 encoded address
readonly address: string;
// the genesisHash for this account (empty if applicable to all)
readonly genesisHash?: string;
// (optional) name for display
readonly name?: string;
};
// exposes accounts
interface Accounts {
// retrieves the list of accounts for right now
get: () => Promise<Account[]>;
// (optional) subscribe to all accounts, updating as they change
subscribe?: (cb: (accounts: Account[]) => any) => () => void
}
// a signer that communicates with the extension via sendMessage
interface Signer extends SignerInterface {
// no specific signer extensions, exposes the `sign` interface for use by
// the polkadot-js API, confirming the Signer interface for this API
}
The information contained in this section may change and evolve. It is therefore recommended that all access is done via the @polkadot/extension-dapp (for dapps) and extension-inject (for extensions) packages, which removes the need to work with the lower-level targets.
The extension injects injectedWeb3 into the global window object, exposing the following: (This is meant to be generic across extensions, allowing any dapp to utilize multiple signers, and pull accounts from multiples, as they are available.)
window.injectedWeb3 = {
// this is the name for this extension, there could be multiples injected,
// each with their own keys, here `blackbeard` is for this extension
'blackbeard': {
// semver for the package
version: '0.1.0',
// this is called to enable the injection, and returns an injected
// object containing the accounts, signer and provider interfaces
// (or it will reject if not authorized)
enable (originName: string): Promise<Injected>
}
}
A wallet's password encrypts the stored recovery phrase and each account's key; the phrase itself is
not protected by it, so anyone with the phrase controls every account the wallet shows. Import accepts
a BIP39 phrase, or a 0x-prefixed 32-byte seed for the dev accounts. There are no derivation paths
to type: account indices are added from the wallet's menu, and derive the same accounts
quantus-cli and the mobile wallet derive for that index.
79 activities
29033e8 feat: post-quantum only; no derivation, no MetaMask shim, no Ethereum addresses29033e8 feat: post-quantum only; no derivation, no MetaMask shim, no Ethereum addressesChecked against main. Everything except identicons is met.
Met:
quantus-cli: common's CLI vectors, and crystal_alice against the chain.util/decodeMethod.spec.ts).extension-chains/src/bundle.ts sets the format, and the spec includes a Bob SS58 vector.util/defaultPrefix.ts.Remaining: nobody has looked at identicons. Check one in the running extension, and note whether it is the polkadot identicon of the 32-byte account id and whether that is acceptable. That is a look-and-decide, not code; close once someone has looked.
wednesday, september 16, 2026 — 17:51:33 utcChecked against main. Most of this is superseded by the wallet model in #14. Two concrete items remain.
Met:
dilithium65 (background.ts, util/defaultType.ts).quantus-cli: the index-0 accounts of a mnemonic, both schemes (Extension.spec.ts wallets block, and common's dilithiumDerive.spec.ts).TEST_ADDRESS_HD_0 and TEST_WORMHOLE_ADDRESS, both also pinned by the mobile SDK, were checked in the running extension.canDerive is false for ML-DSA, and wallets add account indices instead.Remaining:
quantus-cli. Today the path is only correct by construction (//<n> → m/44'/189189'/n'/0'/scheme'), and the tests show only that index 1 differs from index 0. Add a CLI-derived address for an index ≥ 1 as a vector.Export.tsx and ExportAll.tsx are upstream's. They should put the recovery phrase ahead of the JSON file, and say the JSON restores only into this extension.Closing: stages A and B are on main (22eb1c25 to 957db67f) and have been checked against a real mainnet wallet (31 deposits, 9.34 QTC).
Correction to my stage B comment. It said the unchecked, unlock and spent paths were "covered by specs". That overstates it:
wormholeBalance or usedNullifiers themselves;A spent deposit has never been exercised end to end. The first real exit from a wallet this extension holds is the test.
If that logic changes, it needs unit tests with a fake provider and observer first. Sending from a wormhole address is #13.
wednesday, september 16, 2026 — 17:50:47 utcVerified against main, closing.
hw-ledger dependency are gone from the tree, package.json and yarn.lock (87a0b392), and ALLOWED_PATH is pruned.Verified against main, closing.
specVersion: RequestExtrinsicSign uses contextForSpec, rebuilt on @quantus/codec in 34759485.transfer_keep_alive signed under the spec-148 context was included on Heisenberg, at block 1050475 (be691499) and again at 1050581 after the extension was rewired (34759485).1010: bad signature (tier-1 case, 4f759c40), and RequestExtrinsicSign.spec.ts covers it too.signRaw returns sig ‖ pk under the empty context and verifies from the address alone. The specs also check that it does not verify under the extrinsic context.The on-chain proof went through the tier-1 script, which uses the same codec, keyring and context. An extension-popup-signed extrinsic seen in a block is still owed, under #7 tier 2 and qapi#1.
wednesday, september 16, 2026 — 17:50:39 utcVerified against main, closing.
.yarnrc.yml routes the @quantus scope, and package.json pins keyring, networks, ui-keyring and util-crypto to the -quantus.2 tarballs (bff8eec1). Nothing points at local fork checkouts.'wasm-unsafe-eval'. @quantus/crypto runs in the Firefox background page and in the Chrome MV3 service worker, since wallets were created and used in both browsers.e0aa7836); 851500a5 fixed the manifest author and homepage_url it left behind.package.json authorship stays upstream's, as credit for the code.851500a fix: manifest author and homepage are blackbeard's, not polkadot{.js}'se0aa783 feat: rebrand as blackbeard957db67 feat: show what a wallet's wormhole account can still spenda4e19a4 feat: wormhole balances in the background, from chain state and a chosen observerc378b36 feat: wallets in the popup, one card per secret with a tab per account22eb1c2 feat: wallets in the background, one secret shown as every account it unlocks6515b39 fix: switch balances to a new endpoint as soon as it is chosenStage B has now been checked end to end against a real mainnet wallet, the operator's own, whose wormhole receive address 0 is where its mining rewards go.
Still unexercised: a deposit that has been spent. This wallet has exited nothing, so "already exited" is 0 and the bucket lookup found none of its nullifiers used.
wednesday, september 16, 2026 — 16:23:23 utcStage B (read-only wormhole balance) is on quantus-accounts. It differs from the plan above in three ways:
GET /v1/chains/{chain}/wormhole/deposits is blackbeard/observer#22 and is deployed. While building on it, observer turned out to be missing every event kind before its 2026-09-10 policy change (mainnet below 18510); that is blackbeard/observer#23, fixed and deployed, and history is being re-read. The extension checks each address's deposits against the chain's Wormhole::TransferCount, so a gap shows as "N deposits not indexed yet" and never as a smaller balance.UsedNullifiers is Blake2_128Concat, so a key lookup hands the node the nullifier. The extension reads whole buckets by the first byte of blake2_128(nullifier): at least 16, random decoys included, in shuffled order. Checked live: a spent nullifier from a public ProofVerified event is found in its bucket.@quantus/crypto 0.3.0 computes them, pinned to qp-wormhole-circuit's Nullifier::from_preimage.Commits:
quantus-crypto: nullifiers, published as 0.3.0.a4e19a4b: background (balance, unlock, storage and removal) with specs.957db67f: wormhole tab, unlock page, and the "look up wormhole deposits with" setting.Not yet exercised against a wallet that holds wormhole deposits: nothing on hand has both deposits and a known phrase. The unchecked, unlock and spent paths are covered by specs and by the live bucket check, not by an end-to-end run.
wednesday, september 16, 2026 — 15:36:13 utc957db67 feat: show what a wallet's wormhole account can still spenda4e19a4 feat: wormhole balances in the background, from chain state and a chosen observerStage A is on quantus-accounts:
0b1cfe35, published as @quantus/crypto 0.2.0: wormholeAddresses(). It is pinned to the chain node's TEST_WORMHOLE_ADDRESS in Rust and through the packed package.22eb1c25: background wallets (create, preview, add account, rename, forget) and 9 specs against independent vectors.c378b36a: wallet cards with ML-DSA-65 / ML-DSA-87 / Wormhole tabs and an account-index selector. Import and create take a phrase or seed with no key type or path, and show a three-address preview.Checked in Firefox against the built extension:
TEST_MNEMONIC previews TEST_WORMHOLE_ADDRESS and TEST_ADDRESS_HD_0 (the ML-DSA-87 account 0).Still to do for stage A: nothing is migrated automatically. Accounts imported before wallets stay standalone; re-importing their phrase as a wallet adopts the same pairs.
Stage B, the wormhole balance, is next.
wednesday, september 16, 2026 — 13:14:42 utcc378b36 feat: wallets in the popup, one card per secret with a tab per account22eb1c2 feat: wallets in the background, one secret shown as every account it unlocksZK tree sizes, read from ZkTree.LeafCount on 2026-09-16:
| Chain | Leaves | Depth | |---|---|---| | Mainnet | 110,576 | 9 | | Heisenberg | 2,042,328 | 11 | | Planck | 2,414,744 | 11 |
Each ZkTree.Leaves value is 60 bytes. Planck is the newer testnet, but its tree is already bigger than Heisenberg's, so a full leaf scan (download every leaf and keep the recipient's) is workable only on mainnet today. Stage B needs a discovery method that works on the testnets, whether indexer queries or something else, and can treat a full scan as the more private option where the tree is small enough.
6515b39 fix: switch balances to a new endpoint as soon as it is chosenebd3b4b fix: keep extension pages and dapps subscribed when the background restarts4a052de feat: create and import accounts under either ML-DSA parameter setf61cfd4 chore: let the tier-1 harness send a configurable amountbfd3aa8 feat: fetch a chain's metadata from a node, not from the dapp asking for a signatureI reported the cross-tier hex comparison as passing and implied tier 3 was in hand. The comparison is real — createV4Tx assembles byte-identical output to scripts/tier1/submit.mjs, tested directly against Heisenberg metadata — but I tested the assembly function, not the console, and the console does not work against Quantus at all.
Deployed at https://qapi.blackbeard.observer, it renders a bare black page and never finishes loading. Two causes, neither of them the signature:
qp_header::Header encodes number as a plain u32 where Substrate uses #[codec(compact)], and carries an extra zk_tree_root: H256 before the digest. Reading the number as a Compact consumes 2 bytes where 4 were written, everything after shifts, and the digest vector dies on an unknown variant index — innerDecoder is not a function, retried forever.h(rawHeader) === blockHash. No standard hasher satisfies that here, and the preimage is a different construction rather than a different hash of the same bytes.Both are on quantus/papi-console#1 with the byte-level evidence, and the work is quantus/papi-console#4.
So the two tier-3 boxes on this issue — the console connecting and listing extension accounts, and signing a transfer through it — are further away than when I wrote that comment, not closer. What survives is the corroboration itself, which came from calling papi's assembly directly rather than through a browser, and which I should have described that way rather than as "tier 3".
The checklist item "the extrinsic hex from tier 1, tier 2 and tier 3 is identical" stays ticked on the strength of that direct comparison; the tier-3 acceptance criteria on quantus/papi-console#1 do not.
wednesday, september 16, 2026 — 06:16:05 utcpapi assembled 7300 bytes
IDENTICAL to the tier-1 harness
createV4Tx from the patched papi console, fed the same call, nonce and signature as scripts/tier1/submit.mjs, produces the same bytes. quantus/papi-console#1 has the detail.
This matters more than the other checks on this issue. Everything else so far has been our stack agreeing with itself: @quantus/codec builds the payload, @quantus/crypto signs it, and both are ours. polkadot-api shares no code with the five polkadot-js forks — it reads the extrinsic's Address and Signature types out of the metadata and assembles length-agnostically, by an entirely separate implementation. Two independent readings of the same runtime producing the same 7 300 bytes is the corroboration this tier was designed to get.
utility.batch_all pushing the payload past 256 bytesTier 2's copy of that comparison is not done: the harness builds its extrinsic with the same @quantus/codec call tier 1 uses, so comparing them would be comparing a function with itself. What tier 2 adds is the extension's signature rather than a keyring's, and that is what remains unrun.
Everything that needs a browser with the extension installed:
enable(), list accounts, signRaw, and build-sign-submit through the popupI have not managed either. Firefox can install the extension, but its own UI is a privileged browsing context this tooling cannot script — it gets as far as the first-run Welcome screen and no further — and the last attempt failed outright with a navigation timeout. Both harnesses build and serve; what is untested is a human clicking through them.
Everything that can be verified without a browser has been, against a real node: signing, payload construction, context separation, the BLAKE2b branch, both ML-DSA schemes, JSON round-trip, balance reads, and now an independent implementation agreeing on the wire format byte for byte.
tuesday, september 15, 2026 — 19:18:50 utcdfb98b2 feat: put "open in a tab" in the header, one click from anywhere8e767c0 fix: remove the Asset Hub migration notice, and the strings left by other removalsI proposed off-by-default when I filed this. That was the wrong call, and the reason is straightforward: a user can point this at their own node, which is what anyone who doubts the defaults should be doing anyway. Balances off by default means nobody sees them, which is the same as not building it.
So: on by default, pointed at mainnet, with the endpoint editable and "off" in the list.
Balances in the background holds one connection and reads System::Account through @quantus/codec's storage addressing (quantus/wasm#4), then watches it with state_subscribeStorage. Two details that are easy to get wrong:
System::Account is a Default entry. A node returning nothing means a zero balance, not a failure. Conflating them would show an error for every account at the moment it is created, which is the worst possible time to tell somebody their wallet is broken.qzk1Nxai3dZD… 477335996339077 HEI (12dp) crystal_alice
qzkYEQv8tQsm… 31586533849067 HEI (12dp) crystal_bob
qznQBCDo2Ewd… 49981956146000 HEI (12dp) the ML-DSA-65 account from #7's cases
qzmPZ35th1cX… 0 HEI (12dp) never funded — 0, not an error
then, with a transfer submitted while watching:
qzk1Nxai3dZD… 477325015412077 HEI (alice, down by the transfer plus fee)
qzkYEQv8tQsm… 31587533849067 HEI (bob, up by exactly 1000000000)
qznQBCDo2Ewd… 49981956146000 HEI (untouched)
So the read, the decode, the subscription and the merge all work against a real node.
Known endpoints, all verified reachable on 2026-09-15:
| | |
| --- | --- |
| Quantus | wss://rpc1-mainnet.quantus.com |
| Heisenberg | wss://a1-heisenberg.quantus.cat, wss://a2-heisenberg.quantus.cat |
| Planck | wss://a1-planck.quantus.cat, wss://a2-planck.quantus.cat |
A dropdown offers those plus "off"; a text field beside it takes anything, which is the point of the feature rather than an edge case. Nothing connects until the popup asks, and the connection closes with the last subscriber — a signer holding a socket open to somebody's node for the life of the browser would report far more than this needs.
Not stored in @polkadot/ui-settings' apiUrl. That field means the endpoint polkadot-js apps talks to, and it ships a default of ws://127.0.0.1:9944/ — reusing it would have pointed the extension at a local node nobody is running and shown no balances at all, with nothing on screen to say why. It has its own localStorage key, where an empty string is a deliberate "off" and an absent key means "never chosen".
String arithmetic throughout. At 12 decimals 2^53 smallest units is about 9 007 tokens, so any balance above that loses digits to a Number — quietly, on the one screen whose whole job is telling somebody how much money they have. The fraction truncates rather than rounds, so a displayed amount is never more than the account actually holds. Tested.
(I claimed in a first draft of that test that crystal_alice's 527 HEI already exceeded 2^53. It does not — the threshold is 9 007 tokens, not a few hundred. Corrected.)
0, not an errorThe account list itself is unverified visually — driving the extension's own UI needs a privileged browsing context this tooling cannot script, the same limitation as #7 tier 2. The background half above is verified against a real node, and the formatter is unit tested, so what remains untested is the popup rendering a string it has been handed.
tuesday, september 15, 2026 — 12:50:30 utcb32b243 feat: show account balances, from a node the user choosesThis was the interesting one, and it got worse before it got better. Moving call decoding onto @quantus/codec (#7) left the approval screen printing
{ "dest": { "Id": "0x300bb607ba60e89461d2f9005668231ceb30237b33db53a614164b8590965519" } }
— a correct description of the value and the one form nobody reads. Upstream got SS58 for free from toHuman(); the codec deliberately does not, because an SS58 prefix is a property of the chain a caller is talking to rather than of the metadata, and inferring one would put a plausible, wrong address in front of the person being asked to approve a transfer.
So @quantus/codec gained setSs58Format, off until set, and metadataExpand passes the one the chain's own metadata definition carries. The screen now shows qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y.
Account ids are found by their registry path, not by length — scale_value carries each value's type id as its context, so the check is on what the runtime declared. A block hash is 32 bytes too, and rendering one as an address would be a lie a reader cannot catch; there is a test that System::BlockHash stays hex with a prefix set.
The SS58 vector is crystal_bob taken from the chain rather than computed, which also pins the two-byte prefix form. 189 needs it, and getting that arithmetic wrong yields an address that looks right and belongs to nobody.
decodeMethod moved out of Extrinsic.tsx into util/ with tests, because yarn test skips .spec.tsx and as a component method it had no coverage. They pin three things that matter more than the happy path: metadata from a different spec version is refused rather than shown (it decodes a call into something plausible and wrong); a chain with no runtime renders hex; and bytes that are not a call must not throw, since an exception there lands between somebody and their funds.
Recording it as the decision this issue asked for. Upstream falls back to 42, the generic Substrate prefix, because it is a wallet for every Substrate chain and has no reason to prefer one. This one does: every account it can hold is a Quantus account, so 42 would show a correct address in a form no Quantus tool displays — same account id, same funds, unfamiliar string. Somebody comparing the extension against quantus-cli or the mobile wallet would reasonably conclude they had created the wrong account.
A chain's own ss58Format still wins where one is known, and the user setting still overrides. DEFAULT_PREFIX in extension-ui/src/util/ carries the reasoning.
quantus-cli and the mobile wallet show — settled by the CLI fixtures in quantus/common#3, and independently by tier 1: the seed 0x0000…00 resolves to qzk1Nxai…2vSn7, which is the dev-genesis account actually holding funds on Heisenberg, so the chain agrees toobalances.transfer_keep_alive from a dapp renders with the right pallet, call and arguments in the signing popup@quantus/codec reads v14 and refuses anything else loudly rather than mis-reading it. The premise in this issue — that @polkadot/types would decode it as-is — turned out to be false, and quantus/api#1 has why.7fbcae3 feat: show addresses, not account ids, and default the prefix to 189scripts/tier1/cases.mjs, against Heisenberg at spec 148:
PASS the empty signing context is rejected — 1010: Invalid Transaction: Transaction has a bad signature
PASS a payload over 256 bytes is hashed before signing — 279 bytes -> blake2 32
PASS a batch_all over 256 bytes dispatches — block 1050692, holds 50000000000000
PASS an ML-DSA-65 account signs and is accepted — block 1050693, moved 1000000000
PASS an account exported to JSON and re-imported still signs — block 1050694, address preserved=true, moved 1000000000
The first is the one worth having. Every other failure mode here surfaces as a decode error somewhere; a signature under the wrong FIPS 204 context is well-formed, verifies against its own key, and is refused only by the runtime — so nothing local can tell it from a correct one. It is now pinned against a real node.
utility.batch_all — pushes the signing payload past 256 bytes so the blake2 branch is actually exercisedThe last one needs tier 3.
The first run of this matrix reported two batch_all calls as reverted. They had not reverted — both had funded their target with exactly the 50 HEI they were asked for, and the accounts were sitting there holding it while the run printed holds 0.
system_accountNextIndex counts pending pool transactions. It advances the moment a node accepts a transaction, before it is in a block and before it has dispatched. Waiting on it and then reading a balance shows the state from before the dispatch. It is the right call for picking a nonce to sign with and the wrong one for "has this happened yet".
submitAndInclude now looks for the extrinsic's own bytes in the blocks that arrive, which also exercises the decoder against real chain data — every Quantus block opens with a bare-v5 timestamp inherent that @polkadot/api cannot read at all.
Two things follow from it that are worth stating separately, because they are different claims:
batch_all is atomic, and a reverted dispatch still advances the nonce and still appears in a block. Only the balance settles it.payment_queryInfo puts a 5,341-byte ML-DSA-65 extrinsic at a partialFee of 8,196,927,000 — about 0.008 HEI. The Inability to pay some fees in the first run was the funding transfer not having landed yet, not the fee being large.@quantus/codec gained storage addressing for this (quantus/wasm#4, now closed): storageTarget resolves a pallet and item to twox128(prefix) ‖ twox128(item) plus each map key hashed by the hasher the entry declares, and carries the Default/Optional distinction — an account nobody has funded reads as a zero balance through the first and as an error through the second. Nothing in it knows that System::Account is a Blake2_128Concat map over an AccountId32.
It also gained nested calls: a multi-field variant with named fields only accepted a positional array, so Utility.batch_all could not be encoded at all. That is what the batch_all case above is really testing on the codec side.
submit.mjs moved onto the same shared chain.mjs and now asserts the recipient's balance moved rather than printing it, which it could not do before storage reads existed.
Also of note for the runtime's own surface: this chain has Utility.batch_all but not Utility.batch or Utility.force_batch. Narrower than vanilla Substrate, and the sort of thing a hand-written decoder would never find out.
4f759c4 test: the tier-1 cases that prove the plumbing, not the happy path@quantus/codecTwo pieces landed.
Tier 1 proved the crypto in a script that used the forked keyring directly. The extension itself was still calling registry.createType('ExtrinsicPayload', …).sign(pair), which cannot work here for either of its halves — @polkadot/types cannot describe this chain, and ExtrinsicPayload.sign has nowhere to put a FIPS 204 context. So:
RequestExtrinsicSign takes a Runtime instead of a polkadot-js Registry.metadataExpand builds a Runtime from rawMetadata rather than calling registry.setMetadata, which throws on this chain's metadata.Extrinsic.tsx decodes the call through the runtime. Without it the approval screen falls back to raw hex for every transaction — the failure mode where somebody approves bytes nobody read to them.era arrives already SCALE-encoded; it is round-tripped against the runtime's own Era type rather than appended on trust (@quantus/codec 0.2.0).A chain whose metadata the extension does not have is now one it refuses to sign an extrinsic for. That is a deliberate product change. Without the runtime's own description of its signed extensions, building a payload means guessing, and a signature over the wrong bytes comes back from a node as BadProof — which is also what it says about a wrong key, so the user sees a failure nobody can diagnose. The refusal rejects the dapp's promise as well as the popup's, so a page is told rather than left waiting forever.
Re-verified after the rewire: included at Heisenberg block 1050581, nonce 2762 → 2763.
scripts/tier2/ — yarn tier2 bundles it, the README has the run steps and the dev-account seeds. It prints the call, the extra, the payload that was signed, the signature that came back, the assembled extrinsic and the round trip decoded back out of it. Nothing in it uses @polkadot/api; the WebSocket is a JSON-RPC client that decodes nothing.
It also exercises metadata.provide, which is no longer optional given the refusal above.
Verified in a browser: the page loads under Firefox with the extension installed, and @quantus/codec parses Heisenberg's 101,493-byte metadata client side and reports extrinsic v4 with all twelve signed extensions, ReversibleTransactionExtension and WormholeProofRecorderExtension included.
Not verified: the injected signing round trip. Driving the extension's own UI needs a privileged browsing context that the available tooling cannot script — the first-run Welcome screen is as far as it gets — so importing an account and approving a request still wants a human at a keyboard. The background half is covered by Extension.spec.ts, which runs the full pub(extrinsic.sign) → pri(signing.approve.password) path against real metadata, so what is untested is specifically the page → content script → background bridge and the popup, neither of which this work changed much.
Upstream's custom user extension block was five variations on "does this agree with @polkadot/api". That comparison is meaningless now, and the userExtensions mechanism it exercised — a dapp declaring in JavaScript what an unrecognised signed extension contributes to the payload — is gone. Its absence is the point: the runtime declares its extensions and the wallet reads them, so there is nothing for a dapp to assert and no way for it to be believed. Replaced with tests against real Heisenberg metadata, committed as a fixture.
Two things that turned up while doing it:
keyring.loadAll({ store }) with no type, so every account it made was sr25519 — it had been testing the one keypair type this fork does not target. It now passes type: 'dilithium65' as background.ts does.5FP3TT3E…, is ed25519 — what upstream's harness was silently getting from the keyring default. They now say so, and there is a new test that an ML-DSA parent cannot derive at all.84 tests pass, lint clean, the extension builds.
utility.batch_all pushing the payload past 256 bytes, so the BLAKE2b branch is exercisedTier 3 (quantus/papi-console) is untouched, and #11 (mortal eras) and quantus/wasm#4 (storage reads, for balances) came out of tier 1.
tuesday, september 15, 2026 — 11:58:23 utca29a68d feat: add the tier 2 harness dapp3475948 feat: sign and decode in the extension itself via @quantus/codecscripts/tier1/submit.mjs, against Heisenberg (wss://a1-heisenberg.quantus.cat):
signer qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7
chain quantus-runtime spec 148 tx 6
genesis 0xa5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f
extrinsic v4
nonce 2761
call 0x020300300bb607…02286bee
extra 0x00252b0000
payload 117 bytes -> signing 117
context QUANTUS_EXTRINSIC
signature 7220 bytes, variant 0x00
extrinsic 7300 bytes
round trip v4 signed=true {"Balances":{"transfer_keep_alive":{…}}}
accepted 0xd25bb081e4a87da6a2af79f038f6991e914d732322393096128894c0d644e806
included block 1050475 index 1 (0xa9bfb6b910fa263b65cd00ea4a5dbfd85a3cec450bde6acb5210ac24db1eca71)
decoded {"Balances":{"transfer_keep_alive":{"dest":{"Id":"0x300bb607…"},"value":"1000000000"}}}
signature {"Dilithium87":{"bytes":"0xbb2123ac224cbbc91245dabef4535e9d5…
nonce 2761 -> 2762
An ML-DSA-87 key created by the forked keyring from a seed, signing a
balances.transfer_keep_alive under the QUANTUS_EXTRINSIC FIPS 204 context,
accepted by a real node and included in a real block. The chain's Verify impl
checks hash_bytes(public) == signer before it checks the signature, so
inclusion also confirms the Poseidon2 account derivation agrees with the runtime.
@polkadot/api was out of the pathThe first run through @polkadot/api was rejected:
1010: Invalid Transaction: Transaction has a bad signature
and getting there had already needed a local patch to lift @polkadot/types'
2048-byte cap on fixed arrays. The full account is on quantus/api#1; the
conclusion is that its codec can neither read nor write this chain, and the
replacement is quantus/wasm#3 — @quantus/codec, which drives every byte from
metadata the node produced by running Metadata_metadata against the runtime
WASM in a given block's state.
The harness now uses WsProvider purely as a JSON-RPC transport. No type in it
is decoded by polkadot-js.
0x05 — bare, version 5 — next to this
signed extrinsic's 0x84, signed, version 4, while the metadata declares
version 4. Three numbers, all correct. @polkadot/api reads that byte as a
version and therefore cannot decode any block of this chain at all.CheckMortality's implicit is
the genesis hash and there is no birth block to agree with the node about.
Mortality is a hardening step and wants its own issue.system_accountNextIndex
rather than reading System::Account, because @quantus/codec does not do
storage keys yet. The extension will need that for balances — also its own
issue.I did not isolate which part of polkadot-js's payload was wrong, because that path is closed either way; the difference is noted on quantus/api#1.
Tier 1 closed. Tier 2 (the extension's own background signing, exercised through the UI) is next, and #6 is now unblocked.
tuesday, september 15, 2026 — 11:17:19 utcbe69149 feat: build and decode extrinsics with @quantus/codec, and prove tier 1Done, on quantus-accounts (7fa26252). 79 tests pass; typecheck, lint and build:chrome clean.
the context then has to travel through
ExtrinsicPayload.sign(pair)down topair.sign(), and that call passes no options today. Getting the plumbing right, or working around it, is the substance of this issue.
Answer: don't use that helper. It is two lines, and they are reproduced here with the context added —
const encoded = payload.toU8a({ method: true });
const toSign = encoded.length > 256 ? blake2AsU8a(encoded) : encoded;
return { signature: u8aToHex(pair.sign(toSign, { context, withType: true })) };
Forking @polkadot/types to thread an option through was the alternative, and is a much larger commitment for the same result. The 256-byte BLAKE2b rule is Substrate's own, from unchecked_extrinsic.rs; signer and runtime must apply it identically or nothing verifies, so it is spelled out rather than inherited.
Before this, the extension did not merely sign wrongly — it threw, because the keyring refuses to sign an ML-DSA pair without a context (quantus/common#5).
Empty context, deliberately not the extrinsic one. A signature made for a dapp login must never be replayable as a transfer, and the context is bound into the signature itself.
This issue asked which of u8aWrapBytes and the context is authoritative. The context is. u8aWrapBytes stays — it is polkadot-js's <Bytes>…</Bytes> convention, kept so a verifier written against it still sees what it expects — but it is an in-band wrapper a verifier can strip or forget, where the context cannot be.
No Quantus-specific bytes context is invented. Nothing in the ecosystem defines one; the chain and the SDK name only QUANTUS_EXTRINSIC, and making one up would produce signatures no other Quantus tool could verify.
Raw signing returns sig ‖ pk — not a convenience, since the account id is a one-way hash and a verifier holding only an address cannot recover the key.
1 for ML-DSA-65, then sig ‖ pk at the fixed lengthQUANTUS_EXTRINSIC and fails under the empty context — and the converse at spec 147signRaw result verifies against the signer's address with nothing else suppliedThe one criterion this cannot close by itself: "a signed balances.transfer_keep_alive is accepted by a node running spec ≥ 148". Everything here says the bytes are right; only a node can say they are accepted. That is #7 tier 1.
This issue said to rebase before starting, because upstream reworked the area in e66446ed ("derive raw vs extrinsic signing from the request channel"). That rework is already in the fork's base — Request/index.tsx branches on isExtrinsicRequest(request) rather than on payload shape — so there was nothing to reconcile.
7fa2625 feat: choose the signing context from specVersion, and return sig||pk for raw bytesOption 2 taken, on the strength of the capability actually being used:
yeah, i made good use of the address book and wouldn't want to lose that functionality.
Landed on quantus-remove-ledger (6dfd1099). Typecheck, lint and 65 tests clean; build:chrome completes.
The extension has no address book in the polkadot-js/apps sense — keyring.addresses is never touched here. What it has is external accounts: keyless entries that appear in the account list and are injected into dapps. That is what was at risk, and that is what is preserved.
A true contacts list (addresses you send to, distinct from accounts you hold) would be a new feature. Worth knowing if that is what was meant — it is not what this preserved.
TrackAddress takes a pasted SS58 string. For Quantus that is the common case anyway: the address is on screen in another wallet, not on a signing device.
It round-trips through decodeAddress/encodeAddress, which does two jobs — rejects a mistyped address by its checksum rather than storing one that can never receive anything, and normalises the prefix so an address pasted from a tool using a different one displays the way the rest of the extension displays it.
isExternal && !isHardware previously rendered the QR signer. A tracked address satisfies exactly that condition. Left alone, adding a watch-only address and then having a dapp request a signature would have displayed a Parity Signer QR — for an account that can never sign, in a protocol nothing in the Quantus ecosystem can read.
External accounts now show the decoded call and say plainly that the extension holds no key for them. Two specs rewritten to assert that rather than the QR scanner.
Signing/Qr.tsx went with it, along with the CMD_MORTAL / CMD_SIGN_MESSAGE Parity command bytes and _onSignature, whose only job was accepting a signature produced outside the extension.
approveSignSignature in the background is deliberately left. Unlike Ledger, which is gone for good, external signing comes back when this issue ports the flow to multipart UR — the message it carries is the right shape for that, and deleting it would only mean writing it again.
The UR port itself. Its scope is unchanged, minus the removal work now done. When picked up, note that the entry point is a different shape than before: scanning to add an address is now #9's territory, and this issue is specifically the signing round trip.
tuesday, september 15, 2026 — 09:19:28 utc6dfd109 feat: replace the Parity Signer QR flow with "track an address"A question this issue needs to answer before the QR-signer UI is removed, found while doing #5.
createAccountExternal — which calls keyring.addExternal and produces an account with no key — has exactly one caller in the whole codebase:
packages/extension-ui/src/Popup/ImportQr.tsx:51
createAccountExternal(name, account.content, account.genesisHash)
So the "attach external QR-signer account" flow is not merely a way to add a watch-only account, it is the only way. Delete it and the extension can no longer track an address it does not hold keys for — which is a useful capability quite independent of QR, and one nobody asked to lose.
That is why #5 stopped at Ledger and left this alone. The removal is not a cleanup; it is a product decision wearing a cleanup's clothes.
Option 2 is probably right for the first working build: it drops the protocol that cannot work while keeping the capability that has nothing to do with the protocol. It also pairs naturally with #9 — if the extension can show an address QR, being able to add an address by scanning one is the same feature in reverse, and the mobile app already emits exactly that format.
ImportQr.tsx hardcodes createAccountSuri(name, password, account.content, 'sr25519', …) on one branch, and the QR payload it parses is Parity Signer's. An account created through it on a Quantus build is unusable. Whatever is decided, that should not ship as-is.
Done. Branch quantus-remove-ledger (87a0b392). Typecheck clean, 65 tests pass, yarn build:chrome completes.
ImportLedger.tsx, useLedger.ts, LedgerSign.tsx, legerChains.ts (upstream's typo), the route and its ALLOWED_PATH entry, the add-menu item, the Ledger App setting, and the @polkadot/hw-ledger dependency. ~1250 lines.
Also the background handler: accountsCreateHardware, its message type and createAccountHardware in messaging were reachable only from ImportLedger. Dead code that mints accounts is worth deleting rather than leaving for someone to wire up again.
isHardware stays on AccountJson. Nothing creates such an account now, but a restored JSON can carry the flag, and the display branches reading it are correct for that case — an imported hardware account has no key here and cannot sign. Removing the flag would touch unrelated display code to delete a branch that is right.
canEditGenesis becomes unconditional. It was !isHardware || ledgerApp === 'generic', because the chain-specific Ledger app could not sign for an arbitrary genesis hash. With no hardware path and no such setting, nothing restricts it. The two spec cases asserting the old behaviour are rewritten into one asserting the new rule.
No "hardware wallets are not supported" note in the add menu.
The issue's reasoning was that a missing item reads as broken — but that only holds for someone looking for it, and in a Quantus-only wallet nobody is. A permanent dead menu entry, needing translation into every locale, is a worse wart than its absence. It belongs in the README when branding lands (#2). Flagging it because I wrote the requirement and am now declining it, not because it slipped.
The QR-signer entry point is still there — "Attach external QR-signer account" in the add menu, /account/import-qr, ImportQr.tsx. It speaks the Parity Signer protocol and hardcodes sr25519, so an account created through it is unusable on Quantus.
I did not remove it, because doing so is larger than it looks and touches a question worth deciding deliberately — see the note I have added to #10.
tuesday, september 15, 2026 — 09:03:32 utc87a0b39 feat: remove the Ledger account pathDone. Branch quantus-registry (bff8eec1). yarn build:chrome completes with post-quantum crypto in the bundle, and 65 tests pass.
| package | version |
|---|---|
| @quantus/crypto | 0.1.1 |
| @polkadot/keyring | 14.0.3-quantus.1 |
| @polkadot/util-crypto | 14.0.3-quantus.1 |
| @polkadot/networks | 14.0.3-quantus.1 |
| @polkadot/ui-keyring | 3.16.7-quantus.1 |
Everything else — api, types, rpc-provider, util, dev, x-*, hw-ledger — still comes from npm, which is why @polkadot is pinned per-package rather than scope-routed.
The first build:chrome failed:
Module not found: Error: Can't resolve 'quantus_crypto_bg.wasm'
in node_modules/@quantus/crypto/generated
wasm-bindgen's async __wbg_init contains new URL('quantus_crypto_bg.wasm', import.meta.url), and webpack resolves that statically at build time whether or not the branch can run. The file isn't in the package — the wasm ships base64'd in bytes.js, which is the package's whole purpose.
Ten Rust tests, twelve consumer assertions and a browser probe all passed while the package was unusable in a bundler. Fixed in quantus/wasm (e8bf9e20) by stripping the dead init after bindgen, with assertions so a wasm-bindgen upgrade fails the build rather than silently shipping it again.
The lesson worth keeping: this package's consumers bundle, and nothing in its own test suite does.
buildHierarchy.spec.ts failed on account ordering. compareByNetwork resolves a genesis hash through getNetworkMap() → selectableNetworks, which this fork narrowed to post-quantum chains — so Polkadot and Kusama both resolved to '' and compared equal. The network-ordering tests were passing without ordering anything.
Rewritten with Quantus and Heisenberg, whose display names sort in the same direction the originals did, so the fixtures keep their shape. (I got the direction wrong on the first attempt and had to redo it — 'Quantus' < 'Quantus Heisenberg' matches 'Kusama' < 'Polkadot', not the reverse.)
background.js (1.1 MB) — QUANTUS_EXTRINSIC, the dilithium keypair types, ext_poseidon_hash, ext_mldsa_sign, and the base64 wasm payloadextension.js (3.5 MB) — the Quantus network entry, QTC, the mainnet genesis hashThe split is correct: signing lives in the service worker, the UI only renders addresses.
The branding item from the original body — name, description, icons, and the polkadot{.js} strings in both manifests. Untouched. It decides whether a user can tell this apart from the upstream extension when both are installed, which they will be.
Also still true: no CI publishes these packages. Today it is a manual npm publish --tag quantus per package against pass gitea/package-publisher-token. Fine while one person is building; worth a Gitea Actions workflow before a second.
bff8eec build: consume the Quantus forks from the Gitea registryThe "how are the forks consumed" question is answered and in place. Option 2 — a private registry.
Gitea's npm registry at https://git.lair.cafe/api/packages/quantus/npm/:
| package | version |
|---|---|
| @quantus/crypto | 0.1.0 |
| @polkadot/keyring | 14.0.3 |
| @polkadot/util-crypto | 14.0.3 |
| @polkadot/networks | 14.0.3 |
Reads are anonymous — verified — so a consumer needs no credential to install. Publishing uses pass gitea/package-publisher-token, which is now in the agent credential store with its blast radius documented.
@polkadot/util is deliberately not published: this fork does not modify it, so it comes from npm like the rest of the scope.
@quantus is routed as a whole scope in .yarnrc.yml:
npmScopes:
quantus:
npmRegistryServer: "https://git.lair.cafe/api/packages/quantus/npm/"
Safe, because we own every name in that scope.
@polkadot is not, and must not be. Most of that scope is unforked — util, dev, dev-ts, ui-settings, x-*, wasm-crypto and more — so routing it would send yarn looking for dozens of packages that are not there. The three forks are pinned individually to tarball URLs in resolutions:
"@polkadot/keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3/keyring-14.0.3.tgz"
Worth knowing when this repo is wired up: the extension will need the same split. It depends on far more of the @polkadot scope than ui does.
quantus/ui carried four workarounds, all for one cause — node resolves a portal or link symlink to its realpath, after which @polkadot/util-crypto resolved inside common's tree, where it is a workspace source directory with no exports map. All four are gone:
../common/packages/*/buildtsconfig.base.json *.d.ts path mappingstsconfig.json runtime .js overrides for @polkadot/dev-ts--preserve-symlinks in the test scriptstsconfig.base.json and tsconfig.json are now byte-identical to upstream again, which is the rebasability convention honoured rather than merely stated.
common: build clean, 3084 tests passui: build clean, 46 tests pass including the 5 ML-DSA ones, and the installed @polkadot/util-crypto is confirmed to be the fork (has dilithium/, not a symlink)ui lint: 0 errors, down from 25 on pristine upstream — those were no-unsafe-assignment/no-unsafe-argument on values that were any only because the types never resolvedA version cannot be overwritten in place — npm refuses with "You cannot publish over the previously published versions". Republishing during development is delete-then-publish:
curl -X DELETE -H "Authorization: token $T" \
"https://git.lair.cafe/api/v1/packages/quantus/npm/@polkadot%2Fkeyring/14.0.3"
The token can do this (write:package covers delete, returns 204). Worth deciding before long whether fork builds get a distinguishing version — 14.0.3-quantus.1 or similar — rather than shadowing upstream's number. Publishing under upstream's exact version is unambiguous in a lockfile because the URL is recorded, but it reads confusingly in yarn why.
Correction to the body: the CLI-import work is quantus/common#7, not #8 — that number is this issue. The conversion lives in common because it is keyring-adjacent and testable there; the extension side is the import screen that calls it.
Two constraints for this issue, both settled in common and both changing what the UI should do.
quantus-cli writes an entirely different wallet format — Argon2id + AES-256-GCM around a JSON payload, where polkadot-js uses scrypt + NaCl secretbox around PKCS8. Details in quantus/common#3.
Neither tool can read the other's file. Import is fine, because a CLI wallet's mnemonic moves it and that path is verified against real CLI wallets. Export is the problem: a backup this extension writes is readable only by polkadot-js-format tooling, so a user whose only backup is our JSON, with the extension unavailable, cannot recover through quantus-cli or the mobile wallet.
So the backup flow must present the mnemonic at least as prominently as the JSON file, and should say plainly that the JSON is extension-specific while the mnemonic works everywhere. That is a UI decision, not a format one — implementing CLI-format export was considered and rejected, since the format is mid-flight (encryption_version: 2 with empty kyber_* fields) and the mnemonic already covers it.
One exception worth handling: dev-genesis wallets have no mnemonic (derivation_path: "m/", raw seed). They move as a hex seed. seedValidate already accepts a 256-bit hex seed, so the import side works; anything offering "back up your mnemonic" needs to not claim one exists for those accounts.
quantus/common#4 landed the suri syntax:
| form | meaning |
|---|---|
| (nothing) | account 0 |
| //<n> | account index n |
| //m/44'/189189'/… | explicit path |
| anything else | throws |
Send //<n>. //Alice throws, /0 throws, and an unhardened level in an explicit path throws — deliberately, since reinterpreting any of them would produce an address no other Quantus tool derives.
More importantly for the UI: pair.derive() refuses, and cannot do otherwise. ML-DSA keys are not derivable from one another — the Quantus tree derives each account from the mnemonic independently — so "derive a child from this account" is not an unimplemented feature, it is an impossible one. The error names createFromUri and the mnemonic.
That settles the canDerive.ts question this issue left open: it is not a temporary omission to revisit. The derive-from-parent flow in Popup/Derive/* has no ML-DSA meaning at all, and what replaces it is an account index chosen at creation or import time, against the mnemonic the user already has. Worth doing as part of the create/import screens rather than as a separate action that only ever fails.
d7c9ce2 0.64.0 (#1625)91f0b71 [CI Skip] bump/beta 0.63.2-1-xe66446e fix: derive raw vs extrinsic signing from the request channel (#1624)58093e3 [CI Skip] bump/beta 0.63.2-0-xd98cc67 chore: fix docs typos (#1623)