Moves to the quantus.3 forks of keyring, util-crypto, networks and ui-keyring,
which hold only ML-DSA keys and have deleted the classical primitives
(quantus/common#6). What in the extension depended on them goes too:
- the derive-from-parent flow (Popup/Derive, pri(derivation.*), the
AccountsContext master, the menu entries): ML-DSA pairs have no //hard/soft
derivation, and Quantus derives every account from the mnemonic instead
- extension-compat-metamask, which injected Ethereum accounts
- the Ethereum branches in Address and the //m/44'/60' suri suffix
- canDerive and nextDerivationPath, now unused
Restoring a JSON backup of a classical key shows the keyring's reason ("ed25519
keys are not quantum-safe and cannot be held here...") rather than "Invalid
Json file", for single and batch files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
A wormhole address's System.Account balance is a receipt total. An exit
credits the exit account and records a nullifier, and nothing debits the
address. So what a wallet account can still spend is worked out deposit by
deposit, in pri(wallets.wormholeBalance):
- Transfer counts come from the chain's Wormhole.TransferCount, read from the
balance node.
- Deposits come from a blackbeard observer
(/v1/chains/{chain}/wormhole/deposits, blackbeard/observer#22), matched by
genesis, never by name. Anything the chain counts that the observer didn't
return is reported as missing, never folded into a smaller balance.
- Spent status comes from Wormhole.UsedNullifiers, read in whole buckets by
the first byte of blake2_128(nullifier), at least 16 buckets with random
decoys, in a shuffled order. A lookup by key would hand the node the
nullifier, and exits publish theirs.
- A deposit whose nullifier was never computed is reported as unchecked.
Nullifiers are precomputed while the password is available and stored per
wallet account: 128 transfer counts for each of the 40 addresses when a phrase
wallet or account is made. pri(wallets.wormholeUnlock) extends only the
addresses whose deposits outgrew what is stored, to their count plus 128.
Forgetting a wallet removes its nullifiers for every account index along with
the secret and the pairs. They're kept under their own prefix, and the
manifests ask for unlimitedStorage, since a busy mining address alone runs to
megabytes.
@quantus/crypto 0.3.0 provides wormholeNullifiers, pinned there to the chain's
circuit crate.
Specs: a wallet stores a full window whose bytes match a direct derivation; a
raw seed stores none; unlock extends only the addresses past their window and
keeps what was there; forget removes every account's nullifiers.
Live against mainnet and the deployed observer:
- a spent nullifier taken from a public ProofVerified event is found in its
bucket (240 entries, 158 ms);
- for a mining address the chain counts 41,849 transfers, the observer
returned 24,702, and 17,147 are reported missing. That is blackbeard/
observer#23's gap, now being rescanned, surfacing as intended.
Part of quantus/extension#14.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
The account list shows each wallet as one card. Tabs switch between the
ML-DSA-65, ML-DSA-87 and wormhole accounts its secret unlocks, and a selector
switches between account indices when there is more than one. Pairs a wallet
owns aren't listed again on their own; accounts from JSON or quantus-cli
imports, and those made before wallets, still are. The last tab and index
shown are remembered per wallet in browser storage.
Import and create no longer ask for a key type, and import no longer takes a
derivation path. A recovery phrase or 0x seed previews all three account-0
addresses before anything is saved, so a user can recognise their wallet by
whichever address they know it by. This was the gap behind a wormhole account
"importing" as the wrong address. More accounts come from the wallet's menu
(Add account, password required), which replaces derive-from-parent.
- The wallet menu offers rename, add account, export of the selected signing
account, and forget wallet, which removes the stored secret and every pair.
- The wormhole tab shows the receive address and says plainly that balance
and sending aren't in the extension yet (#13, stage B of #14).
- Address no longer shows its hide-from-dapps toggle for an address with no
keyring account behind it, where it could only fail.
- The name/password step can now show why creating failed, such as a secret
that is already a wallet.
Verified in Firefox against the built extension:
- The chain node's TEST_MNEMONIC previews its TEST_WORMHOLE_ADDRESS (qzpWh4…)
and TEST_ADDRESS_HD_0 (qzm5QC…, ML-DSA-87). The latter matches a vector
these tests never used.
- The zero seed previews crystal_alice with no wormhole tab, and importing it
twice is refused.
- Storage holds the secret only encrypted.
- Add account refuses a wrong password, then adds account 1, and the selector
switches to it.
- Forget removes the wallet record and both of its keyring pairs, and leaves
the other wallet alone.
Part of quantus/extension#14.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
A wallet holds one secret: a recovery phrase, or a raw 32-byte seed for the
dev accounts. The secret is encrypted with the wallet password (the keyring's
scrypt + xsalsa20-poly1305 JSON encryption) under its own storage prefix,
quantus:wallet. From it the wallet derives, for each account index:
- an ML-DSA-65 and an ML-DSA-87 signing account, at the paths quantus-cli and
the mobile wallet use. These are ordinary keyring pairs tagged with walletId
and accountIndex, so signing, dapp injection and JSON export don't change.
They're named "<wallet> (ML-DSA-65)" and so on, so a dapp's account list can
tell them apart.
- 20 receive and 20 change wormhole addresses (m/44'/189189189'/n'/b'/k'),
derived by @quantus/crypto 0.2.0. Only addresses leave WASM. They're derived
while the password is available, so a balance can later be shown without
asking for it.
New messages: pri(wallets.subscribe, preview, create, addAccount, rename,
forget).
- The same secret can't become two wallets, because forgetting one would take
the other's pairs with it.
- A raw seed has one account and no wormhole addresses: wormhole derivation
starts from the 64-byte BIP39 seed.
- Adding an account needs the password; a wrong one is refused before any
pair is made.
@quantus/crypto is resolved to 0.2.0 everywhere, so the common forks and the
extension share one copy of the WASM instead of bundling two.
The specs use independent vectors only:
- the chain node's TEST_WORMHOLE_ADDRESS, also pinned by the mobile wallet's
SDK;
- quantus-cli's ML-DSA-65 and ML-DSA-87 accounts for its dev phrase;
- crystal_alice for the zero seed.
Part of quantus/extension#14.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
quantus/extension#7 tier 2: a page that asks the *extension* to sign, then
assembles and submits the extrinsic itself. `yarn tier2` bundles it; the README
has the run steps and where to get a funded dev account.
Every intermediate value is printed — 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. That display is the debugger when a node rejects
something, and it is exactly what a real app hides behind a success toast.
Nothing in it uses @polkadot/api. @quantus/codec produces every byte and the
WebSocket is a JSON-RPC client that decodes nothing.
Also extracts decodeMethod out of Extrinsic.tsx into util/, with tests. It moved
from `registry.createType('Call', data)` to the runtime in the previous commit and
was the one changed path with no coverage — `yarn test` skips .spec.tsx, so as a
component method it would have stayed that way. The tests pin the three cases
that matter more than the happy path: metadata from a different spec version
decodes a call into something plausible and wrong and must be refused; 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.
Verified so far 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. The injected
signing round trip is **not** yet verified end to end — driving the extension's
own UI needs a privileged browsing context this tooling cannot script — so the
popup half of tier 2 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.
Refs #7
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
The tier-1 harness now signs a real transfer and gets it into a real block:
accepted 0xd25bb081e4a87da6a2af79f038f6991e914d732322393096128894c0d644e806
included block 1050475 index 1
decoded {"Balances":{"transfer_keep_alive":{…,"value":"1000000000"}}}
nonce 2761 -> 2762
on Heisenberg at spec 148, signed by the forked keyring under QUANTUS_EXTRINSIC.
First end-to-end proof that the fork can spend.
It got there by dropping @polkadot/api entirely. Every byte on the wire is now
produced by @quantus/codec from metadata the node generated by running
Metadata_metadata against the runtime WASM; WsProvider appears only as a
JSON-RPC transport. The api route failed three times over — a 2048-byte cap on
fixed arrays, a preamble byte read as a version so that no Quantus block decodes
at all, and signed extensions *assumed* to be empty rather than read — and
quantus/api#1 has the evidence. The first submission through it was rejected as
`1010: Invalid Transaction: Transaction has a bad signature`; through the codec
the same transfer is accepted.
The harness reads the block back with the same runtime that built the extrinsic,
which also exercises what @polkadot/api cannot do at all: index 0 of every
Quantus block is a timestamp inherent whose preamble byte is 0x05 — bare,
version 5 — beside this signed extrinsic's 0x84, while the metadata declares
version 4.
Also moves the post-quantum chain gate here from @polkadot/networks. Gating the
library list broke @polkadot/api at import, because @polkadot/types-known throws
for a chain it holds upgrade history for but cannot find. What a wallet offers
is the wallet's decision, and this is the wallet.
Refs #7, quantus/api#1, quantus/wasm#3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Wires this repo to the four forked @polkadot packages and @quantus/crypto, and
the extension now builds with post-quantum crypto in it.
Two mechanisms, for the reason established in quantus/ui: @quantus is routed as a
whole scope in .yarnrc.yml, which is safe because we own every name in it, while
the forked @polkadot packages are pinned individually to tarball URLs in
resolutions. Routing the @polkadot scope would send yarn hunting for the dozens
of packages in it that are not forked — api, types, rpc-provider, dev, x-*,
hw-ledger and more — all of which must keep coming from npm.
The forks carry -quantus.N versions, so a resolution left out fails with "No
candidates found" rather than being quietly satisfied from npm by the unforked
package.
buildHierarchy.spec.ts is rewritten rather than deleted. compareByNetwork
resolves a genesis hash through getNetworkMap(), which is built from
selectableNetworks — and this fork only offers chains whose accounts are
post-quantum, so Polkadot and Kusama are no longer in it. Both fixtures resolved
to '' and compared equal, meaning the network-ordering tests passed without
ordering anything. They now use Quantus and Heisenberg, whose display names sort
in the same direction the originals did, so the fixtures keep their shape.
Verified in the built artifacts rather than assumed: background.js carries
QUANTUS_EXTRINSIC, the dilithium keypair types, ext_poseidon_hash, ext_mldsa_sign
and the base64 wasm payload; extension.js carries the Quantus network entry, QTC
and the mainnet genesis hash. The split is right — signing lives in the service
worker, the UI only needs to render addresses.
65 tests pass; `yarn build:chrome` completes.
Refs quantus/extension#2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f