taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit c727431c67ff83792f9147a425b3d84e1a2e6152
parent b565f5670ab7e1330fcbb2c0286b63362f05d373
Author: Florian Dold <dold@taler.net>
Date:   Thu, 10 Sep 2026 01:31:52 +0200

wallet-core: recover coins through recursive refresh replay

Add testingRecoverCoins for one exchange, starting with fresh coins
by default and resuming unfinished recovery. Authenticate histories,
reconstruct matching v27 or v32 refresh commitments, and verify recovered
coin signatures before importing outputs.

Follow further melts recursively, refresh residual balances, preserve
local coin allocation and suspension, and report recovered balances and
issues. Emit progress through completion, failure or cancellation.

Diffstat:
Mpackages/taler-wallet-core/src/requests.ts | 6++++++
Apackages/taler-wallet-core/src/testing-recover-coins.test.ts | 606+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/testing-recover-coins.ts | 861+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 10++++++++++
4 files changed, 1483 insertions(+), 0 deletions(-)

diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -261,6 +261,7 @@ import { codecForTestPayArgs, codecForTestingCorruptWithdrawalCoinSelRequest, codecForTestingCheckCoinsRequest, + codecForTestingRecoverCoinsRequest, codecForTestingGetDenomStatsRequest, codecForTestingGetReserveHistoryRequest, codecForTestingPlanMigrateExchangeBaseUrlRequest, @@ -472,6 +473,7 @@ import { rematerializeTransactionsAtCurrentVersion, walletExchangeClient, } from "./wallet.js"; +import { testingRecoverCoins } from "./testing-recover-coins.js"; import { testingCheckCoins } from "./testing-check-coins.js"; import { parseExchangeWireAccountPayto } from "./exchange-payto.js"; @@ -2703,6 +2705,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForTestingGetDenomStatsRequest(), handler: handleTestingGetDenomStats, }, + [WalletApiOperation.TestingRecoverCoins]: { + codec: codecForTestingRecoverCoinsRequest(), + handler: testingRecoverCoins, + }, [WalletApiOperation.TestingCheckCoins]: { codec: codecForTestingCheckCoinsRequest(), handler: testingCheckCoins, diff --git a/packages/taler-wallet-core/src/testing-recover-coins.test.ts b/packages/taler-wallet-core/src/testing-recover-coins.test.ts @@ -0,0 +1,606 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + AmountString, + Amounts, + CancellationToken, + CoinHistoryResponse, + CoinMeltTransaction, + CoinStatus, + DenomKeyType, + encodeCrock, + hash, + stringToBytes, + NotificationType, + SetTimeoutTimerAPI, + TimerGroup, + WalletNotification, + codecForTestingRecoverCoinsRequest, + CoinRecoveryProgressNotification, + TalerErrorCode, +} from "@gnu-taler/taler-util"; +import { HttpRequestLibrary, HttpResponse } from "@gnu-taler/taler-util/http"; +import { WalletExecutionContext } from "./wallet.js"; +import { testingRecoverCoins } from "./testing-recover-coins.js"; +import { + CoinSourceType, + DenominationVerificationStatus, + ExchangeEntryDbRecordStatus, + ExchangeEntryDbUpdateStatus, + WalletCoin, + WalletDenomination, + WalletExchangeDetails, + WalletExchangeEntry, +} from "./db/records.js"; +import { WalletDbHandle } from "./db/handle.js"; +import { runnerFactories } from "./db/testing/runners.js"; +import { handleCancelProgressToken } from "./progress.js"; + +const url = "https://exchange.test/"; +const a = (value: string): AmountString => `TESTKUDOS:${value}` as AmountString; +const h = (label: string) => encodeCrock(hash(stringToBytes(label))); +const k = (label: string) => + encodeCrock(hash(stringToBytes(label)).subarray(0, 32)); +const master = k("master"); +const sig = h("signature"); +const stamp = (n: number) => n as WalletDenomination["stampStart"]; +function denom(value: string): WalletDenomination { + return { + exchangeBaseUrl: url, + exchangeMasterPub: master, + denomPubHash: h(value), + denomPub: { + cipher: DenomKeyType.Rsa, + rsa_public_key: "unused", + age_mask: 0, + }, + currency: "TESTKUDOS", + value: a(value), + fees: { + feeWithdraw: a("0"), + feeDeposit: a("0"), + feeRefresh: a("1"), + feeRefund: a("0"), + }, + stampStart: stamp(0), + stampExpireWithdraw: stamp(4000000000000000), + stampExpireDeposit: stamp(4000000000000000), + stampExpireLegal: stamp(4000000000000000), + isOffered: true, + isRevoked: false, + isLost: false, + masterSig: sig, + verificationStatus: DenominationVerificationStatus.VerifiedGood, + }; +} +function coin(label: string, value: string): WalletCoin { + return { + coinPub: k(label), + coinPriv: k("private" + label), + denomPubHash: h(value), + exchangeBaseUrl: url, + exchangeMasterPub: master, + status: CoinStatus.Fresh, + visible: 1, + maxAge: 0, + ageCommitmentProof: undefined, + blindingKey: k("blind" + label), + coinEvHash: h("ev" + label), + exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, + denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: sig }, + coinSource: { + type: CoinSourceType.Withdraw, + reservePub: k("reserve"), + withdrawalGroupId: "withdrawal", + coinIndex: 0, + }, + }; +} + +async function fixture(db: WalletDbHandle) { + const root = coin("root", "6"); + await db.runReadWriteTx(async (tx) => { + const details: WalletExchangeDetails = { + exchangeBaseUrl: url, + masterPublicKey: master, + currency: "TESTKUDOS", + auditors: [], + protocolVersionRange: "34:0:8", + tinyAmount: a("0.01"), + reserveClosingDelay: { d_us: 1000 }, + globalFees: [], + wireInfo: { accounts: [], feesForType: {} }, + bankComplianceLanguage: undefined, + defaultPeerPushExpiration: undefined, + }; + const rowId = await tx.upsertExchangeDetails(details); + await tx.upsertExchange({ + baseUrl: url, + detailsPointer: { + currency: "TESTKUDOS", + masterPublicKey: master, + updateClock: 0, + }, + entryStatus: ExchangeEntryDbRecordStatus.Used, + updateStatus: ExchangeEntryDbUpdateStatus.Ready, + nextUpdateStamp: 0, + nextRefreshCheckStamp: 0, + } as WalletExchangeEntry); + await tx.upsertExchangeSignKey({ + exchangeDetailsRowId: rowId, + signkeyPub: k("exchange"), + stampStart: stamp(0), + stampExpire: stamp(4000000000000000), + stampEnd: stamp(4000000000000000), + masterSig: sig, + }); + for (const value of ["1", "2", "6"]) + await tx.upsertDenomination(denom(value)); + await tx.upsertCoin(root); + await tx.upsertCoinAvailability({ + ...root, + currency: "TESTKUDOS", + value: a("6"), + freshCoinCount: 1, + visibleCoinCount: 1, + hasFreshCoins: 1, + pendingRefreshOutputCount: 0, + }); + }); + const histories = new Map<string, CoinHistoryResponse>(); + const sessions = new Map< + string, + { melt: CoinMeltTransaction; outputs: WalletCoin[]; version: number } + >(); + const events: WalletNotification[] = []; + const requests: { path: string; body: any }[] = []; + let failPath: string | undefined; + let badHistory = false; + let badSignature = false; + let noRevealIndex = 1; + let onRequest: ((path: string) => Promise<void>) | undefined; + function melt(old: WalletCoin, outputs: WalletCoin[], version = 32) { + const seed = h("seed" + old.coinPub); + const amount = Amounts.stringify( + Amounts.add(Amounts.sum(outputs.map((c) => denomValue(c))).amount, a("1")) + .amount, + ); + const m: CoinMeltTransaction = { + type: "MELT", + amount, + melt_fee: a("1"), + history_offset: 1, + rc: h("rc" + old.coinPub), + h_denom_pub: old.denomPubHash, + refresh_seed: seed, + denoms_h: outputs.map((c) => c.denomPubHash), + coin_sig: sig, + }; + sessions.set(seed, { melt: m, outputs, version }); + histories.set(old.coinPub, { + h_denom_pub: old.denomPubHash, + balance: Amounts.stringify(Amounts.sub(denomValue(old), amount).amount), + history: [m], + }); + return m; + } + function denomValue(c: WalletCoin): AmountString { + return ["1", "2", "6"] + .map(denom) + .find((d) => d.denomPubHash === c.denomPubHash)!.value; + } + const http: HttpRequestLibrary = { + async fetch(u, options): Promise<HttpResponse> { + const path = u.slice(url.length).split("?")[0]; + const body = options?.body as any; + requests.push({ path, body }); + await onRequest?.(path); + if (path === failPath) throw Error("network failure"); + let response: unknown; + let status = 200; + if (path.startsWith("coins/")) { + response = histories.get(path.split("/")[1]); + if (!response) { + status = 404; + response = { code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN }; + } + } else if (path === "melt") { + const session = sessions.get(body.refresh_seed)!; + assert.deepEqual(body.denoms_h, session.melt.denoms_h); + response = { + noreveal_index: noRevealIndex, + exchange_pub: k("exchange"), + exchange_sig: sig, + }; + } else if (path === "reveal-melt") { + const s = [...sessions.values()].find((s) => s.melt.rc === body.rc)!; + assert.equal((body.batch_seeds ?? body.signatures).length, 2); + response = { + ev_sigs: s.outputs.map(() => ({ + cipher: DenomKeyType.Rsa, + blinded_rsa_signature: sig, + })), + }; + } else throw Error("Unexpected endpoint " + path); + return { + requestUrl: u, + requestMethod: options?.method ?? "GET", + status, + headers: { + get: (name: string) => + name === "content-type" ? "application/json" : null, + } as HttpResponse["headers"], + json: async () => structuredClone(response), + text: async () => JSON.stringify(response), + bytes: async () => new Uint8Array(), + }; + }, + }; + const derive = async (req: any, version: number) => { + const s = sessions.get(req.sessionPublicSeed)!; + assert.deepEqual( + req.newCoinDenoms.map((d: any) => d.denomPubHash), + s.outputs.map((c) => c.denomPubHash), + ); + const pcs = s.outputs.map((c) => ({ + ...c, + coinEv: { cipher: DenomKeyType.Rsa, rsa_blinded_planchet: sig }, + })); + return { + hash: + s.version === version + ? h( + "rc" + + [...histories].find(([, v]) => v.history[0] === s.melt)![0], + ) + : h("wrong version"), + meltValueWithFee: Amounts.parseOrThrow(s.melt.amount), + confirmSig: sig, + planchets: [pcs, pcs, pcs], + batchSeeds: [h("0"), h("1"), h("2")], + signatures: [sig, sig, sig], + transferPubs: [[], [], []], + }; + }; + const timerGroup = new TimerGroup(new SetTimeoutTimerAPI()); + const cts = CancellationToken.create(); + const wex = { + http, + cts, + cancellationToken: cts.token, + runWalletDbTx: db.runReadWriteTx.bind(db), + ws: { + progressMap: new Map(), + timerGroup, + notify: (n: WalletNotification) => events.push(n), + runSequentialized: async (_: string[], f: () => Promise<unknown>) => f(), + }, + cryptoApi: { + signCoinHistoryRequest: async () => ({ sig }), + isValidCoinHistory: async () => ({ valid: !badHistory }), + deriveRefreshSessionV3: (r: any) => derive(r, 32), + deriveRefreshSessionV2: (r: any) => derive(r, 27), + isValidMeltConfirmation: async () => ({ valid: !badSignature }), + unblindDenominationSignature: async () => ({ + cipher: DenomKeyType.Rsa, + rsa_signature: sig, + }), + verifyDenominationSignature: async () => ({ valid: !badSignature }), + }, + } as unknown as WalletExecutionContext; + return { + db, + root, + melt, + histories, + requests, + events, + wex, + run: (onlyFresh?: boolean) => + testingRecoverCoins(wex, { + exchangeBaseUrl: url, + onlyFresh, + progressToken: "test", + }), + close: async () => { + timerGroup.stopCurrentAndFutureTimers(); + await db.close(); + }, + fail: (path?: string) => { + failPath = path; + }, + badHistory: (v: boolean) => { + badHistory = v; + }, + badSignature: (v: boolean) => { + badSignature = v; + }, + index: (v: number) => { + noRevealIndex = v; + }, + onRequest: (f: (p: string) => Promise<void>) => { + onRequest = f; + }, + }; +} + +for (const makeDb of runnerFactories) { + test(`${makeDb.name}: recursive recovery, duplicate denominations and idempotent retry`, async () => { + const f = await fixture(await makeDb()); + try { + const outputs = [ + coin("first", "2"), + coin("second", "1"), + coin("third", "2"), + ]; + f.melt(f.root, outputs, 27); + f.melt(outputs[0], [coin("grandchild", "1")]); + const result = await f.run(); + assert.equal(result.complete, true, JSON.stringify(result)); + assert.equal(result.recoveredAmount, a("4")); + assert.equal(result.numRecovered, 3); + assert.equal(result.numChecked, 5); + const all = await f.db.runReadWriteTx((tx) => tx.listAllCoins()); + assert.equal(all.filter((c) => c.status === CoinStatus.Fresh).length, 3); + const availability = await f.db.runReadWriteTx((tx) => + tx.getCoinAvailability(outputs[1]), + ); + assert.equal(availability!.freshCoinCount, 2); + assert.equal(availability!.visibleCoinCount, 2); + const again = await f.run(); + assert.equal(again.complete, true); + assert.equal(again.recoveredAmount, a("0")); + const progress = f.events.filter( + (n): n is CoinRecoveryProgressNotification => + n.type === NotificationType.CoinRecoveryProgress, + ); + assert.equal(progress[0].phase, "starting"); + assert.equal(progress.at(-1)!.phase, "complete"); + assert(progress.some((n) => n.phase === "reveal")); + assert.equal(f.wex.ws.progressMap.size, 0); + } finally { + await f.close(); + } + }); + + test(`${makeDb.name}: failed reveal resumes dormant roots through export/import`, async () => { + const f = await fixture(await makeDb()); + try { + f.melt(f.root, [ + coin("first", "2"), + coin("second", "1"), + coin("third", "2"), + ]); + f.fail("reveal-melt"); + assert.equal((await f.run()).complete, false); + assert.equal( + (await f.db.runReadWriteTx((tx) => tx.getCoin(f.root.coinPub)))!.status, + CoinStatus.Dormant, + ); + const dump = await f.db.exportDatabase(); + await f.db.importDatabase(dump, async () => {}); + f.fail(); + const result = await f.run(); + assert.equal(result.complete, true, JSON.stringify(result)); + assert.equal(result.recoveredAmount, a("5")); + const group = ( + await f.db.runReadWriteTx((tx) => tx.listAllRefreshGroups()) + )[0]; + const session = await f.db.runReadWriteTx((tx) => + tx.getRefreshSession(group.refreshGroupId, 0), + ); + assert(session!.sessionPublicSeed); + assert.equal(session!.norevealIndex, 1); + } finally { + await f.close(); + } + }); + + test(`${makeDb.name}: missing data and changed no-reveal index never disclose`, async () => { + const f = await fixture(await makeDb()); + try { + const m = f.melt(f.root, [ + coin("first", "2"), + coin("second", "1"), + coin("third", "2"), + ]); + const denoms = m.denoms_h; + delete m.denoms_h; + let result = await f.run(); + assert.equal(result.issues[0].reason, "missing-recovery-data"); + assert(!f.requests.some((r) => r.path === "melt")); + m.denoms_h = denoms; + f.fail("reveal-melt"); + await f.run(); + const count = f.requests.filter((r) => r.path === "reveal-melt").length; + // The pin must survive a restart even when reveal never completed. + const dump = await f.db.exportDatabase(); + await f.db.importDatabase(dump, async () => {}); + f.index(2); + f.fail(); + result = await f.run(); + assert.equal(result.issues[0].reason, "invalid-history"); + assert.equal( + f.requests.filter((r) => r.path === "reveal-melt").length, + count, + ); + } finally { + await f.close(); + } + }); + + test(`${makeDb.name}: invalid history and concurrent local spend leave balances alone`, async () => { + const f = await fixture(await makeDb()); + try { + f.melt(f.root, [ + coin("first", "2"), + coin("second", "1"), + coin("third", "2"), + ]); + f.badHistory(true); + assert.equal((await f.run()).issues[0].reason, "invalid-history"); + assert.equal( + (await f.db.runReadWriteTx((tx) => tx.getCoin(f.root.coinPub)))!.status, + CoinStatus.Fresh, + ); + f.badHistory(false); + f.onRequest(async (path) => { + if (path.startsWith("coins/")) + await f.db.runReadWriteTx(async (tx) => { + const c = await tx.getCoin(f.root.coinPub); + c!.status = CoinStatus.Dormant; + await tx.upsertCoin(c!); + }); + }); + assert.equal((await f.run()).issues[0].reason, "local-data-changed"); + assert(!f.requests.some((r) => r.path === "melt")); + } finally { + await f.close(); + } + }); + + test(`${makeDb.name}: existing suspended output is preserved and not counted twice`, async () => { + const f = await fixture(await makeDb()); + try { + const existing = coin("existing", "1"); + existing.status = CoinStatus.FreshSuspended; + await f.db.runReadWriteTx((tx) => tx.upsertCoin(existing)); + f.melt(f.root, [coin("first", "2"), existing, coin("third", "2")]); + const result = await f.run(); + assert.equal(result.complete, true, JSON.stringify(result)); + assert.equal(result.recoveredAmount, a("4")); + assert.equal(result.numRecovered, 2); + const current = await f.db.runReadWriteTx((tx) => + tx.getCoin(existing.coinPub), + ); + assert.equal(current!.status, CoinStatus.FreshSuspended); + assert.deepEqual(current!.coinSource, existing.coinSource); + } finally { + await f.close(); + } + }); + + test(`${makeDb.name}: commitment mismatch and invalid confirmation never reveal`, async () => { + const f = await fixture(await makeDb()); + try { + const m = f.melt(f.root, [ + coin("first", "2"), + coin("second", "1"), + coin("third", "2"), + ]); + const commitment = m.rc; + m.rc = h("unrelated commitment"); + assert.equal((await f.run()).issues[0].reason, "commitment-mismatch"); + assert(!f.requests.some((r) => r.path === "melt")); + m.rc = commitment; + f.badSignature(true); + assert.equal((await f.run()).issues[0].reason, "invalid-history"); + assert(!f.requests.some((r) => r.path === "reveal-melt")); + } finally { + await f.close(); + } + }); + + test(`${makeDb.name}: denomination changes during history checking are detected`, async () => { + const f = await fixture(await makeDb()); + try { + f.onRequest(async () => { + await f.db.runReadWriteTx(async (tx) => { + const d = await tx.getDenomination(f.root); + d!.isRevoked = true; + await tx.upsertDenomination(d!); + }); + }); + const result = await f.run(); + assert.equal(result.issues[0].reason, "local-data-changed"); + assert.equal( + (await f.db.runReadWriteTx((tx) => tx.getCoin(f.root.coinPub)))!.status, + CoinStatus.Fresh, + ); + } finally { + await f.close(); + } + }); + + test(`${makeDb.name}: unchecked outputs stay unavailable across a restart`, async () => { + const f = await fixture(await makeDb()); + try { + const output = coin("first", "2"); + f.melt(f.root, [output, coin("second", "1"), coin("third", "2")]); + f.fail(`coins/${output.coinPub}/history`); + const first = await f.run(); + assert.equal(first.complete, false); + assert.equal(first.recoveredAmount, a("3")); + assert.equal( + (await f.db.runReadWriteTx((tx) => tx.getCoin(output.coinPub)))!.status, + CoinStatus.Dormant, + ); + await f.db.importDatabase(await f.db.exportDatabase(), async () => {}); + f.fail(); + const recovered = await f.run(); + assert.equal(recovered.complete, true, JSON.stringify(recovered)); + assert.equal(recovered.recoveredAmount, a("5")); + assert.equal( + (await f.db.runReadWriteTx((tx) => tx.getCoinAvailability(output)))! + .freshCoinCount, + 2, + ); + } finally { + await f.close(); + } + }); + + test(`${makeDb.name}: cancellation reports terminal progress and retains work`, async () => { + const f = await fixture(await makeDb()); + try { + f.onRequest(async () => { + await handleCancelProgressToken(f.wex, { + operation: "testingRecoverCoins", + progressToken: "test", + }); + }); + await assert.rejects(f.run()); + assert( + f.events.some( + (n) => + n.type === NotificationType.CoinRecoveryProgress && + n.phase === "cancelled", + ), + ); + assert.equal(f.wex.ws.progressMap.size, 0); + const j = await f.db.runReadWriteTx((tx) => tx.getCoinRecovery(url)); + assert(j!.pending.includes(f.root.coinPub)); + } finally { + await f.close(); + } + }); +} + +test("recovery request codec defaults selection in the handler and rejects bad fields", () => { + assert.deepEqual( + codecForTestingRecoverCoinsRequest().decode({ exchangeBaseUrl: url }), + { exchangeBaseUrl: url, onlyFresh: undefined, progressToken: undefined }, + ); + assert.throws(() => + codecForTestingRecoverCoinsRequest().decode({ + exchangeBaseUrl: url, + onlyFresh: "yes", + }), + ); +}); diff --git a/packages/taler-wallet-core/src/testing-recover-coins.ts b/packages/taler-wallet-core/src/testing-recover-coins.ts @@ -0,0 +1,861 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ + +import { + AbsoluteTime, + AgeRestriction, + AmountString, + Amounts, + canonicalizeBaseUrl, + canonicalJson, + CoinHistoryResponse, + CoinMeltTransaction, + CoinRecoveryIssue, + CoinRecoveryPhase, + CoinStatus, + DenomKeyType, + encodeCrock, + getRandomBytes, + hash, + hashCoinPub, + HttpStatusCode, + NotificationType, + RefreshReason, + stringToBytes, + succeedOrThrow, + TalerError, + TalerErrorCode, + TalerPreciseTimestamp, + TestingRecoverCoinsRequest, + TestingRecoverCoinsResponse, +} from "@gnu-taler/taler-util"; +import { + CoinSourceType, + RefreshCoinStatus, + RefreshOperationStatus, + timestampPreciseToDb, + timestampProtocolFromDb, + WalletCoin, + WalletCoinRecovery, + WalletDenomination, + WalletRefreshGroup, + WalletRefreshSession, +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; +import { requireValidExchangeCoinHistory } from "./exchange-signatures.js"; +import { runWithMaybeProgressContext } from "./progress.js"; +import { + createRefreshGroup, + deriveRefreshSession, + RefreshTransactionContext, + requireValidMeltConfirmation, + validateAndRecomputeCoinHistoryBalance, +} from "./refresh.js"; +import { + ensureCoinAvailabilityCounters, + EXCHANGE_COINS_LOCK, + WalletExecutionContext, + walletExchangeClient, +} from "./wallet.js"; + +class RecoveryError extends Error { + constructor( + public reason: CoinRecoveryIssue["reason"], + message: string, + ) { + super(message); + } +} + +function checkCancelled(wex: WalletExecutionContext): void { + if (wex.cancellationToken.isCancelled) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED, + {}, + ); + } +} + +function enqueue(journal: WalletCoinRecovery, coinPub: string): void { + if ( + !journal.pending.includes(coinPub) && + !journal.visited.includes(coinPub) + ) { + journal.pending.push(coinPub); + } +} + +/** Do not apply a network snapshot to changed local coins or exchange keys. */ +async function requireUnchangedCoin( + tx: WalletDbTransaction, + coin: WalletCoin, + denom: WalletDenomination, +): Promise<WalletCoin> { + const current = await tx.getCoin(coin.coinPub); + const currentDenom = await tx.getDenomination(coin); + const details = await tx.getExchangeDetails(coin.exchangeBaseUrl); + if ( + canonicalJson(current) !== canonicalJson(coin) || + canonicalJson(currentDenom) !== canonicalJson(denom) || + details?.masterPublicKey !== coin.exchangeMasterPub + ) { + throw new RecoveryError( + "local-data-changed", + "Coin, denomination or exchange keys changed during recovery", + ); + } + return current!; +} + +/** Both status and material counters change in the same DB transaction. */ +async function changeAvailability( + tx: WalletDbTransaction, + coin: WalletCoin, + denom: WalletDenomination, + status: CoinStatus.Fresh | CoinStatus.Dormant, +): Promise<void> { + if (coin.status === status) return; + const car = (await tx.getCoinAvailability(coin)) ?? { + currency: Amounts.currencyOf(denom.value), + value: denom.value, + denomPubHash: coin.denomPubHash, + exchangeBaseUrl: coin.exchangeBaseUrl, + exchangeMasterPub: coin.exchangeMasterPub, + maxAge: coin.maxAge, + freshCoinCount: 0, + visibleCoinCount: 0, + hasFreshCoins: 0 as const, + pendingRefreshOutputCount: 0, + }; + if (coin.status === CoinStatus.Fresh) { + await ensureCoinAvailabilityCounters(tx, car, 1, coin.visible ? 1 : 0); + car.freshCoinCount--; + if (coin.visible) car.visibleCoinCount--; + } + if (status === CoinStatus.Fresh) { + car.freshCoinCount++; + car.visibleCoinCount++; + coin.visible = 1; + } + coin.status = status; + await tx.upsertCoin(coin); + await tx.upsertCoinAvailability(car); + tx.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: coin.sourceTransactionId ?? "", + }); +} + +/** Reconstruct one historical refresh, without disclosing unmatched candidates. */ +export async function recoverCoinMelt( + wex: WalletExecutionContext, + oldCoin: WalletCoin, + oldDenom: WalletDenomination, + melt: CoinMeltTransaction, + phase: (p: CoinRecoveryPhase) => void, +): Promise<{ + coins: WalletCoin[]; + group: WalletRefreshGroup; + session: WalletRefreshSession; +}> { + if (!melt.refresh_seed || !melt.denoms_h?.length) { + throw new RecoveryError( + "missing-recovery-data", + "Exchange history lacks the refresh seed or ordered output denominations", + ); + } + const refs = melt.denoms_h.map((denomPubHash) => ({ + denomPubHash, + exchangeMasterPub: oldCoin.exchangeMasterPub, + })); + const denoms = await wex.runWalletDbTx((tx) => + tx.getDenominationsByRefs(refs), + ); + const byHash = new Map(denoms.map((d) => [d.denomPubHash, d])); + // Do not group or sort this list: output position is part of the commitment. + const newDenoms = melt.denoms_h.map((h) => { + const d = byHash.get(h); + if (!d) + throw new RecoveryError( + "missing-denomination", + `Missing output denomination ${h}`, + ); + return { + count: 1, + value: d.value, + feeWithdraw: d.fees.feeWithdraw, + denomPub: d.denomPub, + denomPubHash: h, + }; + }); + if ( + newDenoms.some((d) => d.denomPub.cipher === DenomKeyType.ClauseSchnorr) && + !melt.blinding_seed + ) { + throw new RecoveryError( + "missing-recovery-data", + "Exchange history lacks the Clause-Schnorr blinding seed", + ); + } + const refreshGroupId = encodeCrock( + hash( + stringToBytes( + canonicalJson([ + "coin-recovery", + oldCoin.exchangeBaseUrl, + oldCoin.coinPub, + melt.rc, + ]), + ), + ), + ); + const amountRefreshOutput = Amounts.stringify( + Amounts.sum(newDenoms.map((d) => d.value)).amount, + ); + const session: WalletRefreshSession = { + refreshGroupId, + coinIndex: 0, + sessionPublicSeed: melt.refresh_seed, + blindingSeed: melt.blinding_seed, + amountRefreshOutput, + newDenoms: newDenoms.map((d) => ({ + denomPubHash: d.denomPubHash, + count: 1, + })), + }; + phase("derive"); + let derived: Awaited<ReturnType<typeof deriveRefreshSession>> | undefined; + for (const version of [32, 27]) { + checkCancelled(wex); + session.refreshProtocolVersion = version; + const candidate = await deriveRefreshSession( + wex, + session, + oldCoin, + WalletDenomination.toDenomInfo(oldDenom), + newDenoms, + ); + if ( + candidate.hash === melt.rc && + Amounts.cmp(candidate.meltValueWithFee, melt.amount) === 0 + ) { + derived = candidate; + break; + } + } + if (!derived) { + throw new RecoveryError( + "commitment-mismatch", + "Recovery data does not reproduce the signed refresh commitment", + ); + } + phase("melt"); + checkCancelled(wex); + const client = walletExchangeClient(oldCoin.exchangeBaseUrl, wex); + const confirmation = succeedOrThrow( + await client.postMelt({ + body: { + old_coin_pub: oldCoin.coinPub, + old_denom_pub_h: oldCoin.denomPubHash, + old_denom_sig: oldCoin.denomSig, + old_age_commitment_h: oldCoin.ageCommitmentProof + ? AgeRestriction.hashCommitment(oldCoin.ageCommitmentProof.commitment) + : undefined, + refresh_seed: melt.refresh_seed, + blinding_seed: derived.blindingSeed, + confirm_sig: derived.confirmSig, + coin_evs: derived.planchets.map((batch) => batch.map((c) => c.coinEv)), + ...(derived.protocolVersion === 32 + ? { transfer_pubs: derived.transferPubs } + : {}), + denoms_h: melt.denoms_h, + value_with_fee: Amounts.stringify(derived.meltValueWithFee), + }, + }), + ); + const index = confirmation.noreveal_index; + // Check sessions created before the shared confirmation pin was introduced, + // too. A signed response must not change a previously disclosed batch. + await wex.runWalletDbTx(async (tx) => { + for (const group of await tx.listAllRefreshGroups()) { + const coinIndex = group.oldCoinPubs.indexOf(oldCoin.coinPub); + if (coinIndex < 0) continue; + const known = await tx.getRefreshSession(group.refreshGroupId, coinIndex); + if ( + known && + known.sessionPublicSeed === melt.refresh_seed && + known.norevealIndex !== undefined && + known.norevealIndex !== index + ) { + throw new RecoveryError( + "invalid-history", + "Exchange changed the no-reveal index of a local refresh", + ); + } + } + }); + try { + await requireValidMeltConfirmation( + wex, + oldCoin.exchangeBaseUrl, + derived.hash, + confirmation, + ); + } catch { + throw new RecoveryError( + "invalid-history", + "Invalid exchange melt confirmation", + ); + } + session.norevealIndex = index; + phase("reveal"); + checkCancelled(wex); + const disclose = (_: unknown, i: number) => i !== index; + const reveal = succeedOrThrow( + await walletExchangeClient( + confirmation.reveal_base_url ?? + confirmation.refresh_base_url ?? + oldCoin.exchangeBaseUrl, + wex, + ).postRevealMelt({ + body: { + rc: derived.hash, + age_commitment: oldCoin.ageCommitmentProof?.commitment.publicKeys, + ...(derived.protocolVersion === 32 + ? { batch_seeds: derived.batchSeeds.filter(disclose) } + : { signatures: derived.signatures.filter(disclose) }), + }, + }), + ); + if (reveal.ev_sigs.length !== newDenoms.length) { + throw new RecoveryError( + "invalid-history", + "Wrong number of refreshed coin signatures", + ); + } + const transactionId = new RefreshTransactionContext(wex, refreshGroupId) + .transactionId; + const coins: WalletCoin[] = []; + for (let i = 0; i < newDenoms.length; i++) { + checkCancelled(wex); + const pc = derived.planchets[index][i]; + const d = newDenoms[i]; + const denomSig = await wex.cryptoApi.unblindDenominationSignature({ + planchet: { + blindingKey: pc.blindingKey, + denomPub: d.denomPub, + exchangeWithdrawValues: pc.exchangeWithdrawValues, + }, + evSig: reveal.ev_sigs[i], + }); + const coinPubHash = encodeCrock( + hashCoinPub( + pc.coinPub, + pc.ageCommitmentProof + ? AgeRestriction.hashCommitment(pc.ageCommitmentProof.commitment) + : undefined, + ), + ); + const valid = await wex.cryptoApi.verifyDenominationSignature({ + coinPubHash, + denomPub: d.denomPub, + denomSig, + }); + if (!valid.valid) + throw new RecoveryError( + "invalid-history", + "Invalid recovered coin denomination signature", + ); + coins.push({ + blindingKey: pc.blindingKey, + exchangeWithdrawValues: pc.exchangeWithdrawValues, + coinPriv: pc.coinPriv, + coinPub: pc.coinPub, + denomPubHash: d.denomPubHash, + denomSig, + exchangeBaseUrl: oldCoin.exchangeBaseUrl, + exchangeMasterPub: oldCoin.exchangeMasterPub, + status: CoinStatus.Dormant, + coinSource: { + type: CoinSourceType.Refresh, + refreshGroupId, + oldCoinPub: oldCoin.coinPub, + nonce: + pc.coinEv.cipher === DenomKeyType.ClauseSchnorr + ? pc.coinEv.cs_nonce + : undefined, + }, + sourceTransactionId: transactionId, + coinEvHash: pc.coinEvHash, + maxAge: pc.maxAge, + ageCommitmentProof: pc.ageCommitmentProof, + }); + } + const now = timestampPreciseToDb(TalerPreciseTimestamp.now()); + return { + coins, + session, + group: { + refreshGroupId, + operationStatus: RefreshOperationStatus.Finished, + currency: Amounts.currencyOf(oldDenom.value), + reason: RefreshReason.BackupRestored, + oldCoinPubs: [oldCoin.coinPub], + inputPerCoin: [melt.amount], + expectedOutputPerCoin: [amountRefreshOutput], + infoPerExchange: { + [oldCoin.exchangeBaseUrl]: { outputEffective: amountRefreshOutput }, + }, + statusPerCoin: [RefreshCoinStatus.Finished], + refundRequests: {}, + timestampCreated: now, + timestampFinished: now, + }, + }; +} + +export async function testingRecoverCoins( + wex: WalletExecutionContext, + req: TestingRecoverCoinsRequest, +): Promise<TestingRecoverCoinsResponse> { + let exchangeBaseUrl: string; + try { + exchangeBaseUrl = canonicalizeBaseUrl(req.exchangeBaseUrl); + if (!req.exchangeBaseUrl.trim() || !/^https?:\/\//.test(exchangeBaseUrl)) + throw Error(); + } catch { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "exchangeBaseUrl" }, + "Invalid exchange base URL", + ); + } + const progressToken = req.progressToken ?? encodeCrock(getRandomBytes(32)); + return runWithMaybeProgressContext( + wex, + "testingRecoverCoins", + progressToken, + () => + wex.ws.runSequentialized([`coin-recovery:${exchangeBaseUrl}`], () => + runCoinRecovery(wex, req, exchangeBaseUrl, progressToken), + ), + ); +} + +async function runCoinRecovery( + wex: WalletExecutionContext, + req: TestingRecoverCoinsRequest, + exchangeBaseUrl: string, + progressToken: string, +): Promise<TestingRecoverCoinsResponse> { + const details = await wex.runWalletDbTx((tx) => + tx.getExchangeDetails(exchangeBaseUrl), + ); + if (!details) + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + {}, + "Exchange is not known to this wallet", + ); + const result: TestingRecoverCoinsResponse = { + exchangeBaseUrl, + progressToken, + complete: false, + numChecked: 0, + numDiscovered: 0, + numQueued: 0, + numRecovered: 0, + recoveredAmount: Amounts.stringify( + Amounts.zeroOfCurrency(details.currency), + ), + issues: [], + }; + let journal: WalletCoinRecovery = { + exchangeBaseUrl, + pending: [], + visited: [], + imported: [], + claimed: [], + quarantined: [], + residuals: {}, + }; + let lastPhase: CoinRecoveryPhase | undefined; + let lastNotification = 0; + const phase = (p: CoinRecoveryPhase) => { + const now = Date.now(); + if (p === lastPhase && now - lastNotification < 100) return; + lastPhase = p; + lastNotification = now; + wex.ws.notify({ + type: NotificationType.CoinRecoveryProgress, + exchangeBaseUrl, + progressToken, + phase: p, + numChecked: result.numChecked, + numDiscovered: result.numDiscovered, + numQueued: journal.pending.length, + numRecovered: result.numRecovered, + recoveredAmount: result.recoveredAmount, + numIssues: result.issues.length, + }); + }; + const issue = (coinPub: string, e: unknown, rc?: string) => { + checkCancelled(wex); + result.issues.push({ + coinPub, + refreshCommitment: rc, + reason: e instanceof RecoveryError ? e.reason : "request-failed", + description: + e instanceof RecoveryError + ? e.message + : "Could not complete the exchange recovery request", + }); + }; + const reloadJournal = async () => { + journal = + (await wex.runWalletDbTx((tx) => tx.getCoinRecovery(exchangeBaseUrl))) ?? + journal; + }; + const updateTotals = async () => { + const amounts = await wex.runWalletDbTx(async (tx) => { + const imported = await tx.getCoinsByPubs(journal.imported); + const spendable = imported.filter( + (c) => + c.status === CoinStatus.Fresh && + journal.visited.includes(c.coinPub) && + c.exchangeMasterPub === details.masterPublicKey, + ); + result.numRecovered = spendable.length; + const denoms = await tx.getDenominationsByRefs(spendable); + const byHash = new Map(denoms.map((d) => [d.denomPubHash, d])); + return spendable.map((c) => byHash.get(c.denomPubHash)!.value); + }); + result.recoveredAmount = Amounts.stringify( + Amounts.sumOrZero(details.currency, amounts).amount, + ); + result.numDiscovered = journal.pending.length + journal.visited.length; + result.numQueued = journal.pending.length; + }; + phase("starting"); + try { + checkCancelled(wex); + journal = await wex.runWalletDbTx(async (tx) => { + const previous = await tx.getCoinRecovery(exchangeBaseUrl); + const j = + previous?.pending.length || + Object.keys(previous?.residuals ?? {}).length + ? previous! + : journal; + const roots = await tx.getCoinsByExchange(exchangeBaseUrl); + for (const c of roots) { + if ( + c.exchangeMasterPub === details.masterPublicKey && + (!(req.onlyFresh ?? true) || c.status === CoinStatus.Fresh) + ) + enqueue(j, c.coinPub); + } + await tx.upsertCoinRecovery(j); + return j; + }); + // A failure remains pending for the next invocation, while independent + // coins (including descendants discovered below) continue in this one. + const attempted = new Set<string>(); + const attemptedResiduals = new Set<string>(); + for (;;) { + while (true) { + checkCancelled(wex); + const pub = journal.pending.find((p) => !attempted.has(p)); + if (!pub) break; + attempted.add(pub); + await updateTotals(); + phase("history"); + try { + const { coin, denom } = await wex.runWalletDbTx(async (tx) => { + const coin = await tx.getCoin(pub); + const denom = coin && (await tx.getDenomination(coin)); + if (!coin || !denom) + throw new RecoveryError( + "missing-denomination", + "Coin or denomination is no longer retained", + ); + return { coin, denom }; + }); + const sig = await wex.cryptoApi.signCoinHistoryRequest({ + coinPub: pub, + coinPriv: coin.coinPriv, + startOffset: 0, + }); + const resp = await walletExchangeClient( + exchangeBaseUrl, + wex, + ).getCoinHistory(pub, sig.sig); + let history: CoinHistoryResponse; + if ( + resp.case === HttpStatusCode.NotFound && + resp.detail?.code === TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN + ) { + history = { + h_denom_pub: coin.denomPubHash, + balance: denom.value, + history: [], + }; + } else history = succeedOrThrow(resp); + let balance: AmountString; + try { + history.history.sort((a, b) => a.history_offset - b.history_offset); + await requireValidExchangeCoinHistory(wex, { + exchangeBaseUrl, + coinPub: pub, + denomination: WalletDenomination.toDenomInfo(denom), + response: history, + }); + balance = validateAndRecomputeCoinHistoryBalance( + coin.denomPubHash, + denom.value, + history, + ); + } catch { + throw new RecoveryError( + "invalid-history", + "Coin history fails signature, denomination, fee or balance validation", + ); + } + checkCancelled(wex); + // Retire a stale fresh input before any network replay. Remember + // ownership so a restarted request can still recover its residual. + await wex.runWalletDbTx(async (tx) => { + const current = await requireUnchangedCoin(tx, coin, denom); + if ( + coin.status === CoinStatus.Fresh && + Amounts.cmp(balance, denom.value) < 0 + ) { + if (!journal.claimed.includes(pub)) journal.claimed.push(pub); + await changeAvailability(tx, current!, denom, CoinStatus.Dormant); + await tx.upsertCoinRecovery(journal); + coin.status = CoinStatus.Dormant; + } + }); + let failed = false; + for (const melt of history.history) { + if (melt.type !== "MELT") continue; + try { + const recovered = await recoverCoinMelt( + wex, + coin, + denom, + melt, + phase, + ); + checkCancelled(wex); + await wex.runWalletDbTx(async (tx) => { + const ctx = new RefreshTransactionContext( + wex, + recovered.group.refreshGroupId, + ); + const missing = + ( + await tx.getCoinsByPubs( + recovered.coins.map((c) => c.coinPub), + ) + ).length !== recovered.coins.length; + if ( + missing && + !(await tx.getRefreshGroup(recovered.group.refreshGroupId)) + ) { + await tx.upsertRefreshGroup(recovered.group); + await tx.upsertRefreshSession(recovered.session); + await ctx.updateTransactionMeta(tx); + } + for (const c of recovered.coins) { + if (!(await tx.getCoin(c.coinPub))) { + await tx.upsertCoin(c); + if (!journal.imported.includes(c.coinPub)) + journal.imported.push(c.coinPub); + journal.quarantined.push(c.coinPub); + } + enqueue(journal, c.coinPub); + } + await tx.upsertCoinRecovery(journal); + }); + } catch (e) { + issue(pub, e, melt.rc); + await reloadJournal(); + failed = true; + } + } + await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () => + wex.runWalletDbTx(async (tx) => { + const current = await requireUnchangedCoin(tx, coin, denom); + if (journal.quarantined.includes(pub)) { + const active = await tx.getActiveRefreshGroups(); + if ( + active.some( + (g) => + g.oldCoinPubs.includes(pub) && + g.refreshGroupId !== journal.residuals[pub], + ) + ) { + throw new RecoveryError( + "local-data-changed", + "Another refresh already owns this recovered coin", + ); + } + } + if ( + journal.quarantined.includes(pub) && + current!.status === CoinStatus.Dormant && + Amounts.cmp(balance, denom.value) === 0 + ) { + if ( + denom.isRevoked || + denom.isLost || + AbsoluteTime.cmp( + AbsoluteTime.now(), + AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(denom.stampExpireDeposit), + ), + ) >= 0 + ) { + throw new RecoveryError( + "missing-denomination", + "Recovered coin denomination can no longer be spent", + ); + } + await changeAvailability(tx, current!, denom, CoinStatus.Fresh); + journal.quarantined = journal.quarantined.filter( + (p) => p !== pub, + ); + } else if ( + (journal.claimed.includes(pub) || + journal.quarantined.includes(pub)) && + current!.status === CoinStatus.Dormant && + !Amounts.isZero(balance) && + !journal.residuals[pub] + ) { + const active = await tx.getActiveRefreshGroups(); + if (active.some((g) => g.oldCoinPubs.includes(pub))) { + throw new RecoveryError( + "local-data-changed", + "Another refresh already owns this coin", + ); + } + const r = await createRefreshGroup( + wex, + tx, + details.currency, + [{ coinPub: pub, amount: balance }], + RefreshReason.BackupRestored, + undefined, + ); + journal.residuals[pub] = r.refreshGroupId; + journal.quarantined = journal.quarantined.filter( + (p) => p !== pub, + ); + } + if ( + current!.status === CoinStatus.Dormant && + !Amounts.isZero(balance) && + !journal.quarantined.includes(pub) && + !journal.claimed.includes(pub) && + !journal.residuals[pub] + ) { + throw new RecoveryError( + "coin-unavailable", + "Coin has a remaining balance but is already unavailable in this wallet", + ); + } + if (!failed) { + journal.quarantined = journal.quarantined.filter( + (p) => p !== pub, + ); + journal.pending = journal.pending.filter((p) => p !== pub); + journal.visited.push(pub); + } + await tx.upsertCoinRecovery(journal); + }), + ); + result.numChecked++; + } catch (e) { + issue(pub, e); + await reloadJournal(); + } + } + // Residuals are ordinary refresh tasks. Wait for their first outcome; + // errors remain journalled and do not block unrelated recovery work. + for (const [pub, groupId] of Object.entries(journal.residuals)) { + if (attemptedResiduals.has(groupId)) continue; + attemptedResiduals.add(groupId); + phase("refresh"); + const ctx = new RefreshTransactionContext(wex, groupId); + wex.taskScheduler.startShepherdTask(ctx.taskId); + while (true) { + checkCancelled(wex); + const state = await wex.runWalletDbTx(async (tx) => ({ + group: await tx.getRefreshGroup(groupId), + retry: await tx.getOperationRetry(ctx.taskId), + })); + if ( + state.group?.operationStatus === RefreshOperationStatus.Finished + ) { + await wex.runWalletDbTx(async (tx) => { + for (const c of await tx.getCoinsBySourceTransaction( + ctx.transactionId, + )) { + if (!journal.imported.includes(c.coinPub)) + journal.imported.push(c.coinPub); + // These coins were minted by our ordinary refresh task. + // Queue a history check before counting them as recovered. + enqueue(journal, c.coinPub); + } + delete journal.residuals[pub]; + await tx.upsertCoinRecovery(journal); + }); + break; + } + if ( + !state.group || + state.group.timestampFinished || + state.retry?.lastError + ) { + issue( + pub, + new RecoveryError( + "refresh-incomplete", + "Residual refresh is unfinished; recovery will resume it", + ), + ); + break; + } + await wex.ws.timerGroup.resolveAfter( + { d_ms: 100 }, + wex.cancellationToken, + ); + } + } + if (!journal.pending.some((p) => !attempted.has(p))) break; + } + await updateTotals(); + result.complete = + journal.pending.length === 0 && + Object.keys(journal.residuals).length === 0 && + !result.issues.length; + phase(result.complete ? "complete" : "incomplete"); + return result; + } catch (e) { + phase(wex.cancellationToken.isCancelled ? "cancelled" : "failed"); + throw e; + } +} diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -201,6 +201,8 @@ import { TestPayArgs, TestPayResult, TestingCorruptWithdrawalCoinSelRequest, + TestingRecoverCoinsRequest, + TestingRecoverCoinsResponse, TestingCheckCoinsRequest, TestingCheckCoinsResponse, TestingGetDenomStatsRequest, @@ -415,6 +417,7 @@ export enum WalletApiOperation { TestingGetDbStats = "testingGetDbStats", TestingSetTimetravel = "testingSetTimetravel", TestingGetDenomStats = "testingGetDenomStats", + TestingRecoverCoins = "testingRecoverCoins", TestingCheckCoins = "testingCheckCoins", TestingPing = "testingPing", TestingGetReserveHistory = "testingGetReserveHistory", @@ -1724,6 +1727,12 @@ export type TestingGetDenomStatsOp = { response: TestingGetDenomStatsResponse; }; +export type TestingRecoverCoinsOp = { + op: WalletApiOperation.TestingRecoverCoins; + request: TestingRecoverCoinsRequest; + response: TestingRecoverCoinsResponse; +}; + export type TestingCheckCoinsOp = { op: WalletApiOperation.TestingCheckCoins; request: TestingCheckCoinsRequest; @@ -2211,6 +2220,7 @@ export type WalletOperations = { [WalletApiOperation.ListAssociatedRefreshes]: ListAssociatedRefreshesOp; [WalletApiOperation.TestingGetDenomStats]: TestingGetDenomStatsOp; [WalletApiOperation.TestingCheckCoins]: TestingCheckCoinsOp; + [WalletApiOperation.TestingRecoverCoins]: TestingRecoverCoinsOp; [WalletApiOperation.TestingRunFixup]: TestingRunFixupOp; [WalletApiOperation.TestingPing]: TestingPingOp; [WalletApiOperation.Shutdown]: ShutdownOp;