All pages

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 entryValueWhy
permissionsstorageKey files, settings and the kept password
host_permissionsNoneThe wallet reaches its node with ordinary cross-origin fetch, which the node's CORS policy allows
content_scriptshttps://qubits.sh/*, https://*.qubits.sh/*window.qubits, only on those sites for now. Soon on every website
content_security_policyscript-src 'self' 'wasm-unsafe-eval'; object-src 'self'No remote code. WebAssembly needs the one exception
minimum_chrome_version111
FileRole
extension/src/core.tsTyped wrapper around the WebAssembly core
extension/src/wallet.tsThe logic, without UI: storage, unlock, node client, fees, tokens, activity
extension/src/popup.tsThe screens
extension/src/shared/api.tsNode API types and client
extension/src/shared/format.tsAmounts and other formatting
extension/src/shared/address.tsBech32m addresses in plain TypeScript, because the background worker has no core
extension/src/shared/dom.tsA 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": "..."
}
ItemValue
Format version1
Key derivationArgon2id, version 0x13, 32-byte output
Default cost64 MiB (m_cost 65,536 KiB), 3 passes, 1 lane
Costs accepted when readingAt most 1 GiB, 64 passes, 16 lanes
Salt16 random bytes
CipherXChaCha20-Poly1305
Nonce24 random bytes
Encrypted valueThe 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 password12 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

StoreKeyHoldsLifetime
chrome.storage.localwalletSaved, belowUntil the last account is removed or the extension is
chrome.storage.sessionunlockedKept passwordsMemory only, gone when the browser closes
chrome.storage.sessionsite-requestsRequests from websites waiting for an answerThe 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 }>;
  1. unlock() calls the core's checkPassword, which runs Argon2id and decrypts the file in WebAssembly.
  2. A right password goes into chrome.storage.session under the account's address, with until set to now plus 15 minutes.
  3. Each screen calls unlocked(), which returns the entry for the account in use, or deletes it once until has passed.
  4. 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.

EventEffect on kept passwords
A right passwordKept for that account for 15 minutes
Creating a keyKept at once, since it was just typed twice
Importing a keyNothing kept: the first use asks
15 minutes pass with the popup openA timer (armLock) forgets all of them and shows the unlock screen
15 minutes pass with the popup closedThe entry lapses at its next read
Log outAll forgotten. Keys stay, encrypted, and sites stay connected, though their requests then ask for the password
The browser closesAll gone
Removing an accountIts 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.mjs checks 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.

ExportArgumentsReturns
createKeyscheme, passwordJSON: keystore, address, public_key, scheme
keyInfokeystoreJSON: address, public_key, scheme
checkPasswordkeystore, passwordNothing. Throws wrong password
signstatus, account, keystore, password, role, payload, valid_until_msJSON: transaction (hex, ready to submit), id, fee
decodeTransactionhexJSON: id and the SignedTransaction fields
checkAddressaddressThe canonical address. Throws otherwise
publicKeyInfotagged_hexJSON: address, public_key, scheme
minPasswordLengthThe 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.

  1. Open. Each screen starts with overview() (GET /v1/status, GET /v1/accounts/{address}), then settle(), discoverTokens() and holdings().
  2. Type. The form runs as you type, and signs nothing. The address goes through checkAddress and is named as the account itself, another account here, sent to before (lastSentTo), or new. The amount goes through baseUnits at 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.
  3. 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.
  4. 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.
  5. Submit. POST /v1/transactions. On a refusal with an HTTP status, the transaction goes into activity as failed with the node's message. If the node can't be reached, nothing is recorded, since nobody knows whether it arrived.
  6. Wait. waitIncluded() polls GET /v1/transactions/{id} every 500 ms for up to 60 s, then settle() 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:

TransactionEstimateReal
ML-DSA-65 transfer, first, with the public key5,5095,432
ML-DSA-65 transfer3,5493,470
SLH-DSA-SHA2-192s transfer, first16,52016,443
SLH-DSA-SHA2-192s transfer16,46416,385
Token transfer with 500,000 gas34,93534,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.

statusMeans
sentAccepted by the node; no block seen yet
includedIn a block, and it did what it was for
failedThe 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:

  1. GET /v1/transactions/{id}.
  2. If it is included, failureOf() reads the receipt. Being in a block is not success: a reverted call is in a block too.
  3. The entry gets its height, a status of included or failed, and the fee replaced with what it cost in the end.
  4. If the request fails and the entry is more than 65 minutes old, the hour a signature is valid plus 5 minutes, it becomes failed with not included before it expired. Otherwise it is asked about again next time.
Receipt outcomenote
None, or doneNone
out_of_gasit ran out of gas
trappedthe contract crashed
invalidthe contract has no such function
revertedThe 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:

  1. GET /v1/contracts for the full list.
  2. Drop every contract already in tokens or notTokens for this chain.
  3. For each one left, call tokenInfo(), once per contract.
  4. A token goes into tokens with found: true. Anything else goes into notTokens, so it isn't asked again.
  5. 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:

FieldRule
name1 to 40 characters, no control characters
symbol1 to 12 characters, no control characters
decimalsLittle-endian number, at most 38

holdings() views balance_of on each token that isn't hidden, and decides what to show:

TokenShown
Found by the walletWhile the balance isn't zero
Added by hand (addToken)Always, even at zero
hiddenNever, 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.

FunctionUseRule
amount()Anything being sentEvery digit, with thousands separated
brief()Balances and feesSix decimals, or four significant digits of something smaller, rounded down
baseUnits()What was typedBase units, or null unless positive and within the token's decimals

Real output of these functions:

Base unitsamount()brief()
100000000000000000011
12345678900000000001.234567891.234567
994999910979999999999994.999910979999999999994.99991
347000000000000.00003470.0000347
10.0000000000000000010.000000000000000001
25000000000000000000002,5002,500
TypedbaseUnits()
1.51500000000000000000
1,0001000000000000000000000
0.0000000000000000011
0.0000000000000000001null: too many decimals
0null: 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.

BuildDefault node
npm run buildhttp://127.0.0.1:8545, or VITE_QUBITS_RPC if set
npm run packagehttps://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.sh does 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 scripts
  • extension/src/wallet.ts: Saved, unlock, activity, fees, tokens
  • extension/src/core.ts: the core's types and the signature lifetime
  • extension/src/popup.ts: screens, send flow, armLock, log out
  • extension/src/shared/api.ts, format.ts, address.ts, dom.ts: client, amounts, addresses, DOM builder
  • extension/scripts/package.sh, extension/vite.config.ts: builds
  • extension/tests/wallet.test.ts, core.test.ts, format.test.ts, site.mjs: tests
  • chain/crates/qubits-wasm/src/lib.rs: the core
  • chain/crates/qubits-types/src/client.rs: build_transaction
  • chain/crates/qubits-crypto/src/keystore.rs: the key file