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:
+1
-1
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user