taler-typescript-core

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

commit 59606ac65563fe908ac58bc67f518e2869fb1d41
parent 9abeece6cf73ac6e8baaf5c39f009fcfba0da448
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:49 +0200

wallet-core: gate old coins on master-key confirmation

Diffstat:
Mpackages/taler-wallet-core/src/coinSelection.test.ts | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/coinSelection.ts | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mpackages/taler-wallet-core/src/common.ts | 11++++-------
Mpackages/taler-wallet-core/src/deposits.ts | 2++
Mpackages/taler-wallet-core/src/exchange-master-pub.test.ts | 25+++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/exchanges.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 22++++++++++++++++++++--
Mpackages/taler-wallet-core/src/pay-peer-common.ts | 10+++++++++-
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 7++++++-
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 3+++
Mpackages/taler-wallet-core/src/recoup.ts | 13++++++++-----
Mpackages/taler-wallet-core/src/refresh.ts | 14++++++++++++--
12 files changed, 368 insertions(+), 52 deletions(-)

diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -23,6 +23,8 @@ import { DenomKeyType, DenominationPubKey, Duration, + TalerError, + TalerErrorCode, TalerProtocolTimestamp, j2s, } from "@gnu-taler/taler-util"; @@ -33,6 +35,7 @@ import { AvailableCoinsOfDenom, CoinSelectionTally, checkExchangeAccepted, + denominationTermsMatchAcrossMasterKeys, emptyTallyForPeerPayment, findMatchingWire, getMaxDepositAmount, @@ -47,12 +50,14 @@ import { import { ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, + DenominationVerificationStatus, WalletCoinAvailability, WalletDenomination, WalletExchangeDetails, WalletExchangeEntry, } from "./db-common.js"; import { WalletDbTransaction } from "./dbtx.js"; +import { requireExchangeCoinUseConfirmedOrThrow } from "./exchanges.js"; import { WalletExecutionContext } from "./wallet.js"; const inTheDistantFuture = AbsoluteTime.toProtocolTimestamp( @@ -728,6 +733,138 @@ test("deposit available max includes pending-only refresh outputs", async () => }); }); +test("old-master coins stay frozen until the replacement key is confirmed", async () => { + const exchangeBaseUrl = "https://rotated.example/"; + const oldMaster = "old-master"; + const newMaster = "new-master"; + const exchange = { + baseUrl: exchangeBaseUrl, + detailsPointer: { + currency: "KUDOS", + masterPublicKey: newMaster, + updateClock: 0, + }, + entryStatus: ExchangeEntryDbRecordStatus.Used, + updateStatus: ExchangeEntryDbUpdateStatus.Ready, + supersededKeySet: { + masterPublicKey: oldMaster, + currency: "KUDOS", + firstSeen: 0, + sharesDenominations: true, + }, + } as WalletExchangeEntry; + const details = { + exchangeBaseUrl, + currency: "KUDOS", + masterPublicKey: newMaster, + auditors: [], + globalFees: [], + wireInfo: { accounts: [], feesForType: {} }, + } as unknown as WalletExchangeDetails; + const availability = { + currency: "KUDOS", + value: "KUDOS:5", + denomPubHash: "shared-denom", + exchangeBaseUrl, + exchangeMasterPub: oldMaster, + maxAge: 0, + freshCoinCount: 1, + hasFreshCoins: 1, + visibleCoinCount: 1, + } as WalletCoinAvailability; + const historical = { + currency: "KUDOS", + value: "KUDOS:5", + denomPub: undefined, + denomPubHash: availability.denomPubHash, + fees: { + feeDeposit: "KUDOS:0", + feeRefresh: "KUDOS:0", + feeRefund: "KUDOS:0", + feeWithdraw: "KUDOS:0", + }, + stampStart: 0, + stampExpireWithdraw: Number.MAX_SAFE_INTEGER, + stampExpireDeposit: Number.MAX_SAFE_INTEGER, + stampExpireLegal: Number.MAX_SAFE_INTEGER, + masterSig: "OLD-SIG", + verificationStatus: DenominationVerificationStatus.VerifiedGood, + isOffered: true, + isRevoked: false, + exchangeBaseUrl, + exchangeMasterPub: oldMaster, + } as unknown as WalletDenomination; + const endorsement = { + ...historical, + masterSig: "NEW-SIG", + exchangeMasterPub: newMaster, + }; + const denominations = new Map([ + [`${oldMaster}:${historical.denomPubHash}`, historical], + [`${newMaster}:${endorsement.denomPubHash}`, endorsement], + ]); + const tx = { + async getExchanges() { + return [exchange]; + }, + async getExchange() { + return exchange; + }, + async getExchangeDetailsByPointer() { + return details; + }, + async getCoinAvailabilityByExchangeAndAgeRange() { + return [availability]; + }, + async getCoinAvailabilityByExchange() { + return [availability]; + }, + async getDenomination(ref: { + exchangeMasterPub: string; + denomPubHash: string; + }) { + return denominations.get(`${ref.exchangeMasterPub}:${ref.denomPubHash}`); + }, + async getDenominationsByRefs( + refs: Array<{ exchangeMasterPub: string; denomPubHash: string }>, + ) { + return refs + .map((ref) => + denominations.get(`${ref.exchangeMasterPub}:${ref.denomPubHash}`), + ) + .filter((x): x is WalletDenomination => !!x); + }, + } as unknown as WalletDbTransaction; + const wex = { + async runWalletDbTx<T>( + callback: (innerTx: WalletDbTransaction) => Promise<T>, + ): Promise<T> { + return callback(tx); + }, + } as WalletExecutionContext; + + const frozen = await getMaxDepositAmount(wex, { currency: "KUDOS" }); + assert.strictEqual(frozen.material.instructedAmount, "KUDOS:0"); + await assert.rejects( + requireExchangeCoinUseConfirmedOrThrow(wex, exchangeBaseUrl), + (e: unknown) => + e instanceof TalerError && + e.errorDetail.code === TalerErrorCode.WALLET_EXCHANGE_KEYS_NOT_ACCEPTED, + ); + + delete exchange.supersededKeySet; + const confirmed = await getMaxDepositAmount(wex, { currency: "KUDOS" }); + assert.strictEqual(confirmed.material.instructedAmount, "KUDOS:5"); + + endorsement.value = "KUDOS:4"; + assert.strictEqual( + denominationTermsMatchAcrossMasterKeys(historical, endorsement), + false, + ); + const mismatched = await getMaxDepositAmount(wex, { currency: "KUDOS" }); + assert.strictEqual(mismatched.material.instructedAmount, "KUDOS:0"); +}); + test("deposit max repeated denom", (t) => { const coinList: TestCoin[] = [ [kudos`2`, 1], diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -73,7 +73,10 @@ import { PaymentBalanceDetails, } from "./balance.js"; import { getAutoRefreshExecuteThreshold } from "./common.js"; -import { WalletDenomination } from "./db-common.js"; +import { + DenominationVerificationStatus, + WalletDenomination, +} from "./db-common.js"; import { checkExchangeInScopeTx, ExchangeDetails, @@ -88,6 +91,25 @@ import { WalletDbTransaction } from "./dbtx.js"; const logger = new Logger("coinSelection.ts"); +export function denominationTermsMatchAcrossMasterKeys( + historical: WalletDenomination, + current: WalletDenomination, +): boolean { + return ( + historical.denomPubHash === current.denomPubHash && + historical.currency === current.currency && + Amounts.cmp(historical.value, current.value) === 0 && + Amounts.cmp(historical.fees.feeDeposit, current.fees.feeDeposit) === 0 && + Amounts.cmp(historical.fees.feeRefresh, current.fees.feeRefresh) === 0 && + Amounts.cmp(historical.fees.feeRefund, current.fees.feeRefund) === 0 && + Amounts.cmp(historical.fees.feeWithdraw, current.fees.feeWithdraw) === 0 && + historical.stampStart === current.stampStart && + historical.stampExpireWithdraw === current.stampExpireWithdraw && + historical.stampExpireDeposit === current.stampExpireDeposit && + historical.stampExpireLegal === current.stampExpireLegal + ); +} + export type PreviousPayCoins = { coinPub: string; contribution: AmountJson; @@ -2043,7 +2065,21 @@ async function selectPayCandidates( } let numUsable = 0; - const candidateDenoms = await tx.getDenominationsByRefs(myExchangeCoins); + const currentEndorsementRefs = new Map( + myExchangeCoins + .filter((x) => x.exchangeMasterPub !== exchangeDetails.masterPublicKey) + .map((x) => { + const ref = { + exchangeMasterPub: exchangeDetails.masterPublicKey, + denomPubHash: x.denomPubHash, + }; + return [denomRefKey(ref), ref] as const; + }), + ); + const candidateDenoms = await tx.getDenominationsByRefs([ + ...myExchangeCoins, + ...currentEndorsementRefs.values(), + ]); const candidateDenomsByRef = new Map( candidateDenoms.map((denom) => [denomRefKey(denom), denom]), ); @@ -2060,22 +2096,43 @@ async function selectPayCandidates( logger.trace("denom is revoked"); continue; } - if (!denom.isOffered) { - logger.trace("denom is unoffered"); - continue; - } - // Signed by a master key the exchange has replaced, and not - // re-advertised under the new one -- an exchange update re-attributes - // the denominations it still offers, so one left on the old key is one - // this exchange has stopped standing behind. The coins keep their - // value in the database and stay visible, but selecting them would - // build a payment the exchange refuses to settle, after the user has - // committed to it. - if (denom.exchangeMasterPub !== exchangeDetails.masterPublicKey) { - logger.trace( - `denom ${denom.denomPubHash} is signed by a superseded master key`, + if (denom.exchangeMasterPub === exchangeDetails.masterPublicKey) { + if (!denom.isOffered) { + logger.trace("denom is unoffered"); + continue; + } + } else { + // The current key set is not allowed to receive authorizations over + // old coins until the user explicitly confirms that master key. + if (exchange.supersededKeySet) { + logger.trace( + `denom ${denom.denomPubHash} is frozen by an unconfirmed master-key change`, + ); + continue; + } + // Confirmation alone is not an assertion that the new operator can + // settle every historical denomination. Require a valid current-key + // certificate for the identical economic terms, while retaining the + // old certificate as the coin's issuance provenance. + const endorsement = candidateDenomsByRef.get( + denomRefKey({ + exchangeMasterPub: exchangeDetails.masterPublicKey, + denomPubHash: denom.denomPubHash, + }), ); - continue; + if ( + !endorsement || + endorsement.verificationStatus !== + DenominationVerificationStatus.VerifiedGood || + endorsement.isRevoked || + !endorsement.isOffered || + !denominationTermsMatchAcrossMasterKeys(denom, endorsement) + ) { + logger.trace( + `denom ${denom.denomPubHash} has no matching current-key endorsement`, + ); + continue; + } } numUsable++; let numAvailable = coinAvail.freshCoinCount ?? 0; diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -1063,13 +1063,10 @@ export function requireExchangeTosAcceptedOrThrow( * Refuse an operation that sends money to an exchange whose key set changed * and has not been confirmed. * - * Only the money-in direction is gated. The wallet has already adopted the - * new key set, so spending, depositing and refreshing the coins it already - * holds keep working -- those settle against denominations the exchange - * signed earlier and are not affected by which key it uses now. A - * withdrawal is different: it pays into bank details signed by the current - * master key, so an exchange URL that changed hands could otherwise redirect - * the transfer. + * This summary-level gate is used by operations that send new money to the + * exchange. Authorizations over coins already held are gated separately, + * immediately before disclosure, by + * requireExchangeCoinUseConfirmedOrThrow(). */ export function requireExchangeKeysConfirmedOrThrow( wex: WalletExecutionContext, diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -122,6 +122,7 @@ import { getExchangeDetailsInTx, getScopeForAllExchanges, markExchangeUsed, + requireExchangeCoinUseConfirmedOrThrow, } from "./exchanges.js"; import { requireValidDirectExchangeRefundConfirmation, @@ -1963,6 +1964,7 @@ async function submitDepositBatch( logger.info(`depositing to ${exchangeBaseUrl}`); logger.trace(`deposit request: ${j2s(batchReq)}`); const exchangeClient = walletExchangeClient(exchangeBaseUrl, wex); + await requireExchangeCoinUseConfirmedOrThrow(wex, exchangeBaseUrl); const depositResp = await exchangeClient.batchDeposit({ body: batchReq }); logger.info(`deposit result status ${depositResp.response.status}`); diff --git a/packages/taler-wallet-core/src/exchange-master-pub.test.ts b/packages/taler-wallet-core/src/exchange-master-pub.test.ts @@ -130,6 +130,31 @@ for (const makeRunner of runnerFactories) { }); }); + test("one base URL preserves details for old and new master keys", async () => { + await withRunner(async (runner) => { + const exchangeBaseUrl = "https://rotated/"; + const oldMaster = key("rotation-old"); + const newMaster = key("rotation-new"); + await runner.runReadWriteTx(async (tx) => { + await tx.upsertExchangeDetails(makeDetails(exchangeBaseUrl, oldMaster)); + await tx.upsertExchangeDetails(makeDetails(exchangeBaseUrl, newMaster)); + }); + const details = await runner.runReadWriteTx((tx) => + tx.listExchangeDetailsByBaseUrl(exchangeBaseUrl), + ); + assert.deepStrictEqual( + details.map((x) => x.masterPublicKey).sort(), + [oldMaster, newMaster].sort(), + ); + const [oldUrl, newUrl] = await runner.runReadWriteTx(async (tx) => [ + await getExchangeBaseUrlForMasterPub(tx, oldMaster), + await getExchangeBaseUrlForMasterPub(tx, newMaster), + ]); + assert.strictEqual(oldUrl, exchangeBaseUrl); + assert.strictEqual(newUrl, exchangeBaseUrl); + }); + }); + test("the entry's own pointer wins over another base URL", async () => { await withRunner(async (runner) => { // Both URLs carry the same key, as they do mid-migration. Only the diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -745,6 +745,50 @@ export async function confirmExchangeKeyChange( }); }); wex.ws.exchangeCache.clear(); + // Coin-using tasks may be sleeping on the confirmation error. Wake them + // now that the policy gate has been removed. + await wex.taskScheduler.reload(); +} + +/** + * Refuse to disclose an authorization over wallet coins while the exchange's + * replacement master key is still awaiting explicit user confirmation. + * + * This check belongs immediately next to the network operation that carries + * the authorization. Coin selection performs the same policy check, but a + * transaction can have persisted its selection before the key change was + * observed. + */ +export async function requireExchangeCoinUseConfirmedOrThrow( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<void> { + const change = await wex.runWalletDbTx(async (tx) => { + const exchange = await tx.getExchange(exchangeBaseUrl); + if (!exchange?.supersededKeySet) { + return undefined; + } + checkDbInvariant( + !!exchange.detailsPointer, + "exchange with a pending key change has no current details pointer", + ); + return { + currentMasterPub: exchange.detailsPointer.masterPublicKey, + supersededMasterPub: exchange.supersededKeySet.masterPublicKey, + }; + }); + if (!change) { + return; + } + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_KEYS_NOT_ACCEPTED, + { + exchangeBaseUrl, + currentMasterPub: change.currentMasterPub, + supersededMasterPub: change.supersededMasterPub, + }, + "coin use is blocked until the exchange's new master public key is confirmed", + ); } export async function acceptExchangeTermsOfService( @@ -2357,12 +2401,11 @@ export async function updateExchangeFromUrlHandler( // FIXME: We need to do some more consistency checks! } if (detailsIncompatible) { - // The exchange presents a different signing authority. The new key set - // is adopted right away, so the entry keeps working and the coins - // already held stay spendable -- what is withheld is the part that - // sends money the other way. Until the user confirms the change, the - // wire details a withdrawal would pay into are not trusted, because a - // URL taken over by someone else would otherwise redirect it. + // The exchange presents a different signing authority. Keep its + // metadata as the current observed key set so the user can inspect and + // confirm it, but gate both money sent to it and authorizations over + // existing coins. A URL takeover must not receive either before the + // replacement master public key is explicitly trusted. logger.warn( `exchange ${r.baseUrl} changed its key set (${conflictHint})`, ); @@ -2451,7 +2494,10 @@ export async function updateExchangeFromUrlHandler( r.tosCurrentEtag = tosMeta.etag; break; } - if (existingDetails?.rowId) { + // A different master key is a different signing identity. Preserve the + // old details row so historical denomination references keep resolving; + // confirmation changes authorization policy, not issuance provenance. + if (existingDetails?.rowId && !detailsIncompatible) { newDetails.rowId = existingDetails.rowId; } r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now()); @@ -2517,15 +2563,16 @@ export async function updateExchangeFromUrlHandler( tx, exchangeBaseUrl, ); - // Every denomination stored for this URL, whichever key signed it. An - // operator that rotated its master key re-advertises the same - // denominations under the new one, and recognising them here is what - // re-attributes them to the new key instead of storing a second copy -- - // or, worse, skipping them as not worth storing and leaving the coins - // pointing at a key the exchange no longer uses. - const oldDenomByDph = new Map<string, WalletDenomination>(); + // Keep one denomination certificate per signing master key. A new master + // can re-advertise the same denomination public key, but that endorsement + // must not rewrite the provenance of coins issued under the old key. + const currentDenomByDph = new Map<string, WalletDenomination>(); + const knownDenomHashes = new Set<string>(); for (const denom of allOldDenoms) { - oldDenomByDph.set(denom.denomPubHash, denom); + knownDenomHashes.add(denom.denomPubHash); + if (denom.exchangeMasterPub === keysInfo.master_public_key) { + currentDenomByDph.set(denom.denomPubHash, denom); + } } // The retirement sweep below, by contrast, may only judge denominations // of the key set this response speaks for: those of a superseded key are @@ -2557,14 +2604,18 @@ export async function updateExchangeFromUrlHandler( let numDenomsSkipped = 0; for (const currentDenom of denomInfos) { - const oldDenom = oldDenomByDph.get(currentDenom.denomPubHash); + const oldDenom = currentDenomByDph.get(currentDenom.denomPubHash); // A denomination that can no longer be withdrawn from is only of // interest for coins we already hold, and we cannot hold a coin of one // we never stored. Exchanges keep advertising these for years after // they stop issuing them -- on the demo exchange 952 of 1022 -- so not // writing them is most of the cost of adding an exchange. - if (!oldDenom && !isDenomWorthStoring(currentDenom)) { + if ( + !oldDenom && + !knownDenomHashes.has(currentDenom.denomPubHash) && + !isDenomWorthStoring(currentDenom) + ) { numDenomsSkipped++; continue; } diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -160,6 +160,7 @@ import { getExchangeScopeInfoOrUndefined, getScopeForAllCoins, getScopeForAllExchanges, + requireExchangeCoinUseConfirmedOrThrow, } from "./exchanges.js"; import { requireValidExchangeRefundConfirmation } from "./exchange-signatures.js"; import { instantiateTemplateRaw } from "./pay-template.js"; @@ -2299,6 +2300,12 @@ export async function generateDepositPermissions( } }); + for (const exchangeBaseUrl of new Set( + coinWithDenom.map(({ coin }) => coin.exchangeBaseUrl), + )) { + await requireExchangeCoinUseConfirmedOrThrow(wex, exchangeBaseUrl); + } + const walletDataHash = walletData ? encodeCrock(hashPayWalletData(walletData)) : undefined; @@ -3414,8 +3421,19 @@ async function processPurchasePay( wex, getPayRequestTimeout(purchase), ); - const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () => - merchantClient.makePayment(download.contractTerms.order_id, reqBody), + const resp = await wex.ws.runSequentialized( + [EXCHANGE_COINS_LOCK], + async () => { + for (const exchangeBaseUrl of new Set( + depositPermissions.map((x) => x.exchange_url), + )) { + await requireExchangeCoinUseConfirmedOrThrow(wex, exchangeBaseUrl); + } + return merchantClient.makePayment( + download.contractTerms.order_id, + reqBody, + ); + }, ); logger.trace(`got resp ${JSON.stringify(resp)}`); diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -27,7 +27,10 @@ import { import { WalletReserve } from "./db-common.js"; import { SpendCoinDetails } from "./crypto/cryptoImplementation.js"; import { DbPeerPushPaymentCoinSelection } from "./db-indexeddb.js"; -import { markExchangeUsed } from "./exchanges.js"; +import { + markExchangeUsed, + requireExchangeCoinUseConfirmedOrThrow, +} from "./exchanges.js"; import { getTotalRefreshCosts } from "./refresh.js"; import { denomRefKey, @@ -45,6 +48,7 @@ export async function queryCoinInfosForSelection( csel: DbPeerPushPaymentCoinSelection, ): Promise<SpendCoinDetails[]> { let infos: SpendCoinDetails[] = []; + const exchangeBaseUrls = new Set<string>(); await wex.runWalletDbTx(async (tx) => { const coins = await tx.getCoinsByPubs(csel.coinPubs); const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); @@ -58,6 +62,7 @@ export async function queryCoinInfosForSelection( if (!denom) { throw Error("denom for coin not found anymore"); } + exchangeBaseUrls.add(coin.exchangeBaseUrl); infos.push({ coinPriv: coin.coinPriv, coinPub: coin.coinPub, @@ -69,6 +74,9 @@ export async function queryCoinInfosForSelection( }); } }); + for (const exchangeBaseUrl of exchangeBaseUrls) { + await requireExchangeCoinUseConfirmedOrThrow(wex, exchangeBaseUrl); + } return infos; } diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -88,7 +88,11 @@ import { timestampPreciseFromDb, timestampPreciseToDb, } from "./db-common.js"; -import { getExchangeScopeInfo, getScopeForAllExchanges } from "./exchanges.js"; +import { + getExchangeScopeInfo, + getScopeForAllExchanges, + requireExchangeCoinUseConfirmedOrThrow, +} from "./exchanges.js"; import { runWithMaybeProgressContext, runWithProgressRetries, @@ -1091,6 +1095,7 @@ async function processPeerPullDebitPendingDeposit( if (logger.shouldLogTrace()) { logger.trace(`purse deposit payload: ${j2s(depositPayload)}`); } + await requireExchangeCoinUseConfirmedOrThrow(wex, exchangeBaseUrl); const resp = await exchangeClient.depositIntoPurse( pursePub, depositPayload, diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -98,6 +98,7 @@ import { getExchangeDetailsInTx, getPreferredExchangeForCurrency, getScopeForAllExchanges, + requireExchangeCoinUseConfirmedOrThrow, } from "./exchanges.js"; import { getTotalPeerPaymentCost, @@ -913,6 +914,7 @@ async function processPeerPushDebitCreateReserve( econtract: econtractResp.econtract, }; + await requireExchangeCoinUseConfirmedOrThrow(wex, exchangeBaseUrl); const resp = await exchangeClient.createPurseFromDeposit( peerPushInitiation.pursePub, reqBody, @@ -948,6 +950,7 @@ async function processPeerPushDebitCreateReserve( const depositPayload: ExchangePurseDeposits = { deposits: depositSigsResp.deposits, }; + await requireExchangeCoinUseConfirmedOrThrow(wex, exchangeBaseUrl); const resp = await exchangeClient.depositIntoPurse( peerPushInitiation.pursePub, depositPayload, diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts @@ -58,6 +58,7 @@ import { WithdrawalRecordType, } from "./db-common.js"; import { CoinSourceType } from "./db-indexeddb.js"; +import { requireExchangeCoinUseConfirmedOrThrow } from "./exchanges.js"; import { createRefreshGroup } from "./refresh.js"; import { constructTransactionIdentifier, @@ -135,6 +136,8 @@ async function recoupRefreshCoin( return; } + await requireExchangeCoinUseConfirmedOrThrow(wex, coin.exchangeBaseUrl); + const recoupRequest = await wex.cryptoApi.createRecoupRefreshRequest({ blindingKey: coin.blindingKey, coinPriv: coin.coinPriv, @@ -146,6 +149,7 @@ async function recoupRefreshCoin( logger.trace(`making recoup request for ${coin.coinPub}`); const exchangeClient = walletExchangeClient(coin.exchangeBaseUrl, wex); + await requireExchangeCoinUseConfirmedOrThrow(wex, coin.exchangeBaseUrl); const recoupResp = await exchangeClient.recoupRefreshCoin( coin.coinPub, recoupRequest, @@ -185,11 +189,7 @@ async function recoupRefreshCoin( `no revoked denom for coin, hash ${revokedCoin.denomPubHash}`, ); revokedCoin.status = CoinStatus.Dormant; - scheduleRecoupRefresh( - recoupGroup, - oldCoin.coinPub, - revokedCoinDenom.value, - ); + scheduleRecoupRefresh(recoupGroup, oldCoin.coinPub, revokedCoinDenom.value); await tx.upsertCoin(revokedCoin); await tx.upsertCoin(oldCoin); await putGroupAsFinished(wex, tx, recoupGroup, coinIdx); @@ -237,6 +237,8 @@ export async function recoupWithdrawCoin( return; } + await requireExchangeCoinUseConfirmedOrThrow(wex, coin.exchangeBaseUrl); + const recoupRequest = await wex.cryptoApi.createRecoupRequest({ blindingKey: coin.blindingKey, coinPriv: coin.coinPriv, @@ -247,6 +249,7 @@ export async function recoupWithdrawCoin( }); logger.trace(`requesting recoup for coin ${coin.coinPub}`); const exchangeClient = walletExchangeClient(coin.exchangeBaseUrl, wex); + await requireExchangeCoinUseConfirmedOrThrow(wex, coin.exchangeBaseUrl); const recoupResp = await exchangeClient.recoupCoin( coin.coinPub, recoupRequest, diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -112,6 +112,7 @@ import { selectWithdrawalDenominations } from "./denomSelection.js"; import { fetchFreshExchange, getScopeForAllExchanges, + requireExchangeCoinUseConfirmedOrThrow, requireExchangeReadyTx, } from "./exchanges.js"; import { @@ -928,6 +929,8 @@ async function refreshMelt( checkLogicInvariant(refreshSession.sessionPublicSeed != null); + await requireExchangeCoinUseConfirmedOrThrow(wex, oldCoin.exchangeBaseUrl); + let maybeAch: HashCodeString | undefined; if (oldCoin.ageCommitmentProof) { maybeAch = AgeRestriction.hashCommitment( @@ -976,8 +979,15 @@ async function refreshMelt( wex, getRefreshRequestTimeout(refreshGroup), ); - const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => - exchangeClient.postMelt({ body: meltReqBody }), + const resp = await wex.ws.runSequentialized( + [EXCHANGE_COINS_LOCK], + async () => { + await requireExchangeCoinUseConfirmedOrThrow( + wex, + oldCoin.exchangeBaseUrl, + ); + return exchangeClient.postMelt({ body: meltReqBody }); + }, ); switch (resp.case) {