From 957db67f7b8357b9598d7c3243da58aca2b99f23 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 16 Sep 2026 18:35:58 +0300 Subject: [PATCH] feat: show what a wallet's wormhole account can still spend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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) Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f --- .../extension-base/src/background/Wormhole.ts | 180 +++++++++------- .../src/Popup/Accounts/Wallet.tsx | 13 +- .../src/Popup/Accounts/WormholeSummary.tsx | 197 ++++++++++++++++++ .../src/Popup/Wallet/WormholeUnlock.tsx | 136 ++++++++++++ packages/extension-ui/src/Popup/index.tsx | 2 + packages/extension-ui/src/messaging.ts | 11 +- .../src/partials/MenuSettings.tsx | 37 ++++ .../extension-ui/src/util/wormholeObserver.ts | 58 ++++++ .../public/locales/en/translation.json | 23 +- 9 files changed, 572 insertions(+), 85 deletions(-) create mode 100644 packages/extension-ui/src/Popup/Accounts/WormholeSummary.tsx create mode 100644 packages/extension-ui/src/Popup/Wallet/WormholeUnlock.tsx create mode 100644 packages/extension-ui/src/util/wormholeObserver.ts diff --git a/packages/extension-base/src/background/Wormhole.ts b/packages/extension-base/src/background/Wormhole.ts index 81a2fbbb..94bbd2bb 100644 --- a/packages/extension-base/src/background/Wormhole.ts +++ b/packages/extension-base/src/background/Wormhole.ts @@ -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 (promise: Promise, ms: number, message: string): Promise { + let timer: ReturnType | undefined; + + return Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }) + ]).finally(() => clearTimeout(timer)); +} + const NULLIFIER_BYTES = 32; const BRANCHES = [ @@ -264,97 +281,100 @@ function first (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 { +export function wormholeBalance (account: WalletAccount, nullifiers: WormholeNullifiersJson | null, endpoint: string, observer: string): Promise { + 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 { 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('chain_getBlockHash', [0]), + provider.send('state_getMetadata', []), + provider.send>('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('chain_getBlockHash', [0]), - provider.send('state_getMetadata', []), - provider.send>('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(); - - 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(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(); + + 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 + }; } diff --git a/packages/extension-ui/src/Popup/Accounts/Wallet.tsx b/packages/extension-ui/src/Popup/Accounts/Wallet.tsx index e937a25e..b8dda4fb 100644 --- a/packages/extension-ui/src/Popup/Accounts/Wallet.tsx +++ b/packages/extension-ui/src/Popup/Accounts/Wallet.tsx @@ -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 { )} {current === 'wormhole' && ( -
- {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.')} -
+ <> + +
+ {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.')} +
+ )} diff --git a/packages/extension-ui/src/Popup/Accounts/WormholeSummary.tsx b/packages/extension-ui/src/Popup/Accounts/WormholeSummary.tsx new file mode 100644 index 00000000..65dddcf7 --- /dev/null +++ b/packages/extension-ui/src/Popup/Accounts/WormholeSummary.tsx @@ -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(); + +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 { + const { t } = useTranslation(); + const [endpoint, setEndpoint] = useState(getBalanceEndpoint); + const [observer, setObserver] = useState(getWormholeObserver); + const key = answerKey(walletId, accountIndex, endpoint, observer); + const [state, setState] = useState(() => { + 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 ( +
+ {state.kind === 'off' &&
{state.reason}
} + {state.kind === 'checking' && ( +
{t('Checking deposits against the chain…')}
+ )} + {state.kind === 'error' && ( +
+ {t('Could not check the wormhole balance: {{message}}', { replace: { message: state.message } })} + {' '} + {t('Retry')} +
+ )} + {state.kind === 'done' && (() => { + const { balance } = state; + const floor = balance.missing > 0 || balance.uncheckedDeposits > 0; + + return ( + <> +
+ {floor ? t('At least') : ''} {amount(balance.spendable, balance)} {t('spendable')} +
+
+ {balance.transfers === 0 + ? t('No deposits yet.') + : t('{{deposits}} deposits, {{spent}} already exited.', { + replace: { deposits: balance.transfers, spent: amount(balance.spent, balance) } + })} +
+ {balance.missing > 0 && ( +
+ {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 } + })} +
+ )} + {balance.uncheckedDeposits > 0 && ( +
+ {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 } + })} + {' '} + {t('Unlock to check')} +
+ )} +
+ {t('Checked {{at}}.', { replace: { at: new Date(balance.checkedAt).toLocaleTimeString() } })} + {' '} + {t('Refresh')} +
+ + ); + })()} +
+ ); +} + +export default styled(WormholeSummary)` + 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); + } +`; diff --git a/packages/extension-ui/src/Popup/Wallet/WormholeUnlock.tsx b/packages/extension-ui/src/Popup/Wallet/WormholeUnlock.tsx new file mode 100644 index 00000000..4a6d59ee --- /dev/null +++ b/packages/extension-ui/src/Popup/Wallet/WormholeUnlock.tsx @@ -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 { + 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 ( + <> +
+
+ {wallet && answer + ? ( + <> +

+ {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 } })} +

+ + {error && ( + + {error} + + )} + + + ) + : {t('Open the wallet\'s wormhole tab first, so there is a balance to extend.')} + } + + + +
+ + ); +} + +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; + } +`); diff --git a/packages/extension-ui/src/Popup/index.tsx b/packages/extension-ui/src/Popup/index.tsx index a90b1b4f..f5626536 100644 --- a/packages/extension-ui/src/Popup/index.tsx +++ b/packages/extension-ui/src/Popup/index.tsx @@ -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 { {wrapWithErrorBoundary(, 'auth-list')} {wrapWithErrorBoundary(, 'account-creation')} {wrapWithErrorBoundary(, 'wallet-add-account')} + {wrapWithErrorBoundary(, 'wallet-wormhole-unlock')} {wrapWithErrorBoundary(, 'wallet-forget')} {wrapWithErrorBoundary(, 'forget-address')} {wrapWithErrorBoundary(, 'export-address')} diff --git a/packages/extension-ui/src/messaging.ts b/packages/extension-ui/src/messaging.ts index 9c3c1521..e96c5c1f 100644 --- a/packages/extension-ui/src/messaging.ts +++ b/packages/extension-ui/src/messaging.ts @@ -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 { 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 { + return sendMessage('pri(wallets.wormholeBalance)', { accountIndex, endpoint, id, observer }); +} + +export async function wormholeUnlock (id: string, accountIndex: number, password: string, counts: WormholeBalance['counts']): Promise { + return sendMessage('pri(wallets.wormholeUnlock)', { accountIndex, counts, id, password }); +} + export async function subscribeAccounts (cb: (accounts: AccountJson[]) => void): Promise { return sendMessage('pri(accounts.subscribe)', null, cb); } diff --git a/packages/extension-ui/src/partials/MenuSettings.tsx b/packages/extension-ui/src/partials/MenuSettings.tsx index be9cb25f..998ccc83 100644 --- a/packages/extension-ui/src/partials/MenuSettings.tsx +++ b/packages/extension-ui/src/partials/MenuSettings.tsx @@ -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 { + 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 + + value === observer) + ? OBSERVER_OPTIONS + : [...OBSERVER_OPTIONS, { text: t('Custom'), value: observer }]} + value={observer} + /> + + 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); + }; +} diff --git a/packages/extension/public/locales/en/translation.json b/packages/extension/public/locales/en/translation.json index 607ca067..429cb7ba 100644 --- a/packages/extension/public/locales/en/translation.json +++ b/packages/extension/public/locales/en/translation.json @@ -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.": "" }