commit d2f9f7c4e79be44bd56ba7791c7aac8f2c7e5505
parent bcc6f2b106699145a0f75f44b27a0e49adcbb57a
Author: Florian Dold <dold@taler.net>
Date: Tue, 28 Jul 2026 16:11:49 +0200
wallet: report a revoked denomination as revoked, not as unoffered
The exchange drops a revoked denomination from /keys, so the loss was booked
as a plain retirement. Handle recoup before the loss accounting, so that
isRevoked is already known when the event type is picked.
Diffstat:
4 files changed, 102 insertions(+), 2 deletions(-)
diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts
@@ -482,6 +482,12 @@ export enum DenomLossEventType {
DenomExpired = "denom-expired",
DenomVanished = "denom-vanished",
DenomUnoffered = "denom-unoffered",
+ /**
+ * The exchange revoked the denomination. A revoked denomination also stops
+ * being offered, so this must be checked before DenomUnoffered to say what
+ * actually happened.
+ */
+ DenomRevoked = "denom-revoked",
}
/**
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -2160,12 +2160,15 @@ export async function updateExchangeFromUrlHandler(
logger.trace("done updating denominations in database");
- await handleDenomLoss(wex, tx, newDetails.currency, exchangeBaseUrl);
-
+ // Revocations first: a revoked denomination is dropped from the
+ // denominations list as well, so handleDenomLoss can only tell a
+ // revocation apart from a plain retirement once isRevoked is set.
if (keysInfo.recoup != null) {
await handleRecoup(wex, tx, exchangeBaseUrl, keysInfo.recoup);
}
+ await handleDenomLoss(wex, tx, newDetails.currency, exchangeBaseUrl);
+
const newExchangeState = getExchangeState(r);
tx.notify({
@@ -2326,10 +2329,12 @@ async function handleDenomLoss(
const coinAvailabilityRecs =
await tx.getCoinAvailabilityByExchange(exchangeBaseUrl);
const denomsVanished: string[] = [];
+ const denomsRevoked: string[] = [];
const denomsUnoffered: string[] = [];
const denomsExpired: string[] = [];
let amountVanished = Amount.zeroOfCurrency(currency);
let amountExpired = Amount.zeroOfCurrency(currency);
+ let amountRevoked = Amount.zeroOfCurrency(currency);
let amountUnoffered = Amount.zeroOfCurrency(currency);
for (const coinAv of coinAvailabilityRecs) {
@@ -2352,6 +2357,16 @@ async function handleDenomLoss(
denomsVanished.push(coinAv.denomPubHash);
const total = Amount.from(coinAv.value).mult(n);
amountVanished = amountVanished.add(total);
+ } else if (denom.isRevoked) {
+ // Checked before isOffered, because the exchange drops a revoked
+ // denomination from /keys and it is therefore unoffered as well.
+ // Remove availability
+ coinAv.freshCoinCount = 0;
+ coinAv.visibleCoinCount = 0;
+ await tx.upsertCoinAvailability(coinAv);
+ denomsRevoked.push(coinAv.denomPubHash);
+ const total = Amount.from(coinAv.value).mult(n);
+ amountRevoked = amountRevoked.add(total);
} else if (!denom.isOffered) {
// Remove availability
coinAv.freshCoinCount = 0;
@@ -2424,6 +2439,39 @@ async function handleDenomLoss(
});
}
+ if (denomsRevoked.length > 0) {
+ const denomLossEventId = encodeCrock(getRandomBytes(32));
+ const rec: WalletDenomLossEvent = {
+ denomLossEventId,
+ amount: amountRevoked.toString(),
+ currency,
+ exchangeBaseUrl,
+ denomPubHashes: denomsRevoked,
+ eventType: DenomLossEventType.DenomRevoked,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+ await tx.upsertDenomLossEvent(rec);
+ const ctx = new DenomLossTransactionContext(wex, denomLossEventId);
+ await ctx.updateTransactionMeta(tx);
+ tx.notify({
+ type: NotificationType.TransactionStateTransition,
+ causeHint: "denom-revoked",
+ transactionId: ctx.transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState: {
+ major: TransactionMajorState.Done,
+ },
+ newStId: rec.status,
+ });
+ tx.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+ }
+
if (denomsUnoffered.length > 0) {
const denomLossEventId = encodeCrock(getRandomBytes(32));
const rec: WalletDenomLossEvent = {
diff --git a/packages/taler-wallet-webextension/src/components/HistoryItem.tsx b/packages/taler-wallet-webextension/src/components/HistoryItem.tsx
@@ -277,6 +277,20 @@ export function HistoryItem(props: { tx: Transaction }): VNode {
/>
);
}
+ case DenomLossEventType.DenomRevoked: {
+ return (
+ <Layout
+ href={Pages.balanceTransaction({ tid: tx.transactionId })}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"debit"}
+ title={i18n.str`Denomination revoked`}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"L"}
+ currentState={tx.txState.major}
+ description={undefined}
+ />
+ );
+ }
case DenomLossEventType.DenomUnoffered: {
return (
<Layout
diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx
@@ -1225,6 +1225,38 @@ export function TransactionView({
</TransactionTemplate>
);
}
+ case DenomLossEventType.DenomRevoked: {
+ return (
+ <TransactionTemplate
+ transaction={transaction}
+ onDelete={onDelete}
+ onRetry={onRetry}
+ onAbort={onAbort}
+ onResume={onResume}
+ onSuspend={onSuspend}
+ onCancel={onCancel}
+ >
+ <Header
+ timestamp={transaction.timestamp}
+ type={i18n.str`Debit`}
+ total={effective}
+ kind="negative"
+ >
+ <i18n.Translate>Lost</i18n.Translate>
+ </Header>
+
+ <Part
+ title={i18n.str`Exchange`}
+ text={transaction.exchangeBaseUrl as TranslatedString}
+ kind="neutral"
+ />
+ <Part
+ title={i18n.str`Reason`}
+ text={i18n.str`Denomination was revoked by the exchange.`}
+ />
+ </TransactionTemplate>
+ );
+ }
case DenomLossEventType.DenomUnoffered: {
return (
<TransactionTemplate