commit 11d86e89effa93f9751499d1d67f2c031c900f7e parent 7299e337408c143056337a62445b4b9ba8e3ad0f Author: Florian Dold <dold@taler.net> Date: Fri, 4 Sep 2026 18:07:39 +0200 wallet web UI: handle legacy exchange identities Show legacy balances and route blocked operations to exchange identity recovery. Let users accept replacement keys or purge balances tied to old exchange keys. Diffstat:
19 files changed, 1125 insertions(+), 199 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-withdrawal.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-withdrawal.ts @@ -1076,13 +1076,7 @@ async function runPwaWithdrawalTest( .getByRole("heading", { name: "Exchange identity changed" }) .waitFor(); await page - .getByRole("button", { name: "I verified this key change" }) - .click(); - await page - .getByRole("dialog", { name: "Confirm exchange key change?" }) - .waitFor(); - await page - .getByRole("button", { name: "Confirm key change" }) + .getByRole("button", { name: "Accept new identity" }) .click(); await page .getByRole("heading", { name: "Review withdrawal" }) diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -2030,7 +2030,8 @@ export interface ExchangeListItem { legacyMasterPubs: string[]; /** * Set when the exchange changed its key set and the user has not confirmed - * the change yet. Withdrawals are refused while this is present. + * the change yet. Operations that send money to the exchange or disclose + * coin authorizations are refused while this is present. */ unconfirmedKeyChange?: ExchangeKeyChangeInfo; currency: string; diff --git a/packages/taler-wallet-core/src/wallet-api-types.test.ts b/packages/taler-wallet-core/src/wallet-api-types.test.ts @@ -16,10 +16,12 @@ import assert from "node:assert"; import { test } from "node:test"; +import { TalerErrorCode } from "@gnu-taler/taler-util"; import { DeleteExchangeOp, PurgeExchangeLegacyKeysOp, WalletApiOperation, + walletApiExpectedErrors, } from "./wallet-api-types.js"; test("DeleteExchange operation type has the matching discriminant", () => { @@ -46,3 +48,20 @@ test("PurgeExchangeLegacyKeys operation type has the matching discriminant", () assert.strictEqual(operation.op, WalletApiOperation.PurgeExchangeLegacyKeys); }); + +test("key-confirmation gates are expected API errors", () => { + for (const operation of [ + WalletApiOperation.ConfirmWithdrawal, + WalletApiOperation.AcceptBankIntegratedWithdrawal, + WalletApiOperation.AcceptManualWithdrawal, + WalletApiOperation.ConfirmPeerPushCredit, + WalletApiOperation.InitiatePeerPullCredit, + ] as const) { + assert( + walletApiExpectedErrors[operation]?.includes( + TalerErrorCode.WALLET_EXCHANGE_KEYS_NOT_ACCEPTED, + ), + `${operation} must expose an unconfirmed exchange key as an expected error`, + ); + } +}); diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -1886,11 +1886,16 @@ export const walletApiExpectedErrors = { TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, TalerErrorCode.GENERIC_CURRENCY_MISMATCH, TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + TalerErrorCode.WALLET_EXCHANGE_KEYS_NOT_ACCEPTED, + ], + [WalletApiOperation.AcceptBankIntegratedWithdrawal]: [ + TalerErrorCode.WALLET_EXCHANGE_KEYS_NOT_ACCEPTED, ], [WalletApiOperation.AcceptManualWithdrawal]: [ TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED, TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED, TalerErrorCode.GENERIC_CURRENCY_MISMATCH, + TalerErrorCode.WALLET_EXCHANGE_KEYS_NOT_ACCEPTED, ], [WalletApiOperation.PrepareWithdrawExchange]: [ TalerErrorCode.WALLET_TALER_URI_MALFORMED, @@ -1923,6 +1928,7 @@ export const walletApiExpectedErrors = { TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED, TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED, TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, + TalerErrorCode.WALLET_EXCHANGE_KEYS_NOT_ACCEPTED, ], [WalletApiOperation.PreparePeerPullDebit]: [ TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND, @@ -1960,6 +1966,7 @@ export const walletApiExpectedErrors = { TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED, TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED, TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + TalerErrorCode.WALLET_EXCHANGE_KEYS_NOT_ACCEPTED, ], // --- Exchanges ---------------------------------------------------------- diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx @@ -162,6 +162,12 @@ import { type ExchangeActionGate, } from "./exchange-gate-model.js"; import { + exchangeKeyRecoveryFromError, + exchangeKeyRecoveryFromInsufficientBalance, + insufficientBalanceDetailsFromError, + type ExchangeKeyRecovery, +} from "./exchange-key-recovery-model.js"; +import { withdrawalReviewModel, type WithdrawalPreparation, } from "./withdrawal-review-model.js"; @@ -684,7 +690,8 @@ function BalanceRoute() { }; const activitySupported = selectedBalance?.scopeInfo.type === ScopeType.Global || - selectedBalance?.scopeInfo.type === ScopeType.Exchange; + selectedBalance?.scopeInfo.type === ScopeType.Exchange || + selectedBalance?.scopeInfo.type === ScopeType.ExchangeLegacyKeys; const [explicitTrendPeriod, setExplicitTrendPeriod] = useState<BalanceHistoryPeriod>(); const trendEnabled = @@ -783,9 +790,11 @@ function BalanceRoute() { : undefined } trendNotice={ - selectedBalance?.scopeInfo.type === ScopeType.Exchange - ? i18n.str`Estimated history: a transaction involving more than one exchange is counted in full for each exchange, and deleted activity is unavailable.` - : i18n.str`Estimated history reconstructed from retained completed transactions; deleted activity is unavailable.` + selectedBalance?.scopeInfo.type === ScopeType.ExchangeLegacyKeys + ? i18n.str`Estimated history reconstructed from transactions tied to these superseded exchange keys; deleted activity is unavailable.` + : selectedBalance?.scopeInfo.type === ScopeType.Exchange + ? i18n.str`Estimated history: a transaction involving more than one exchange is counted in full for each exchange, and deleted activity is unavailable.` + : i18n.str`Estimated history reconstructed from retained completed transactions; deleted activity is unavailable.` } recentTransactions={recent.data?.transactions.map((transaction) => transactionHistoryView(transaction, language), @@ -831,6 +840,11 @@ function BalanceRoute() { onDeposit={(currency) => withCurrency("/deposit", currency)} onSend={(scope) => withScope("/peer/send", scope)} onRequest={(scope) => withScope("/peer/request", scope)} + onManageLegacyBalance={(exchangeBaseUrl) => + navigate( + `/exchange/${exchangeRouteId(exchangeBaseUrl)}?returnTo=${encodeURIComponent("/")}`, + ) + } onShop={(currency) => withCurrency("/shopping", currency)} onOpenTransaction={(transactionId) => navigate(`/transaction/${transactionId}`) @@ -1153,6 +1167,68 @@ function exchangeGateRoute( return `/exchange/${exchangeRouteId(exchangeBaseUrl)}${suffix}?returnTo=${encodeURIComponent(returnTo)}`; } +function exchangeRecoveryRoute( + recovery: ExchangeKeyRecovery, + returnTo: string, +): string { + const query = `?returnTo=${encodeURIComponent(returnTo)}`; + return recovery.exchangeBaseUrls.length === 1 + ? `/exchange/${exchangeRouteId(recovery.exchangeBaseUrls[0])}${query}` + : `/exchanges${query}`; +} + +interface DepositGateDraft { + amount: AmountString; + paytoUri: string; + scope?: ScopeInfo; +} + +const depositGateDraftKey = "wallet-webui:deposit-gate-draft:v1"; + +function loadDepositGateDraft(returnTo: string): DepositGateDraft | undefined { + try { + const raw = sessionStorage.getItem(depositGateDraftKey); + if (!raw) return undefined; + const value = JSON.parse(raw) as { + savedAt?: unknown; + returnTo?: unknown; + draft?: Partial<DepositGateDraft>; + }; + if ( + typeof value.savedAt !== "number" || + value.returnTo !== returnTo || + Date.now() - value.savedAt > 30 * 60 * 1000 || + typeof value.draft?.amount !== "string" || + typeof value.draft.paytoUri !== "string" + ) { + sessionStorage.removeItem(depositGateDraftKey); + return undefined; + } + return value.draft as DepositGateDraft; + } catch { + return undefined; + } +} + +function saveDepositGateDraft(returnTo: string, draft: DepositGateDraft): void { + try { + sessionStorage.setItem( + depositGateDraftKey, + JSON.stringify({ savedAt: Date.now(), returnTo, draft }), + ); + } catch { + // The user can still return manually when session storage is unavailable. + } +} + +function clearDepositGateDraft(): void { + try { + sessionStorage.removeItem(depositGateDraftKey); + } catch { + // Nothing else to clear. + } +} + function DepositRoute() { const { connection, platform } = useServices(); const [, actionParams] = useRoute("/deposit-action/:actionId"); @@ -1169,11 +1245,17 @@ function DepositRoute() { WalletApiOperation.GetBalances, {}, ); + const exchanges = useWalletQuery( + connection, + WalletApiOperation.ListExchanges, + {}, + ); const eligibleBalances = balances.data?.balances.filter( (balance) => !balance.disableDirectDeposits && - balance.scopeInfo.type !== ScopeType.Auditor, + balance.scopeInfo.type !== ScopeType.Auditor && + balance.scopeInfo.type !== ScopeType.ExchangeLegacyKeys, ) ?? []; const requestedScope = scopeFromIdentity(hashQueryValue("scope")); const requestedCurrency = hashQueryValue("currency"); @@ -1189,6 +1271,8 @@ function DepositRoute() { eligibleBalances[0]; const currency = initialBalance?.scopeInfo.currency ?? requestedCurrency; const actionId = actionParams?.actionId ?? hashQueryValue("actionId"); + const returnTo = actionId ? `/deposit-action/${actionId}` : "/deposit"; + const [gateDraft] = useState(() => loadDepositGateDraft(returnTo)); const [initialPaytoUri, setInitialPaytoUri] = useState<string>(); const [actionLoading, setActionLoading] = useState(actionId.length > 0); const [prepared, setPrepared] = useState<{ @@ -1199,6 +1283,8 @@ function DepositRoute() { }>(); const [working, setWorking] = useState(false); const [error, setError] = useState<ErrorPresentation>(); + const [recovery, setRecovery] = useState<ExchangeKeyRecovery>(); + const [recoveryDraft, setRecoveryDraft] = useState<DepositGateDraft>(); useEffect(() => { if (!actionId) return; let active = true; @@ -1239,6 +1325,7 @@ function DepositRoute() { }) => { setWorking(true); setError(undefined); + setRecovery(undefined); try { const amount = amountFromForm(values); const paytoUri = validatedPaytoUri(values.paytoUri); @@ -1250,14 +1337,26 @@ function DepositRoute() { ...(values.scope ? { restrictScope: values.scope } : {}), }, ); - if (Result.isError(result)) + if (Result.isError(result)) { + const nextRecovery = exchangeKeyRecoveryFromInsufficientBalance( + insufficientBalanceDetailsFromError(result.detail), + exchanges.data?.exchanges, + ); + setRecovery(nextRecovery); + setRecoveryDraft({ + amount, + paytoUri, + ...(values.scope ? { scope: values.scope } : {}), + }); setError( - walletErrorHint( - result.detail, - i18n.str`The wallet could not prepare this deposit.`, - ), + nextRecovery + ? localError(nextRecovery.message) + : walletErrorHint( + result.detail, + i18n.str`The wallet could not prepare this deposit.`, + ), ); - else + } else setPrepared({ amount, paytoUri, @@ -1278,6 +1377,7 @@ function DepositRoute() { if (!(await durability.guardValueOperation())) return; setWorking(true); setError(undefined); + setRecovery(undefined); try { const result = await progressRequest.callAndInvalidate( WalletApiOperation.CreateDepositGroup, @@ -1288,14 +1388,29 @@ function DepositRoute() { }, ["balances", "transactions"], ); - if (Result.isError(result)) + if (Result.isError(result)) { + const nextRecovery = + exchangeKeyRecoveryFromError(result.detail) ?? + exchangeKeyRecoveryFromInsufficientBalance( + insufficientBalanceDetailsFromError(result.detail), + exchanges.data?.exchanges, + ); + setRecovery(nextRecovery); + setRecoveryDraft({ + amount: prepared.amount, + paytoUri: prepared.paytoUri, + ...(prepared.scope ? { scope: prepared.scope } : {}), + }); setError( - walletErrorHint( - result.detail, - i18n.str`The wallet could not create this deposit.`, - ), + nextRecovery + ? localError(nextRecovery.message) + : walletErrorHint( + result.detail, + i18n.str`The wallet could not create this deposit.`, + ), ); - else { + } else { + clearDepositGateDraft(); navigate(`/transaction/${result.value.transactionId}`); void completeActionBestEffort(platform.actionInbox, actionId); } @@ -1311,7 +1426,11 @@ function DepositRoute() { <DepositScreen currency={currency} initialScopeId={ - initialBalance ? scopeIdentity(initialBalance.scopeInfo) : undefined + gateDraft?.scope + ? scopeIdentity(gateDraft.scope) + : initialBalance + ? scopeIdentity(initialBalance.scopeInfo) + : undefined } scopes={eligibleBalances.map((balance) => ({ id: scopeIdentity(balance.scopeInfo), @@ -1320,9 +1439,16 @@ function DepositRoute() { available: balance.available, scope: balance.scopeInfo, }))} - initialPaytoUri={initialPaytoUri} + initialPaytoUri={gateDraft?.paytoUri ?? initialPaytoUri} + initialAmount={ + gateDraft + ? Amounts.stringifyValue(Amounts.parseOrThrow(gateDraft.amount)) + : undefined + } initialDestination={ - initialPaytoUri ? depositDestinationView(initialPaytoUri) : undefined + gateDraft?.paytoUri || initialPaytoUri + ? depositDestinationView(gateDraft?.paytoUri ?? initialPaytoUri!) + : undefined } accounts={accounts.data?.accounts.map((account) => ({ id: account.bankAccountId, @@ -1347,6 +1473,15 @@ function DepositRoute() { ) : undefined) } + errorActionLabel={recovery?.actionLabel} + onErrorAction={ + recovery + ? () => { + if (recoveryDraft) saveDepositGateDraft(returnTo, recoveryDraft); + navigate(exchangeRecoveryRoute(recovery, returnTo)); + } + : undefined + } review={ prepared && response ? { @@ -1373,10 +1508,12 @@ function DepositRoute() { onReview={(values) => void review(values)} onConfirm={() => void confirm()} onBack={() => { + clearDepositGateDraft(); setPrepared(undefined); setError(undefined); }} onCancel={() => { + clearDepositGateDraft(); void completeActionBestEffort(platform.actionInbox, actionId); navigate("/"); }} @@ -1396,6 +1533,7 @@ interface PeerCreatePreparation { scope: ScopeInfo; peerPushDebitQuote?: string; insufficient?: string; + recovery?: ExchangeKeyRecovery; } const peerRequestDraftKey = "wallet-webui-peer-request-draft-v1"; @@ -1409,16 +1547,23 @@ function removePeerRequestDraft(): void { } } -function loadPeerRequestDraft(): PeerCreatePreparation | undefined { +function loadPeerRequestDraft( + mode: "send" | "request", +): PeerCreatePreparation | undefined { if (peerRequestDraftConsumed) return undefined; try { const raw = sessionStorage.getItem(peerRequestDraftKey); if (!raw) return undefined; - const parsed = JSON.parse(raw) as { savedAt?: unknown; prepared?: unknown }; + const parsed = JSON.parse(raw) as { + savedAt?: unknown; + mode?: unknown; + prepared?: unknown; + }; if ( typeof parsed.savedAt !== "number" || parsed.savedAt > Date.now() + 60 * 1000 || Date.now() - parsed.savedAt > 30 * 60 * 1000 || + parsed.mode !== mode || !parsed.prepared || typeof parsed.prepared !== "object" ) { @@ -1443,12 +1588,17 @@ function loadPeerRequestDraft(): PeerCreatePreparation | undefined { } } -function savePeerRequestDraft(prepared: PeerCreatePreparation): void { +function savePeerRequestDraft( + mode: "send" | "request", + prepared: PeerCreatePreparation, +): void { peerRequestDraftConsumed = false; + const draft = { ...prepared }; + delete draft.recovery; try { sessionStorage.setItem( peerRequestDraftKey, - JSON.stringify({ savedAt: Date.now(), prepared }), + JSON.stringify({ savedAt: Date.now(), mode, prepared: draft }), ); } catch { peerRequestDraftConsumed = true; @@ -1483,8 +1633,11 @@ function PeerCreateRoute(props: { mode: "send" | "request" }) { ""; const requestedScopeId = hashQueryValue("scope"); const eligibleBalances = - balances.data?.balances.filter((balance) => !balance.disablePeerPayments) ?? - []; + balances.data?.balances.filter( + (balance) => + !balance.disablePeerPayments && + balance.scopeInfo.type !== ScopeType.ExchangeLegacyKeys, + ) ?? []; const initialBalance = eligibleBalances.find( (balance) => scopeIdentity(balance.scopeInfo) === requestedScopeId, @@ -1494,8 +1647,7 @@ function PeerCreateRoute(props: { mode: "send" | "request" }) { ) ?? eligibleBalances[0]; const [prepared, setPrepared] = useState<PeerCreatePreparation | undefined>( - () => - props.mode === "request" && !demo ? loadPeerRequestDraft() : undefined, + () => (!demo ? loadPeerRequestDraft(props.mode) : undefined), ); const [working, setWorking] = useState(false); const [error, setError] = useState<ErrorPresentation>(); @@ -1582,7 +1734,11 @@ function PeerCreateRoute(props: { mode: "send" | "request" }) { i18n.str`The wallet could not prepare this transfer.`, ), ); - else if (result.value.type === "insufficient-balance") + else if (result.value.type === "insufficient-balance") { + const recovery = exchangeKeyRecoveryFromInsufficientBalance( + result.value.insufficientBalanceDetails, + exchanges.data?.exchanges, + ); setPrepared({ amount, summary: values.summary, @@ -1596,9 +1752,12 @@ function PeerCreateRoute(props: { mode: "send" | "request" }) { amountEffective: amount, exchange: i18n.str`No suitable exchange`, scope, - insufficient: i18n.str`The wallet does not have enough compatible funds for this transfer.`, + insufficient: + recovery?.message ?? + i18n.str`The wallet does not have enough compatible funds for this transfer.`, + ...(recovery ? { recovery } : {}), }); - else { + } else { const resolvedExpiration = explicitExpiration ?? expirationFromProtocolDuration(result.value.defaultExpiration); @@ -1704,14 +1863,25 @@ function PeerCreateRoute(props: { mode: "send" | "request" }) { i18n.str`The available coins, fees, or exchange changed. Review the transfer again before sending.`, ), ); - } else if (Result.isError(result)) + } else if (Result.isError(result)) { + const recovery = + exchangeKeyRecoveryFromError(result.detail) ?? + exchangeKeyRecoveryFromInsufficientBalance( + insufficientBalanceDetailsFromError(result.detail), + exchanges.data?.exchanges, + ); + if (recovery) { + setPrepared({ ...prepared, recovery }); + } setError( - walletErrorHint( - result.detail, - i18n.str`The wallet could not create the transfer.`, - ), + recovery + ? localError(recovery.message) + : walletErrorHint( + result.detail, + i18n.str`The wallet could not create the transfer.`, + ), ); - else { + } else { if (!demo) clearPeerRequestDraft(); navigate(`/peer/share/${result.value.transactionId}/${props.mode}`); } @@ -1731,14 +1901,20 @@ function PeerCreateRoute(props: { mode: "send" | "request" }) { }, ["balances", "transactions"], ); - if (Result.isError(result)) + if (Result.isError(result)) { + const recovery = exchangeKeyRecoveryFromError(result.detail); + if (recovery && prepared) { + setPrepared({ ...prepared, recovery }); + } setError( - walletErrorHint( - result.detail, - i18n.str`The wallet could not create the request.`, - ), + recovery + ? localError(recovery.message) + : walletErrorHint( + result.detail, + i18n.str`The wallet could not create the request.`, + ), ); - else { + } else { if (!demo) clearPeerRequestDraft(); navigate(`/peer/share/${result.value.transactionId}/${props.mode}`); } @@ -1764,6 +1940,7 @@ function PeerCreateRoute(props: { mode: "send" | "request" }) { managedExchange?.tosStatus, ) : undefined; + const recovery = prepared?.recovery; const trustCheckPending = props.mode === "request" && Boolean(prepared) && @@ -1799,12 +1976,13 @@ function PeerCreateRoute(props: { mode: "send" | "request" }) { : undefined) } blockedReason={ + recovery?.message ?? gate?.reason ?? (trustCheckPending ? i18n.str`Checking this exchange's terms and signing keys…` : undefined) } - blockedActionLabel={gate?.label} + blockedActionLabel={recovery?.actionLabel ?? gate?.label} onResolveDefaultExpiration={resolveDefaultExpiration} review={ prepared @@ -1823,14 +2001,16 @@ function PeerCreateRoute(props: { mode: "send" | "request" }) { onReview={(values) => void review(values)} onConfirm={() => void confirm()} onResolveBlock={ - gate && prepared + (gate || recovery) && prepared ? () => { - if (!demo) savePeerRequestDraft(prepared); - window.location.hash = exchangeGateRoute( - prepared.exchange, - gate.route, - "/peer/request", - ); + if (!demo) savePeerRequestDraft(props.mode, prepared); + window.location.hash = recovery + ? exchangeRecoveryRoute(recovery, `/peer/${props.mode}`) + : exchangeGateRoute( + prepared.exchange, + gate!.route, + "/peer/request", + ); } : undefined } @@ -1886,6 +2066,7 @@ function PeerShareRoute() { i18n.str`The exchange could not prepare the peer share link yet.`, ) : undefined; + const recovery = exchangeKeyRecoveryFromError(transaction?.error); const retryable = Boolean( transactionProblem && transaction?.txActions.includes(TransactionAction.Retry), @@ -1957,6 +2138,18 @@ function PeerShareRoute() { }} onDone={() => navigate(`/transaction/${transactionId}`)} onRetry={() => void retry()} + recoveryActionLabel={recovery?.actionLabel} + onRecoveryAction={ + recovery + ? () => + navigate( + exchangeRecoveryRoute( + recovery, + `/peer/share/${transactionId}/${mode}`, + ), + ) + : undefined + } /> ); } @@ -1985,6 +2178,8 @@ function PeerReceiveRoute() { const [cancelling, setCancelling] = useState(false); const [error, setError] = useState<ErrorPresentation>(); const [requiredExchangeUrl, setRequiredExchangeUrl] = useState<string>(); + const [requiredKeyRecovery, setRequiredKeyRecovery] = + useState<ExchangeKeyRecovery>(); useEffect(() => { let active = true; setPrepared(undefined); @@ -1994,6 +2189,7 @@ function PeerReceiveRoute() { setCancelling(false); setError(undefined); setRequiredExchangeUrl(undefined); + setRequiredKeyRecovery(undefined); void (async () => { try { const entry = await platform.actionInbox.get(actionId); @@ -2107,7 +2303,10 @@ function PeerReceiveRoute() { ); if (Result.isError(result)) { const tosExchangeUrl = exchangeTosErrorUrl(result.detail); - if (prepared.kind === "receive" && tosExchangeUrl) { + const keyRecovery = exchangeKeyRecoveryFromError(result.detail); + if (prepared.kind === "receive" && keyRecovery) { + setRequiredKeyRecovery(keyRecovery); + } else if (prepared.kind === "receive" && tosExchangeUrl) { setRequiredExchangeUrl(tosExchangeUrl); } else { setError( @@ -2144,8 +2343,9 @@ function PeerReceiveRoute() { (exchange) => exchange.exchangeBaseUrl === preparedExchangeUrl, ) : undefined; - const gate = - prepared?.kind === "receive" + const gate = requiredKeyRecovery + ? undefined + : prepared?.kind === "receive" ? requiredExchangeUrl ? peerReceiveExchangeGate(false, ExchangeTosStatus.Proposed) : managedExchange @@ -2165,12 +2365,13 @@ function PeerReceiveRoute() { cancelling={cancelling} error={error} blockedReason={ + requiredKeyRecovery?.message ?? gate?.reason ?? (trustCheckPending ? i18n.str`Checking this exchange's terms and signing keys…` : undefined) } - blockedActionLabel={gate?.label} + blockedActionLabel={requiredKeyRecovery?.actionLabel ?? gate?.label} amountRaw={prepared?.data.amountRaw} amountEffective={prepared?.data.amountEffective} summary={prepared?.data.contractTerms.summary} @@ -2178,15 +2379,23 @@ function PeerReceiveRoute() { exchange={prepared?.data.exchangeBaseUrl} onConfirm={() => void confirm()} onResolveBlock={ - gate && (requiredExchangeUrl ?? preparedExchangeUrl) - ? () => { - window.location.hash = exchangeGateRoute( - requiredExchangeUrl ?? preparedExchangeUrl!, - gate.route, - `/peer-receive/${actionId}`, - ); - } - : undefined + requiredKeyRecovery + ? () => + navigate( + exchangeRecoveryRoute( + requiredKeyRecovery, + `/peer-receive/${actionId}`, + ), + ) + : gate && (requiredExchangeUrl ?? preparedExchangeUrl) + ? () => { + window.location.hash = exchangeGateRoute( + requiredExchangeUrl ?? preparedExchangeUrl!, + gate.route, + `/peer-receive/${actionId}`, + ); + } + : undefined } onCancel={() => void cancel()} onWithdraw={() => navigate("/withdraw")} @@ -2420,6 +2629,7 @@ function WithdrawalRoute( const [preparing, setPreparing] = useState(false); const [accepting, setAccepting] = useState(false); const [error, setError] = useState<ErrorPresentation>(); + const [keyRecovery, setKeyRecovery] = useState<ExchangeKeyRecovery>(); const [prepared, setPrepared] = useState<WithdrawalPreparation | undefined>( () => loadWithdrawalGateDraft(returnTo), ); @@ -2492,6 +2702,7 @@ function WithdrawalRoute( ) => { setPreparing(true); setError(undefined); + setKeyRecovery(undefined); try { const amount = `${provider.currency}:${value}` as AmountString; const result = await progressRequest.callAndInvalidate( @@ -2534,6 +2745,7 @@ function WithdrawalRoute( if (!(await durability.guardValueOperation())) return; setAccepting(true); setError(undefined); + setKeyRecovery(undefined); try { const result = await progressRequest.callAndInvalidate( WalletApiOperation.AcceptManualWithdrawal, @@ -2547,6 +2759,7 @@ function WithdrawalRoute( ["balances", "transactions"], ); if (Result.isError(result)) { + setKeyRecovery(exchangeKeyRecoveryFromError(result.detail)); setError( walletErrorHint( result.detail, @@ -2633,6 +2846,12 @@ function WithdrawalRoute( ); } : undefined; + const resolveKeyRecovery = keyRecovery + ? () => { + saveWithdrawalGateDraft(returnTo, prepared); + navigate(exchangeRecoveryRoute(keyRecovery, returnTo)); + } + : undefined; return ( <WithdrawalReviewScreen exchange={prepared.provider.exchangeBaseUrl} @@ -2641,12 +2860,12 @@ function WithdrawalRoute( fee={review.fee} ageRestrictionOptions={prepared.ageRestrictionOptions} restrictAge={prepared.restrictAge} - blockedReason={review.blockedReason} - blockedActionLabel={review.gate?.label} + blockedReason={keyRecovery?.message ?? review.blockedReason} + blockedActionLabel={keyRecovery?.actionLabel ?? review.gate?.label} warning={review.warning} working={accepting || preparing || review.trustLoading} error={error} - onBlockedAction={resolveGate} + onBlockedAction={resolveKeyRecovery ?? resolveGate} onRestrictAge={(age) => void prepare( prepared.provider, @@ -3173,6 +3392,7 @@ function IntegratedWithdrawalRoute() { const [accepting, setAccepting] = useState(false); const [cancelling, setCancelling] = useState(false); const [error, setError] = useState<ErrorPresentation>(); + const [keyRecovery, setKeyRecovery] = useState<ExchangeKeyRecovery>(); const load = useCallback( async (isActive: () => boolean = () => true) => { setLoading(true); @@ -3368,6 +3588,7 @@ function IntegratedWithdrawalRoute() { ) => { setPreparing(true); setError(undefined); + setKeyRecovery(undefined); try { const amount = `${provider.currency}:${value}` as AmountString; const result = await progressRequest.callAndInvalidate( @@ -3414,6 +3635,7 @@ function IntegratedWithdrawalRoute() { if (!(await durability.guardValueOperation())) return; setAccepting(true); setError(undefined); + setKeyRecovery(undefined); try { const restrictAge = prepared?.restrictAge ?? cashAcceptorAge; const result = await progressRequest.callAndInvalidate( @@ -3427,6 +3649,7 @@ function IntegratedWithdrawalRoute() { ["balances", "transactions"], ); if (Result.isError(result)) { + setKeyRecovery(exchangeKeyRecoveryFromError(result.detail)); setError( walletErrorHint( result.detail, @@ -3542,6 +3765,12 @@ function IntegratedWithdrawalRoute() { ); } : undefined; + const resolveKeyRecovery = keyRecovery + ? () => { + saveWithdrawalGateDraft(returnTo, prepared); + navigate(exchangeRecoveryRoute(keyRecovery, returnTo)); + } + : undefined; return ( <WithdrawalReviewScreen exchange={prepared.provider.exchangeBaseUrl} @@ -3550,13 +3779,13 @@ function IntegratedWithdrawalRoute() { fee={review.fee} ageRestrictionOptions={prepared.ageRestrictionOptions} restrictAge={prepared.restrictAge} - blockedReason={review.blockedReason} - blockedActionLabel={review.gate?.label} + blockedReason={keyRecovery?.message ?? review.blockedReason} + blockedActionLabel={keyRecovery?.actionLabel ?? review.gate?.label} warning={review.warning} working={accepting || preparing || review.trustLoading} cancelling={cancelling} error={error} - onBlockedAction={resolveGate} + onBlockedAction={resolveKeyRecovery ?? resolveGate} onRestrictAge={(age) => void prepare( prepared.provider, @@ -4123,6 +4352,11 @@ function PaymentDialog(props: { WalletApiOperation.GetChoicesForPayment, { transactionId: props.transaction.transactionId }, ); + const exchanges = useWalletQuery( + connection, + WalletApiOperation.ListExchanges, + {}, + ); const donauQuery = useWalletQuery( connection, WalletApiOperation.GetDonau, @@ -4158,6 +4392,7 @@ function PaymentDialog(props: { | "error" >("ready"); const [error, setError] = useState<ErrorPresentation>(); + const [errorRecovery, setErrorRecovery] = useState<ExchangeKeyRecovery>(); const [cancelling, setCancelling] = useState(false); const [resuming, setResuming] = useState(false); const [unclaiming, setUnclaiming] = useState(false); @@ -4171,6 +4406,7 @@ function PaymentDialog(props: { setUseDonau(false); setState("ready"); setError(undefined); + setErrorRecovery(undefined); setCancelling(false); setResuming(false); setUnclaiming(false); @@ -4181,12 +4417,19 @@ function PaymentDialog(props: { reclaimingRef.current = false; }, [props.actionId, props.transaction.transactionId]); const activeChoice = activePaymentChoiceIndex(review, selectedChoice); + const choiceRecovery = exchangeKeyRecoveryFromInsufficientBalance( + review?.choices.find((choice) => choice.index === activeChoice) + ?.balanceDetails, + exchanges.data?.exchanges, + ); + const recovery = errorRecovery ?? choiceRecovery; const confirm = useCallback( async (choiceIndex = activeChoice, collectDonationReceipt = useDonau) => { if (choiceIndex === undefined) return; if (!(await durability.guardValueOperation())) return; setState("paying"); setError(undefined); + setErrorRecovery(undefined); try { const choice = review?.choices.find( (choice) => choice.index === choiceIndex, @@ -4202,6 +4445,7 @@ function PaymentDialog(props: { ["balances", "transactions"], ); if (Result.isError(result)) { + setErrorRecovery(exchangeKeyRecoveryFromError(result.detail)); setError( walletCoreError( result.detail, @@ -4215,6 +4459,9 @@ function PaymentDialog(props: { setState("done"); void completeActionBestEffort(platform.actionInbox, props.actionId); } else if (result.value.lastError) { + setErrorRecovery( + exchangeKeyRecoveryFromError(result.value.lastError), + ); setError( walletCoreError( result.value.lastError, @@ -4447,6 +4694,18 @@ function PaymentDialog(props: { choices={review.choices} selectedChoice={activeChoice} error={error} + recoveryActionLabel={recovery?.actionLabel} + onRecoveryAction={ + recovery + ? () => + navigate( + exchangeRecoveryRoute( + recovery, + `/pay/${props.transaction.transactionId}/${props.actionId}`, + ), + ) + : undefined + } fulfillmentMessage={review.fulfillmentMessage} fulfillmentUrl={fulfillmentUrl} useDonau={useDonau} @@ -4487,7 +4746,8 @@ function TransactionsRoute() { const requestedScope = scopeFromIdentity(hashQueryValue("scope")); const supportedScope = requestedScope?.type === ScopeType.Global || - requestedScope?.type === ScopeType.Exchange + requestedScope?.type === ScopeType.Exchange || + requestedScope?.type === ScopeType.ExchangeLegacyKeys ? requestedScope : undefined; const query = useWalletQuery( @@ -4532,6 +4792,11 @@ function TransactionDetailRoute() { WalletApiOperation.GetTransactionById, { transactionId, includeContractTerms: true }, ); + const exchanges = useWalletQuery( + connection, + WalletApiOperation.ListExchanges, + {}, + ); const paymentDialog = query.data?.type === TransactionType.Payment && query.data.txState.major === TransactionMajorState.Dialog && @@ -4602,6 +4867,15 @@ function TransactionDetailRoute() { paymentReview, selectedPaymentChoice, ); + const recovery = + exchangeKeyRecoveryFromError(error?.detail) ?? + exchangeKeyRecoveryFromError(query.data?.error) ?? + exchangeKeyRecoveryFromInsufficientBalance( + paymentReview?.choices.find( + (candidate) => candidate.index === activePaymentChoice, + )?.balanceDetails, + exchanges.data?.exchanges, + ); useEffect(() => setUseDonau(false), [activePaymentChoice]); const confirmPayment = async (collectDonationReceipt = useDonau) => { if (!paymentDialog || !paymentReview || activePaymentChoice === undefined) @@ -4901,6 +5175,18 @@ function TransactionDetailRoute() { ) } onWithdraw={() => navigate("/withdraw")} + recoveryActionLabel={recovery?.actionLabel} + onRecoveryAction={ + recovery + ? () => + navigate( + exchangeRecoveryRoute( + recovery, + `/transaction/${transactionId}`, + ), + ) + : undefined + } /> ); } diff --git a/packages/wallet-webui/src/routes/ManagementRoutes.tsx b/packages/wallet-webui/src/routes/ManagementRoutes.tsx @@ -2,6 +2,7 @@ import { Paytos, PaytoType, Result, + ScopeType, isSafeExternalUrl, type GetExchangeTosResult, } from "@gnu-taler/taler-util"; @@ -87,6 +88,7 @@ export function ExchangesRoute() { ); const [working, setWorking] = useState(false); const [error, setError] = useState<ErrorPresentation>(); + const [returnTo] = useState(() => safeWalletReturnTo()); const add = async (uri: string) => { setWorking(true); setError(undefined); @@ -129,8 +131,10 @@ export function ExchangesRoute() { : undefined) } onAdd={(uri) => void add(uri)} - onOpen={(url) => navigate(`/exchange/${exchangeRouteId(url)}`)} - onBack={() => navigate("/settings")} + onOpen={(url) => + navigate(withReturnTo(`/exchange/${exchangeRouteId(url)}`, returnTo)) + } + onBack={() => navigate(returnTo ?? "/settings")} /> ); } @@ -159,6 +163,11 @@ export function ExchangeDetailRoute() { WalletApiOperation.GetExchangeDetailedInfo, { exchangeBaseUrl: exchangeUrl }, ); + const balances = useWalletQuery( + connection, + WalletApiOperation.GetBalances, + {}, + ); const entry = list.data?.exchanges.find( (item) => item.exchangeBaseUrl === exchangeUrl, ); @@ -177,6 +186,20 @@ export function ExchangeDetailRoute() { noFees: entry.noFees, peerPaymentsDisabled: entry.peerPaymentsDisabled, directDepositsDisabled: entry.directDepositsDisabled, + legacyMasterPubs: entry.legacyMasterPubs, + legacyBalances: + balances.data?.balances.flatMap((balance) => + balance.scopeInfo.type === ScopeType.ExchangeLegacyKeys && + balance.scopeInfo.url === entry.exchangeBaseUrl + ? [ + { + currency: balance.scopeInfo.currency, + available: balance.available, + masterPub: balance.scopeInfo.masterPub, + }, + ] + : [], + ) ?? [], ...(entry.lastUpdateErrorInfo ? { lastError: walletCoreError( @@ -193,7 +216,7 @@ export function ExchangeDetailRoute() { : {}), } : undefined, - [details.data, entry], + [balances.data, details.data, entry], ); const updateInProgress = exchangeUpdateInProgress( entry?.exchangeUpdateStatus, @@ -203,7 +226,7 @@ export function ExchangeDetailRoute() { setShowUpdateNotice(false); } }, [entry, showUpdateNotice, updateInProgress]); - const run = async (operation: "update" | "key") => { + const run = async (operation: "update" | "key" | "purge-legacy") => { if (!entry) return; setWorking(true); setError(undefined); @@ -231,7 +254,7 @@ export function ExchangeDetailRoute() { exchangeBaseUrl: exchangeUrl, currentMasterPub: entry.unconfirmedKeyChange.currentMasterPub, }, - ["exchanges", "transactions"], + ["exchanges", "balances", "transactions"], ); if (Result.isError(result)) setError( @@ -242,6 +265,24 @@ export function ExchangeDetailRoute() { ); else if (returnTo) navigate(returnTo); else setMessage(i18n.str`Exchange key change confirmed.`); + } else if (operation === "purge-legacy" && entry.masterPub) { + const result = await callMutation( + WalletApiOperation.PurgeExchangeLegacyKeys, + { + exchangeBaseUrl: exchangeUrl, + currentMasterPub: entry.masterPub, + }, + ["exchanges", "balances", "transactions"], + ); + if (Result.isError(result)) + setError( + errorHint( + result.detail, + i18n.str`The legacy balances could not be purged. Refresh the exchange details and review them again.`, + ), + ); + else if (returnTo) navigate(returnTo); + else setMessage(i18n.str`Legacy exchange balances purged.`); } } catch (cause) { setError(errorFromException(cause, i18n.str`Exchange action failed`)); @@ -358,6 +399,7 @@ export function ExchangeDetailRoute() { } onUpdate={() => void run("update")} onConfirmKeyChange={() => void run("key")} + onPurgeLegacyBalances={() => void run("purge-legacy")} onRequestDelete={() => void requestDelete()} onCancelDelete={() => setDeleteMode(undefined)} onConfirmDelete={(purge) => void confirmDelete(purge)} diff --git a/packages/wallet-webui/src/routes/balance-model.ts b/packages/wallet-webui/src/routes/balance-model.ts @@ -82,6 +82,9 @@ function balanceView(balance: WalletBalance): BalanceView { shoppingUrls: balance.shoppingUrls ?? [], peerPaymentsDisabled: balance.disablePeerPayments === true, depositsDisabled: balance.disableDirectDeposits === true, + ...(balance.scopeInfo.type === ScopeType.ExchangeLegacyKeys + ? { legacy: true } + : {}), }; } diff --git a/packages/wallet-webui/src/routes/exchange-key-recovery-model.ts b/packages/wallet-webui/src/routes/exchange-key-recovery-model.ts @@ -0,0 +1,97 @@ +import { + CoinSelectionFailureReasonType, + TalerErrorCode, + type ExchangeListItem, + type PaymentInsufficientBalanceDetails, +} from "@gnu-taler/taler-util"; +import { i18n } from "../i18n/runtime.js"; + +export interface ExchangeKeyRecovery { + kind: "verify" | "legacy"; + exchangeBaseUrls: string[]; + message: string; + actionLabel: string; +} + +export function exchangeKeyChangeErrorUrl(detail: unknown): string | undefined { + if (!detail || typeof detail !== "object") return undefined; + const value = detail as { code?: unknown; exchangeBaseUrl?: unknown }; + return value.code === TalerErrorCode.WALLET_EXCHANGE_KEYS_NOT_ACCEPTED && + typeof value.exchangeBaseUrl === "string" + ? value.exchangeBaseUrl + : undefined; +} + +function supersededExchangeUrls( + detail: PaymentInsufficientBalanceDetails | undefined, +): string[] { + if (!detail?.exchanges) return []; + return Object.entries(detail.exchanges) + .filter(([, diagnostic]) => + diagnostic.reasons.some( + (reason) => + reason.type === + CoinSelectionFailureReasonType.SupersededExchangeMasterPub, + ), + ) + .map(([url]) => url) + .sort(); +} + +export function exchangeKeyRecoveryFromInsufficientBalance( + detail: PaymentInsufficientBalanceDetails | undefined, + exchanges: ExchangeListItem[] | undefined, +): ExchangeKeyRecovery | undefined { + const affected = supersededExchangeUrls(detail); + if (affected.length === 0) return undefined; + const pending = affected.filter((url) => + exchanges?.some( + (exchange) => + exchange.exchangeBaseUrl === url && exchange.unconfirmedKeyChange, + ), + ); + if (pending.length > 0) { + return { + kind: "verify", + exchangeBaseUrls: pending, + message: i18n.str`Some funds are locked until you verify an exchange signing-key change. Review the transfer again afterward because older denominations might remain unusable.`, + actionLabel: + pending.length === 1 + ? i18n.str`Verify key change` + : i18n.str`Review exchanges`, + }; + } + return { + kind: "legacy", + exchangeBaseUrls: affected, + message: i18n.str`Some funds were issued under legacy exchange keys and cannot be used for this operation.`, + actionLabel: + affected.length === 1 + ? i18n.str`Manage legacy balance` + : i18n.str`Review exchanges`, + }; +} + +export function insufficientBalanceDetailsFromError( + detail: unknown, +): PaymentInsufficientBalanceDetails | undefined { + if (!detail || typeof detail !== "object") return undefined; + const value = (detail as { insufficientBalanceDetails?: unknown }) + .insufficientBalanceDetails; + return value && typeof value === "object" + ? (value as PaymentInsufficientBalanceDetails) + : undefined; +} + +export function exchangeKeyRecoveryFromError( + detail: unknown, +): ExchangeKeyRecovery | undefined { + const exchangeBaseUrl = exchangeKeyChangeErrorUrl(detail); + if (!exchangeBaseUrl) return undefined; + return { + kind: "verify", + exchangeBaseUrls: [exchangeBaseUrl], + message: i18n.str`This operation is blocked until you verify the exchange signing-key change.`, + actionLabel: i18n.str`Verify key change`, + }; +} diff --git a/packages/wallet-webui/src/routes/payment-model.ts b/packages/wallet-webui/src/routes/payment-model.ts @@ -228,6 +228,7 @@ export function paymentReviewView( : { payable: false, availableBalance: details.balanceDetails?.balanceAvailable, + balanceDetails: details.balanceDetails, unavailableReason: paymentUnavailableReason( details.balanceDetails, Boolean(details.tokenDetails?.tokensRequested), diff --git a/packages/wallet-webui/src/screens/BalanceScreen.tsx b/packages/wallet-webui/src/screens/BalanceScreen.tsx @@ -1,4 +1,4 @@ -import { Amounts, type ScopeInfo } from "@gnu-taler/taler-util"; +import { Amounts, ScopeType, type ScopeInfo } from "@gnu-taler/taler-util"; import { useState } from "preact/hooks"; import type { TransactionHistoryView } from "../routes/transaction-model.js"; import { Button } from "../ui/Button.js"; @@ -25,6 +25,28 @@ export interface BalanceView { shoppingUrls: string[]; peerPaymentsDisabled: boolean; depositsDisabled: boolean; + legacy?: boolean; +} + +function BalanceAmount(props: { balance: BalanceView }) { + return ( + <strong + class={props.balance.legacy ? "text-secondary line-through" : undefined} + aria-label={ + props.balance.legacy + ? i18n.str`Legacy balance ${props.balance.available}, unavailable for normal use` + : undefined + } + > + {props.balance.availableValue} + </strong> + ); +} + +function legacyExchangeUrl(balance: BalanceView): string | undefined { + return balance.scopeInfo.type === ScopeType.ExchangeLegacyKeys + ? balance.scopeInfo.url + : undefined; } export interface BalanceTrendPoint { @@ -571,7 +593,7 @@ function BalanceListOverlay(props: { <BalanceWarning balance={balance} compact /> <span class="mt-5 block text-xl">{balance.currency}</span> <span class="block text-2xl font-medium"> - {balance.availableValue} + <BalanceAmount balance={balance} /> </span> </button> ))} @@ -604,6 +626,7 @@ export function BalanceScreen(props: { onDeposit: (currency: string) => void; onSend: (scope: ScopeInfo) => void; onRequest: (scope: ScopeInfo) => void; + onManageLegacyBalance?: (exchangeBaseUrl: string) => void; onShop?: (currency: string) => void; onOpenTransaction?: (transactionId: string) => void; onShowTransactions?: (scope: ScopeInfo) => void; @@ -709,7 +732,7 @@ export function BalanceScreen(props: { <BalanceWarning balance={balance} compact /> <span class="mt-3 flex items-baseline justify-between gap-3 text-xl"> <span>{balance.currency}</span> - <strong>{balance.availableValue}</strong> + <BalanceAmount balance={balance} /> </span> </button> ))} @@ -727,9 +750,19 @@ export function BalanceScreen(props: { class="mt-3 flex items-baseline gap-8 text-3xl" > <span>{selected.currency}</span> - <strong>{selected.availableValue}</strong> + <BalanceAmount balance={selected} /> </h1> <BalanceWarning balance={selected} /> + {selected.legacy && props.onManageLegacyBalance && ( + <div class="mt-4"> + <Button + tone="secondary" + onClick={() => + props.onManageLegacyBalance!(legacyExchangeUrl(selected)!) + } + >{i18n.str`Manage legacy balance`}</Button> + </div> + )} </div> <div class="min-w-64"> <PendingAmounts balance={selected} /> @@ -800,7 +833,7 @@ export function BalanceScreen(props: { class="mt-4 flex items-baseline gap-5 text-3xl" > <span>{selected.currency}</span> - <strong>{selected.availableValue}</strong> + <BalanceAmount balance={selected} /> </h1> <BalanceTrendSection enabled={props.trendEnabled === true} @@ -817,19 +850,21 @@ export function BalanceScreen(props: { <PendingAmounts balance={selected} /> </div> </section> - {selected.shoppingUrls.length > 0 && props.onShop && ( - <button - type="button" - onClick={() => props.onShop!(selected.currency)} - class="flex min-h-14 w-full items-center justify-center gap-5 rounded-xl bg-secondaryContainer px-5 font-semibold text-onSecondaryContainer hover:brightness-95" - > - <WalletIcon name="map" class="h-8 w-8" /> - { - // Opens merchant locations; the placeholder is a currency code. - i18n.str`Where to pay with ${selected.currency}` - } - </button> - )} + {!selected.legacy && + selected.shoppingUrls.length > 0 && + props.onShop && ( + <button + type="button" + onClick={() => props.onShop!(selected.currency)} + class="flex min-h-14 w-full items-center justify-center gap-5 rounded-xl bg-secondaryContainer px-5 font-semibold text-onSecondaryContainer hover:brightness-95" + > + <WalletIcon name="map" class="h-8 w-8" /> + { + // Opens merchant locations; the placeholder is a currency code. + i18n.str`Where to pay with ${selected.currency}` + } + </button> + )} <div class="flex items-center justify-center gap-3" aria-label={i18n.str`Balance selector`} @@ -858,70 +893,79 @@ export function BalanceScreen(props: { /> ))} </div> - <div class="grid grid-cols-5 gap-2"> - <MoneyAction - icon="send" - label={sendActionLabel} - disabled={selected.peerPaymentsDisabled} - onClick={() => props.onSend(selected.scopeInfo)} - /> - <MoneyAction - icon="request" - label={requestActionLabel} - disabled={selected.peerPaymentsDisabled} - onClick={() => props.onRequest(selected.scopeInfo)} - /> - <MoneyAction - icon="withdraw" - label={withdrawActionLabel} - onClick={() => props.onWithdraw(selected.scopeInfo)} - /> - <MoneyAction - icon="deposit" - label={depositActionLabel} - disabled={selected.depositsDisabled} - onClick={() => props.onDeposit(selected.currency)} - /> - <details class="relative"> - <summary class="list-none"> - <span class="flex cursor-pointer flex-col items-center gap-2 text-sm"> - <span class="grid h-12 w-12 place-items-center rounded-full bg-secondaryContainer text-onSecondaryContainer"> - <WalletIcon name="more" class="h-7 w-7" /> + {selected.legacy && props.onManageLegacyBalance ? ( + <Button + tone="secondary" + onClick={() => + props.onManageLegacyBalance!(legacyExchangeUrl(selected)!) + } + >{i18n.str`Manage legacy balance`}</Button> + ) : ( + <div class="grid grid-cols-5 gap-2"> + <MoneyAction + icon="send" + label={sendActionLabel} + disabled={selected.peerPaymentsDisabled} + onClick={() => props.onSend(selected.scopeInfo)} + /> + <MoneyAction + icon="request" + label={requestActionLabel} + disabled={selected.peerPaymentsDisabled} + onClick={() => props.onRequest(selected.scopeInfo)} + /> + <MoneyAction + icon="withdraw" + label={withdrawActionLabel} + onClick={() => props.onWithdraw(selected.scopeInfo)} + /> + <MoneyAction + icon="deposit" + label={depositActionLabel} + disabled={selected.depositsDisabled} + onClick={() => props.onDeposit(selected.currency)} + /> + <details class="relative"> + <summary class="list-none"> + <span class="flex cursor-pointer flex-col items-center gap-2 text-sm"> + <span class="grid h-12 w-12 place-items-center rounded-full bg-secondaryContainer text-onSecondaryContainer"> + <WalletIcon name="more" class="h-7 w-7" /> + </span> + {i18n.str`More`} </span> - {i18n.str`More`} - </span> - </summary> - <div class="absolute right-0 z-20 mt-2 w-52 overflow-hidden rounded-xl border border-outlineVariant bg-surface py-1 shadow-xl"> - {props.onEnterLink && ( - <button - type="button" - onClick={props.onEnterLink} - class="flex min-h-11 w-full items-center gap-3 px-4 text-left hover:bg-secondaryContainer" - >{i18n.str`Enter Taler link`}</button> - )} - {props.onScan && ( - <button - type="button" - onClick={props.onScan} - class="flex min-h-11 w-full items-center gap-3 px-4 text-left hover:bg-secondaryContainer" - > - <WalletIcon name="scan" class="h-5 w-5" /> - {i18n.str`Scan QR code`} - </button> - )} - {showAll && ( - <button - type="button" - onClick={showAll} - class="flex min-h-11 w-full items-center gap-3 px-4 text-left hover:bg-secondaryContainer" - > - <WalletIcon name="history" class="h-5 w-5" /> - {i18n.str`Transaction history`} - </button> - )} - </div> - </details> - </div> + </summary> + <div class="absolute right-0 z-20 mt-2 w-52 overflow-hidden rounded-xl border border-outlineVariant bg-surface py-1 shadow-xl"> + {props.onEnterLink && ( + <button + type="button" + onClick={props.onEnterLink} + class="flex min-h-11 w-full items-center gap-3 px-4 text-left hover:bg-secondaryContainer" + >{i18n.str`Enter Taler link`}</button> + )} + {props.onScan && ( + <button + type="button" + onClick={props.onScan} + class="flex min-h-11 w-full items-center gap-3 px-4 text-left hover:bg-secondaryContainer" + > + <WalletIcon name="scan" class="h-5 w-5" /> + {i18n.str`Scan QR code`} + </button> + )} + {showAll && ( + <button + type="button" + onClick={showAll} + class="flex min-h-11 w-full items-center gap-3 px-4 text-left hover:bg-secondaryContainer" + > + <WalletIcon name="history" class="h-5 w-5" /> + {i18n.str`Transaction history`} + </button> + )} + </div> + </details> + </div> + )} <ActivityCard title={i18n.str`Recent transactions`} transactions={props.recentTransactions} diff --git a/packages/wallet-webui/src/screens/DepositScreen.tsx b/packages/wallet-webui/src/screens/DepositScreen.tsx @@ -53,11 +53,13 @@ export function DepositScreen(props: { initialScopeId?: string; scopes?: DepositScopeView[]; initialPaytoUri?: string; + initialAmount?: string; initialDestination?: DepositDestinationView; accounts?: DepositAccountView[]; loading: boolean; working: boolean; error?: ErrorPresentation; + errorActionLabel?: string; review?: DepositReviewView; onReview: (values: { currency: string; @@ -69,8 +71,9 @@ export function DepositScreen(props: { onBack: () => void; onCancel: () => void; onManageAccounts?: () => void; + onErrorAction?: () => void; }) { - const [amount, setAmount] = useState(""); + const [amount, setAmount] = useState(props.initialAmount ?? ""); const [accountId, setAccountId] = useState(""); const accounts = useMemo(() => props.accounts ?? [], [props.accounts]); const scopes = useMemo<DepositScopeView[]>( @@ -137,7 +140,17 @@ export function DepositScreen(props: { <p role="status">{i18n.str`Loading bank accounts…`}</p> </Card> )} - {props.error && <ErrorCard error={props.error} />} + {props.error && ( + <ErrorCard error={props.error}> + {props.errorActionLabel && props.onErrorAction && ( + <div class="mt-4"> + <Button onClick={props.onErrorAction}> + {props.errorActionLabel} + </Button> + </div> + )} + </ErrorCard> + )} {!props.loading && !props.review && ( <form class="space-y-5" diff --git a/packages/wallet-webui/src/screens/ExchangeDetailScreen.tsx b/packages/wallet-webui/src/screens/ExchangeDetailScreen.tsx @@ -23,6 +23,12 @@ export interface ExchangeDetailView { noFees: boolean; peerPaymentsDisabled: boolean; directDepositsDisabled: boolean; + legacyMasterPubs?: string[]; + legacyBalances?: Array<{ + currency: string; + available: string; + masterPub: string; + }>; lastError?: ErrorPresentation; kycUrl?: string; keyChange?: { @@ -107,13 +113,14 @@ export function ExchangeDetailScreen(props: { onTerms: () => void; onUpdate: () => void; onConfirmKeyChange: () => void; + onPurgeLegacyBalances?: () => void; onRequestDelete: () => void; onCancelDelete: () => void; onConfirmDelete: (purge: boolean) => void; onKyc: () => void; }) { const exchange = props.exchange; - const [confirmingKeyChange, setConfirmingKeyChange] = useState(false); + const [confirmingLegacyPurge, setConfirmingLegacyPurge] = useState(false); return ( <div class="mx-auto max-w-3xl space-y-6"> <div class="flex items-center gap-4"> @@ -217,7 +224,9 @@ export function ExchangeDetailScreen(props: { {exchange.keyChange && ( <Card class="border-error bg-errorContainer"> <h2 class="font-semibold text-onErrorContainer">{i18n.str`Exchange identity changed`}</h2> - <p class="mt-2 text-sm text-onErrorContainer">{i18n.str`Withdrawals are blocked until you verify the new master key through a trusted channel.`}</p> + <p class="mt-2 text-sm text-onErrorContainer">{i18n.str`The security identity of ${exchangeHostname(exchange.url)} has changed unexpectedly.`}</p> + <p class="mt-3 text-sm text-onErrorContainer">{i18n.str`Only trust the new identity if the exchange operator has confirmed this change. Otherwise, wait and try again later.`}</p> + <p class="mt-3 text-sm font-medium text-onErrorContainer">{i18n.str`Trusting it may make digital cash issued under the previous identity unusable in this wallet.`}</p> <dl class="mt-4 grid gap-2 text-xs"> <dt>{i18n.str`Previous key (${exchange.keyChange.supersededCurrency})`}</dt> <dd class="break-all font-mono"> @@ -231,15 +240,57 @@ export function ExchangeDetailScreen(props: { {!exchange.keyChange.sharesDenominations && ( <p class="mt-3 font-semibold text-onErrorContainer">{i18n.str`The new exchange does not claim support for the wallet's older denominations.`}</p> )} - <div class="mt-4"> + <div class="mt-4 flex flex-wrap justify-end gap-3"> + <Button + tone="secondary" + onClick={props.onBack} + disabled={props.working} + >{i18n.str`Cancel`}</Button> <Button tone="danger" - onClick={() => setConfirmingKeyChange(true)} + onClick={props.onConfirmKeyChange} disabled={props.working} - >{ - // The user asserts that they verified the exchange identity through a trusted channel. - i18n.str`I verified this key change` - }</Button> + >{i18n.str`Accept new identity`}</Button> + </div> + </Card> + )} + {(exchange.legacyMasterPubs?.length ?? 0) > 0 && ( + <Card class="border-warning bg-warningContainer text-onWarningContainer"> + <h2 class="font-semibold">{i18n.str`Legacy exchange balances`}</h2> + <p class="mt-2 text-sm">{i18n.str`These funds and cached keys belong to master keys this exchange has replaced.`}</p> + {(exchange.legacyBalances?.length ?? 0) > 0 && ( + <ul class="mt-4 space-y-3"> + {exchange.legacyBalances?.map((balance) => ( + <li + key={`${balance.currency}-${balance.masterPub}`} + class="rounded-xl bg-surface/70 p-3" + > + <span class="block font-medium line-through"> + {balance.available} + </span> + <span class="mt-1 block break-all font-mono text-xs"> + {balance.masterPub} + </span> + </li> + ))} + </ul> + )} + <p class="mt-3 break-all font-mono text-xs"> + {exchange.legacyMasterPubs?.join("\n")} + </p> + {exchange.keyChange && ( + <p class="mt-3 text-sm font-medium">{i18n.str`Purging these balances will not verify or unblock the replacement key.`}</p> + )} + <div class="mt-4"> + <Button + tone="danger" + disabled={ + props.working || + !exchange.masterPub || + !props.onPurgeLegacyBalances + } + onClick={() => setConfirmingLegacyPurge(true)} + >{i18n.str`Purge legacy balances`}</Button> </div> </Card> )} @@ -273,21 +324,59 @@ export function ExchangeDetailScreen(props: { )} </> )} - {confirmingKeyChange && ( - <ConfirmationDialog - title={i18n.str`Confirm exchange key change?`} - description={i18n.str`Continue only if you verified the new master public key through a trusted channel.`} - cancelLabel={i18n.str`Cancel`} - confirmLabel={i18n.str`Confirm key change`} - tone="danger" + {confirmingLegacyPurge && exchange && ( + <LegacyPurgeDialog + exchange={exchange} working={props.working} - onCancel={() => setConfirmingKeyChange(false)} + onCancel={() => setConfirmingLegacyPurge(false)} onConfirm={() => { - setConfirmingKeyChange(false); - props.onConfirmKeyChange(); + setConfirmingLegacyPurge(false); + props.onPurgeLegacyBalances?.(); }} /> )} </div> ); } + +function exchangeHostname(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +function LegacyPurgeDialog(props: { + exchange: ExchangeDetailView; + working: boolean; + onCancel: () => void; + onConfirm: () => void; +}) { + const [acknowledged, setAcknowledged] = useState(false); + return ( + <ConfirmationDialog + title={i18n.str`Purge legacy balances?`} + description={i18n.str`This permanently deletes all digital cash and cached key data issued under historical master keys at this exchange. The current exchange and retained transaction history remain. This cannot be undone.`} + cancelLabel={i18n.str`Keep legacy balances`} + confirmLabel={ + props.working ? i18n.str`Purging…` : i18n.str`Purge legacy balances` + } + tone="danger" + working={props.working} + confirmDisabled={!acknowledged} + onCancel={props.onCancel} + onConfirm={props.onConfirm} + > + <label class="mt-5 flex min-h-11 cursor-pointer items-start gap-3 rounded-xl bg-errorContainer p-3 text-onErrorContainer"> + <input + type="checkbox" + checked={acknowledged} + onChange={(event) => setAcknowledged(event.currentTarget.checked)} + class="mt-0.5 h-5 w-5 shrink-0 accent-error" + /> + <span>{i18n.str`I understand that these legacy balances will be permanently lost.`}</span> + </label> + </ConfirmationDialog> + ); +} diff --git a/packages/wallet-webui/src/screens/PaymentScreen.tsx b/packages/wallet-webui/src/screens/PaymentScreen.tsx @@ -4,6 +4,7 @@ import { OrderSummary, type OrderSummaryView } from "../ui/OrderSummary.js"; import { i18n } from "../i18n/runtime.js"; import { ErrorCard } from "../ui/ErrorCard.js"; import type { ErrorPresentation } from "../ui/error.js"; +import type { PaymentInsufficientBalanceDetails } from "@gnu-taler/taler-util"; import { ConfirmationDialog } from "../ui/ConfirmationDialog.js"; import { QrFrame } from "../ui/QrFrame.js"; import { useState } from "preact/hooks"; @@ -16,6 +17,7 @@ export interface PaymentChoiceView { payable: boolean; availableBalance?: string; unavailableReason?: string; + balanceDetails?: PaymentInsufficientBalanceDetails; tokenWarning?: string; forceTokenSelection?: boolean; inputs: PaymentChoiceTokenView[]; @@ -174,6 +176,8 @@ export function PaymentOptions(props: { onToggleDonau?: (enabled: boolean) => void; onConfigureDonau?: (donauBaseUrl: string) => void; onWithdraw: () => void; + recoveryActionLabel?: string; + onRecoveryAction?: () => void; onCancel?: () => void; onUnclaim?: () => void; onKeepHere?: () => void; @@ -347,7 +351,12 @@ export function PaymentOptions(props: { {props.cancelling ? i18n.str`Cancelling…` : i18n.str`Cancel`} </Button> )} - {selected && !selected.payable && ( + {selected && !selected.payable && props.onRecoveryAction && ( + <Button onClick={props.onRecoveryAction}> + {props.recoveryActionLabel ?? i18n.str`Review exchange`} + </Button> + )} + {selected && !selected.payable && !props.onRecoveryAction && ( <Button tone="secondary" onClick={props.onWithdraw} @@ -447,6 +456,8 @@ export function PaymentScreen(props: { onToggleDonau?: (enabled: boolean) => void; onConfigureDonau?: (donauBaseUrl: string) => void; onWithdraw: () => void; + recoveryActionLabel?: string; + onRecoveryAction?: () => void; onBack: () => void; onCancel: () => void; onResume?: () => void; @@ -502,7 +513,12 @@ export function PaymentScreen(props: { } } > - <div class="mt-4"> + <div class="mt-4 flex flex-wrap gap-3"> + {props.onRecoveryAction && ( + <Button onClick={props.onRecoveryAction}> + {props.recoveryActionLabel ?? i18n.str`Review exchange`} + </Button> + )} <Button tone="secondary" onClick={props.onCancel} @@ -583,6 +599,8 @@ export function PaymentScreen(props: { onToggleDonau={props.onToggleDonau} onConfigureDonau={props.onConfigureDonau} onWithdraw={props.onWithdraw} + recoveryActionLabel={props.recoveryActionLabel} + onRecoveryAction={props.onRecoveryAction} onCancel={props.onCancel} onKeepHere={props.onKeepHere} onUnclaim={ @@ -605,7 +623,15 @@ export function PaymentScreen(props: { <ErrorCard title={i18n.str`Payment could not continue`} error={props.error} - /> + > + {props.onRecoveryAction && ( + <div class="mt-4"> + <Button onClick={props.onRecoveryAction}> + {props.recoveryActionLabel ?? i18n.str`Review exchange`} + </Button> + </div> + )} + </ErrorCard> )} {props.state === "handed-off" && ( @@ -649,6 +675,11 @@ export function PaymentScreen(props: { } > <div class="mt-4 flex flex-wrap gap-3"> + {props.onRecoveryAction && ( + <Button onClick={props.onRecoveryAction}> + {props.recoveryActionLabel ?? i18n.str`Review exchange`} + </Button> + )} <Button onClick={props.onResume} disabled={props.resuming || props.cancelling} diff --git a/packages/wallet-webui/src/screens/PeerShareScreen.tsx b/packages/wallet-webui/src/screens/PeerShareScreen.tsx @@ -16,6 +16,8 @@ export function PeerShareScreen(props: { onCopy: () => void; onDone: () => void; onRetry?: () => void; + recoveryActionLabel?: string; + onRecoveryAction?: () => void; }) { return ( <div class="mx-auto w-full max-w-2xl space-y-6"> @@ -46,6 +48,11 @@ export function PeerShareScreen(props: { </p> )} <div class="mt-4 flex flex-wrap justify-end gap-3"> + {props.onRecoveryAction && ( + <Button onClick={props.onRecoveryAction}> + {props.recoveryActionLabel ?? i18n.str`Review exchange`} + </Button> + )} <Button tone="secondary" onClick={props.onDone} diff --git a/packages/wallet-webui/src/screens/PopupScreen.tsx b/packages/wallet-webui/src/screens/PopupScreen.tsx @@ -45,7 +45,16 @@ export function PopupScreen(props: { {b.provider && ( <p class="truncate text-xs italic text-secondary">{b.provider}</p> )} - <p class="text-xl font-bold">{b.available}</p> + <p + class={`text-xl font-bold ${b.legacy ? "text-secondary line-through" : ""}`} + aria-label={ + b.legacy + ? i18n.str`Legacy balance ${b.available}, unavailable for normal use` + : undefined + } + > + {b.available} + </p> </Card> ))} {!props.loading && !props.error && props.balances?.length === 0 && ( diff --git a/packages/wallet-webui/src/screens/TransactionDetailScreen.tsx b/packages/wallet-webui/src/screens/TransactionDetailScreen.tsx @@ -60,6 +60,8 @@ export function TransactionDetailScreen(props: { onToggleDonau?: (enabled: boolean) => void; onConfigureDonau?: (donauBaseUrl: string) => void; onWithdraw?: () => void; + recoveryActionLabel?: string; + onRecoveryAction?: () => void; }) { const [confirmation, setConfirmation] = useState<TransactionActionView>(); const [confirmUnclaim, setConfirmUnclaim] = useState(false); @@ -166,9 +168,27 @@ export function TransactionDetailScreen(props: { <ErrorCard title={i18n.str`Wallet reported a problem`} error={transaction.error} - /> + > + {props.onRecoveryAction && ( + <div class="mt-4"> + <Button onClick={props.onRecoveryAction}> + {props.recoveryActionLabel ?? i18n.str`Review exchange`} + </Button> + </div> + )} + </ErrorCard> + )} + {props.error && ( + <ErrorCard error={props.error}> + {props.onRecoveryAction && ( + <div class="mt-4"> + <Button onClick={props.onRecoveryAction}> + {props.recoveryActionLabel ?? i18n.str`Review exchange`} + </Button> + </div> + )} + </ErrorCard> )} - {props.error && <ErrorCard error={props.error} />} {props.message && ( <Card> <p role="status">{props.message}</p> @@ -270,6 +290,8 @@ export function TransactionDetailScreen(props: { onToggleDonau={props.onToggleDonau} onConfigureDonau={props.onConfigureDonau} onWithdraw={() => props.onWithdraw?.()} + recoveryActionLabel={props.recoveryActionLabel} + onRecoveryAction={props.onRecoveryAction} onKeepHere={props.onKeepPaymentHere} onUnclaim={ props.onUnclaimPayment ? () => setConfirmUnclaim(true) : undefined diff --git a/packages/wallet-webui/src/ui/error.ts b/packages/wallet-webui/src/ui/error.ts @@ -1,4 +1,5 @@ import { + CoinSelectionFailureReasonType, InsufficientBalanceHint, TalerError, TalerErrorCode, @@ -137,6 +138,14 @@ export function insufficientBalanceMessage( ): string { if (!detail) return i18n.str`The wallet does not have enough compatible funds.`; + if ( + detail.reasons?.some( + (reason) => + reason.type === + CoinSelectionFailureReasonType.SupersededExchangeMasterPub, + ) + ) + return i18n.str`Some funds were issued under legacy exchange keys and cannot be used for this operation.`; const exchangeHints = Array.from( new Set( Object.values(detail.perExchange ?? {}).flatMap((exchange) => diff --git a/packages/wallet-webui/test/exchange-key-recovery-model.test.ts b/packages/wallet-webui/test/exchange-key-recovery-model.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + CoinSelectionFailureReasonType, + TalerErrorCode, + type ExchangeListItem, + type PaymentInsufficientBalanceDetails, +} from "@gnu-taler/taler-util"; +import { + exchangeKeyChangeErrorUrl, + exchangeKeyRecoveryFromInsufficientBalance, +} from "../src/routes/exchange-key-recovery-model.js"; + +const detail = { + amountRequested: "CHF:5", + balanceAvailable: "CHF:4", + balanceMaterial: "CHF:4", + balanceAgeAcceptable: "CHF:4", + balanceReceiverAccepted: "CHF:4", + balanceReceiverDepositable: "CHF:4", + perExchange: {}, + balance: { material: "CHF:4", pendingRefresh: "CHF:0", available: "CHF:4" }, + maximumPayableAmount: "CHF:0", + reasons: [ + { + type: CoinSelectionFailureReasonType.SupersededExchangeMasterPub, + amountAffected: "CHF:4", + }, + ], + exchanges: { + "https://exchange.example/": { + balance: { + material: "CHF:4", + pendingRefresh: "CHF:0", + available: "CHF:4", + }, + maximumPayableAmount: "CHF:0", + reasons: [ + { + type: CoinSelectionFailureReasonType.SupersededExchangeMasterPub, + amountAffected: "CHF:4", + }, + ], + }, + }, +} as unknown as PaymentInsufficientBalanceDetails; + +test("key-change errors expose only their exchange URL", () => { + assert.equal( + exchangeKeyChangeErrorUrl({ + code: TalerErrorCode.WALLET_EXCHANGE_KEYS_NOT_ACCEPTED, + exchangeBaseUrl: "https://exchange.example/", + }), + "https://exchange.example/", + ); + assert.equal( + exchangeKeyChangeErrorUrl({ + code: TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED, + exchangeBaseUrl: "https://exchange.example/", + }), + undefined, + ); +}); + +test("superseded funds are actionable only while a key change is pending", () => { + const baseExchange = { + exchangeBaseUrl: "https://exchange.example/", + } as ExchangeListItem; + assert.equal( + exchangeKeyRecoveryFromInsufficientBalance(detail, [ + { + ...baseExchange, + unconfirmedKeyChange: { + currentMasterPub: "new", + supersededMasterPub: "old", + }, + } as ExchangeListItem, + ])?.kind, + "verify", + ); + assert.equal( + exchangeKeyRecoveryFromInsufficientBalance(detail, [baseExchange])?.kind, + "legacy", + ); +}); diff --git a/packages/wallet-webui/test/screens.test.tsx b/packages/wallet-webui/test/screens.test.tsx @@ -350,13 +350,42 @@ test("extension popup has useful empty state and keyboard-sized actions", async assert.equal(selected, "wallet"); view.rerender( <PopupScreen - balances={[]} + balances={[ + { + scopeId: "legacy:CHF:old", + scopeInfo: { + type: ScopeType.ExchangeLegacyKeys, + currency: "CHF", + url: "https://exchange.example/", + masterPub: "old", + }, + scopeLabel: "CHF via exchange.example (legacy keys)", + provider: "exchange.example", + currency: "CHF", + available: "CHF:5", + availableValue: "5.00", + pendingIncoming: "CHF:0", + pendingIncomingValue: "0.00", + pendingOutgoing: "CHF:0", + pendingOutgoingValue: "0.00", + warnings: ["Issued under superseded exchange keys"], + shoppingUrls: [], + peerPaymentsDisabled: false, + depositsDisabled: false, + legacy: true, + }, + ]} hasCurrentPageAction={false} onOpenWallet={() => {}} onOpenCurrentPageAction={() => {}} />, ); assert.equal(view.queryByRole("button", { name: "Open Taler action" }), null); + assert( + view + .getByLabelText("Legacy balance CHF:5, unavailable for normal use") + .classList.contains("line-through"), + ); const result = await axe.run(window.document.body as unknown as Element); assert.deepEqual(result.violations, []); cleanup(); @@ -915,6 +944,144 @@ test("balance capability flags block unavailable money flows and expose shopping await window.happyDOM.abort(); }); +test("legacy balances are struck through and only expose management", async () => { + const window = installDom(); + const { render, cleanup } = await import("@testing-library/preact"); + const userEvent = (await import("@testing-library/user-event")) + .default as unknown as { + setup(options: { document: Document }): { + click(element: Element): Promise<void>; + }; + }; + let managed = ""; + const view = render( + <main> + <BalanceScreen + balances={[ + { + scopeId: "legacy:CHF:old", + scopeInfo: { + type: ScopeType.ExchangeLegacyKeys, + currency: "CHF", + url: "https://exchange.example/", + masterPub: "old", + }, + scopeLabel: "CHF via exchange.example (legacy keys)", + provider: "exchange.example", + currency: "CHF", + available: "CHF:5", + availableValue: "5.00", + pendingIncoming: "CHF:0", + pendingIncomingValue: "0.00", + pendingOutgoing: "CHF:0", + pendingOutgoingValue: "0.00", + warnings: ["Issued under superseded exchange keys"], + shoppingUrls: [], + peerPaymentsDisabled: false, + depositsDisabled: false, + legacy: true, + }, + ]} + loading={false} + onWithdraw={() => {}} + onGetDemoCash={() => {}} + onDeposit={() => {}} + onSend={() => {}} + onRequest={() => {}} + onManageLegacyBalance={(url) => { + managed = url; + }} + /> + </main>, + ); + const amounts = view.getAllByLabelText( + "Legacy balance CHF:5, unavailable for normal use", + ); + assert(amounts.length >= 2); + assert( + amounts.every((amount: Element) => + amount.classList.contains("line-through"), + ), + ); + assert.equal(view.queryByRole("button", { name: "Send" }), null); + await userEvent + .setup({ document: window.document as unknown as Document }) + .click(view.getAllByRole("button", { name: "Manage legacy balance" })[0]); + assert.equal(managed, "https://exchange.example/"); + cleanup(); + await window.happyDOM.abort(); +}); + +test("legacy balance purge requires explicit acknowledgement", async () => { + const window = installDom(); + const { render, cleanup } = await import("@testing-library/preact"); + const userEvent = (await import("@testing-library/user-event")) + .default as unknown as { + setup(options: { document: Document }): { + click(element: Element): Promise<void>; + }; + }; + const user = userEvent.setup({ + document: window.document as unknown as Document, + }); + let purged = false; + const view = render( + <main> + <ExchangeDetailScreen + loading={false} + working={false} + exchange={{ + url: "https://exchange.example/", + currency: "CHF", + masterPub: "new", + updateStatus: "ready", + entryStatus: "used", + tosStatus: "accepted", + paytoUris: [], + noFees: false, + peerPaymentsDisabled: false, + directDepositsDisabled: false, + legacyMasterPubs: ["old"], + legacyBalances: [ + { currency: "CHF", available: "CHF:5", masterPub: "old" }, + ], + }} + onBack={() => {}} + onTerms={() => {}} + onUpdate={() => {}} + onConfirmKeyChange={() => {}} + onPurgeLegacyBalances={() => { + purged = true; + }} + onRequestDelete={() => {}} + onCancelDelete={() => {}} + onConfirmDelete={() => {}} + onKyc={() => {}} + /> + </main>, + ); + await user.click(view.getByRole("button", { name: "Purge legacy balances" })); + const dialog = view.getByRole("dialog", { name: "Purge legacy balances?" }); + const confirm = Array.from( + (dialog as Element).querySelectorAll( + "button", + ) as NodeListOf<HTMLButtonElement>, + ).find( + (button: HTMLButtonElement) => + button.textContent === "Purge legacy balances", + )!; + assert.equal(confirm.disabled, true); + await user.click( + view.getByRole("checkbox", { + name: "I understand that these legacy balances will be permanently lost.", + }), + ); + await user.click(confirm); + assert.equal(purged, true); + cleanup(); + await window.happyDOM.abort(); +}); + test("Penpot balance view selects scopes, opens the balance list, and hands off activity", async () => { const window = installDom(); const { render, cleanup, act } = await import("@testing-library/preact"); @@ -2729,7 +2896,7 @@ test("bank account management guides supported wire types and retains advanced e await window.happyDOM.abort(); }); -test("exchange key changes require a dedicated confirmation", async () => { +test("exchange key changes show a dedicated identity notice", async () => { const window = installDom(); const { render, cleanup } = await import("@testing-library/preact"); const userEvent = (await import("@testing-library/user-event")) @@ -2778,7 +2945,12 @@ test("exchange key changes require a dedicated confirmation", async () => { ); await userEvent .setup({ document: window.document as unknown as Document }) - .click(view.getByRole("button", { name: "I verified this key change" })); + .click(view.getByRole("button", { name: "Accept new identity" })); + assert( + view.getByText( + "The security identity of exchange.example has changed unexpectedly.", + ), + ); assert.equal( view.getByText("Update status").nextElementSibling?.textContent, "Ready", @@ -2791,11 +2963,6 @@ test("exchange key changes require a dedicated confirmation", async () => { view.getByText("Terms").nextElementSibling?.textContent, "Terms accepted", ); - assert.equal(confirmed, false); - assert(view.getByRole("dialog", { name: "Confirm exchange key change?" })); - await userEvent - .setup({ document: window.document as unknown as Document }) - .click(view.getByRole("button", { name: "Confirm key change" })); assert.equal(confirmed, true); cleanup(); await window.happyDOM.abort();