taler-typescript-core

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

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:
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 6++++++
Mpackages/taler-wallet-core/src/exchanges.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-webextension/src/components/HistoryItem.tsx | 14++++++++++++++
Mpackages/taler-wallet-webextension/src/wallet/Transaction.tsx | 32++++++++++++++++++++++++++++++++
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