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:
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) {