feat: show what a wallet's wormhole account can still spend
The wormhole tab now shows its spendable balance and, in plain words, every
way that number could be short:
- deposits the chain counts that the observer hasn't indexed yet, with the
block it has read from ("at least" is shown when that happens);
- deposits whose nullifier this wallet hasn't computed yet, with an Unlock to
check link. It asks for the wallet password, computes their nullifiers,
stores them with the wallet, and refreshes;
- the amount already exited, when the check ran, and a Refresh link.
Settings gain "Look up wormhole deposits with": blackbeard.observer by
default, off, or an observer of your own, committed on Enter or blur as the
balance endpoint is.
An answer is cached for the page's lifetime so switching tabs doesn't start
another multi-second check. The cache is keyed by node and observer as well as
wallet and account, so an answer read from mainnet is never shown as
Heisenberg's.
A check now fails in 15 s with "No answer from <node>" when the node is
unreachable, and in 2 min at most overall. Before, WsProvider retried forever
and the tab said "Checking…" indefinitely.
Verified in Firefox against the built extension:
- importing the chain node's test phrase stores its nullifiers (219 KB), and
the wormhole tab answers "0 QTC spendable, No deposits yet" in about a
second on mainnet;
- turning lookups off shows that they are off;
- an unreachable node shows the error after 15 s;
- forgetting the wallet leaves extension storage empty: the wallet record,
both signing pairs, and the nullifiers are all gone.
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:
@@ -38,6 +38,23 @@ const NULLIFIER_BUCKETS_MIN = 16;
|
||||
|
||||
const KEYS_PAGE = 1000;
|
||||
|
||||
/** How long to wait for the node to answer at all. WsProvider retries forever. */
|
||||
const CONNECT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** How long a whole check may take before it is abandoned. */
|
||||
const CHECK_TIMEOUT_MS = 120_000;
|
||||
|
||||
function within<T> (promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), ms);
|
||||
})
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
const NULLIFIER_BYTES = 32;
|
||||
|
||||
const BRANCHES = [
|
||||
@@ -264,97 +281,100 @@ function first<T> (value: unknown): T | undefined {
|
||||
* 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> {
|
||||
export function wormholeBalance (account: WalletAccount, nullifiers: WormholeNullifiersJson | null, endpoint: string, observer: string): Promise<WormholeBalance> {
|
||||
const provider = new WsProvider(endpoint, false);
|
||||
|
||||
return within(check(provider, account, nullifiers, endpoint, observer), CHECK_TIMEOUT_MS, 'The check took more than two minutes and was abandoned')
|
||||
.finally(() => {
|
||||
provider.disconnect().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
async function check (provider: WsProvider, 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);
|
||||
await provider.connect();
|
||||
await within(provider.isReady, CONNECT_TIMEOUT_MS, `No answer from ${endpoint}`);
|
||||
|
||||
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 [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';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
// our nullifier for each found deposit, when it was precomputed
|
||||
const byId = new Map<string, typeof active[number]>(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 {
|
||||
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
|
||||
amount: BigInt(deposit.amount),
|
||||
nullifier: bytes && bytes.length >= at + NULLIFIER_BYTES
|
||||
? bytes.subarray(at, at + NULLIFIER_BYTES)
|
||||
: null
|
||||
};
|
||||
} finally {
|
||||
await provider.disconnect().catch(() => undefined);
|
||||
});
|
||||
|
||||
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 = BigInt(0);
|
||||
let spent = BigInt(0);
|
||||
let unchecked = BigInt(0);
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTranslation } from '../../hooks/index.js';
|
||||
import { renameWallet } from '../../messaging.js';
|
||||
import { Name } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import WormholeSummary from './WormholeSummary.js';
|
||||
|
||||
type Tab = 'mldsa65' | 'mldsa87' | 'wormhole';
|
||||
|
||||
@@ -179,9 +180,15 @@ function Wallet ({ className, wallet }: Props): React.ReactElement<Props> {
|
||||
)}
|
||||
</div>
|
||||
{current === 'wormhole' && (
|
||||
<div className='walletNote'>
|
||||
{t('Receive address. Funds here leave only through a zero-knowledge proof, so this account has no key to sign with. Its balance and sending are not in the extension yet.')}
|
||||
</div>
|
||||
<>
|
||||
<WormholeSummary
|
||||
accountIndex={account.index}
|
||||
walletId={wallet.id}
|
||||
/>
|
||||
<div className='walletNote'>
|
||||
{t('Receive address. Funds here leave only through a zero-knowledge proof, so this account has no key to sign with, and sending from it is not in the extension yet.')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Address>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { WormholeBalance } from '@polkadot/extension-base/background/types';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Link } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { wormholeBalance } from '../../messaging.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import { getBalanceEndpoint, onBalanceEndpointChange } from '../../util/balanceEndpoint.js';
|
||||
import { formatBalance } from '../../util/formatBalance.js';
|
||||
import { getWormholeObserver, onWormholeObserverChange } from '../../util/wormholeObserver.js';
|
||||
|
||||
interface Props {
|
||||
accountIndex: number;
|
||||
className?: string;
|
||||
walletId: string;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { kind: 'off', reason: string }
|
||||
| { kind: 'checking' }
|
||||
| { kind: 'error', message: string }
|
||||
| { kind: 'done', balance: WormholeBalance };
|
||||
|
||||
/**
|
||||
* The last answer per wallet account and source, for this page's lifetime. A
|
||||
* check takes seconds, and switching tabs or accounts should not start another.
|
||||
* The node and observer are part of the key: an answer read from mainnet is not
|
||||
* an answer about Heisenberg, and showing it after a switch would be a lie.
|
||||
*/
|
||||
const answers = new Map<string, WormholeBalance>();
|
||||
|
||||
const answerKey = (walletId: string, accountIndex: number, endpoint = getBalanceEndpoint(), observer = getWormholeObserver()) =>
|
||||
`${walletId}:${accountIndex}:${endpoint}:${observer}`;
|
||||
|
||||
export function forgetWormholeAnswer (walletId: string, accountIndex: number): void {
|
||||
const prefix = `${walletId}:${accountIndex}:`;
|
||||
|
||||
[...answers.keys()].filter((k) => k.startsWith(prefix)).forEach((k) => answers.delete(k));
|
||||
}
|
||||
|
||||
export function lastWormholeAnswer (walletId: string, accountIndex: number): WormholeBalance | undefined {
|
||||
return answers.get(answerKey(walletId, accountIndex));
|
||||
}
|
||||
|
||||
/**
|
||||
* What a wallet account's wormhole addresses can still spend, and how far to
|
||||
* trust that.
|
||||
*
|
||||
* Every way the number could be short is said out loud rather than hidden in
|
||||
* it: deposits the observer has not indexed yet, and deposits whose nullifier
|
||||
* this wallet has not computed. A balance that is a floor is labelled a floor.
|
||||
*/
|
||||
function WormholeSummary ({ accountIndex, className, walletId }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [endpoint, setEndpoint] = useState(getBalanceEndpoint);
|
||||
const [observer, setObserver] = useState(getWormholeObserver);
|
||||
const key = answerKey(walletId, accountIndex, endpoint, observer);
|
||||
const [state, setState] = useState<State>(() => {
|
||||
const cached = answers.get(key);
|
||||
|
||||
return cached ? { balance: cached, kind: 'done' } : { kind: 'checking' };
|
||||
});
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
useEffect(() => onBalanceEndpointChange(setEndpoint), []);
|
||||
useEffect(() => onWormholeObserverChange(setObserver), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpoint) {
|
||||
setState({ kind: 'off', reason: t('Balances are off in settings.') });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!observer) {
|
||||
setState({ kind: 'off', reason: t('Wormhole deposit lookups are off in settings.') });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = answers.get(key);
|
||||
|
||||
if (cached && generation === 0) {
|
||||
setState({ balance: cached, kind: 'done' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let isCurrent = true;
|
||||
|
||||
setState({ kind: 'checking' });
|
||||
wormholeBalance(walletId, accountIndex, endpoint, observer)
|
||||
.then((balance) => {
|
||||
answers.set(key, balance);
|
||||
isCurrent && setState({ balance, kind: 'done' });
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
isCurrent && setState({ kind: 'error', message: error.message });
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [accountIndex, endpoint, generation, key, observer, t, walletId]);
|
||||
|
||||
const _refresh = useCallback(() => setGeneration((g) => g + 1), []);
|
||||
|
||||
const amount = (free: string, { decimals, symbol }: WormholeBalance) =>
|
||||
formatBalance({ decimals, free, symbol });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
data-field='wormhole-balance'
|
||||
>
|
||||
{state.kind === 'off' && <div className='note'>{state.reason}</div>}
|
||||
{state.kind === 'checking' && (
|
||||
<div className='note'>{t('Checking deposits against the chain…')}</div>
|
||||
)}
|
||||
{state.kind === 'error' && (
|
||||
<div className='note error'>
|
||||
{t('Could not check the wormhole balance: {{message}}', { replace: { message: state.message } })}
|
||||
{' '}
|
||||
<Link onClick={_refresh}>{t('Retry')}</Link>
|
||||
</div>
|
||||
)}
|
||||
{state.kind === 'done' && (() => {
|
||||
const { balance } = state;
|
||||
const floor = balance.missing > 0 || balance.uncheckedDeposits > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='spendable'>
|
||||
{floor ? t('At least') : ''} {amount(balance.spendable, balance)} {t('spendable')}
|
||||
</div>
|
||||
<div className='note'>
|
||||
{balance.transfers === 0
|
||||
? t('No deposits yet.')
|
||||
: t('{{deposits}} deposits, {{spent}} already exited.', {
|
||||
replace: { deposits: balance.transfers, spent: amount(balance.spent, balance) }
|
||||
})}
|
||||
</div>
|
||||
{balance.missing > 0 && (
|
||||
<div className='note warn'>
|
||||
{t('{{missing}} deposits are not indexed by the observer yet (it has read from block {{from}}); they are not counted.', {
|
||||
replace: { from: balance.indexedFrom ?? '?', missing: balance.missing }
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{balance.uncheckedDeposits > 0 && (
|
||||
<div className='note warn'>
|
||||
{t('{{count}} deposits ({{amount}}) arrived after this wallet last computed its nullifiers, so whether they are spent is unknown.', {
|
||||
replace: { amount: amount(balance.unchecked, balance), count: balance.uncheckedDeposits }
|
||||
})}
|
||||
{' '}
|
||||
<Link to={`/wallet/wormhole-unlock/${walletId}/${accountIndex}`}>{t('Unlock to check')}</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className='note'>
|
||||
{t('Checked {{at}}.', { replace: { at: new Date(balance.checkedAt).toLocaleTimeString() } })}
|
||||
{' '}
|
||||
<Link onClick={_refresh}>{t('Refresh')}</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(WormholeSummary)<Props>`
|
||||
padding: 0 12px 8px;
|
||||
|
||||
.spendable {
|
||||
font-size: 15px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--labelColor);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.warn {
|
||||
color: var(--warningColor, var(--labelColor));
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--errorColor);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { RouteComponentProps } from 'react-router';
|
||||
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import { withRouter } from 'react-router';
|
||||
|
||||
import { ActionBar, ActionContext, ActionText, Button, InputWithLabel, WalletContext, Warning } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { wormholeUnlock } from '../../messaging.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import { forgetWormholeAnswer, lastWormholeAnswer } from '../Accounts/WormholeSummary.js';
|
||||
|
||||
interface Props extends RouteComponentProps<{ id: string, index: string }> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute nullifiers past what is stored, so newer deposits can be checked.
|
||||
*
|
||||
* Needs the password because a nullifier needs the address's secret. The
|
||||
* result is stored like the ones computed at creation, and removed with the
|
||||
* wallet.
|
||||
*/
|
||||
function WormholeUnlock ({ className, match: { params: { id, index } } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const wallet = useContext(WalletContext).find((w) => w.id === id);
|
||||
const accountIndex = parseInt(index, 10);
|
||||
const answer = lastWormholeAnswer(id, accountIndex);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
const _goHome = useCallback(() => onAction('/'), [onAction]);
|
||||
|
||||
const _onChange = useCallback((value: string) => {
|
||||
setPassword(value);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const _onUnlock = useCallback(() => {
|
||||
if (!answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
wormholeUnlock(id, accountIndex, password, answer.counts)
|
||||
.then(() => {
|
||||
forgetWormholeAnswer(id, accountIndex);
|
||||
onAction('/');
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setError(error.message);
|
||||
setIsBusy(false);
|
||||
});
|
||||
}, [accountIndex, answer, id, onAction, password]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
text={t('Check newer deposits')}
|
||||
/>
|
||||
<div className={className}>
|
||||
{wallet && answer
|
||||
? (
|
||||
<>
|
||||
<p className='explain'>
|
||||
{t('Whether a wormhole deposit is spent is decided by its nullifier, which only this wallet can compute. {{count}} deposits to "{{name}}" arrived after it last did. Enter the wallet password to compute theirs; they are stored with the wallet and removed if you forget it.', { replace: { count: answer.uncheckedDeposits, name: wallet.name } })}
|
||||
</p>
|
||||
<InputWithLabel
|
||||
disabled={isBusy}
|
||||
isError={!!error}
|
||||
isFocused
|
||||
label={t('Wallet password')}
|
||||
onChange={_onChange}
|
||||
onEnter={_onUnlock}
|
||||
type='password'
|
||||
value={password}
|
||||
/>
|
||||
{error && (
|
||||
<Warning
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
<Button
|
||||
className='action'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!password}
|
||||
onClick={_onUnlock}
|
||||
>
|
||||
{t('Compute and check')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
: <Warning>{t('Open the wallet\'s wormhole tab first, so there is a balance to extend.')}</Warning>
|
||||
}
|
||||
<ActionBar className='withMarginTop'>
|
||||
<ActionText
|
||||
className='center'
|
||||
onClick={_goHome}
|
||||
text={t('Cancel')}
|
||||
/>
|
||||
</ActionBar>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(styled(WormholeUnlock)`
|
||||
padding: 0 24px;
|
||||
|
||||
.explain {
|
||||
color: var(--labelColor);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.withMarginTop {
|
||||
margin-top: 4px;
|
||||
}
|
||||
`);
|
||||
@@ -28,6 +28,7 @@ import Metadata from './Metadata/index.js';
|
||||
import Signing from './Signing/index.js';
|
||||
import AddWalletAccount from './Wallet/AddAccount.js';
|
||||
import ForgetWallet from './Wallet/Forget.js';
|
||||
import WormholeUnlock from './Wallet/WormholeUnlock.js';
|
||||
import Export from './Export.js';
|
||||
import ExportAll from './ExportAll.js';
|
||||
import Forget from './Forget.js';
|
||||
@@ -166,6 +167,7 @@ export default function Popup (): React.ReactElement {
|
||||
<Route path='/auth-list'>{wrapWithErrorBoundary(<AuthList />, 'auth-list')}</Route>
|
||||
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
|
||||
<Route path='/wallet/add-account/:id'>{wrapWithErrorBoundary(<AddWalletAccount />, 'wallet-add-account')}</Route>
|
||||
<Route path='/wallet/wormhole-unlock/:id/:index'>{wrapWithErrorBoundary(<WormholeUnlock />, 'wallet-wormhole-unlock')}</Route>
|
||||
<Route path='/wallet/forget/:id'>{wrapWithErrorBoundary(<ForgetWallet />, 'wallet-forget')}</Route>
|
||||
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
|
||||
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
/* eslint-disable no-redeclare */
|
||||
|
||||
import type { AccountBalances, AccountJson, AllowedPath, AuthorizeRequest, ConnectedTabsUrlResponse, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, ResponseWalletPreview, SeedLengths, SigningRequest, SubscriptionMessageTypes, WalletInfo } from '@polkadot/extension-base/background/types';
|
||||
import type { AccountBalances, AccountJson, AllowedPath, AuthorizeRequest, ConnectedTabsUrlResponse, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, ResponseWalletPreview, SeedLengths, SigningRequest, SubscriptionMessageTypes, WalletInfo, WormholeBalance } from '@polkadot/extension-base/background/types';
|
||||
import type { Message } from '@polkadot/extension-base/types';
|
||||
import type { Chain } from '@polkadot/extension-chains/types';
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
@@ -218,6 +218,15 @@ export async function forgetWallet (id: string): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.forget)', { id });
|
||||
}
|
||||
|
||||
/** What one wallet account's wormhole addresses can still spend. Slow: seconds, not milliseconds. */
|
||||
export async function wormholeBalance (id: string, accountIndex: number, endpoint: string, observer: string): Promise<WormholeBalance> {
|
||||
return sendMessage('pri(wallets.wormholeBalance)', { accountIndex, endpoint, id, observer });
|
||||
}
|
||||
|
||||
export async function wormholeUnlock (id: string, accountIndex: number, password: string, counts: WormholeBalance['counts']): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.wormholeUnlock)', { accountIndex, counts, id, password });
|
||||
}
|
||||
|
||||
export async function subscribeAccounts (cb: (accounts: AccountJson[]) => void): Promise<boolean> {
|
||||
return sendMessage('pri(accounts.subscribe)', null, cb);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { styled } from '../styled.js';
|
||||
import { getBalanceEndpoint, setBalanceEndpoint } from '../util/balanceEndpoint.js';
|
||||
import { DEFAULT_PREFIX } from '../util/defaultPrefix.js';
|
||||
import getLanguageOptions from '../util/getLanguageOptions.js';
|
||||
import { getWormholeObserver, OBSERVER_OPTIONS, setWormholeObserver } from '../util/wormholeObserver.js';
|
||||
|
||||
interface Option {
|
||||
text: string;
|
||||
@@ -50,6 +51,7 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
|
||||
const [camera, setCamera] = useState(settings.camera === 'on');
|
||||
const [prefix, setPrefix] = useState(`${settings.prefix === -1 ? DEFAULT_PREFIX : settings.prefix}`);
|
||||
const [endpoint, setEndpoint] = useState(getBalanceEndpoint());
|
||||
const [observer, setObserver] = useState(getWormholeObserver());
|
||||
const [notification, updateNotification] = useState(settings.notification);
|
||||
const [theme, setTheme] = useState(chooseTheme());
|
||||
const setThemeContext = useContext(ThemeSwitchContext);
|
||||
@@ -85,6 +87,19 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
|
||||
}, [endpoint]
|
||||
);
|
||||
|
||||
const _onChangeObserver = useCallback(
|
||||
(value: string): void => {
|
||||
setObserver(value);
|
||||
setWormholeObserver(value);
|
||||
}, []
|
||||
);
|
||||
|
||||
const _onCommitObserver = useCallback(
|
||||
(): void => {
|
||||
observer !== getWormholeObserver() && setWormholeObserver(observer);
|
||||
}, [observer]
|
||||
);
|
||||
|
||||
const _onChangeNotification = useCallback(
|
||||
(value: string): void => {
|
||||
setNotification(value).catch(console.error);
|
||||
@@ -172,6 +187,28 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
|
||||
value={endpoint}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className='setting'
|
||||
title={t('Look up wormhole deposits with')}
|
||||
>
|
||||
<Dropdown
|
||||
className='dropdown'
|
||||
label=''
|
||||
onChange={_onChangeObserver}
|
||||
options={OBSERVER_OPTIONS.some(({ value }) => value === observer)
|
||||
? OBSERVER_OPTIONS
|
||||
: [...OBSERVER_OPTIONS, { text: t('Custom'), value: observer }]}
|
||||
value={observer}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label={t('or an observer of your own')}
|
||||
onBlur={_onCommitObserver}
|
||||
onChange={setObserver}
|
||||
onEnter={_onCommitObserver}
|
||||
placeholder='https://…'
|
||||
value={observer}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className='setting'
|
||||
title={t('Language')}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/**
|
||||
* Where wormhole deposits are looked up.
|
||||
*
|
||||
* The chain cannot answer "which deposits went to these addresses" (the zk
|
||||
* tree has no index by recipient), so something that has read the whole chain
|
||||
* has to, and whatever answers learns that the addresses belong to one person.
|
||||
* The default is a blackbeard observer rather than Quantus's own indexer; a user
|
||||
* can point this at their own, or turn it off.
|
||||
*/
|
||||
const KEY = 'quantus:wormholeObserver';
|
||||
const CHANGED = 'quantus:wormholeObserverChanged';
|
||||
|
||||
export const DEFAULT_OBSERVER = 'https://blackbeard.observer';
|
||||
|
||||
export const OBSERVER_OPTIONS = [
|
||||
{ text: 'blackbeard.observer', value: DEFAULT_OBSERVER },
|
||||
{ text: 'Off — do not look up wormhole deposits', value: '' }
|
||||
];
|
||||
|
||||
/** The configured observer, or the default. An empty string means off. */
|
||||
export function getWormholeObserver (): string {
|
||||
try {
|
||||
const stored = localStorage.getItem(KEY);
|
||||
|
||||
return stored === null ? DEFAULT_OBSERVER : stored;
|
||||
} catch {
|
||||
return DEFAULT_OBSERVER;
|
||||
}
|
||||
}
|
||||
|
||||
export function setWormholeObserver (observer: string): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, observer.trim().replace(/\/+$/, ''));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event(CHANGED));
|
||||
}
|
||||
|
||||
export function onWormholeObserverChange (cb: (observer: string) => void): () => void {
|
||||
const onChanged = (): void => cb(getWormholeObserver());
|
||||
|
||||
const onStorage = ({ key }: StorageEvent): void => {
|
||||
key === KEY && onChanged();
|
||||
};
|
||||
|
||||
window.addEventListener(CHANGED, onChanged);
|
||||
window.addEventListener('storage', onStorage);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener(CHANGED, onChanged);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}
|
||||
@@ -171,5 +171,26 @@
|
||||
"This wallet no longer exists.": "",
|
||||
"This removes the stored recovery phrase and all {{count}} accounts derived from it: each one's ML-DSA-65, ML-DSA-87 and wormhole account. Without the recovery phrase they cannot be recovered.": "",
|
||||
"This removes the stored recovery phrase or seed and every account derived from it. Without it they cannot be recovered.": "",
|
||||
"I want to forget this wallet": ""
|
||||
"I want to forget this wallet": "",
|
||||
"Look up wormhole deposits with": "",
|
||||
"or an observer of your own": "",
|
||||
"Receive address. Funds here leave only through a zero-knowledge proof, so this account has no key to sign with, and sending from it is not in the extension yet.": "",
|
||||
"Balances are off in settings.": "",
|
||||
"Wormhole deposit lookups are off in settings.": "",
|
||||
"Checking deposits against the chain…": "",
|
||||
"Could not check the wormhole balance: {{message}}": "",
|
||||
"Retry": "",
|
||||
"At least": "",
|
||||
"spendable": "",
|
||||
"No deposits yet.": "",
|
||||
"{{deposits}} deposits, {{spent}} already exited.": "",
|
||||
"{{missing}} deposits are not indexed by the observer yet (it has read from block {{from}}); they are not counted.": "",
|
||||
"{{count}} deposits ({{amount}}) arrived after this wallet last computed its nullifiers, so whether they are spent is unknown.": "",
|
||||
"Unlock to check": "",
|
||||
"Checked {{at}}.": "",
|
||||
"Refresh": "",
|
||||
"Check newer deposits": "",
|
||||
"Whether a wormhole deposit is spent is decided by its nullifier, which only this wallet can compute. {{count}} deposits to \"{{name}}\" arrived after it last did. Enter the wallet password to compute theirs; they are stored with the wallet and removed if you forget it.": "",
|
||||
"Compute and check": "",
|
||||
"Open the wallet's wormhole tab first, so there is a balance to extend.": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user