taler-typescript-core

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

commit b681b9b9b2db0b49c47f8f3589a49c44a8c681ab
parent 56956b1740b188a1cc0efc354eec831c0fae76dc
Author: Florian Dold <florian@dold.me>
Date:   Wed, 17 Sep 2025 02:08:45 +0200

wallet-core: towards better withdrawal redenomination

Diffstat:
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-util/src/notifications.ts | 6++++++
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 5+++++
Mpackages/taler-util/src/types-taler-wallet.ts | 6+++++-
Mpackages/taler-wallet-core/src/balance.ts | 5++++-
Mpackages/taler-wallet-core/src/db.ts | 24++++++++++++++++++++----
Mpackages/taler-wallet-core/src/deposits.ts | 38++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/exchanges.ts | 215+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/pay-peer-common.ts | 18+++++++++++++-----
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 7++++++-
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 6+++++-
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 29++++++++++++++++++++---------
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 6+++++-
Mpackages/taler-wallet-core/src/refresh.ts | 13+++++++++++++
Mpackages/taler-wallet-core/src/shepherd.ts | 57++++++++++++++++++++++++++++++++++++++++++++-------------
Mpackages/taler-wallet-core/src/testing.ts | 2++
Mpackages/taler-wallet-core/src/transactions.ts | 8++++++++
Mpackages/taler-wallet-core/src/withdraw.ts | 156++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
19 files changed, 514 insertions(+), 149 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -43,6 +43,7 @@ import { runBankWopTest } from "./test-bank-wop.js"; import { runClaimLoopTest } from "./test-claim-loop.js"; import { runClauseSchnorrTest } from "./test-clause-schnorr.js"; import { runCurrencyScopeTest } from "./test-currency-scope.js"; +import { runDenomLostComplexTest } from "./test-denom-lost-complex.js"; import { runDenomLostTest } from "./test-denom-lost.js"; import { runDenomUnofferedTest } from "./test-denom-unoffered.js"; import { runDepositFaultTest } from "./test-deposit-fault.js"; @@ -375,6 +376,7 @@ const allTests: TestMainFunction[] = [ runDepositTooLargeTest, runMerchantAcctselTest, runLibeufinConversionTest, + runDenomLostComplexTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts @@ -66,6 +66,12 @@ export interface TransactionStateTransitionNotification { newTxState: TransactionState; /** + * Internal ID of the new state. + * Must not be used by the UI, only used for testing. + */ + newStId: number; + + /** * Short summary of the error for an error transition. */ errorInfo?: ErrorInfoSummary; diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts @@ -270,6 +270,11 @@ export interface TransactionCommon { txState: TransactionState; /** + * Wallet-internal state ID, only used for debugging and testing. + */ + stId: number; + + /** * Possible transitions based on the current state. */ txActions: TransactionAction[]; diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -1828,6 +1828,10 @@ export interface DenomSelItem { /** * Number of denoms/planchets to skip, because * a re-denomination effectively deleted them. + * + * For denom revocations, this equals count. + * But for re-denominations to a smaller withdrawal + * amounts, skip < count is possible. */ skip?: number; } @@ -3662,7 +3666,7 @@ export interface TestingWaitTransactionRequest { */ timeout?: DurationUnitSpec; - txState: TransactionStatePattern | TransactionStatePattern[]; + txState: TransactionStatePattern | TransactionStatePattern[] | number; } export interface TestingGetReserveHistoryRequest { diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -393,6 +393,8 @@ export async function getBalancesInsideTransaction( // Does not count as pendingIncoming return; case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.PendingRedenominate: + case WithdrawalGroupStatus.SuspendedRedenominate: case WithdrawalGroupStatus.AbortingBank: case WithdrawalGroupStatus.PendingQueryingStatus: case WithdrawalGroupStatus.SuspendedWaitConfirmBank: @@ -810,7 +812,8 @@ export async function getPaymentBalanceDetailsInTx( } // Skip exchanges if excluded by the receiver. - if (req.restrictSenderScope && + if ( + req.restrictSenderScope && !(await checkExchangeInScopeTx( tx, ca.exchangeBaseUrl, diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -316,6 +316,13 @@ export enum WithdrawalGroupStatus { SuspendedReady = 0x0110_0004, /** + * Redenominate the withdrawal + * after the exchange entry is ready again. + */ + PendingRedenominate = 0x0100_0008, + SuspendedRedenominate = 0x0110_0008, + + /** * Exchange wants KYC info from the user. */ PendingKyc = 0x0100_0005, @@ -3589,8 +3596,8 @@ export interface FixupDescription { export const walletDbFixups: FixupDescription[] = [ // Can be removed 2025-11-01. { - fn: fixup20250915TopsBlunder, - name: "fixup20250915TopsBlunder", + fn: fixup20250916TopsBlunder, + name: "fixup20250916TopsBlunder", }, // Removing this would cause old transactions // to show up under multiple exchanges @@ -3649,7 +3656,7 @@ async function fixup20250915TransactionsScope( * Make sure to re-request keys and re-do denom selection * for withdrawal groups with zero selected denominations. */ -async function fixup20250915TopsBlunder( +async function fixup20250916TopsBlunder( tx: WalletDbAllStoresReadWriteTransaction, ): Promise<void> { const exchangeUrls = [ @@ -3694,7 +3701,16 @@ async function fixup20250915TopsBlunder( if (wg.status !== WithdrawalGroupStatus.Done) { continue; } - if (wg.denomsSel?.selectedDenoms.length != 0) { + let numActiveDenoms = 0; + if (wg.denomsSel?.selectedDenoms) { + for (const sd of wg.denomsSel.selectedDenoms) { + if (sd.skip) { + continue; + } + numActiveDenoms++; + } + } + if (numActiveDenoms > 0) { continue; } logger.info(`updating withdrawal group status`); diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -278,6 +278,7 @@ export class DepositTransactionContext implements TransactionContext { return { type: TransactionType.Deposit, txState, + stId: dg.operationStatus, scopes: await getScopeForAllExchanges( tx, !dg.infoPerExchange ? [] : Object.keys(dg.infoPerExchange), @@ -363,6 +364,7 @@ export class DepositTransactionContext implements TransactionContext { newTxState: { major: TransactionMajorState.Deleted, }, + newStId: -1, }); return { notifs }; } @@ -380,6 +382,7 @@ export class DepositTransactionContext implements TransactionContext { return undefined; } const oldState = computeDepositTransactionStatus(dg); + const oldStId = dg.operationStatus; let newOpStatus: DepositOperationStatus | undefined; switch (dg.operationStatus) { case DepositOperationStatus.AbortedDeposit: @@ -428,6 +431,8 @@ export class DepositTransactionContext implements TransactionContext { oldTxState: oldState, newTxState: computeDepositTransactionStatus(dg), balanceEffect: BalanceEffect.None, + newStId: dg.operationStatus, + oldStId, }); }, ); @@ -447,6 +452,7 @@ export class DepositTransactionContext implements TransactionContext { return undefined; } const oldState = computeDepositTransactionStatus(dg); + const oldStId = dg.operationStatus; switch (dg.operationStatus) { case DepositOperationStatus.PendingDepositKyc: case DepositOperationStatus.SuspendedDepositKyc: @@ -462,6 +468,8 @@ export class DepositTransactionContext implements TransactionContext { oldTxState: oldState, newTxState: computeDepositTransactionStatus(dg), balanceEffect: BalanceEffect.Any, + oldStId, + newStId: dg.operationStatus, }); return; } @@ -501,6 +509,7 @@ export class DepositTransactionContext implements TransactionContext { return; } const oldState = computeDepositTransactionStatus(dg); + const oldStId = dg.operationStatus; let newOpStatus: DepositOperationStatus | undefined; switch (dg.operationStatus) { case DepositOperationStatus.AbortedDeposit: @@ -549,6 +558,8 @@ export class DepositTransactionContext implements TransactionContext { oldTxState: oldState, newTxState: computeDepositTransactionStatus(dg), balanceEffect: BalanceEffect.None, + newStId: dg.operationStatus, + oldStId, }); }, ); @@ -568,6 +579,7 @@ export class DepositTransactionContext implements TransactionContext { return undefined; } const oldState = computeDepositTransactionStatus(dg); + const oldStId = dg.operationStatus; let newState: DepositOperationStatus; switch (dg.operationStatus) { case DepositOperationStatus.PendingAggregateKyc: @@ -606,6 +618,8 @@ export class DepositTransactionContext implements TransactionContext { oldTxState: oldState, newTxState: computeDepositTransactionStatus(dg), balanceEffect: BalanceEffect.Any, + newStId: dg.operationStatus, + oldStId, }); }, ); @@ -1059,14 +1073,18 @@ async function waitForRefreshOnDepositGroup( return false; } const oldTxState = computeDepositTransactionStatus(newDg); + const oldStId = newDg.operationStatus; newDg.operationStatus = newOpState; const newTxState = computeDepositTransactionStatus(newDg); + const newStId = newDg.operationStatus; await tx.depositGroups.put(newDg); await ctx.updateTransactionMeta(tx); applyNotifyTransition(tx.notify, ctx.transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + newStId, + oldStId, }); return true; }, @@ -1182,6 +1200,7 @@ async function processDepositGroupPendingKyc( return TaskRunResult.finished(); } const oldTxState = computeDepositTransactionStatus(newDg); + const oldStId = newDg.operationStatus; switch (newDg.operationStatus) { case DepositOperationStatus.PendingAggregateKyc: if (requiresAuth) { @@ -1206,10 +1225,13 @@ async function processDepositGroupPendingKyc( await tx.depositGroups.put(newDg); await ctx.updateTransactionMeta(tx); const newTxState = computeDepositTransactionStatus(newDg); + const newStId = newDg.operationStatus; applyNotifyTransition(tx.notify, ctx.transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + oldStId, + newStId, }); return algoRes.taskResult; }, @@ -1315,6 +1337,7 @@ async function transitionToKycRequired( return undefined; } const oldTxState = computeDepositTransactionStatus(dg); + const oldStId = dg.operationStatus; switch (dg.operationStatus) { case DepositOperationStatus.LegacyPendingTrack: case DepositOperationStatus.FinalizingTrack: @@ -1364,10 +1387,13 @@ async function transitionToKycRequired( await tx.depositGroups.put(dg); await ctx.updateTransactionMeta(tx); const newTxState = computeDepositTransactionStatus(dg); + const newStId = dg.operationStatus; applyNotifyTransition(tx.notify, ctx.transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + newStId, + oldStId, }); }, ); @@ -1517,6 +1543,7 @@ async function processDepositGroupTrack( return undefined; } const oldTxState = computeDepositTransactionStatus(dg); + const oldStId = dg.operationStatus; for (let i = 0; i < dg.statusPerCoin.length; i++) { if (dg.statusPerCoin[i] !== DepositElementStatus.Wired) { allWired = false; @@ -1532,10 +1559,13 @@ async function processDepositGroupTrack( await ctx.updateTransactionMeta(tx); } const newTxState = computeDepositTransactionStatus(dg); + const newStId = dg.operationStatus; applyNotifyTransition(tx.notify, ctx.transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + oldStId, + newStId, }); }, ); @@ -1868,14 +1898,18 @@ async function processDepositGroupPendingDeposit( return undefined; } const oldTxState = computeDepositTransactionStatus(dg); + const oldStId = dg.operationStatus; dg.operationStatus = DepositOperationStatus.FinalizingTrack; await tx.depositGroups.put(dg); await ctx.updateTransactionMeta(tx); const newTxState = computeDepositTransactionStatus(dg); + const newStId = dg.operationStatus; applyNotifyTransition(tx.notify, ctx.transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.None, + oldStId, + newStId, }); }, ); @@ -2395,11 +2429,15 @@ export async function createDepositGroup( }); await ctx.updateTransactionMeta(tx); const oldTxState = { major: TransactionMajorState.None }; + const oldStId = 0; const newTxState = computeDepositTransactionStatus(depositGroup); + const newStId = depositGroup.operationStatus; applyNotifyTransition(tx.notify, transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + oldStId, + newStId, }); return newTxState; }, diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -862,7 +862,6 @@ async function provideExchangeRecordInTx( ): Promise<{ exchange: ExchangeEntryRecord; exchangeDetails: ExchangeDetailsRecord | undefined; - notification?: WalletNotification; }> { let notification: WalletNotification | undefined = undefined; let exchange = await tx.exchanges.get(baseUrl); @@ -890,15 +889,15 @@ async function provideExchangeRecordInTx( }; await tx.exchanges.put(r); exchange = r; - notification = { + tx.notify({ type: NotificationType.ExchangeStateTransition, exchangeBaseUrl: r.baseUrl, oldExchangeState: undefined, newExchangeState: getExchangeState(r), - }; + }); } const exchangeDetails = await getExchangeRecordsInternal(tx, baseUrl); - return { exchange, exchangeDetails, notification }; + return { exchange, exchangeDetails }; } export interface ExchangeKeysDownloadSuccessResult { @@ -1226,11 +1225,16 @@ async function checkExchangeEntryOutdated( * * If the exchange entry doesn't exist, * a new ephemeral entry is created. + * + * If options.forceUnavailable is set, the exchange is put into + * an unavailable state. This flag should be set whenever + * some other operation notices that something is wrong + * with the exchange. */ export async function startUpdateExchangeEntry( wex: WalletExecutionContext, exchangeBaseUrl: string, - options: { forceUpdate?: boolean } = {}, + options: { forceUpdate?: boolean; forceUnavailable?: boolean } = {}, ): Promise<void> { logger.trace( `starting update of exchange entry ${exchangeBaseUrl}, forced=${ @@ -1238,16 +1242,13 @@ export async function startUpdateExchangeEntry( }`, ); - const { notification } = await wex.db.runReadWriteTx( + await wex.db.runReadWriteTx( { storeNames: ["exchanges", "exchangeDetails"] }, async (tx) => { wex.ws.exchangeCache.clear(); return provideExchangeRecordInTx(wex.ws, tx, exchangeBaseUrl); }, ); - if (notification) { - wex.ws.notify(notification); - } const res = await wex.db.runReadWriteTx( { storeNames: ["exchanges", "operationRetries", "denominations"] }, @@ -1258,62 +1259,77 @@ export async function startUpdateExchangeEntry( } const oldExchangeState = getExchangeState(r); - switch (r.updateStatus) { - case ExchangeEntryDbUpdateStatus.UnavailableUpdate: - r.cachebreakNextUpdate = options.forceUpdate; - break; - case ExchangeEntryDbUpdateStatus.Suspended: - r.cachebreakNextUpdate = options.forceUpdate; - break; - case ExchangeEntryDbUpdateStatus.ReadyUpdate: { - const outdated = await checkExchangeEntryOutdated( - wex, - tx, - exchangeBaseUrl, - ); - if (outdated) { - r.updateStatus = ExchangeEntryDbUpdateStatus.OutdatedUpdate; - } else { - r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate; - } - r.cachebreakNextUpdate = options.forceUpdate; - break; - } - case ExchangeEntryDbUpdateStatus.OutdatedUpdate: - r.cachebreakNextUpdate = options.forceUpdate; - break; - case ExchangeEntryDbUpdateStatus.Ready: { - const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp( - timestampPreciseFromDb(r.nextUpdateStamp), - ); - // Only update if entry is outdated or update is forced. - if ( - !( - options.forceUpdate || AbsoluteTime.isExpired(nextUpdateTimestamp) - ) - ) { + if (options.forceUnavailable) { + switch (r.updateStatus) { + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: return undefined; + default: + r.lastUpdate = undefined; + r.nextUpdateStamp = timestampPreciseToDb( + TalerPreciseTimestamp.now(), + ); + r.cachebreakNextUpdate = options.forceUpdate; + r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate; + } + } else { + switch (r.updateStatus) { + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + r.cachebreakNextUpdate = options.forceUpdate; + break; + case ExchangeEntryDbUpdateStatus.Suspended: + r.cachebreakNextUpdate = options.forceUpdate; + break; + case ExchangeEntryDbUpdateStatus.ReadyUpdate: { + const outdated = await checkExchangeEntryOutdated( + wex, + tx, + exchangeBaseUrl, + ); + if (outdated) { + r.updateStatus = ExchangeEntryDbUpdateStatus.OutdatedUpdate; + } else { + r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate; + } + r.cachebreakNextUpdate = options.forceUpdate; + break; } - const outdated = await checkExchangeEntryOutdated( - wex, - tx, - exchangeBaseUrl, - ); - if (outdated) { - r.updateStatus = ExchangeEntryDbUpdateStatus.OutdatedUpdate; - } else { - r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate; + case ExchangeEntryDbUpdateStatus.OutdatedUpdate: + r.cachebreakNextUpdate = options.forceUpdate; + break; + case ExchangeEntryDbUpdateStatus.Ready: { + const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp( + timestampPreciseFromDb(r.nextUpdateStamp), + ); + // Only update if entry is outdated or update is forced. + if ( + !( + options.forceUpdate || + AbsoluteTime.isExpired(nextUpdateTimestamp) + ) + ) { + return undefined; + } + const outdated = await checkExchangeEntryOutdated( + wex, + tx, + exchangeBaseUrl, + ); + if (outdated) { + r.updateStatus = ExchangeEntryDbUpdateStatus.OutdatedUpdate; + } else { + r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate; + } + r.cachebreakNextUpdate = options.forceUpdate; + break; } - r.cachebreakNextUpdate = options.forceUpdate; - break; + case ExchangeEntryDbUpdateStatus.Initial: + r.cachebreakNextUpdate = options.forceUpdate; + r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate; + break; + case ExchangeEntryDbUpdateStatus.InitialUpdate: + r.cachebreakNextUpdate = options.forceUpdate; + break; } - case ExchangeEntryDbUpdateStatus.Initial: - r.cachebreakNextUpdate = options.forceUpdate; - r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate; - break; - case ExchangeEntryDbUpdateStatus.InitialUpdate: - r.cachebreakNextUpdate = options.forceUpdate; - break; } wex.ws.exchangeCache.clear(); await tx.exchanges.put(r); @@ -1361,8 +1377,6 @@ export interface ReadyExchangeSummary { * Ensure that a fresh exchange entry exists for the given * exchange base URL. * - * The cancellation token can be used to abort waiting for the - * updated exchange entry. * * If an exchange entry for the database doesn't exist in the * DB, it will be added ephemerally. @@ -1407,23 +1421,33 @@ export async function fetchFreshExchange( }); const resp = await waitReadyExchange(wex, baseUrl, options); - wex.ws.exchangeCache.put(baseUrl, resp); return resp; } -async function waitReadyExchange( +/** + * Wait until an exchange is in a ready state. + * + * If options.noBail is set, do not stop waiting when + * the exchange is in an unavailable or error state. + * + * If options.forceUpdate is set, only return when + * the exchange is really not updating anymore, + * even when the exchange entry still looks recent enough. + */ +export async function waitReadyExchange( wex: WalletExecutionContext, - canonUrl: string, + exchangeBaseUrl: string, options: { forceUpdate?: boolean; expectedMasterPub?: string; + noBail?: boolean; } = {}, ): Promise<ReadyExchangeSummary> { - logger.trace(`waiting for exchange ${canonUrl} to become ready`); + logger.trace(`waiting for exchange ${exchangeBaseUrl} to become ready`); const operationId = constructTaskIdentifier({ tag: PendingTaskType.ExchangeUpdate, - exchangeBaseUrl: canonUrl, + exchangeBaseUrl: exchangeBaseUrl, }); let res: ReadyExchangeSummary | undefined = undefined; @@ -1432,7 +1456,7 @@ async function waitReadyExchange( filterNotification(notif): boolean { return ( notif.type === NotificationType.ExchangeStateTransition && - notif.exchangeBaseUrl === canonUrl + notif.exchangeBaseUrl === exchangeBaseUrl ); }, async checkState(): Promise<boolean> { @@ -1448,10 +1472,10 @@ async function waitReadyExchange( ], }, async (tx) => { - const exchange = await tx.exchanges.get(canonUrl); + const exchange = await tx.exchanges.get(exchangeBaseUrl); const exchangeDetails = await getExchangeRecordsInternal( tx, - canonUrl, + exchangeBaseUrl, ); const retryInfo = await tx.operationRetries.get(operationId); let scopeInfo: ScopeInfo | undefined = undefined; @@ -1483,25 +1507,30 @@ async function waitReadyExchange( ready = true; } break; - case ExchangeEntryDbUpdateStatus.UnavailableUpdate: - throw TalerError.fromDetail( - TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, - { - exchangeBaseUrl: canonUrl, - innerError: retryInfo?.lastError ?? exchange.unavailableReason, - }, - ); + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: { + if (!options.noBail) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + { + exchangeBaseUrl: exchangeBaseUrl, + innerError: retryInfo?.lastError ?? exchange.unavailableReason, + }, + ); + } + break; + } case ExchangeEntryDbUpdateStatus.OutdatedUpdate: default: { - if (retryInfo) { + if (retryInfo && !options.noBail) { throw TalerError.fromDetail( TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, { - exchangeBaseUrl: canonUrl, + exchangeBaseUrl: exchangeBaseUrl, innerError: retryInfo?.lastError ?? exchange.unavailableReason, }, ); } + break; } } @@ -1519,7 +1548,7 @@ async function waitReadyExchange( const mySummary: ReadyExchangeSummary = { currency: exchangeDetails.currency, - exchangeBaseUrl: canonUrl, + exchangeBaseUrl: exchangeBaseUrl, masterPub: exchangeDetails.masterPublicKey, tosStatus: getExchangeTosStatusFromRecord(exchange), tosAcceptedEtag: exchange.tosAcceptedEtag, @@ -1548,6 +1577,7 @@ async function waitReadyExchange( }); checkLogicInvariant(!!res); + wex.ws.exchangeCache.put(exchangeBaseUrl, res); return res; } @@ -2373,7 +2403,7 @@ async function handleDenomLoss( if (denomsVanished.length > 0) { const denomLossEventId = encodeCrock(getRandomBytes(32)); - await tx.denomLossEvents.add({ + const rec: DenomLossEventRecord = { denomLossEventId, amount: amountVanished.toString(), currency, @@ -2382,7 +2412,8 @@ async function handleDenomLoss( eventType: DenomLossEventType.DenomVanished, status: DenomLossStatus.Done, timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), - }); + }; + await tx.denomLossEvents.add(rec); const ctx = new DenomLossTransactionContext(wex, denomLossEventId); await ctx.updateTransactionMeta(tx); result.notifications.push({ @@ -2394,6 +2425,7 @@ async function handleDenomLoss( newTxState: { major: TransactionMajorState.Done, }, + newStId: rec.status, }); result.notifications.push({ type: NotificationType.BalanceChange, @@ -2403,7 +2435,7 @@ async function handleDenomLoss( if (denomsUnoffered.length > 0) { const denomLossEventId = encodeCrock(getRandomBytes(32)); - await tx.denomLossEvents.add({ + const rec: DenomLossEventRecord = { denomLossEventId, amount: amountUnoffered.toString(), currency, @@ -2412,7 +2444,8 @@ async function handleDenomLoss( eventType: DenomLossEventType.DenomUnoffered, status: DenomLossStatus.Done, timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), - }); + }; + await tx.denomLossEvents.add(rec); const ctx = new DenomLossTransactionContext(wex, denomLossEventId); await ctx.updateTransactionMeta(tx); result.notifications.push({ @@ -2424,6 +2457,7 @@ async function handleDenomLoss( newTxState: { major: TransactionMajorState.Done, }, + newStId: rec.status, }); result.notifications.push({ type: NotificationType.BalanceChange, @@ -2433,7 +2467,7 @@ async function handleDenomLoss( if (denomsExpired.length > 0) { const denomLossEventId = encodeCrock(getRandomBytes(32)); - await tx.denomLossEvents.add({ + const rec: DenomLossEventRecord = { denomLossEventId, amount: amountExpired.toString(), currency, @@ -2442,7 +2476,8 @@ async function handleDenomLoss( eventType: DenomLossEventType.DenomExpired, status: DenomLossStatus.Done, timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), - }); + }; + await tx.denomLossEvents.add(rec); const transactionId = constructTransactionIdentifier({ tag: TransactionType.DenomLoss, denomLossEventId, @@ -2456,6 +2491,7 @@ async function handleDenomLoss( newTxState: { major: TransactionMajorState.Done, }, + newStId: rec.status, }); result.notifications.push({ type: NotificationType.BalanceChange, @@ -2547,6 +2583,8 @@ export class DenomLossTransactionContext implements TransactionContext { major: TransactionMajorState.Deleted, }, balanceEffect: BalanceEffect.Any, + newStId: -1, + oldStId: rec.status, }); }, ); @@ -2574,6 +2612,7 @@ export class DenomLossTransactionContext implements TransactionContext { }), lossEventType: rec.eventType, exchangeBaseUrl: rec.exchangeBaseUrl, + stId: rec.status, }; } } diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -354,6 +354,7 @@ export class PayMerchantTransactionContext implements TransactionContext { return { type: TransactionType.Payment, txState, + stId: purchaseRec.purchaseStatus, scopes, txActions: computePayMerchantTransactionActions(purchaseRec), amountRaw, @@ -417,18 +418,22 @@ export class PayMerchantTransactionContext implements TransactionContext { throw Error("purchase not found anymore"); } const oldTxState = computePayMerchantTransactionState(purchaseRec); + const oldStId = purchaseRec.purchaseStatus; const res = await f(purchaseRec, tx); switch (res) { case TransitionResultType.Transition: { await tx.purchases.put(purchaseRec); await this.updateTransactionMeta(tx); const newTxState = computePayMerchantTransactionState(purchaseRec); + const newStId = purchaseRec.purchaseStatus; applyNotifyTransition(tx.notify, this.transactionId, { oldTxState, newTxState, // FIXME: The transition function should really return the effect // and not just the status. balanceEffect: BalanceEffect.Any, + oldStId, + newStId, }); return; } @@ -441,6 +446,8 @@ export class PayMerchantTransactionContext implements TransactionContext { major: TransactionMajorState.None, }, balanceEffect: BalanceEffect.Any, + oldStId, + newStId: -1, }); return; default: @@ -505,6 +512,7 @@ export class PayMerchantTransactionContext implements TransactionContext { newTxState: { major: TransactionMajorState.Deleted, }, + newStId: -1, }); } const oldTxState = computePayMerchantTransactionState(rec); @@ -517,6 +525,7 @@ export class PayMerchantTransactionContext implements TransactionContext { newTxState: { major: TransactionMajorState.Deleted, }, + newStId: -1, }); return { notifs }; } @@ -532,6 +541,7 @@ export class PayMerchantTransactionContext implements TransactionContext { throw Error("purchase not found"); } const oldTxState = computePayMerchantTransactionState(purchase); + const oldStId = purchase.purchaseStatus; let newStatus = transitionSuspend[purchase.purchaseStatus]; if (!newStatus) { return; @@ -539,10 +549,13 @@ export class PayMerchantTransactionContext implements TransactionContext { await tx.purchases.put(purchase); await this.updateTransactionMeta(tx); const newTxState = computePayMerchantTransactionState(purchase); + const newStId = purchase.purchaseStatus; applyNotifyTransition(tx.notify, transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.None, + oldStId, + newStId, }); }, ); @@ -570,8 +583,8 @@ export class PayMerchantTransactionContext implements TransactionContext { throw Error("purchase not found"); } const oldTxState = computePayMerchantTransactionState(purchase); - const oldStatus = purchase.purchaseStatus; - switch (oldStatus) { + const oldStId = purchase.purchaseStatus; + switch (oldStId) { case PurchaseStatus.Done: return; case PurchaseStatus.PendingPaying: @@ -621,10 +634,13 @@ export class PayMerchantTransactionContext implements TransactionContext { await tx.purchases.put(purchase); await this.updateTransactionMeta(tx); const newTxState = computePayMerchantTransactionState(purchase); + const newStId = purchase.purchaseStatus; applyNotifyTransition(tx.notify, transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + oldStId, + newStId, }); }, ); @@ -642,6 +658,7 @@ export class PayMerchantTransactionContext implements TransactionContext { throw Error("purchase not found"); } const oldTxState = computePayMerchantTransactionState(purchase); + const oldStId = purchase.purchaseStatus; let newStatus = transitionResume[purchase.purchaseStatus]; if (!newStatus) { return undefined; @@ -649,10 +666,13 @@ export class PayMerchantTransactionContext implements TransactionContext { await tx.purchases.put(purchase); await this.updateTransactionMeta(tx); const newTxState = computePayMerchantTransactionState(purchase); + const newStId = purchase.purchaseStatus; applyNotifyTransition(tx.notify, transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + oldStId, + newStId, }); }, ); @@ -679,6 +699,7 @@ export class PayMerchantTransactionContext implements TransactionContext { throw Error("purchase not found"); } const oldTxState = computePayMerchantTransactionState(purchase); + const oldStId = purchase.purchaseStatus; let newState: PurchaseStatus | undefined = undefined; switch (purchase.purchaseStatus) { case PurchaseStatus.AbortingWithRefund: @@ -692,10 +713,13 @@ export class PayMerchantTransactionContext implements TransactionContext { } await this.updateTransactionMeta(tx); const newTxState = computePayMerchantTransactionState(purchase); + const newStId = purchase.purchaseStatus; applyNotifyTransition(tx.notify, transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + oldStId, + newStId, }); }, ); @@ -786,6 +810,7 @@ export class RefundTransactionContext implements TransactionContext { refundGroupId: refundRecord.refundGroupId, }), txState, + stId: refundRecord.status, txActions: [TransactionAction.Delete], paymentInfo, }; @@ -945,14 +970,18 @@ async function failProposalPermanently( } // FIXME: We don't store the error detail here?! const oldTxState = computePayMerchantTransactionState(p); + const oldStId = p.purchaseStatus; p.purchaseStatus = PurchaseStatus.FailedClaim; const newTxState = computePayMerchantTransactionState(p); + const newStId = p.purchaseStatus; await tx.purchases.put(p); await ctx.updateTransactionMeta(tx); applyNotifyTransition(tx.notify, ctx.transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + newStId, + oldStId, }); }, ); @@ -1226,6 +1255,7 @@ async function processDownloadProposal( return; } const oldTxState = computePayMerchantTransactionState(p); + const oldStId = p.purchaseStatus; const secretSeed = encodeCrock(getRandomBytes(32)); p.secretSeed = secretSeed; @@ -1303,10 +1333,13 @@ async function processDownloadProposal( } await ctx.updateTransactionMeta(tx); const newTxState = computePayMerchantTransactionState(p); + const newStId = p.purchaseStatus; applyNotifyTransition(tx.notify, transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.None, + oldStId, + newStId, }); }, ); @@ -1543,6 +1576,8 @@ async function createOrReusePurchase( oldTxState, newTxState, balanceEffect: BalanceEffect.None, + oldStId: 0, + newStId: proposalRecord.purchaseStatus, }); }, ); @@ -2920,6 +2955,7 @@ export async function confirmPay( } const oldTxState = computePayMerchantTransactionState(p); + const oldStId = p.purchaseStatus; switch (p.purchaseStatus) { case PurchaseStatus.DialogShared: case PurchaseStatus.DialogProposed: @@ -2968,6 +3004,8 @@ export async function confirmPay( oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + oldStId, + newStId: p.purchaseStatus, }); }); @@ -3989,6 +4027,7 @@ export async function sharePayment( return undefined; } const oldTxState = computePayMerchantTransactionState(p); + const oldStId = p.purchaseStatus; if (p.purchaseStatus === PurchaseStatus.DialogProposed) { p.purchaseStatus = PurchaseStatus.DialogShared; p.shared = true; @@ -4003,6 +4042,8 @@ export async function sharePayment( oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + oldStId, + newStId: p.purchaseStatus, }); return { @@ -4416,7 +4457,7 @@ export async function startQueryRefund( proposalId: string, ): Promise<void> { const ctx = new PayMerchantTransactionContext(wex, proposalId); - const transitionInfo = await wex.db.runReadWriteTx( + await wex.db.runReadWriteTx( { storeNames: ["purchases", "transactionsMeta"] }, async (tx) => { const p = await tx.purchases.get(proposalId); @@ -4428,14 +4469,18 @@ export async function startQueryRefund( return; } const oldTxState = computePayMerchantTransactionState(p); + const oldStId = p.purchaseStatus; p.purchaseStatus = PurchaseStatus.PendingQueryingRefund; const newTxState = computePayMerchantTransactionState(p); + const newStId = p.purchaseStatus; await tx.purchases.put(p); await ctx.updateTransactionMeta(tx); applyNotifyTransition(tx.notify, ctx.transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + newStId, + oldStId, }); }, ); @@ -4644,6 +4689,8 @@ async function storeRefunds( oldTxState: { major: TransactionMajorState.None }, newTxState: computeRefundTransactionState(newGroup), balanceEffect: BalanceEffect.Any, + newStId: newGroup.status, + oldStId: 0, }); } @@ -4682,6 +4729,7 @@ async function storeRefunds( } const oldTxState: TransactionState = computeRefundTransactionState(refundGroup); + const oldStId = refundGroup.status; if (numPending === 0) { // We're done for this refund group! if (numFailed === 0) { @@ -4694,10 +4742,13 @@ async function storeRefunds( const refreshCoins = await computeRefreshRequest(wex, tx, items); const newTxState: TransactionState = computeRefundTransactionState(refundGroup); + const newStId = refundGroup.status; applyNotifyTransition(tx.notify, refundCtx.transactionId, { oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + newStId, + oldStId, }); await createRefreshGroup( wex, @@ -4716,6 +4767,7 @@ async function storeRefunds( } const oldTxState = computePayMerchantTransactionState(myPurchase); + const oldStId = myPurchase.purchaseStatus; const shouldCheckAutoRefund = myPurchase.autoRefundDeadline && @@ -4744,6 +4796,8 @@ async function storeRefunds( oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + newStId: myPurchase.purchaseStatus, + oldStId, }); return { diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -198,7 +198,10 @@ interface RecordCtx<Store extends WalletDbStoresName> { recordId: IDBValidKey; wex: WalletExecutionContext; recordMeta: (rec: StoreType<Store>) => TransactionMetaRecord; - recordState: (rec: StoreType<Store>) => TransactionState; + recordState: (rec: StoreType<Store>) => { + txState: TransactionState; + stId: number; + }; } /** Create a new record, update its metadata and notify its creation */ @@ -231,8 +234,10 @@ export async function recordCreate< const newTxState = ctx.recordState(rec); applyNotifyTransition(tx.notify, ctx.transactionId, { oldTxState, - newTxState, + newTxState: newTxState.txState, balanceEffect: BalanceEffect.Any, + oldStId: 0, + newStId: newTxState.stId, }); }, ); @@ -275,9 +280,11 @@ export async function recordTransition< await tx.transactionsMeta.put(ctx.recordMeta(rec)); const newTxState = ctx.recordState(rec); applyNotifyTransition(tx.notify, ctx.transactionId, { - oldTxState, - newTxState, + oldTxState: oldTxState.txState, + newTxState: newTxState.txState, balanceEffect: BalanceEffect.Any, + oldStId: oldTxState.stId, + newStId: newTxState.stId, }); return; } @@ -335,10 +342,11 @@ export async function recordDelete<Store extends WalletDbStoresName>( notifs.push({ type: NotificationType.TransactionStateTransition, transactionId: ctx.transactionId, - oldTxState, + oldTxState: oldTxState.txState, newTxState: { major: TransactionMajorState.Deleted, }, + newStId: -1, }); return { notifs }; } diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -135,7 +135,10 @@ export class PeerPullCreditTransactionContext implements TransactionContext { readonly store = "peerPullCredit"; readonly recordId = this.pursePub; - readonly recordState = computePeerPullCreditTransactionState; + readonly recordState = (rec: PeerPullCreditRecord) => ({ + txState: computePeerPullCreditTransactionState(rec), + stId: rec.status, + }); readonly recordMeta = (rec: PeerPullCreditRecord) => ({ transactionId: this.transactionId, status: rec.status, @@ -236,6 +239,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext { return { type: TransactionType.PeerPullCredit, txState, + stId: pullCredit.status, scopes: await getScopeForAllExchanges(tx, [pullCredit.exchangeBaseUrl]), txActions: computePeerPullCreditTransactionActions(pullCredit), amountEffective: isUnsuccessfulTransaction(txState) @@ -271,6 +275,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext { return { type: TransactionType.PeerPullCredit, txState, + stId: pullCredit.status, scopes: await getScopeForAllExchanges(tx, [pullCredit.exchangeBaseUrl]), txActions: computePeerPullCreditTransactionActions(pullCredit), amountEffective: isUnsuccessfulTransaction(txState) diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -124,7 +124,10 @@ export class PeerPullDebitTransactionContext implements TransactionContext { readonly store = "peerPullDebit"; readonly recordId = this.peerPullDebitId; - readonly recordState = computePeerPullDebitTransactionState; + readonly recordState = (rec: PeerPullPaymentIncomingRecord) => ({ + txState: computePeerPullDebitTransactionState(rec), + stId: rec.status, + }); readonly recordMeta = (rec: PeerPullPaymentIncomingRecord) => ({ transactionId: this.transactionId, status: rec.status, @@ -157,6 +160,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext { return { type: TransactionType.PeerPullDebit, txState, + stId: pi.status, scopes: await getScopeForAllExchanges(tx, [pi.exchangeBaseUrl]), txActions: computePeerPullDebitTransactionActions(pi), amountEffective: isUnsuccessfulTransaction(txState) diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -138,7 +138,10 @@ export class PeerPushCreditTransactionContext implements TransactionContext { readonly store = "peerPushCredit"; readonly recordId = this.peerPushCreditId; - readonly recordState = computePeerPushCreditTransactionState; + readonly recordState = (rec: PeerPushPaymentIncomingRecord) => ({ + txState: computePeerPushCreditTransactionState(rec), + stId: rec.status, + }); readonly recordMeta = (rec: PeerPushPaymentIncomingRecord) => ({ transactionId: this.transactionId, status: rec.status, @@ -211,6 +214,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext { return { type: TransactionType.PeerPushCredit, txState, + stId: pushInc.status, scopes: await getScopeForAllExchanges(tx, [pushInc.exchangeBaseUrl]), txActions: computePeerPushCreditTransactionActions(pushInc), amountEffective: isUnsuccessfulTransaction(txState) @@ -236,6 +240,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext { return { type: TransactionType.PeerPushCredit, txState, + stId: pushInc.status, scopes: await getScopeForAllExchanges(tx, [pushInc.exchangeBaseUrl]), txActions: computePeerPushCreditTransactionActions(pushInc), amountEffective: isUnsuccessfulTransaction(txState) @@ -698,7 +703,7 @@ async function transitionPeerPushCreditKycRequired( if (!peerInc) { return TaskRunResult.finished(); } - const oldTxState = computePeerPushCreditTransactionState(peerInc); + const oldTxState = ctx.recordState(peerInc); peerInc.kycPaytoHash = kycPending.h_payto; peerInc.status = PeerPushCreditStatus.PendingMergeKycRequired; peerInc.kycLastDeny = timestampPreciseToDb(TalerPreciseTimestamp.now()); @@ -706,9 +711,11 @@ async function transitionPeerPushCreditKycRequired( await tx.peerPushCredit.put(peerInc); await ctx.updateTransactionMeta(tx); applyNotifyTransition(tx.notify, ctx.transactionId, { - oldTxState, + oldTxState: oldTxState.txState, newTxState, balanceEffect: BalanceEffect.Flags, + newStId: peerInc.status, + oldStId: oldTxState.stId, }); return TaskRunResult.progress(); }, @@ -854,7 +861,7 @@ async function processPendingMerge( if (!peerInc) { return undefined; } - const oldTxState = computePeerPushCreditTransactionState(peerInc); + const oldTxState = ctx.recordState(peerInc); let wgCreateRes: PerformCreateWithdrawalGroupResult | undefined = undefined; switch (peerInc.status) { @@ -875,9 +882,11 @@ async function processPendingMerge( await ctx.updateTransactionMeta(tx); const newTxState = computePeerPushCreditTransactionState(peerInc); applyNotifyTransition(tx.notify, ctx.transactionId, { - oldTxState, + oldTxState: oldTxState.txState, newTxState, balanceEffect: BalanceEffect.Any, + newStId: peerInc.status, + oldStId: oldTxState.stId, }); }, ); @@ -910,7 +919,7 @@ async function processPendingWithdrawing( finished = true; return; } - const oldTxState = computePeerPushCreditTransactionState(ppi); + const oldTxState = ctx.recordState(ppi); const wg = await tx.withdrawalGroups.get(wgId); if (!wg) { // FIXME: Fail the operation instead? @@ -925,11 +934,13 @@ async function processPendingWithdrawing( } await tx.peerPushCredit.put(ppi); await ctx.updateTransactionMeta(tx); - const newTxState = computePeerPushCreditTransactionState(ppi); + const newTxState = ctx.recordState(ppi); applyNotifyTransition(tx.notify, ctx.transactionId, { - oldTxState, - newTxState, + oldTxState: oldTxState.txState, + newTxState: newTxState.txState, balanceEffect: BalanceEffect.Any, + oldStId: oldTxState.stId, + newStId: newTxState.stId, }); return; }, diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -122,7 +122,10 @@ export class PeerPushDebitTransactionContext implements TransactionContext { readonly store = "peerPushDebit"; readonly recordId = this.pursePub; - readonly recordState = computePeerPushDebitTransactionState; + readonly recordState = (rec: PeerPushDebitRecord) => ({ + txState: computePeerPushDebitTransactionState(rec), + stId: rec.status, + }); readonly recordMeta = (rec: PeerPushDebitRecord) => ({ transactionId: this.transactionId, status: rec.status, @@ -170,6 +173,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext { return { type: TransactionType.PeerPushDebit, txState, + stId: pushDebitRec.status, scopes: await getScopeForAllExchanges(tx, [pushDebitRec.exchangeBaseUrl]), txActions: computePeerPushDebitTransactionActions(pushDebitRec), amountEffective: isUnsuccessfulTransaction(txState) diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -198,6 +198,7 @@ export class RefreshTransactionContext implements TransactionContext { return { type: TransactionType.Refresh, txState, + stId: refreshGroupRecord.operationStatus, scopes: await getScopeForAllExchanges( tx, !refreshGroupRecord.infoPerExchange @@ -259,13 +260,16 @@ export class RefreshTransactionContext implements TransactionContext { { storeNames: stores }, async (tx) => { const wgRec = await tx.refreshGroups.get(this.refreshGroupId); + let oldStId: number; let oldTxState: TransactionState; if (wgRec) { oldTxState = computeRefreshTransactionState(wgRec); + oldStId = wgRec.operationStatus; } else { oldTxState = { major: TransactionMajorState.None, }; + oldStId = 0; } const res = await f(wgRec, tx); switch (res.type) { @@ -277,6 +281,8 @@ export class RefreshTransactionContext implements TransactionContext { oldTxState, newTxState, balanceEffect: BalanceEffect.PreserveUserVisible, + oldStId, + newStId: res.rec.operationStatus, }); return true; } @@ -290,6 +296,8 @@ export class RefreshTransactionContext implements TransactionContext { }, // Deletion will affect balance balanceEffect: BalanceEffect.Any, + newStId: -1, + oldStId, }); return true; default: @@ -341,6 +349,7 @@ export class RefreshTransactionContext implements TransactionContext { newTxState: { major: TransactionMajorState.Deleted, }, + newStId: -1, }); return { notifs }; } @@ -1767,6 +1776,7 @@ export async function processRefreshGroup( return false; } const oldTxState = computeRefreshTransactionState(rg); + const oldStId = rg.operationStatus; const allFinal = fnutil.all( rg.statusPerCoin, (x) => @@ -1799,6 +1809,8 @@ export async function processRefreshGroup( rg.operationStatus === RefreshOperationStatus.Failed ? BalanceEffect.Any : BalanceEffect.PreserveUserVisible, + oldStId, + newStId: rg.operationStatus, }); return true; } @@ -2116,6 +2128,7 @@ export async function createRefreshGroup( major: TransactionMajorState.None, }, newTxState, + newStId: 0, }); return { diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts @@ -738,7 +738,7 @@ async function getTransactionState( ] >, transactionId: string, -): Promise<TransactionState | undefined> { +): Promise<{ txState: TransactionState; stId: number } | undefined> { const parsedTxId = parseTransactionIdentifier(transactionId); if (!parsedTxId) { throw Error("invalid tx identifier"); @@ -749,7 +749,10 @@ async function getTransactionState( if (!rec) { return undefined; } - return computeDepositTransactionStatus(rec); + return { + txState: computeDepositTransactionStatus(rec), + stId: rec.operationStatus, + }; } case TransactionType.InternalWithdrawal: case TransactionType.Withdrawal: { @@ -757,56 +760,80 @@ async function getTransactionState( if (!rec) { return undefined; } - return computeWithdrawalTransactionStatus(rec); + return { + txState: computeWithdrawalTransactionStatus(rec), + stId: rec.status, + }; } case TransactionType.Payment: { const rec = await tx.purchases.get(parsedTxId.proposalId); if (!rec) { return; } - return computePayMerchantTransactionState(rec); + return { + txState: computePayMerchantTransactionState(rec), + stId: rec.purchaseStatus, + }; } case TransactionType.Refund: { const rec = await tx.refundGroups.get(parsedTxId.refundGroupId); if (!rec) { return undefined; } - return computeRefundTransactionState(rec); + return { + txState: computeRefundTransactionState(rec), + stId: rec.status, + }; } case TransactionType.PeerPullCredit: { const rec = await tx.peerPullCredit.get(parsedTxId.pursePub); if (!rec) { return undefined; } - return computePeerPullCreditTransactionState(rec); + return { + txState: computePeerPullCreditTransactionState(rec), + stId: rec.status, + }; } case TransactionType.PeerPullDebit: { const rec = await tx.peerPullDebit.get(parsedTxId.peerPullDebitId); if (!rec) { return undefined; } - return computePeerPullDebitTransactionState(rec); + return { + txState: computePeerPullDebitTransactionState(rec), + stId: rec.status, + }; } case TransactionType.PeerPushCredit: { const rec = await tx.peerPushCredit.get(parsedTxId.peerPushCreditId); if (!rec) { return undefined; } - return computePeerPushCreditTransactionState(rec); + return { + txState: computePeerPushCreditTransactionState(rec), + stId: rec.status, + }; } case TransactionType.PeerPushDebit: { const rec = await tx.peerPushDebit.get(parsedTxId.pursePub); if (!rec) { return undefined; } - return computePeerPushDebitTransactionState(rec); + return { + txState: computePeerPushDebitTransactionState(rec), + stId: rec.status, + }; } case TransactionType.Refresh: { const rec = await tx.refreshGroups.get(parsedTxId.refreshGroupId); if (!rec) { return undefined; } - return computeRefreshTransactionState(rec); + return { + txState: computeRefreshTransactionState(rec), + stId: rec.operationStatus, + }; } case TransactionType.Recoup: throw Error("not yet supported"); @@ -815,7 +842,10 @@ async function getTransactionState( if (!rec) { return undefined; } - return computeDenomLossTransactionStatus(rec); + return { + txState: computeDenomLossTransactionStatus(rec), + stId: rec.status, + }; } default: assertUnreachable(parsedTxId); @@ -839,8 +869,9 @@ async function makeTransactionRetryNotification( const notif: WalletNotification = { type: NotificationType.TransactionStateTransition, transactionId: txId, - oldTxState: txState, - newTxState: txState, + oldTxState: txState.txState, + newTxState: txState.txState, + newStId: txState.stId, }; if (e) { notif.errorInfo = { diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts @@ -673,6 +673,8 @@ export async function waitTransactionState( } } return false; + } else if (typeof txState === "number") { + return tx.stId === txState; } else { return matchState(tx.txState, txState); } diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts @@ -916,9 +916,15 @@ export async function abortTransaction( } export interface TransitionInfo { + // FIXME: Get rid in favor of oldStId oldTxState: TransactionState; + // FIXME: Get rid in favor of newStId newTxState: TransactionState; + balanceEffect: BalanceEffect; + + newStId: number; + oldStId: number; } export enum BalanceEffect { @@ -972,6 +978,7 @@ export function applyNotifyTransition( if ( transitionInfo && !( + transitionInfo.oldStId === transitionInfo.newStId && transitionInfo.oldTxState.major === transitionInfo.newTxState.major && transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor ) @@ -982,6 +989,7 @@ export function applyNotifyTransition( newTxState: transitionInfo.newTxState, transactionId, experimentalUserData, + newStId: transitionInfo.newStId, }); applyNotifyBalanceEffect( diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -176,6 +176,7 @@ import { lookupExchangeByUri, markExchangeUsed, startUpdateExchangeEntry, + waitReadyExchange, } from "./exchanges.js"; import { GenericKycStatusReq, @@ -187,6 +188,7 @@ import { import { DbAccess } from "./query.js"; import { BalanceEffect, + TransitionInfo, applyNotifyTransition, constructTransactionIdentifier, isUnsuccessfulTransaction, @@ -230,6 +232,7 @@ function buildTransactionForBankIntegratedWithdraw( let txDetails: TransactionWithdrawal = { type: TransactionType.Withdrawal, txState, + stId: wg.status, scopes, txActions: computeWithdrawalTransactionActions(wg), exchangeBaseUrl: wg.exchangeBaseUrl, @@ -302,6 +305,7 @@ function buildTransactionForManualWithdraw( let txDetails: TransactionWithdrawal = { type: TransactionType.Withdrawal, + stId: wg.status, txState, scopes, txActions: computeWithdrawalTransactionActions(wg), @@ -518,12 +522,15 @@ export class WithdrawTransactionContext implements TransactionContext { async (tx) => { const wgRec = await tx.withdrawalGroups.get(this.withdrawalGroupId); let oldTxState: TransactionState; + let oldStId: number; if (wgRec) { oldTxState = computeWithdrawalTransactionStatus(wgRec); + oldStId = wgRec.status; } else { oldTxState = { major: TransactionMajorState.None, }; + oldStId = 0; } let res: TransitionResult<WithdrawalGroupRecord> | undefined; try { @@ -544,6 +551,8 @@ export class WithdrawTransactionContext implements TransactionContext { oldTxState, newTxState, balanceEffect: res.balanceEffect, + oldStId, + newStId: res.rec.status, }); return true; } @@ -556,6 +565,8 @@ export class WithdrawTransactionContext implements TransactionContext { major: TransactionMajorState.None, }, balanceEffect: BalanceEffect.Any, + oldStId, + newStId: -1, }); return true; default: @@ -614,6 +625,7 @@ export class WithdrawTransactionContext implements TransactionContext { newTxState: { major: TransactionMajorState.Deleted, }, + newStId: -1, }); return { notifs }; } @@ -668,6 +680,8 @@ export class WithdrawTransactionContext implements TransactionContext { transactionLabel: "abort-transaction-withdraw", }, async (wg, _tx) => { + // FIXME: When aborting a partially succeeded withdrawal, + // we need to mark already withdrawn coins as visible. if (!wg) { logger.warn(`withdrawal group ${withdrawalGroupId} not found`); return TransitionResult.stay(); @@ -692,6 +706,8 @@ export class WithdrawTransactionContext implements TransactionContext { newStatus = WithdrawalGroupStatus.AbortedExchange; break; case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.SuspendedRedenominate: + case WithdrawalGroupStatus.PendingRedenominate: newStatus = WithdrawalGroupStatus.SuspendedReady; break; case WithdrawalGroupStatus.SuspendedAbortingBank: @@ -803,6 +819,20 @@ export function computeWithdrawalTransactionStatus( return { major: TransactionMajorState.Failed, }; + case WithdrawalGroupStatus.PendingRedenominate: { + // Intentionally not faithful. + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.WithdrawCoins, + }; + } + case WithdrawalGroupStatus.SuspendedRedenominate: { + // Intentionally not faithful. + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.WithdrawCoins, + }; + } case WithdrawalGroupStatus.Done: return { major: TransactionMajorState.Done, @@ -986,6 +1016,14 @@ export function computeWithdrawalTransactionActions( return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedReady: return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.SuspendedRedenominate: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.PendingRedenominate: + return [ + TransactionAction.Suspend, + TransactionAction.Abort, + TransactionAction.Retry, + ]; case WithdrawalGroupStatus.PendingKyc: return [ TransactionAction.Suspend, @@ -1021,6 +1059,40 @@ export function computeWithdrawalTransactionActions( } } +async function processWithdrawalGroupRedenominate( + ctx: WithdrawTransactionContext, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<TaskRunResult> { + logger.trace("in processWithdrawalGroupRedenominate"); + const wex = ctx.wex; + const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + if (!exchangeBaseUrl) { + throw Error("invalid state (no exchange base URL)"); + } + await waitReadyExchange(wex, exchangeBaseUrl, { + noBail: true, + }); + await redenominateWithdrawal( + wex, + exchangeBaseUrl, + withdrawalGroup.withdrawalGroupId, + ); + const didTransition = await ctx.transition({}, async (rec) => { + switch (rec?.status) { + case WithdrawalGroupStatus.PendingRedenominate: + break; + default: + return TransitionResult.stay(); + } + rec.status = WithdrawalGroupStatus.PendingReady; + return TransitionResult.transition(rec); + }); + if (!didTransition) { + return TaskRunResult.backoff(); + } + return TaskRunResult.progress(); +} + async function processWithdrawalGroupBalanceKyc( ctx: WithdrawTransactionContext, withdrawalGroup: WithdrawalGroupRecord, @@ -1809,6 +1881,14 @@ async function processPlanchetExchangeBatchRequest( coinIdxs: [], }; } + if (resp.status === HttpStatusCode.InternalServerError) { + const e = await readTalerErrorResponse(resp); + await storeCoinError(e, requestCoinIdxs[0]); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } const r = await readSuccessResponseJsonOrThrow( resp, codecForExchangeWithdrawResponse(), @@ -2192,34 +2272,37 @@ async function processQueryReserve( // We only allow changing the amount *down*, so that user error // in the wire transfer won't result in a giant withdrawal. // See https://bugs.taler.net/n/9732 - // We also re-select when the initial selection had zero coins. - let amountChanged = false; + // We also re-select when the initial selection had zero coins + // (skipped denoms are not counted). + let shouldReselectDenoms = false; if ( Amounts.cmp( result.response.balance, withdrawalGroup.denomsSel.totalWithdrawCost, ) === -1 || - withdrawalGroup.denomsSel.selectedDenoms.length === 0 + withdrawalGroup.denomsSel.selectedDenoms.filter((x) => !x.skip).length === 0 ) { - amountChanged = true; + shouldReselectDenoms = true; } - logger.trace(`amount change ${j2s(result.response)}`); - logger.trace( - `amount change ${j2s(withdrawalGroup.denomsSel.totalWithdrawCost)}`, - ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; const currency = Amounts.currencyOf(withdrawalGroup.instructedAmount); // If we re-select denominations, make sure we have current // information about the exchange. - if (amountChanged) { + if (shouldReselectDenoms) { + // FIXME: Would be better if we transition to a new state here. + // Or at least set some lastError (lastWarning?) state. await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl); + await updateWithdrawalDenomsForExchange( + wex, + withdrawalGroup.exchangeBaseUrl, + ); } const transitionResult = await ctx.transition( { - extraStores: ["denominations", "bankAccountsV2"], + extraStores: ["denominations", "bankAccountsV2", "planchets"], }, async (wg, tx) => { if (!wg) { @@ -2238,7 +2321,13 @@ async function processQueryReserve( if (lastOrigin != null && !externalConfirmation) { await storeKnownBankAccount(tx, currency, lastOrigin); } - if (amountChanged) { + if (shouldReselectDenoms) { + const planchetKeys = await tx.planchets.indexes.byGroup.getAllKeys( + wg.withdrawalGroupId, + ); + for (const pk of planchetKeys) { + await tx.planchets.delete(pk); + } const candidates = await getWithdrawableDenomsTx( wex, tx, @@ -2393,9 +2482,6 @@ async function redenominateWithdrawal( exchangeBaseUrl: string, withdrawalGroupId: string, ): Promise<void> { - await fetchFreshExchange(wex, exchangeBaseUrl, { - forceUpdate: true, - }); await updateWithdrawalDenomsForExchange(wex, exchangeBaseUrl); logger.trace(`redenominating withdrawal group ${withdrawalGroupId}`); await wex.db.runReadWriteTx( @@ -2690,6 +2776,7 @@ async function processWithdrawalGroupPendingReady( switch (p.lastError.code) { case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_EXPIRED: case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_REVOKED: + case TalerErrorCode.EXCHANGE_GENERIC_KEYS_MISSING: redenomRequired = true; return; } @@ -2697,9 +2784,7 @@ async function processWithdrawalGroupPendingReady( }); if (redenomRequired) { - logger.warn(`withdrawal ${withdrawalGroupId} requires redenomination`); - await redenominateWithdrawal(wex, exchangeBaseUrl, withdrawalGroupId); - return TaskRunResult.backoff(); + return startRedenomination(ctx, exchangeBaseUrl); } const errorsPerCoin: Record<number, TalerErrorDetail> = {}; @@ -2768,6 +2853,31 @@ async function processWithdrawalGroupPendingReady( return TaskRunResult.backoff(); } +async function startRedenomination( + ctx: WithdrawTransactionContext, + exchangeBaseUrl: string, +): Promise<TaskRunResult> { + // Exchange is broken. Mark exchange as unavailable and re-fetch + // exchange entry. + await startUpdateExchangeEntry(ctx.wex, exchangeBaseUrl, { + forceUnavailable: true, + }); + const didTransition = await ctx.transition({}, async (rec) => { + switch (rec?.status) { + case WithdrawalGroupStatus.PendingReady: + break; + default: + return TransitionResult.stay(); + } + rec.status = WithdrawalGroupStatus.PendingRedenominate; + return TransitionResult.transition(rec); + }); + if (didTransition) { + return TaskRunResult.progress(); + } + return TaskRunResult.backoff(); +} + export async function processWithdrawalGroup( wex: WalletExecutionContext, withdrawalGroupId: string, @@ -2807,6 +2917,8 @@ export async function processWithdrawalGroup( case WithdrawalGroupStatus.PendingBalanceKyc: case WithdrawalGroupStatus.PendingBalanceKycInit: return await processWithdrawalGroupBalanceKyc(ctx, withdrawalGroup); + case WithdrawalGroupStatus.PendingRedenominate: + return await processWithdrawalGroupRedenominate(ctx, withdrawalGroup); case WithdrawalGroupStatus.AbortedBank: case WithdrawalGroupStatus.AbortedExchange: case WithdrawalGroupStatus.FailedAbortingBank: @@ -2822,6 +2934,7 @@ export async function processWithdrawalGroup( case WithdrawalGroupStatus.FailedBankAborted: case WithdrawalGroupStatus.AbortedUserRefused: case WithdrawalGroupStatus.AbortedOtherWallet: + case WithdrawalGroupStatus.SuspendedRedenominate: // Nothing to do. return TaskRunResult.finished(); default: @@ -3629,12 +3742,15 @@ async function internalPerformExchangeWasUsed( const oldTxState = { major: TransactionMajorState.None, minor: undefined, + internalId: 0, }; const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup); - const transitionInfo = { + const transitionInfo: TransitionInfo = { oldTxState, newTxState, balanceEffect: BalanceEffect.Any, + oldStId: 0, + newStId: withdrawalGroup.status, }; await markExchangeUsed(tx, canonExchange); @@ -3676,10 +3792,6 @@ export async function internalCreateWithdrawalGroup( }, ): Promise<WithdrawalGroupRecord> { const prep = await internalPrepareCreateWithdrawalGroup(wex, args); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId, - }); const ctx = new WithdrawTransactionContext( wex, prep.withdrawalGroup.withdrawalGroupId,