commit 308b2b3dab853a9505e4cd9343ed0715e112e6be
parent c3b21df23776c687e9e12d1b46dd94f00383553e
Author: Florian Dold <dold@taler.net>
Date: Thu, 6 Aug 2026 18:26:37 +0200
wallet: cherry-pick the exchange's /keys response
A cherry-picked response omits the denominations the wallet already knows, so
it must not read their absence as retirement -- that path writes the coins held
of them off as lost. Forced updates and entries in an error state still
download everything, as that is exactly the state the wallet distrusts.
Issue: https://bugs.taler.net/n/11715
Diffstat:
1 file changed, 101 insertions(+), 2 deletions(-)
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -128,6 +128,7 @@ import {
reservePaytoFromExchange,
} from "./common.js";
import {
+ DbProtocolTimestamp,
DenomLossStatus,
DenominationVerificationStatus,
ExchangeEntryDbRecordStatus,
@@ -850,6 +851,10 @@ export type ExchangeKeysDownloadResult =
/**
* Download and validate an exchange's /keys data.
*
+ * When @a lastIssueDate is given, the exchange is asked to cherry-pick, i.e.
+ * to leave out the denominations that started before that timestamp. See
+ * {@link getKeysCherryPickDate} for how the caller arrives at it.
+ *
* FIXME: Use the HTTP client lib for this!
*/
async function downloadExchangeKeysInfo(
@@ -858,13 +863,14 @@ async function downloadExchangeKeysInfo(
timeout: Duration,
cancellationToken: CancellationToken,
noCache: boolean,
+ lastIssueDate?: number,
): Promise<ExchangeKeysDownloadResult> {
const exchangeClient = new TalerExchangeHttpClient(baseUrl, {
httpClient: http,
cancelationToken: cancellationToken,
timeout,
});
- const resp = await exchangeClient.getKeys({ noCache });
+ const resp = await exchangeClient.getKeys({ noCache, lastIssueDate });
logger.trace("got response to /keys request");
@@ -945,6 +951,43 @@ async function checkExchangeEntryOutdated(
return numOkay === 0;
}
+/**
+ * Timestamp (in seconds) to ask the exchange to cherry-pick /keys by, or
+ * undefined when the whole response should be downloaded.
+ *
+ * The protocol wants the largest stamp_start among the denominations the
+ * client already knows, and the exchange answers with exactly the
+ * denominations that started at or after it. Deriving that from the
+ * denominations in the database rather than remembering it separately means
+ * the request can never claim knowledge the wallet does not have.
+ */
+async function getKeysCherryPickDate(
+ tx: WalletDbTransaction,
+ exchangeBaseUrl: string,
+): Promise<number | undefined> {
+ let maxStampStart: DbProtocolTimestamp | undefined;
+ const denoms = await tx.getDenominationsByExchange(exchangeBaseUrl);
+ for (const denom of denoms) {
+ // Only denominations the exchange should still be listing are candidates.
+ // A revoked or retired one is gone from /keys, so its start date names no
+ // cherry-picking point and would cost us the full response every time.
+ if (denom.isRevoked || !denom.isOffered) {
+ continue;
+ }
+ if (maxStampStart == null || denom.stampStart > maxStampStart) {
+ maxStampStart = denom.stampStart;
+ }
+ }
+ if (maxStampStart == null) {
+ return undefined;
+ }
+ const stampStart = timestampProtocolFromDb(maxStampStart);
+ if (stampStart.t_s === "never") {
+ return undefined;
+ }
+ return stampStart.t_s;
+}
+
export interface StartUpdateExchangeResult {
/**
* Canonical or updated base URL.
@@ -1704,6 +1747,20 @@ export async function updateExchangeFromUrlHandler(
}
}
+ // Only an entry that is otherwise healthy is cherry-picked. After an
+ // error, a conflict or an entry that ran out of usable denominations it is
+ // the wallet's picture of the denominations that is in doubt, so those
+ // updates ask for the whole response instead.
+ let cherryPickDate: number | undefined = undefined;
+ switch (oldExchangeRec.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.Ready:
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ cherryPickDate = await wex.runWalletDbTx(async (tx) => {
+ return await getKeysCherryPickDate(tx, exchangeBaseUrl);
+ });
+ break;
+ }
+
// When doing the auto-refresh check, we always update
// the key info before that.
@@ -1720,6 +1777,7 @@ export async function updateExchangeFromUrlHandler(
timeout,
wex.cancellationToken,
oldExchangeRec.cachebreakNextUpdate ?? false,
+ cherryPickDate,
);
} catch (e) {
logger.warn(`unable to download exchange keys for ${exchangeBaseUrl}`);
@@ -1843,12 +1901,32 @@ export async function updateExchangeFromUrlHandler(
logger.trace("updating exchange info in database");
+ // Did the exchange honour our cherry-picking request? It falls back to the
+ // full response whenever the timestamp doesn't name a denomination it
+ // currently offers, and then echoes that response's own issue date instead.
+ const cherryPicked =
+ cherryPickDate != null && keysInfo.list_issue_date.t_s === cherryPickDate;
+
+ if (cherryPicked) {
+ logger.trace(
+ `exchange cherry-picked /keys from ${cherryPickDate}, ` +
+ `denominations that started earlier are not in the response`,
+ );
+ }
+
// Age mask advertised by the exchange, i.e. the one shared by its
// age-restricted denominations. Zero when it offers none.
let exchangeAgeMask = 0;
let noFees = checkNoFees(keysInfo);
let peerPaymentsDisabled = checkPeerPaymentsDisabled(keysInfo);
+ if (cherryPicked && oldExchangeRec.noFees === false) {
+ // Part of being fee-free is a property of the denominations, and the ones
+ // a cherry-picked response leaves out are exactly the ones that could
+ // have carried a fee. Their fees are signed and cannot have changed, so
+ // the verdict from the full list still stands.
+ noFees = false;
+ }
const denomInfos: DenominationInfo[] = [];
const currentDenomSet = new Set<string>();
@@ -1973,7 +2051,13 @@ export async function updateExchangeFromUrlHandler(
globalFees,
exchangeBaseUrl: r.baseUrl,
wireInfo,
- ageMask: exchangeAgeMask,
+ // A cherry-picked response need not carry an age-restricted
+ // denomination even though the exchange offers some, so seeing none
+ // there is not evidence that the age mask went away.
+ ageMask:
+ cherryPicked && exchangeAgeMask === 0
+ ? (existingDetails?.ageMask ?? 0)
+ : exchangeAgeMask,
walletBalanceLimits: keysInfo.wallet_balance_limit_without_kyc,
hardLimits: keysInfo.hard_limits,
zeroLimits: keysInfo.zero_limits,
@@ -2157,7 +2241,22 @@ export async function updateExchangeFromUrlHandler(
// Update list issue date for all denominations,
// and mark non-offered denominations as such.
+ //
+ // A cherry-picked response only speaks about the denominations that
+ // started at or after the date we asked for. Absence from it says
+ // nothing about the older ones, and taking it as retirement would strand
+ // the coins the wallet holds of them, so they are left alone until an
+ // update downloads the full list again.
+ const coveredFromStampStart = !cherryPicked
+ ? undefined
+ : timestampProtocolToDb(keysInfo.list_issue_date);
for (const x of allOldDenoms) {
+ if (
+ coveredFromStampStart != null &&
+ x.stampStart < coveredFromStampStart
+ ) {
+ continue;
+ }
if (!currentDenomSet.has(x.denomPubHash)) {
// FIXME: Here, an auditor report should be created, unless
// the denomination is really legally expired.