Browser wallet
How the Chrome extension wallet is built: where keys and passwords live, the storage format, the WebAssembly core, the send flow, fee estimates, activity tracking, token discovery and packaging. It is written for auditors and for developers who want to read or change the wallet. Websites reach it through window.qubits.
What it is
Qubits Wallet is a Manifest V3 extension. All cryptography runs in qubits-wasm, a Rust crate compiled to WebAssembly, which shares its transaction builder and key file format with the CLI.
| Manifest entry | Value | Why |
|---|---|---|
permissions | storage | Key files, settings and the kept password |
host_permissions | None | The wallet reaches its node with ordinary cross-origin fetch, which the node's CORS policy allows |
content_scripts | https://qubits.sh/*, https://*.qubits.sh/* | window.qubits, only on those sites for now. Soon on every website |
content_security_policy | script-src 'self' 'wasm-unsafe-eval'; object-src 'self' | No remote code. WebAssembly needs the one exception |
minimum_chrome_version | 111 |
| File | Role |
|---|---|
extension/src/core.ts | Typed wrapper around the WebAssembly core |
extension/src/wallet.ts | The logic, without UI: storage, unlock, node client, fees, tokens, activity |
extension/src/popup.ts | The screens |
extension/src/shared/api.ts | Node API types and client |
extension/src/shared/format.ts | Amounts and other formatting |
extension/src/shared/address.ts | Bech32m addresses in plain TypeScript, because the background worker has no core |
extension/src/shared/dom.ts | A DOM builder that writes text nodes only, never innerHTML, so values read from the chain can't inject markup |
The wallet has no vault, recovery or waiting screens. The protocol keeps vaults, and the CLI manages them. If the account in use happens to be a vault, the wallet says a transfer leaves after the delay, and that is all.
An account here is a key: its address is the 1-of-1 address of its key file, and every transaction it signs has role owner. There is no equivalent of the CLI's --account, so a key rotated into an older account can't act for that account in the wallet, and a multisig account can't be used at all. The core refuses one with the policy needs several signatures; multisig signing is not supported here yet.
The wallet believes its node. It reads balances and receipts with plain API calls and checks no state proofs or finality proofs. Use a node you trust.
Key files
The wallet stores the key only as an encrypted key file, the format qubits key new writes. A file moves freely between the CLI and the wallet: Download the key file in Settings saves it as qubits-key-<8 characters of the address>.json, and Import a key file takes one from either side. Importing asks for no password, and the file keeps the one it was made with.
{
"version": 1,
"scheme": "ml-dsa-65",
"public_key": "0a89cbc19bf70ae575e6b7c9a89362062a43ce45...",
"kdf": {
"name": "argon2id",
"m_cost": 65536,
"t_cost": 3,
"p_cost": 1,
"salt": "81d9251aca960cd852b0e1acf17140df"
},
"cipher": {
"name": "xchacha20poly1305",
"nonce": "4ffeb324..."
},
"ciphertext": "..."
}
| Item | Value |
|---|---|
| Format version | 1 |
| Key derivation | Argon2id, version 0x13, 32-byte output |
| Default cost | 64 MiB (m_cost 65,536 KiB), 3 passes, 1 lane |
| Costs accepted when reading | At most 1 GiB, 64 passes, 16 lanes |
| Salt | 16 random bytes |
| Cipher | XChaCha20-Poly1305 |
| Nonce | 24 random bytes |
| Encrypted value | The key's seed, not the expanded key: 32 bytes for ML-DSA-65, 72 for SLH-DSA-SHA2-192s. The ciphertext is 16 bytes longer |
| Shortest password | 12 characters (MIN_PASSWORD_LEN) |
public_key is the raw key, without the scheme byte. The associated data binds the fields around the ciphertext, so changing any of them makes decryption fail:
"qubits-keystore/v1" || scheme byte || raw public key
|| m_cost (u32 LE) || t_cost (u32 LE) || p_cost (u32 LE) || salt
After decrypting, the code rebuilds the key from the seed and checks that it gives public_key. Unknown JSON fields are refused. See Cryptography.
What is stored where
| Store | Key | Holds | Lifetime |
|---|---|---|---|
chrome.storage.local | wallet | Saved, below | Until the last account is removed or the extension is |
chrome.storage.session | unlocked | Kept passwords | Memory only, gone when the browser closes |
chrome.storage.session | site-requests | Requests from websites waiting for an answer | The same. See window.qubits |
chrome.storage.local is not synced to a Google account. Opened as a plain web page, for example from vite preview, the popup falls back to localStorage and sessionStorage.
Saved, version 2
interface Saved {
version: 2;
accounts: Account[];
active: number; // index of the account in use
nodeUrl: string;
activity: Activity[];
tokens: Token[];
notTokens: NotToken[];
sites: Site[]; // which account each website may see
}
interface Account {
name: string; // only for this wallet's list; at most 32 characters
keystore: string; // the encrypted key file, as text
info: KeyInfo;
}
interface KeyInfo {
address: string;
public_key: string; // scheme-tagged, hex
scheme: "ml-dsa-65" | "slh-dsa-sha2-192s";
}
interface Activity {
id: string; // transaction id
at: number; // when it left, ms
account: string; // sender
payload: Payload; // as in the API's JSON
role: "owner" | "recovery";
fee: string;
status: "sent" | "included" | "failed";
height?: number;
note?: string; // why it failed
token?: TokenSend; // what a token transfer sent
site?: string; // the website that asked for this
}
interface TokenSend {
to: string;
amount: string;
symbol: string;
decimals: number;
}
interface Token {
chain: string; // genesis hash
contract: string;
name: string;
symbol: string;
decimals: number;
found?: boolean; // found by the wallet, not added by the user
hidden?: boolean; // the user asked not to see it
}
interface NotToken {
chain: string;
contract: string;
}
interface Site {
origin: string;
address: string;
at: number;
}
load() carries an older wallet forward. A version 1 record held one key, as { keystore, info, nodeUrl }, and becomes version 2 with the account named Account 1. A version 2 record missing tokens, notTokens or sites gets empty lists.
Removing an account drops it from accounts and leaves activity and sites as they are. Removing the last one deletes the whole wallet key.
Unlock
The wallet opens on an unlock screen. Nothing comes from the node until a password is right.
const UNLOCK_MS = 15 * 60_000;
type Unlocked = Record<Address, { password: string; until: number }>;
unlock()calls the core'scheckPassword, which runs Argon2id and decrypts the file in WebAssembly.- A right password goes into
chrome.storage.sessionunder the account's address, withuntilset to now plus 15 minutes. - Each screen calls
unlocked(), which returns the entry for the account in use, or deletes it onceuntilhas passed. - Signing reads the kept password and hands it to the core, which decrypts the file again for that one signature. The decrypted key never leaves WebAssembly and is not kept.
The window is measured from the password, and using the wallet doesn't extend it. Each account has its own entry, because each key file has its own password.
| Event | Effect on kept passwords |
|---|---|
| A right password | Kept for that account for 15 minutes |
| Creating a key | Kept at once, since it was just typed twice |
| Importing a key | Nothing kept: the first use asks |
| 15 minutes pass with the popup open | A timer (armLock) forgets all of them and shows the unlock screen |
| 15 minutes pass with the popup closed | The entry lapses at its next read |
| Log out | All forgotten. Keys stay, encrypted, and sites stay connected, though their requests then ask for the password |
| The browser closes | All gone |
| Removing an account | Its password is always asked for, unlocked or not, then all are forgotten |
The kept password is a plain string in session memory, readable by the extension's own pages and worker. It stays out of reach of content scripts, because Chrome limits session storage to trusted contexts by default, and
tests/site.mjschecks that. It is what lets a transaction go without a prompt at each step.
The WebAssembly core
qubits-wasm exports the functions below through wasm-bindgen. Apart from valid_until_ms, every argument is a string. Failures throw.
| Export | Arguments | Returns |
|---|---|---|
createKey | scheme, password | JSON: keystore, address, public_key, scheme |
keyInfo | keystore | JSON: address, public_key, scheme |
checkPassword | keystore, password | Nothing. Throws wrong password |
sign | status, account, keystore, password, role, payload, valid_until_ms | JSON: transaction (hex, ready to submit), id, fee |
decodeTransaction | hex | JSON: id and the SignedTransaction fields |
checkAddress | address | The canonical address. Throws otherwise |
publicKeyInfo | tagged_hex | JSON: address, public_key, scheme |
minPasswordLength | The number 12 |
status and account are the node's own JSON, payload the API's JSON form, and role the bare word owner or recovery. sign runs build_transaction, the same Rust the CLI runs. It reads the nonce and policy from account, reveals the 1-of-1 policy if the account has none yet, sets the fee to the exact minimum and signs. core.ts passes Date.now() + 1 hour as valid_until_ms.
The functions underneath are plain Rust, tested natively. One test executes a transaction signed through them under the real chain rules. The wasm-bindgen crate is pinned to =0.2.100 and must match the installed wasm-bindgen CLI exactly.
Sending
The wallet builds two kinds of payload itself. A native transfer is Payload::Transfer with asset 0. A token transfer is a contract call, token.transfer(to, amount): args is the 32 address bytes and a little-endian u128, amount is "0", and gas_limit is TOKEN_TRANSFER_GAS, 500,000.
- Open. Each screen starts with
overview()(GET /v1/status,GET /v1/accounts/{address}), thensettle(),discoverTokens()andholdings(). - Type. The form runs as you type, and signs nothing. The address goes through
checkAddressand is named as the account itself, another account here, sent to before (lastSentTo), or new. The amount goes throughbaseUnitsat the decimals of what is being sent, and the form shows the fee and what remains. An account with no native balance gets no form: a token's fee is paid in the native token too. - Review. It shows who receives, the amount to the last digit, the fee estimate, the balance after, and when it leaves. An address nothing was sent to before gets a warning.
- Send it. The wallet reads the kept password and calls
prepare(), which fetches status and account afresh, signs in the core, and decodes the signed bytes. If the unlock lapsed during the review, it asks and comes back to the same review. - Submit.
POST /v1/transactions. On a refusal with an HTTP status, the transaction goes into activity asfailedwith the node's message. If the node can't be reached, nothing is recorded, since nobody knows whether it arrived. - Wait.
waitIncluded()pollsGET /v1/transactions/{id}every 500 ms for up to 60 s, thensettle()runs and the wallet goes home. Running out of time is not a failure: activity shows the outcome when a block does.
Fee estimates
The chain's fee rule is in Transactions. The core sets the exact fee when it signs, but the review happens before signing, so the wallet estimates:
const SIGNATURE_BYTES = { "ml-dsa-65": 3_309, "slh-dsa-sha2-192s": 16_224 };
const PUBLIC_KEY_BYTES = { "ml-dsa-65": 1_952, "slh-dsa-sha2-192s": 48 };
const TX_OVERHEAD_BYTES = 240;
const CALL_BYTES = 80; // contract, function name's length, amount and gas limit
const GAS_PER_BYTE = 16;
const TOKEN_TRANSFER_GAS = 500_000;
feeEstimate = min_fee_per_byte * (signature + 240 [+ public key + 8 if auth is null])
callFeeEstimate = feeEstimate + min_fee_per_byte
* (80 + function name length + args bytes + gas_limit / 16, rounded up)
maxAmount = balance - feeEstimate, or 0
The estimate runs a little over the real fee. Here it is against real transactions on a local chain, in bytes of weight:
| Transaction | Estimate | Real |
|---|---|---|
| ML-DSA-65 transfer, first, with the public key | 5,509 | 5,432 |
| ML-DSA-65 transfer | 3,549 | 3,470 |
| SLH-DSA-SHA2-192s transfer, first | 16,520 | 16,443 |
| SLH-DSA-SHA2-192s transfer | 16,464 | 16,385 |
| Token transfer with 500,000 gas | 34,935 | 34,788 |
Max sends maxAmount, so it leaves the difference in the account. For a token transfer, the fee line reads "at most": the gas it doesn't use comes back.
Activity
Activity is what was sent from this wallet, newest first, at most 200 entries. It is not the account's history: the API has no index by sender, and the wallet doesn't scan blocks.
status | Means |
|---|---|
sent | Accepted by the node; no block seen yet |
included | In a block, and it did what it was for |
failed | The node refused it, its contract failed, or it expired. note says which |
settle() runs each time a screen opens and takes the five newest sent entries:
GET /v1/transactions/{id}.- If it is
included,failureOf()reads the receipt. Being in a block is not success: a reverted call is in a block too. - The entry gets its height, a status of
includedorfailed, and the fee replaced with what it cost in the end. - If the request fails and the entry is more than 65 minutes old, the hour a signature is valid plus 5 minutes, it becomes
failedwithnot included before it expired. Otherwise it is asked about again next time.
Receipt outcome | note |
|---|---|
None, or done | None |
out_of_gas | it ran out of gas |
trapped | the contract crashed |
invalid | the contract has no such function |
reverted | The reason, decoded as text |
The final fee of a contract call applies the same rule as the chain's refund:
fee paid = fee - (gas_limit - gas_used) / 16, rounded down, * min_fee_per_byte
An entry's detail shows its status, time, recipient, amount, fee and id, and for a site's call which site asked. Its explorer link goes to https://explorer.qubits.sh/id/<id>, and the explorer is not built.
Tokens
A token is a contract, and the chain has no record of who holds which token. So the wallet asks. See Standard contracts for the functions it relies on.
discoverTokens() runs each time a screen opens, and never fails:
GET /v1/contractsfor the full list.- Drop every contract already in
tokensornotTokensfor this chain. - For each one left, call
tokenInfo(), once per contract. - A token goes into
tokenswithfound: true. Anything else goes intonotTokens, so it isn't asked again. - If the node fails with anything but a 400 or 404, nothing is recorded, and the contract is asked again next time. A busy node says nothing about a contract.
tokenInfo() views name, symbol and decimals at once. The contract is a token if all three return done, as long as what it says is readable:
| Field | Rule |
|---|---|
name | 1 to 40 characters, no control characters |
symbol | 1 to 12 characters, no control characters |
decimals | Little-endian number, at most 38 |
holdings() views balance_of on each token that isn't hidden, and decides what to show:
| Token | Shown |
|---|---|
| Found by the wallet | While the balance isn't zero |
Added by hand (addToken) | Always, even at zero |
hidden | Never, until added again by hand |
A balance the node won't give is null and the wallet shows ?, never zero. A found token stays on screen in that state.
Hiding sets hidden and keeps the entry, or the next look would find the token again. Adding by hand clears found and hidden. All of this is per chain, by genesis hash, so changing the node to another chain starts from that chain's own list.
This scales only because governance alone deploys, so the list of contracts is short. The wallet asks each new contract once and each kept token on every screen. Open deployment would need an index by holder and a defence against spam tokens.
Amounts
The native token has 18 decimals. The chain knows nothing of this: it is display only, and NATIVE_DECIMALS is the same number in Rust. A token has its own. Everything is computed on strings and bigint, because amounts pass 2^53.
| Function | Use | Rule |
|---|---|---|
amount() | Anything being sent | Every digit, with thousands separated |
brief() | Balances and fees | Six decimals, or four significant digits of something smaller, rounded down |
baseUnits() | What was typed | Base units, or null unless positive and within the token's decimals |
Real output of these functions:
| Base units | amount() | brief() |
|---|---|---|
1000000000000000000 | 1 | 1 |
1234567890000000000 | 1.23456789 | 1.234567 |
994999910979999999999 | 994.999910979999999999 | 994.99991 |
34700000000000 | 0.0000347 | 0.0000347 |
1 | 0.000000000000000001 | 0.000000000000000001 |
2500000000000000000000 | 2,500 | 2,500 |
| Typed | baseUnits() |
|---|---|
1.5 | 1500000000000000000 |
1,000 | 1000000000000000000000 |
0.000000000000000001 | 1 |
0.0000000000000000001 | null: too many decimals |
0 | null: not positive |
At 6 decimals, baseUnits("2.5", 6) is 2500000.
Node and packaging
The node address is Saved.nodeUrl, set from the build's default when the first key is created and changeable in Settings. It must be an http or https URL, and trailing slashes are dropped. If the node can't be reached the wallet shows that, with the same settings, so a wrong address can be fixed from there.
The wallet uses these routes of the HTTP API: GET /v1/status, GET /v1/accounts/{address}, GET /v1/contracts, GET /v1/transactions/{id}, POST /v1/transactions and POST /v1/contracts/{address}/view.
| Build | Default node |
|---|---|
npm run build | http://127.0.0.1:8545, or VITE_QUBITS_RPC if set |
npm run package | https://mainnet.qubits.sh, or VITE_QUBITS_RPC if set |
npm run package refuses any address that isn't https: a store build pointing at the user's own machine would show everyone the "can't reach" screen. It builds, zips dist/ into release/qubits-wallet-<version>.zip with manifest.json at the top of the zip, checks the address made it into the bundle, then rebuilds dist/ for development. The address is read at build time, so existing wallets keep the nodeUrl they saved.
https://mainnet.qubits.shdoes not answer yet. See Status.
For development, npm run build compiles the core and the extension, and Chrome's "Load unpacked" takes either extension/ or extension/dist. The manifest at the root points into dist/, and the build writes a copy into dist/ with that prefix removed.
Source
extension/manifest.json: permissions and content scriptsextension/src/wallet.ts:Saved, unlock, activity, fees, tokensextension/src/core.ts: the core's types and the signature lifetimeextension/src/popup.ts: screens, send flow,armLock, log outextension/src/shared/api.ts,format.ts,address.ts,dom.ts: client, amounts, addresses, DOM builderextension/scripts/package.sh,extension/vite.config.ts: buildsextension/tests/wallet.test.ts,core.test.ts,format.test.ts,site.mjs: testschain/crates/qubits-wasm/src/lib.rs: the corechain/crates/qubits-types/src/client.rs:build_transactionchain/crates/qubits-crypto/src/keystore.rs: the key file