feat: wormhole balances in the background, from chain state and a chosen observer

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
This commit is contained in:
2026-09-16 18:22:23 +03:00
co-authored by Claude Opus 5
parent c378b36a3a
commit a4e19a4b1b
12 changed files with 621 additions and 28 deletions
+1 -1
View File
@@ -61,7 +61,7 @@
"@polkadot/util": "^14.0.3",
"@polkadot/util-crypto": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.2/util-crypto-14.0.3-quantus.2.tgz",
"@polkadot/x-fetch": "^14.0.3",
"@quantus/crypto": "^0.2.0",
"@quantus/crypto": "^0.3.0",
"safe-buffer": "^5.2.1",
"typescript": "^5.5.4"
}
+1 -1
View File
@@ -35,7 +35,7 @@
"@polkadot/util": "^14.0.3",
"@polkadot/util-crypto": "^14.0.3",
"@quantus/codec": "^0.5.0",
"@quantus/crypto": "^0.2.0",
"@quantus/crypto": "^0.3.0",
"eventemitter3": "^5.0.1",
"rxjs": "^7.8.1",
"tslib": "^2.8.1"
@@ -1,7 +1,7 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { RequestWalletCreate, ResponseWalletPreview, WalletAccount, WalletInfo, WalletJson } from './types.js';
import type { RequestWalletCreate, ResponseWalletPreview, WalletAccount, WalletInfo, WalletJson, WormholeBalance } from './types.js';
import { wormholeAddresses, WormholeBranch } from '@quantus/crypto';
import { BehaviorSubject } from 'rxjs';
@@ -11,6 +11,7 @@ import { assert, isHex, objectSpread, stringToU8a, u8aToHex, u8aToString } from
import { jsonDecrypt, jsonEncrypt, mnemonicValidate } from '@polkadot/util-crypto';
import { WalletsStore } from '../stores/index.js';
import { extendNullifiers, initialNullifiers, Nullifiers, wormholeBalance } from './Wormhole.js';
/**
* How many wormhole addresses to derive per branch, per account index.
@@ -120,6 +121,7 @@ export default class Wallets {
readonly subject = new BehaviorSubject<WalletInfo[]>([]);
readonly #store = new WalletsStore();
readonly #nullifiers = new Nullifiers();
readonly #wallets = new Map<string, WalletJson>();
readonly #ready: Promise<void>;
@@ -199,6 +201,9 @@ export default class Wallets {
};
this.#addPairs(wallet, normalised, 0, password);
// While the phrase is to hand, so a wormhole balance never needs it just to
// be shown. A raw seed has no wormhole addresses and so nothing to store.
source === 'mnemonic' && await this.#nullifiers.set(wallet.id, 0, initialNullifiers(normalised, account));
await this.#save(wallet);
return wallet.id;
@@ -220,6 +225,7 @@ export default class Wallets {
});
this.#addPairs(wallet, secret, index, password);
await this.#nullifiers.set(wallet.id, index, initialNullifiers(secret, account));
await this.#save(objectSpread({}, wallet, { accounts: [...wallet.accounts, account] }));
}
@@ -241,7 +247,45 @@ export default class Wallets {
await this.#save(objectSpread({}, wallet, { name }));
}
/** Forget the wallet, its secret, and every signing pair it owns. */
#account (id: string, accountIndex: number): { account: WalletAccount, wallet: WalletJson } {
const wallet = this.#wallets.get(id);
assert(wallet, 'Unable to find the wallet');
const account = wallet.accounts.find((a) => a.index === accountIndex);
assert(account, `The wallet has no account ${accountIndex}`);
return { account, wallet };
}
/** What one account's wormhole addresses can still spend. No password needed. */
async wormholeBalance (id: string, accountIndex: number, endpoint: string, observer: string): Promise<WormholeBalance> {
await this.#ready;
const { account } = this.#account(id, accountIndex);
return wormholeBalance(account, await this.#nullifiers.get(id, accountIndex), endpoint, observer.replace(/\/+$/, ''));
}
/**
* Derive nullifiers past each address's current transfer count, so deposits a
* balance reported as unchecked can be checked. Needs the password.
*/
async wormholeUnlock (id: string, accountIndex: number, password: string, counts: WormholeBalance['counts']): Promise<void> {
await this.#ready;
const { account, wallet } = this.#account(id, accountIndex);
const secret = this.#unlock(wallet, password);
await this.#nullifiers.set(id, accountIndex, extendNullifiers(secret, account, await this.#nullifiers.get(id, accountIndex), counts));
}
/**
* Forget the wallet: its secret, every signing pair it owns, and the
* nullifiers precomputed for its wormhole addresses, which are the one thing
* left behind that could still tie this user to their exits.
*/
async forget (id: string): Promise<void> {
await this.#ready;
@@ -259,6 +303,7 @@ export default class Wallets {
}
}
await this.#nullifiers.remove(id, wallet.accounts.map((a) => a.index));
this.#wallets.delete(id);
await this.#store.remove(id);
this.#publish();
@@ -0,0 +1,360 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ProviderInterface } from '@polkadot/rpc-provider/types';
import type { WalletAccount, WormholeBalance, WormholeNullifiersJson } from './types.js';
import { Runtime } from '@quantus/codec';
import { WormholeBranch, wormholeNullifiers } from '@quantus/crypto';
import { WsProvider } from '@polkadot/rpc-provider';
import { hexToU8a, u8aConcat, u8aToHex } from '@polkadot/util';
import { base64Decode, base64Encode, blake2AsU8a, decodeAddress, xxhashAsU8a } from '@polkadot/util-crypto';
import { NullifiersStore } from '../stores/index.js';
/**
* Transfer counts precomputed per wormhole address whenever the password is at
* hand. A deposit whose count is past this cannot be checked until the wallet
* is unlocked again, and the balance says so rather than guessing.
*/
export const NULLIFIER_WINDOW = 128;
/** Headroom added past an address's current transfer count when unlocking. */
const NULLIFIER_HEADROOM = 128;
/**
* Buckets of `Wormhole::UsedNullifiers` fetched per check, at least.
*
* The map is `Blake2_128Concat`, so a key is `blake2_128(n) ‖ n`: looking one up
* by key hands the node the nullifier itself, and exits publish their
* nullifiers, so the node could name the exit. Instead whole buckets are read,
* keyed by the first byte of that hash, and checked here. The node learns which
* buckets were asked for, never which entry in them mattered. Buckets holding
* no nullifier of ours are added at random until there are this many, so one
* deposit is hidden among a sixteenth of all spends rather than a 256th.
*/
const NULLIFIER_BUCKETS_MIN = 16;
const KEYS_PAGE = 1000;
const NULLIFIER_BYTES = 32;
const BRANCHES = [
{ branch: WormholeBranch.Receive, key: 'receive' },
{ branch: WormholeBranch.Change, key: 'change' }
] as const;
type Branch = typeof BRANCHES[number]['key'];
function packed (rows: Uint8Array[]): string {
return base64Encode(u8aConcat(...rows));
}
/** The stored nullifiers of one address, as one byte array of 32-byte entries. */
function unpack (stored: string | undefined): Uint8Array {
return stored ? base64Decode(stored) : new Uint8Array();
}
/**
* Nullifiers for every wormhole address of one wallet account, for transfer
* counts `0..NULLIFIER_WINDOW`. Needs the recovery phrase.
*/
export function initialNullifiers (mnemonic: string, account: WalletAccount): WormholeNullifiersJson | null {
if (!account.wormhole) {
return null;
}
const out = {} as WormholeNullifiersJson;
for (const { branch, key } of BRANCHES) {
out[key] = wormholeNullifiers(mnemonic, '', account.index, branch, 0, account.wormhole[key].length, 0, NULLIFIER_WINDOW)
.map(packed);
}
return out;
}
/**
* Extend stored nullifiers so each address covers its transfer count plus
* headroom. Only the addresses that fell short are derived, one call each.
*/
export function extendNullifiers (mnemonic: string, account: WalletAccount, stored: WormholeNullifiersJson | null, counts: Record<Branch, number[]>): WormholeNullifiersJson | null {
if (!account.wormhole) {
return null;
}
const out: WormholeNullifiersJson = {
change: [...(stored?.change ?? [])],
receive: [...(stored?.receive ?? [])]
};
for (const { branch, key } of BRANCHES) {
account.wormhole[key].forEach((_, index) => {
const have = unpack(out[key][index]).length / NULLIFIER_BYTES;
const count = counts[key][index] ?? 0;
// Only where a deposit is past what is stored (or nothing is stored yet,
// for a wallet made before nullifiers were): then to its count plus
// headroom, so the next few deposits are covered too.
const target = have === 0
? Math.max(count + NULLIFIER_HEADROOM, NULLIFIER_WINDOW)
: count >= have
? count + NULLIFIER_HEADROOM
: have;
if (target > have) {
const [row] = wormholeNullifiers(mnemonic, '', account.index, branch, index, 1, have, target - have);
out[key][index] = base64Encode(u8aConcat(unpack(out[key][index]), ...row));
}
});
}
return out;
}
export class Nullifiers {
readonly #store = new NullifiersStore();
static key (walletId: string, accountIndex: number): string {
return `${walletId}:${accountIndex}`;
}
get (walletId: string, accountIndex: number): Promise<WormholeNullifiersJson | null> {
return new Promise((resolve) => {
this.#store.get(Nullifiers.key(walletId, accountIndex), (value) => resolve(value ?? null))
.catch(() => resolve(null));
});
}
async set (walletId: string, accountIndex: number, value: WormholeNullifiersJson | null): Promise<void> {
if (value) {
await this.#store.set(Nullifiers.key(walletId, accountIndex), value);
}
}
/** Remove every account's nullifiers for a wallet. */
async remove (walletId: string, accountIndices: number[]): Promise<void> {
for (const index of accountIndices) {
await this.#store.remove(Nullifiers.key(walletId, index));
}
}
}
interface ObserverChain {
genesis: string;
id: string;
status?: string;
}
interface ObserverDeposit {
amount: string;
to: string;
transfer_count: number;
}
interface ObserverDeposits {
deposits: ObserverDeposit[];
indexed_from: number | null;
indexed_to: number | null;
next: string | null;
}
async function json<T> (url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${url} answered ${response.status}`);
}
return response.json() as Promise<T>;
}
/** The observer's name for a chain, found by genesis rather than trusted by name. */
async function observerChain (observer: string, genesis: string): Promise<string> {
const chains = await json<ObserverChain[]>(`${observer}/v1/chains`);
const matches = chains.filter((c) => c.genesis === genesis);
const live = matches.find((c) => c.status === 'live') ?? matches[0];
if (!live) {
throw new Error(`The observer at ${observer} does not index this chain`);
}
return live.id;
}
async function depositsFor (observer: string, chain: string, tos: string[]): Promise<ObserverDeposits> {
const all: ObserverDeposit[] = [];
let page: ObserverDeposits;
let after: string | null = null;
do {
const query = new URLSearchParams({ limit: '1000', to: tos.join(',') });
after && query.set('after', after);
page = await json<ObserverDeposits>(`${observer}/v1/chains/${chain}/wormhole/deposits?${query.toString()}`);
all.push(...page.deposits);
after = page.next;
} while (after);
return { deposits: all, indexed_from: page.indexed_from, indexed_to: page.indexed_to, next: null };
}
/** Every used nullifier in the given first-byte buckets, as lowercase hex. */
export async function usedNullifiers (provider: ProviderInterface, buckets: number[]): Promise<Set<string>> {
const prefix = u8aConcat(xxhashAsU8a('Wormhole', 128), xxhashAsU8a('UsedNullifiers', 128));
const used = new Set<string>();
for (const bucket of buckets) {
const bucketPrefix = u8aToHex(u8aConcat(prefix, new Uint8Array([bucket])));
let start: string | undefined;
for (;;) {
const keys = await provider.send<string[]>('state_getKeysPaged', start ? [bucketPrefix, KEYS_PAGE, start] : [bucketPrefix, KEYS_PAGE]);
// blake2_128(n) ‖ n: the nullifier is the key's last 32 bytes
keys.forEach((key) => used.add(`0x${key.slice(-64).toLowerCase()}`));
if (keys.length < KEYS_PAGE) {
break;
}
start = keys[keys.length - 1];
}
}
return used;
}
function randomBuckets (have: Set<number>): number[] {
const out = new Set(have);
while (out.size < NULLIFIER_BUCKETS_MIN) {
out.add(globalThis.crypto.getRandomValues(new Uint8Array(1))[0]);
}
// Asked in a shuffled order, so the ones that matter do not come first.
const list = [...out];
for (let i = list.length - 1; i > 0; i--) {
const j = globalThis.crypto.getRandomValues(new Uint32Array(1))[0] % (i + 1);
[list[i], list[j]] = [list[j], list[i]];
}
return list;
}
function first<T> (value: unknown): T | undefined {
return (Array.isArray(value) ? value[0] : value) as T | undefined;
}
/**
* What one wallet account's wormhole addresses can still spend.
*
* - Deposits come from an observer the user chose, by address. Its answer is
* checked against the chain's own `Wormhole::TransferCount`, so an index that
* is behind shows as deposits it has not found, never as a smaller balance.
* - A deposit is spent if its nullifier is in `Wormhole::UsedNullifiers`, read
* in whole buckets (see `NULLIFIER_BUCKETS_MIN`). Nullifiers never leave here.
* - A deposit whose nullifier was never precomputed is reported as unchecked:
* the wallet has to be unlocked to derive it.
*
* `TransferCount` is keyed on the canonical recipient (limbs reduced mod the
* Goldilocks prime). A wormhole address is a Poseidon digest, whose limbs are
* already canonical, so the address itself is the key.
*/
export async function wormholeBalance (account: WalletAccount, nullifiers: WormholeNullifiersJson | null, endpoint: string, observer: string): Promise<WormholeBalance> {
if (!account.wormhole) {
throw new Error('This account has no wormhole addresses');
}
const provider = new WsProvider(endpoint, false);
try {
await provider.connect();
await provider.isReady;
const [genesis, metadata, properties] = await Promise.all([
provider.send<string>('chain_getBlockHash', [0]),
provider.send<string>('state_getMetadata', []),
provider.send<Record<string, unknown>>('system_properties', [])
]);
const runtime = Runtime.fromMetadata(hexToU8a(metadata));
const addresses = BRANCHES.flatMap(({ key }) =>
(account.wormhole?.[key] ?? []).map((address, index) => ({ branch: key, id: u8aToHex(decodeAddress(address)), index }))
);
const targets = addresses.map(({ id }) => runtime.storageTarget('Wormhole', 'TransferCount', [id]));
const [{ changes }] = await provider.send<{ changes: [string, string | null][] }[]>('state_queryStorageAt', [targets.map((t) => t.key)]);
const raw = new Map(changes.map(([key, value]) => [key, value]));
const counts = targets.map((t) => {
const value = raw.get(t.key) ?? t.default ?? '0x0000000000000000';
return Number(runtime.decodeStorage(t.valueTy, hexToU8a(value)) as string);
});
const active = addresses.filter((_, i) => counts[i] > 0);
const transfers = counts.reduce((a, b) => a + b, 0);
const found = active.length
? await depositsFor(observer, await observerChain(observer, genesis), active.map(({ id }) => id))
: { deposits: [], indexed_from: null, indexed_to: null, next: null };
// our nullifier for each found deposit, when it was precomputed
const byId = new Map(active.map((a) => [a.id, a]));
const ours = found.deposits.map((deposit) => {
const address = byId.get(deposit.to.toLowerCase());
const bytes = address && unpack(nullifiers?.[address.branch][address.index]);
const at = deposit.transfer_count * NULLIFIER_BYTES;
return {
amount: BigInt(deposit.amount),
nullifier: bytes && bytes.length >= at + NULLIFIER_BYTES
? bytes.subarray(at, at + NULLIFIER_BYTES)
: null
};
});
const buckets = new Set(ours.flatMap(({ nullifier }) => nullifier ? [blake2AsU8a(nullifier, 128)[0]] : []));
const used = buckets.size
? await usedNullifiers(provider, randomBuckets(buckets))
: new Set<string>();
let spendable = 0n;
let spent = 0n;
let unchecked = 0n;
let uncheckedDeposits = 0;
for (const { amount, nullifier } of ours) {
if (!nullifier) {
unchecked += amount;
uncheckedDeposits++;
} else if (used.has(u8aToHex(nullifier))) {
spent += amount;
} else {
spendable += amount;
}
}
return {
checkedAt: Date.now(),
counts: {
change: counts.slice(account.wormhole.receive.length),
receive: counts.slice(0, account.wormhole.receive.length)
},
decimals: Number(first(properties['tokenDecimals']) ?? 12),
deposits: found.deposits.length,
indexedFrom: found.indexed_from,
indexedTo: found.indexed_to,
missing: Math.max(0, transfers - found.deposits.length),
spendable: spendable.toString(),
spent: spent.toString(),
symbol: String(first(properties['tokenSymbol']) ?? ''),
transfers,
unchecked: unchecked.toString(),
uncheckedDeposits
};
} finally {
await provider.disconnect().catch(() => undefined);
}
}
@@ -6,20 +6,21 @@
import '@polkadot/extension-mocks/chrome';
import type * as _ from '@polkadot/dev-test/globals.d.ts';
import type { ResponseSigning, WalletInfo } from '@polkadot/extension-base/background/types';
import type { ResponseSigning, WalletInfo, WormholeNullifiersJson } from '@polkadot/extension-base/background/types';
import type { KeyringPair } from '@polkadot/keyring/types';
import type { SignerPayloadJSON } from '@polkadot/types/types';
import type { HexString } from '@polkadot/util/types';
import type { KeypairType } from '@polkadot/util-crypto/types';
import { Scheme, sizes } from '@quantus/crypto';
import { Scheme, sizes, WormholeBranch, wormholeNullifiers } from '@quantus/crypto';
import keyring from '@polkadot/ui-keyring';
import { assert, hexToU8a, stringToHex, u8aToHex } from '@polkadot/util';
import { cryptoWaitReady, decodeAddress } from '@polkadot/util-crypto';
import { assert, hexToU8a, stringToHex, u8aConcat, u8aToHex } from '@polkadot/util';
import { base64Decode, cryptoWaitReady, decodeAddress } from '@polkadot/util-crypto';
import { AccountsStore } from '../../stores/index.js';
import { HEISENBERG_GENESIS, heisenbergMetadataDef } from '../../test/metadata.js';
import { extendNullifiers, NULLIFIER_WINDOW } from '../Wormhole.js';
import Extension from './Extension.js';
import State from './State.js';
import Tabs from './Tabs.js';
@@ -544,6 +545,68 @@ describe('Extension', () => {
expect(keyring.getPair(wallet.accounts[0].mldsa65).meta.name).toEqual('crystal (ML-DSA-65)');
});
// the mock store: what was last written under a key, and what was removed
// sinon stubs: only their recorded calls are read, never invoked unbound
// eslint-disable-next-line @typescript-eslint/unbound-method
const setStub = () => chrome.storage.local.set as unknown as { args: [Record<string, unknown>][] };
// eslint-disable-next-line @typescript-eslint/unbound-method
const removeStub = () => chrome.storage.local.remove as unknown as { args: [string][] };
// Keys carry EXTENSION_PREFIX when one is set, as it is under `yarn test`.
const written = (key: string) => setStub().args
.flatMap(([v]) => Object.entries(v).filter(([k]) => k.endsWith(key)).map(([, value]) => value))
.pop() as WormholeNullifiersJson | undefined;
it('stores a nullifier window for every wormhole address when a phrase wallet is made', () => {
const wallet = walletNamed('dev');
assert(wallet, 'wallet missing');
for (const index of [0, 1]) {
const stored = written(`quantus:nullifiers:${wallet.id}:${index}`);
assert(stored, `no nullifiers for account ${index}`);
expect(stored.receive).toHaveLength(20);
expect(stored.change).toHaveLength(20);
expect(stored.receive.every((b) => base64Decode(b).length === NULLIFIER_WINDOW * 32)).toBe(true);
expect(stored.change.every((b) => base64Decode(b).length === NULLIFIER_WINDOW * 32)).toBe(true);
}
// the stored bytes are the derivation, address by address and count by count
const stored = written(`quantus:nullifiers:${wallet.id}:1`);
const direct = wormholeNullifiers(DEV_PHRASE, '', 1, WormholeBranch.Change, 3, 1, 100, 2)[0];
expect(u8aToHex(base64Decode(stored?.change[3] ?? '').subarray(100 * 32, 102 * 32))).toEqual(u8aToHex(u8aConcat(...direct)));
});
it('stores no nullifiers for a raw seed wallet, which has no wormhole', () => {
const wallet = walletNamed('crystal');
assert(wallet, 'wallet missing');
expect(written(`quantus:nullifiers:${wallet.id}:0`)).toBeUndefined();
});
it('extends only the addresses whose transfer count outgrew the window', () => {
const wallet = walletNamed('dev');
assert(wallet, 'wallet missing');
const [account] = wallet.accounts;
const stored = written(`quantus:nullifiers:${wallet.id}:0`) ?? null;
const receive = new Array<number>(20).fill(0);
receive[2] = 200;
const extended = extendNullifiers(DEV_PHRASE, account, stored, { change: new Array<number>(20).fill(5), receive });
assert(extended && stored, 'nothing extended');
expect(base64Decode(extended.receive[2]).length).toEqual((200 + 128) * 32);
expect(extended.receive[3]).toEqual(stored.receive[3]);
expect(extended.change).toEqual(stored.change);
// what was there is kept as it was, and what was added continues from it
expect(u8aToHex(base64Decode(extended.receive[2]).subarray(0, NULLIFIER_WINDOW * 32))).toEqual(u8aToHex(base64Decode(stored.receive[2])));
expect(u8aToHex(base64Decode(extended.receive[2]).subarray(300 * 32, 301 * 32))).toEqual(u8aToHex(wormholeNullifiers(DEV_PHRASE, '', 0, WormholeBranch.Receive, 2, 1, 300, 1)[0][0]));
});
it('forgets the wallet and every pair it owns', async () => {
const wallet = walletNamed('dev');
@@ -552,6 +615,13 @@ describe('Extension', () => {
expect(walletNamed('dev')).toBeUndefined();
// and the nullifiers of every account index with it
const removed = (key: string) => removeStub().args.some(([k]) => k.endsWith(key));
expect(removed(`quantus:nullifiers:${wallet.id}:0`)).toBe(true);
expect(removed(`quantus:nullifiers:${wallet.id}:1`)).toBe(true);
expect(removed(`quantus:wallet:${wallet.id}`)).toBe(true);
for (const account of wallet.accounts) {
expect(() => keyring.getPair(account.mldsa65)).toThrow();
expect(() => keyring.getPair(account.mldsa87)).toThrow();
@@ -8,7 +8,7 @@ import type { MetadataDef } from '@polkadot/extension-inject/types';
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types';
import type { KeypairType } from '@polkadot/util-crypto/types';
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBalancesSubscribe, RequestBalancesUnsubscribe, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, RequestWalletAddAccount, RequestWalletCreate, RequestWalletForget, RequestWalletPreview, RequestWalletRename, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, ResponseWalletPreview, SigningRequest, WalletInfo } from '../types.js';
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBalancesSubscribe, RequestBalancesUnsubscribe, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, RequestWalletAddAccount, RequestWalletCreate, RequestWalletForget, RequestWalletPreview, RequestWalletRename, RequestWormholeBalance, RequestWormholeUnlock, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, ResponseWalletPreview, SigningRequest, WalletInfo, WormholeBalance } from '../types.js';
import type { AuthorizedAccountsDiff } from './State.js';
import type State from './State.js';
@@ -240,6 +240,16 @@ export default class Extension {
return true;
}
private walletsWormholeBalance ({ accountIndex, endpoint, id, observer }: RequestWormholeBalance): Promise<WormholeBalance> {
return this.#wallets.wormholeBalance(id, accountIndex, endpoint, observer);
}
private async walletsWormholeUnlock ({ accountIndex, counts, id, password }: RequestWormholeUnlock): Promise<boolean> {
await this.#wallets.wormholeUnlock(id, accountIndex, password, counts);
return true;
}
private async walletsForget ({ id }: RequestWalletForget): Promise<boolean> {
await this.#wallets.forget(id);
@@ -722,6 +732,12 @@ export default class Extension {
case 'pri(wallets.rename)':
return this.walletsRename(request as RequestWalletRename);
case 'pri(wallets.wormholeBalance)':
return this.walletsWormholeBalance(request as RequestWormholeBalance);
case 'pri(wallets.wormholeUnlock)':
return this.walletsWormholeUnlock(request as RequestWormholeUnlock);
case 'pri(wallets.subscribe)':
return port && this.walletsSubscribe(id, port);
@@ -99,6 +99,8 @@ export interface RequestSignatures {
'pri(wallets.preview)': [RequestWalletPreview, ResponseWalletPreview];
'pri(wallets.rename)': [RequestWalletRename, boolean];
'pri(wallets.subscribe)': [null, boolean, WalletInfo[]];
'pri(wallets.wormholeBalance)': [RequestWormholeBalance, WormholeBalance];
'pri(wallets.wormholeUnlock)': [RequestWormholeUnlock, boolean];
'pri(accounts.changePassword)': [RequestAccountChangePassword, boolean];
'pri(authorize.approve)': [RequestAuthorizeApprove, boolean];
'pri(authorize.list)': [null, ResponseAuthorizeList];
@@ -233,6 +235,57 @@ export interface WalletJson extends WalletInfo {
secret: EncryptedJson;
}
/**
* Nullifiers for one wallet account's wormhole addresses: per branch, per
* address index, the 32-byte nullifiers for transfer counts 0.. packed and
* base64-encoded.
*/
export interface WormholeNullifiersJson {
change: string[];
receive: string[];
}
/** What a wallet account's wormhole addresses can still spend, and how sure that is. */
export interface WormholeBalance {
checkedAt: number;
/** The chain's transfer count for each address, per branch. */
counts: { change: number[], receive: number[] };
decimals: number;
/** Deposits the observer returned. */
deposits: number;
indexedFrom: number | null;
indexedTo: number | null;
/** Transfers the chain counts that the observer did not return. */
missing: number;
/** Planck, unspent. */
spendable: string;
/** Planck, already exited. */
spent: string;
symbol: string;
/** The chain's total transfer count across the addresses. */
transfers: number;
/** Planck in deposits whose nullifier was never precomputed: unlock to check. */
unchecked: string;
uncheckedDeposits: number;
}
export interface RequestWormholeBalance {
accountIndex: number;
/** The node to read chain state from: the balance endpoint. */
endpoint: string;
id: string;
/** Base URL of a blackbeard observer to find deposits with. */
observer: string;
}
export interface RequestWormholeUnlock {
accountIndex: number;
/** From the last balance: derive nullifiers past these. */
counts: WormholeBalance['counts'];
id: string;
password: string;
}
export interface RequestWalletCreate {
genesisHash?: HexString | null;
name: string;
@@ -0,0 +1,24 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { WormholeNullifiersJson } from '../background/types.js';
import { EXTENSION_PREFIX } from '../defaults.js';
import BaseStore from './Base.js';
/**
* Precomputed wormhole nullifiers, per wallet and account index.
*
* Kept apart from the wallet record because they are large (32 bytes per
* address per transfer count) and grow, and because they are the one thing
* here that could name a user's exits: a wallet's forget removes them with it.
*/
export default class NullifiersStore extends BaseStore<WormholeNullifiersJson> {
constructor () {
super(
EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}'
? `${EXTENSION_PREFIX}quantus:nullifiers`
: 'quantus:nullifiers'
);
}
}
@@ -3,4 +3,5 @@
export { default as AccountsStore } from './Accounts.js';
export { default as MetadataStore } from './Metadata.js';
export { default as NullifiersStore } from './Nullifiers.js';
export { default as WalletsStore } from './Wallets.js';
+17 -6
View File
@@ -5,7 +5,11 @@
"name": "polkadot{.js} extension",
"short_name": "polkadot{.js}",
"manifest_version": 3,
"permissions": ["storage", "tabs"],
"permissions": [
"storage",
"tabs",
"unlimitedStorage"
],
"background": {
"service_worker": "background.js",
"type": "module"
@@ -14,11 +18,18 @@
"default_title": "polkadot{.js}",
"default_popup": "index.html"
},
"content_scripts": [{
"js": ["content.js"],
"matches": ["http://*/*", "https://*/*"],
"run_at": "document_start"
}],
"content_scripts": [
{
"js": [
"content.js"
],
"matches": [
"http://*/*",
"https://*/*"
],
"run_at": "document_start"
}
],
"icons": {
"16": "images/icon-16.png",
"32": "images/icon-32.png",
+21 -8
View File
@@ -5,9 +5,15 @@
"name": "polkadot{.js} extension",
"short_name": "polkadot{.js}",
"manifest_version": 3,
"permissions": ["storage", "tabs"],
"permissions": [
"storage",
"tabs",
"unlimitedStorage"
],
"background": {
"scripts": ["background.js"],
"scripts": [
"background.js"
],
"type": "module"
},
"action": {
@@ -20,11 +26,18 @@
"strict_min_version": "108.0.2"
}
},
"content_scripts": [{
"js": ["content.js"],
"matches": ["http://*/*", "https://*/*"],
"run_at": "document_start"
}],
"content_scripts": [
{
"js": [
"content.js"
],
"matches": [
"http://*/*",
"https://*/*"
],
"run_at": "document_start"
}
],
"icons": {
"16": "images/icon-16.png",
"32": "images/icon-32.png",
@@ -45,4 +58,4 @@
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
}
}
+5 -5
View File
@@ -983,7 +983,7 @@ __metadata:
"@polkadot/util": "npm:^14.0.3"
"@polkadot/util-crypto": "npm:^14.0.3"
"@quantus/codec": "npm:^0.5.0"
"@quantus/crypto": "npm:^0.2.0"
"@quantus/crypto": "npm:^0.3.0"
eventemitter3: "npm:^5.0.1"
rxjs: "npm:^7.8.1"
tslib: "npm:^2.8.1"
@@ -1614,13 +1614,13 @@ __metadata:
languageName: node
linkType: hard
"@quantus/crypto@npm:^0.2.0":
version: 0.2.0
resolution: "@quantus/crypto@npm:0.2.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcrypto%2F-%2F0.2.0%2Fcrypto-0.2.0.tgz"
"@quantus/crypto@npm:^0.3.0":
version: 0.3.0
resolution: "@quantus/crypto@npm:0.3.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcrypto%2F-%2F0.3.0%2Fcrypto-0.3.0.tgz"
dependencies:
fflate: "npm:^0.8.2"
tslib: "npm:^2.7.0"
checksum: 10/dcb974070f9e02a53c239692bda662b9e7a6eda9f214aca5f5e1a4e3014a4d4a36951b5c891b560a30e659da904f555a28b437e74660cb2f96a62abc26fbed4f
checksum: 10/fa40dbe5206d1ff28038c35825f65f113e4ccf503b3e0126c1d67c7645081c3855b972dfd7e6d76a142b73b1dbfc8947a22354d587b09515f0acf065d6507f13
languageName: node
linkType: hard