diff options
author | Christian Blättler <blatc2@bfh.ch> | 2024-06-13 11:35:52 +0200 |
---|---|---|
committer | Christian Blättler <blatc2@bfh.ch> | 2024-06-13 11:35:52 +0200 |
commit | eb964dfae0a12f9a90eb066d610f627538f8997c (patch) | |
tree | 26a6cd74c9a29edce05b2dcd51cf497374bf8e30 /packages/taler-wallet-core | |
parent | 9d0fc80a905e02a0a0b63dd547daac6e7b17fb52 (diff) | |
parent | f9d4ff5b43e48a07ac81d7e7ef800ddb12f5f90a (diff) | |
download | wallet-core-eb964dfae0a12f9a90eb066d610f627538f8997c.tar.xz |
Merge branch 'master' into feature/tokens
Diffstat (limited to 'packages/taler-wallet-core')
24 files changed, 1149 insertions, 777 deletions
diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json index 46b3cef4e..c710861d3 100644 --- a/packages/taler-wallet-core/package.json +++ b/packages/taler-wallet-core/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-core", - "version": "0.10.7", + "version": "0.11.4", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/taler-wallet-core/src/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts index 15904b470..09d5ae75d 100644 --- a/packages/taler-wallet-core/src/backup/index.ts +++ b/packages/taler-wallet-core/src/backup/index.ts @@ -805,9 +805,10 @@ async function backupRecoveryTheirs( let backupStateEntry: ConfigRecord | undefined = await tx.config.get( ConfigRecordKey.WalletBackupState, ); - checkDbInvariant(!!backupStateEntry); + checkDbInvariant(!!backupStateEntry, `no backup entry`); checkDbInvariant( backupStateEntry.key === ConfigRecordKey.WalletBackupState, + `backup entry inconsistent`, ); backupStateEntry.value.lastBackupNonce = undefined; backupStateEntry.value.lastBackupTimestamp = undefined; @@ -913,7 +914,10 @@ export async function provideBackupState( }, ); if (bs) { - checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState); + checkDbInvariant( + bs.key === ConfigRecordKey.WalletBackupState, + `backup entry inconsistent`, + ); return bs.value; } // We need to generate the key outside of the transaction @@ -941,6 +945,7 @@ export async function provideBackupState( } checkDbInvariant( backupStateEntry.key === ConfigRecordKey.WalletBackupState, + `backup entry inconsistent`, ); return backupStateEntry.value; }); @@ -952,7 +957,10 @@ export async function getWalletBackupState( ): Promise<WalletBackupConfState> { const bs = await tx.config.get(ConfigRecordKey.WalletBackupState); checkDbInvariant(!!bs, "wallet backup state should be in DB"); - checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState); + checkDbInvariant( + bs.key === ConfigRecordKey.WalletBackupState, + `backup entry inconsistent`, + ); return bs.value; } @@ -962,7 +970,7 @@ export async function setWalletDeviceId( ): Promise<void> { await provideBackupState(wex); await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { - let backupStateEntry: ConfigRecord | undefined = await tx.config.get( + const backupStateEntry: ConfigRecord | undefined = await tx.config.get( ConfigRecordKey.WalletBackupState, ); if ( diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts index 76e604324..381028906 100644 --- a/packages/taler-wallet-core/src/balance.ts +++ b/packages/taler-wallet-core/src/balance.ts @@ -69,8 +69,8 @@ import { ExchangeRestrictionSpec, findMatchingWire } from "./coinSelection.js"; import { DepositOperationStatus, ExchangeEntryDbRecordStatus, - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, PeerPushDebitStatus, RefreshGroupRecord, RefreshOperationStatus, @@ -304,8 +304,8 @@ export async function getBalancesInsideTransaction( const balanceStore: BalancesStore = new BalancesStore(wex, tx); const keyRangeActive = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.exchanges.iter().forEachAsync(async (ex) => { @@ -379,6 +379,10 @@ export async function getBalancesInsideTransaction( wg.denomsSel !== undefined, "wg in kyc state should have been initialized", ); + checkDbInvariant( + wg.exchangeBaseUrl !== undefined, + "wg in kyc state should have been initialized", + ); const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); await balanceStore.setFlagIncomingKyc(currency, wg.exchangeBaseUrl); break; @@ -389,6 +393,10 @@ export async function getBalancesInsideTransaction( wg.denomsSel !== undefined, "wg in aml state should have been initialized", ); + checkDbInvariant( + wg.exchangeBaseUrl !== undefined, + "wg in kyc state should have been initialized", + ); const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); await balanceStore.setFlagIncomingAml(currency, wg.exchangeBaseUrl); break; @@ -408,6 +416,10 @@ export async function getBalancesInsideTransaction( wg.denomsSel !== undefined, "wg in confirmed state should have been initialized", ); + checkDbInvariant( + wg.exchangeBaseUrl !== undefined, + "wg in kyc state should have been initialized", + ); const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); await balanceStore.setFlagIncomingConfirmation( currency, diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts index a60e41ecd..db6384c93 100644 --- a/packages/taler-wallet-core/src/coinSelection.ts +++ b/packages/taler-wallet-core/src/coinSelection.ts @@ -691,7 +691,7 @@ export function checkAccountRestriction( switch (myRestriction.type) { case "deny": return { ok: false }; - case "regex": + case "regex": { const regex = new RegExp(myRestriction.payto_regex); if (!regex.test(paytoUri)) { return { @@ -700,6 +700,7 @@ export function checkAccountRestriction( hintI18n: myRestriction.human_hint_i18n, }; } + } } } return { @@ -909,7 +910,7 @@ async function selectPayCandidates( coinAvail.exchangeBaseUrl, coinAvail.denomPubHash, ]); - checkDbInvariant(!!denom); + checkDbInvariant(!!denom, `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`); if (denom.isRevoked) { logger.trace("denom is revoked"); continue; diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts index edaba5ba4..13c875575 100644 --- a/packages/taler-wallet-core/src/common.ts +++ b/packages/taler-wallet-core/src/common.ts @@ -31,6 +31,8 @@ import { ExchangeUpdateStatus, Logger, RefreshReason, + TalerError, + TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolTimestamp, @@ -57,11 +59,11 @@ import { PurchaseRecord, RecoupGroupRecord, RefreshGroupRecord, - RewardRecord, WalletDbReadWriteTransaction, WithdrawalGroupRecord, timestampPreciseToDb, } from "./db.js"; +import { ReadyExchangeSummary } from "./exchanges.js"; import { createRefreshGroup } from "./refresh.js"; import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; @@ -121,7 +123,10 @@ export async function makeCoinAvailable( coinRecord.exchangeBaseUrl, coinRecord.denomPubHash, ]); - checkDbInvariant(!!denom); + checkDbInvariant( + !!denom, + `denomination of a coin is missing hash: ${coinRecord.denomPubHash}`, + ); const ageRestriction = coinRecord.maxAge; let car = await tx.coinAvailability.get([ coinRecord.exchangeBaseUrl, @@ -175,13 +180,19 @@ export async function spendCoins( coin.exchangeBaseUrl, coin.denomPubHash, ); - checkDbInvariant(!!denom); + checkDbInvariant( + !!denom, + `denomination of a coin is missing hash: ${coin.denomPubHash}`, + ); const coinAvailability = await tx.coinAvailability.get([ coin.exchangeBaseUrl, coin.denomPubHash, coin.maxAge, ]); - checkDbInvariant(!!coinAvailability); + checkDbInvariant( + !!coinAvailability, + `age denom info is missing for ${coin.maxAge}`, + ); const contrib = csi.contributions[i]; if (coin.status !== CoinStatus.Fresh) { const alloc = coin.spendAllocation; @@ -213,7 +224,6 @@ export async function spendCoins( amount: Amounts.stringify(remaining.amount), coinPub: coin.coinPub, }); - checkDbInvariant(!!coinAvailability); if (coinAvailability.freshCoinCount === 0) { throw Error( `invalid coin count ${coinAvailability.freshCoinCount} in DB`, @@ -258,6 +268,9 @@ export enum TombstoneTag { export function getExchangeTosStatusFromRecord( exchange: ExchangeEntryRecord, ): ExchangeTosStatus { + if (exchange.tosCurrentEtag == null) { + return ExchangeTosStatus.MissingTos; + } if (!exchange.tosAcceptedEtag) { return ExchangeTosStatus.Proposed; } @@ -558,6 +571,28 @@ export function getAutoRefreshExecuteThreshold(d: { } /** + * Type and schema definitions for pending tasks in the wallet. + * + * These are only used internally, and are not part of the stable public + * interface to the wallet. + */ + +export enum PendingTaskType { + ExchangeUpdate = "exchange-update", + Purchase = "purchase", + Refresh = "refresh", + Recoup = "recoup", + RewardPickup = "reward-pickup", + Withdraw = "withdraw", + Deposit = "deposit", + Backup = "backup", + PeerPushDebit = "peer-push-debit", + PeerPullCredit = "peer-pull-credit", + PeerPushCredit = "peer-push-credit", + PeerPullDebit = "peer-pull-debit", +} + +/** * Parsed representation of task identifiers. */ export type ParsedTaskIdentifier = @@ -660,9 +695,6 @@ export namespace TaskIdentifiers { exchBaseUrl, )}` as TaskIdStr; } - export function forTipPickup(tipRecord: RewardRecord): TaskIdStr { - return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskIdStr; - } export function forRefresh( refreshGroupRecord: RefreshGroupRecord, ): TaskIdStr { @@ -747,28 +779,6 @@ export interface TransactionContext { deleteTransaction(): Promise<void>; } -/** - * Type and schema definitions for pending tasks in the wallet. - * - * These are only used internally, and are not part of the stable public - * interface to the wallet. - */ - -export enum PendingTaskType { - ExchangeUpdate = "exchange-update", - Purchase = "purchase", - Refresh = "refresh", - Recoup = "recoup", - RewardPickup = "reward-pickup", - Withdraw = "withdraw", - Deposit = "deposit", - Backup = "backup", - PeerPushDebit = "peer-push-debit", - PeerPullCredit = "peer-pull-credit", - PeerPushCredit = "peer-push-credit", - PeerPullDebit = "peer-pull-debit", -} - declare const __taskIdStr: unique symbol; export type TaskIdStr = string & { [__taskIdStr]: true }; @@ -799,7 +809,7 @@ export async function genericWaitForState( flag.raise(); } }); - const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { + const unregisterOnCancelled = wex.cancellationToken.onCancelled((reason) => { cancelNotif(); flag.raise(); }); @@ -819,5 +829,25 @@ export async function genericWaitForState( } catch (e) { unregisterOnCancelled(); cancelNotif(); + throw e; + } +} + +export function requireExchangeTosAcceptedOrThrow( + exchange: ReadyExchangeSummary, +): void { + switch (exchange.tosStatus) { + case ExchangeTosStatus.Accepted: + case ExchangeTosStatus.MissingTos: + break; + default: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED, + { + exchangeBaseUrl: exchange.exchangeBaseUrl, + currentEtag: exchange.tosCurrentEtag, + tosStatus: exchange.tosStatus, + }, + ); } } diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 44c241aed..5c381eea7 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -248,6 +248,9 @@ export function timestampOptionalAbsoluteFromDb( * 0x0103_nnnn: aborting * 0x0110_nnnn: suspended * 0x0113_nnnn: suspended-aborting + * a=2: finalizing + * 0x0200_nnnn: finalizing + * 0x0210_nnnn: suspended-finalizing * a=5: final * 0x0500_nnnn: done * 0x0501_nnnn: failed @@ -260,12 +263,12 @@ export function timestampOptionalAbsoluteFromDb( /** * First possible operation status in the active range (inclusive). */ -export const OPERATION_STATUS_ACTIVE_FIRST = 0x0100_0000; +export const OPERATION_STATUS_NONFINAL_FIRST = 0x0100_0000; /** * LAST possible operation status in the active range (inclusive). */ -export const OPERATION_STATUS_ACTIVE_LAST = 0x0113_ffff; +export const OPERATION_STATUS_NONFINAL_LAST = 0x0210_ffff; /** * Status of a withdrawal. @@ -395,6 +398,8 @@ export interface ReserveBankInfo { timestampBankConfirmed: DbPreciseTimestamp | undefined; wireTypes: string[] | undefined; + + currency: string | undefined; } /** @@ -918,92 +923,6 @@ export interface CoinAllocation { amount: AmountString; } -/** - * Status of a reward we got from a merchant. - */ -export interface RewardRecord { - /** - * Has the user accepted the tip? Only after the tip has been accepted coins - * withdrawn from the tip may be used. - */ - acceptedTimestamp: DbPreciseTimestamp | undefined; - - /** - * The tipped amount. - */ - rewardAmountRaw: AmountString; - - /** - * Effect on the balance (including fees etc). - */ - rewardAmountEffective: AmountString; - - /** - * Timestamp, the tip can't be picked up anymore after this deadline. - */ - rewardExpiration: DbProtocolTimestamp; - - /** - * The exchange that will sign our coins, chosen by the merchant. - */ - exchangeBaseUrl: string; - - /** - * Base URL of the merchant that is giving us the tip. - */ - merchantBaseUrl: string; - - /** - * Denomination selection made by the wallet for picking up - * this tip. - * - * FIXME: Put this into some DenomSelectionCacheRecord instead of - * storing it here! - */ - denomsSel: DenomSelectionState; - - denomSelUid: string; - - /** - * Tip ID chosen by the wallet. - */ - walletRewardId: string; - - /** - * Secret seed used to derive planchets for this tip. - */ - secretSeed: string; - - /** - * The merchant's identifier for this reward. - */ - merchantRewardId: string; - - createdTimestamp: DbPreciseTimestamp; - - /** - * The url to be redirected after the tip is accepted. - */ - next_url: string | undefined; - - /** - * Timestamp for when the wallet finished picking up the tip - * from the merchant. - */ - pickedUpTimestamp: DbPreciseTimestamp | undefined; - - status: RewardRecordStatus; -} - -export enum RewardRecordStatus { - PendingPickup = 0x0100_0000, - SuspendedPickup = 0x0110_0000, - DialogAccept = 0x0101_0000, - Done = 0x0500_0000, - Aborted = 0x0500_0000, - Failed = 0x0501_000, -} - export enum RefreshCoinStatus { Pending = 0x0100_0000, Finished = 0x0500_0000, @@ -1178,10 +1097,15 @@ export enum PurchaseStatus { /** * Query for refund (until auto-refund deadline is reached). + * + * Legacy state for compatibility. */ PendingQueryingAutoRefund = 0x0100_0004, SuspendedQueryingAutoRefund = 0x0110_0004, + FinalizingQueryingAutoRefund = 0x0200_0001, + SuspendedFinalizingQueryingAutoRefund = 0x0210_0001, + PendingAcceptRefund = 0x0100_0005, SuspendedPendingAcceptRefund = 0x0110_0005, @@ -1197,11 +1121,6 @@ export enum PurchaseStatus { DialogShared = 0x0101_0001, /** - * The user has rejected the proposal. - */ - AbortedProposalRefused = 0x0503_0000, - - /** * Downloading or processing the proposal has failed permanently. */ FailedClaim = 0x0501_0000, @@ -1224,13 +1143,18 @@ export enum PurchaseStatus { DoneRepurchaseDetected = 0x0500_0001, /** - * The payment has been aborted. + * The user has rejected the proposal. */ - AbortedIncompletePayment = 0x0503_0000, + AbortedProposalRefused = 0x0503_0000, AbortedRefunded = 0x0503_0001, AbortedOrderDeleted = 0x0503_0002, + + /** + * The payment has been aborted. + */ + AbortedIncompletePayment = 0x0503_0003, } /** @@ -1439,6 +1363,7 @@ export interface WgInfoBankIntegrated { * a Taler-integrated bank. */ bankInfo: ReserveBankInfo; + /** * Info about withdrawal accounts, possibly including currency conversion. */ @@ -1530,7 +1455,7 @@ export interface WithdrawalGroupRecord { * The exchange base URL that we're withdrawing from. * (Redundantly stored, as the reserve record also has this info.) */ - exchangeBaseUrl: string; + exchangeBaseUrl?: string; /** * When was the withdrawal operation started started? @@ -1976,7 +1901,7 @@ export enum PeerPullPaymentCreditStatus { SuspendedCreatePurse = 0x0110_0000, SuspendedReady = 0x0110_0001, SuspendedMergeKycRequired = 0x0110_0002, - SuspendedWithdrawing = 0x0110_0000, + SuspendedWithdrawing = 0x0110_0003, SuspendedAbortingDeletePurse = 0x0113_0000, @@ -2630,9 +2555,10 @@ export const WalletStoresV1 = { ]), }, ), + // Just a tombstone at this point. rewards: describeStore( "rewards", - describeContents<RewardRecord>({ keyPath: "walletRewardId" }), + describeContents<any>({ keyPath: "walletRewardId" }), { byMerchantTipIdAndBaseUrl: describeIndex("byMerchantRewardIdAndBaseUrl", [ "merchantRewardId", @@ -2940,6 +2866,8 @@ export interface DbDump { }; } +const logger = new Logger("db.ts"); + export async function exportSingleDb( idb: IDBFactory, dbName: string, @@ -3081,8 +3009,6 @@ export interface FixupDescription { */ export const walletDbFixups: FixupDescription[] = []; -const logger = new Logger("db.ts"); - export async function applyFixups( db: DbAccess<typeof WalletStoresV1>, ): Promise<void> { diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts index d3085ecb4..ec9655e6f 100644 --- a/packages/taler-wallet-core/src/dbless.ts +++ b/packages/taler-wallet-core/src/dbless.ts @@ -123,7 +123,7 @@ export async function topupReserveWithBank(args: TopupReserveWithBankArgs) { ); const bankInfo = await getBankWithdrawalInfo(http, wopi.taler_withdraw_uri); const bankStatusUrl = getBankStatusUrl(wopi.taler_withdraw_uri); - if (!bankInfo.suggestedExchange) { + if (!bankInfo.exchange) { throw Error("no suggested exchange"); } const plainPaytoUris = diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts index c4cd98d73..2004c12cb 100644 --- a/packages/taler-wallet-core/src/deposits.ts +++ b/packages/taler-wallet-core/src/deposits.ts @@ -387,11 +387,19 @@ export function computeDepositTransactionActions( case DepositOperationStatus.Finished: return [TransactionAction.Delete]; case DepositOperationStatus.PendingDeposit: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case DepositOperationStatus.SuspendedDeposit: return [TransactionAction.Resume]; case DepositOperationStatus.Aborting: - return [TransactionAction.Fail, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Fail, + TransactionAction.Suspend, + ]; case DepositOperationStatus.Aborted: return [TransactionAction.Delete]; case DepositOperationStatus.Failed: @@ -399,9 +407,17 @@ export function computeDepositTransactionActions( case DepositOperationStatus.SuspendedAborting: return [TransactionAction.Resume, TransactionAction.Fail]; case DepositOperationStatus.PendingKyc: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case DepositOperationStatus.PendingTrack: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case DepositOperationStatus.SuspendedKyc: return [TransactionAction.Resume, TransactionAction.Fail]; case DepositOperationStatus.SuspendedTrack: @@ -441,7 +457,7 @@ async function refundDepositGroup( { storeNames: ["coins"] }, async (tx) => { const coinRecord = await tx.coins.get(coinPub); - checkDbInvariant(!!coinRecord); + checkDbInvariant(!!coinRecord, `coin ${coinPub} not found in DB`); return coinRecord.exchangeBaseUrl; }, ); diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts index d8063d561..dd88fa836 100644 --- a/packages/taler-wallet-core/src/exchanges.ts +++ b/packages/taler-wallet-core/src/exchanges.ts @@ -28,7 +28,6 @@ import { AgeRestriction, Amount, Amounts, - AsyncFlag, CancellationToken, CoinRefreshRequest, CoinStatus, @@ -53,6 +52,7 @@ import { GetExchangeResourcesResponse, GetExchangeTosResult, GlobalFees, + HttpStatusCode, LibtoolVersion, Logger, NotificationType, @@ -79,6 +79,7 @@ import { WireInfo, assertUnreachable, checkDbInvariant, + checkLogicInvariant, codecForExchangeKeysJson, durationMul, encodeCrock, @@ -93,6 +94,8 @@ import { getExpiry, readSuccessResponseJsonOrThrow, readSuccessResponseTextOrThrow, + readTalerErrorResponse, + throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { PendingTaskType, @@ -103,6 +106,7 @@ import { TransactionContext, computeDbBackoff, constructTaskIdentifier, + genericWaitForState, getAutoRefreshExecuteThreshold, getExchangeEntryStatusFromRecord, getExchangeState, @@ -861,6 +865,41 @@ async function downloadExchangeKeysInfo( }; } +type TosMetaResult = { type: "not-found" } | { type: "ok"; etag: string }; + +/** + * Download metadata about an exchange's terms of service. + */ +async function downloadTosMeta( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<TosMetaResult> { + logger.trace(`downloading exchange tos metadata for ${exchangeBaseUrl}`); + const reqUrl = new URL("terms", exchangeBaseUrl); + + // FIXME: We can/should make a HEAD request here. + // Not sure if qtart supports it at the moment. + const resp = await wex.http.fetch(reqUrl.href, { + cancellationToken: wex.cancellationToken, + }); + + switch (resp.status) { + case HttpStatusCode.NotFound: + case HttpStatusCode.NotImplemented: + return { type: "not-found" }; + case HttpStatusCode.Ok: + break; + default: + throwUnexpectedRequestError(resp, await readTalerErrorResponse(resp)); + } + + const etag = resp.headers.get("etag") || "unknown"; + return { + type: "ok", + etag, + }; +} + async function downloadTosFromAcceptedFormat( wex: WalletExecutionContext, baseUrl: string, @@ -977,9 +1016,7 @@ async function startUpdateExchangeEntry( wex.ws.exchangeCache.clear(); await tx.exchanges.put(r); const newExchangeState = getExchangeState(r); - // Reset retries for updating the exchange entry. const taskId = TaskIdentifiers.forExchangeUpdate(r); - await tx.operationRetries.delete(taskId); return { oldExchangeState, newExchangeState, taskId }; }, ); @@ -989,6 +1026,8 @@ async function startUpdateExchangeEntry( newExchangeState: newExchangeState, oldExchangeState: oldExchangeState, }); + logger.info(`start update ${exchangeBaseUrl} task ${taskId}`); + await wex.taskScheduler.resetTaskRetries(taskId); } @@ -1008,132 +1047,6 @@ export interface ReadyExchangeSummary { scopeInfo: ScopeInfo; } -async function internalWaitReadyExchange( - wex: WalletExecutionContext, - canonUrl: string, - exchangeNotifFlag: AsyncFlag, - options: { - cancellationToken?: CancellationToken; - forceUpdate?: boolean; - expectedMasterPub?: string; - } = {}, -): Promise<ReadyExchangeSummary> { - const operationId = constructTaskIdentifier({ - tag: PendingTaskType.ExchangeUpdate, - exchangeBaseUrl: canonUrl, - }); - while (true) { - if (wex.cancellationToken.isCancelled) { - throw Error("cancelled"); - } - logger.info(`waiting for ready exchange ${canonUrl}`); - const { exchange, exchangeDetails, retryInfo, scopeInfo } = - await wex.db.runReadOnlyTx( - { - storeNames: [ - "exchanges", - "exchangeDetails", - "operationRetries", - "globalCurrencyAuditors", - "globalCurrencyExchanges", - ], - }, - async (tx) => { - const exchange = await tx.exchanges.get(canonUrl); - const exchangeDetails = await getExchangeRecordsInternal( - tx, - canonUrl, - ); - const retryInfo = await tx.operationRetries.get(operationId); - let scopeInfo: ScopeInfo | undefined = undefined; - if (exchange && exchangeDetails) { - scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails); - } - return { exchange, exchangeDetails, retryInfo, scopeInfo }; - }, - ); - - if (!exchange) { - throw Error("exchange entry does not exist anymore"); - } - - let ready = false; - - switch (exchange.updateStatus) { - case ExchangeEntryDbUpdateStatus.Ready: - ready = true; - break; - case ExchangeEntryDbUpdateStatus.ReadyUpdate: - // If the update is forced, - // we wait until we're in a full "ready" state, - // as we're not happy with the stale information. - if (!options.forceUpdate) { - ready = true; - } - break; - case ExchangeEntryDbUpdateStatus.UnavailableUpdate: - throw TalerError.fromDetail( - TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, - { - exchangeBaseUrl: canonUrl, - innerError: retryInfo?.lastError, - }, - ); - default: { - if (retryInfo) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, - { - exchangeBaseUrl: canonUrl, - innerError: retryInfo?.lastError, - }, - ); - } - } - } - - if (!ready) { - logger.info("waiting for exchange update notification"); - await exchangeNotifFlag.wait(); - logger.info("done waiting for exchange update notification"); - exchangeNotifFlag.reset(); - continue; - } - - if (!exchangeDetails) { - throw Error("invariant failed"); - } - - if (!scopeInfo) { - throw Error("invariant failed"); - } - - const res: ReadyExchangeSummary = { - currency: exchangeDetails.currency, - exchangeBaseUrl: canonUrl, - masterPub: exchangeDetails.masterPublicKey, - tosStatus: getExchangeTosStatusFromRecord(exchange), - tosAcceptedEtag: exchange.tosAcceptedEtag, - wireInfo: exchangeDetails.wireInfo, - protocolVersionRange: exchangeDetails.protocolVersionRange, - tosCurrentEtag: exchange.tosCurrentEtag, - tosAcceptedTimestamp: timestampOptionalPreciseFromDb( - exchange.tosAcceptedTimestamp, - ), - scopeInfo, - }; - - if (options.expectedMasterPub) { - if (res.masterPub !== options.expectedMasterPub) { - throw Error( - "public key of the exchange does not match expected public key", - ); - } - } - return res; - } -} - /** * Ensure that a fresh exchange entry exists for the given * exchange base URL. @@ -1155,6 +1068,8 @@ export async function fetchFreshExchange( forceUpdate?: boolean; } = {}, ): Promise<ReadyExchangeSummary> { + logger.info(`fetch fresh ${baseUrl} forced ${options.forceUpdate}`); + if (!options.forceUpdate) { const cachedResp = wex.ws.exchangeCache.get(baseUrl); if (cachedResp) { @@ -1184,39 +1099,131 @@ async function waitReadyExchange( } = {}, ): Promise<ReadyExchangeSummary> { logger.trace(`waiting for exchange ${canonUrl} to become ready`); - // FIXME: We should use Symbol.dispose magic here for cleanup! - const exchangeNotifFlag = new AsyncFlag(); - // Raise exchangeNotifFlag whenever we get a notification - // about our exchange. - const cancelNotif = wex.ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.ExchangeStateTransition && - notif.exchangeBaseUrl === canonUrl - ) { - logger.info(`raising update notification: ${j2s(notif)}`); - exchangeNotifFlag.raise(); - } + const operationId = constructTaskIdentifier({ + tag: PendingTaskType.ExchangeUpdate, + exchangeBaseUrl: canonUrl, }); - const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { - cancelNotif(); - exchangeNotifFlag.raise(); + let res: ReadyExchangeSummary | undefined = undefined; + + await genericWaitForState(wex, { + filterNotification(notif): boolean { + return ( + notif.type === NotificationType.ExchangeStateTransition && + notif.exchangeBaseUrl === canonUrl + ); + }, + async checkState(): Promise<boolean> { + const { exchange, exchangeDetails, retryInfo, scopeInfo } = + await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "exchangeDetails", + "operationRetries", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ], + }, + async (tx) => { + const exchange = await tx.exchanges.get(canonUrl); + const exchangeDetails = await getExchangeRecordsInternal( + tx, + canonUrl, + ); + const retryInfo = await tx.operationRetries.get(operationId); + let scopeInfo: ScopeInfo | undefined = undefined; + if (exchange && exchangeDetails) { + scopeInfo = await internalGetExchangeScopeInfo( + tx, + exchangeDetails, + ); + } + return { exchange, exchangeDetails, retryInfo, scopeInfo }; + }, + ); + + if (!exchange) { + throw Error("exchange entry does not exist anymore"); + } + + let ready = false; + + switch (exchange.updateStatus) { + case ExchangeEntryDbUpdateStatus.Ready: + ready = true; + break; + case ExchangeEntryDbUpdateStatus.ReadyUpdate: + // If the update is forced, + // we wait until we're in a full "ready" state, + // as we're not happy with the stale information. + if (!options.forceUpdate) { + ready = true; + } + break; + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + { + exchangeBaseUrl: canonUrl, + innerError: retryInfo?.lastError, + }, + ); + default: { + if (retryInfo) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + { + exchangeBaseUrl: canonUrl, + innerError: retryInfo?.lastError, + }, + ); + } + } + } + + if (!ready) { + return false; + } + + if (!exchangeDetails) { + throw Error("invariant failed"); + } + + if (!scopeInfo) { + throw Error("invariant failed"); + } + + const mySummary: ReadyExchangeSummary = { + currency: exchangeDetails.currency, + exchangeBaseUrl: canonUrl, + masterPub: exchangeDetails.masterPublicKey, + tosStatus: getExchangeTosStatusFromRecord(exchange), + tosAcceptedEtag: exchange.tosAcceptedEtag, + wireInfo: exchangeDetails.wireInfo, + protocolVersionRange: exchangeDetails.protocolVersionRange, + tosCurrentEtag: exchange.tosCurrentEtag, + tosAcceptedTimestamp: timestampOptionalPreciseFromDb( + exchange.tosAcceptedTimestamp, + ), + scopeInfo, + }; + + if (options.expectedMasterPub) { + if (mySummary.masterPub !== options.expectedMasterPub) { + throw Error( + "public key of the exchange does not match expected public key", + ); + } + } + res = mySummary; + return true; + }, }); - try { - const res = await internalWaitReadyExchange( - wex, - canonUrl, - exchangeNotifFlag, - options, - ); - logger.info("done waiting for ready exchange"); - return res; - } finally { - unregisterOnCancelled(); - cancelNotif(); - } + checkLogicInvariant(!!res); + return res; } function checkPeerPaymentsDisabled( @@ -1359,7 +1366,6 @@ export async function updateExchangeFromUrlHandler( ); refreshCheckNecessary = false; } - if (!(updateNecessary || refreshCheckNecessary)) { logger.trace("update not necessary, running again later"); return TaskRunResult.runAgainAt( @@ -1421,15 +1427,7 @@ export async function updateExchangeFromUrlHandler( logger.trace("finished validating exchange /wire info"); - // We download the text/plain version here, - // because that one needs to exist, and we - // will get the current etag from the response. - const tosDownload = await downloadTosFromAcceptedFormat( - wex, - exchangeBaseUrl, - timeout, - ["text/plain"], - ); + const tosMeta = await downloadTosMeta(wex, exchangeBaseUrl); logger.trace("updating exchange info in database"); @@ -1522,7 +1520,14 @@ export async function updateExchangeFromUrlHandler( }; r.noFees = noFees; r.peerPaymentsDisabled = peerPaymentsDisabled; - r.tosCurrentEtag = tosDownload.tosEtag; + switch (tosMeta.type) { + case "not-found": + r.tosCurrentEtag = undefined; + break; + case "ok": + r.tosCurrentEtag = tosMeta.etag; + break; + } if (existingDetails?.rowId) { newDetails.rowId = existingDetails.rowId; } @@ -1548,7 +1553,10 @@ export async function updateExchangeFromUrlHandler( r.cachebreakNextUpdate = false; await tx.exchanges.put(r); const drRowId = await tx.exchangeDetails.put(newDetails); - checkDbInvariant(typeof drRowId.key === "number"); + checkDbInvariant( + typeof drRowId.key === "number", + "exchange details key is not a number", + ); for (const sk of keysInfo.signingKeys) { // FIXME: validate signing keys before inserting them @@ -2227,10 +2235,12 @@ export async function markExchangeUsed( logger.info(`marking exchange ${exchangeBaseUrl} as used`); const exch = await tx.exchanges.get(exchangeBaseUrl); if (!exch) { + logger.info(`exchange ${exchangeBaseUrl} NOT found`); return { notif: undefined, }; } + const oldExchangeState = getExchangeState(exch); switch (exch.entryStatus) { case ExchangeEntryDbRecordStatus.Ephemeral: diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts index 1f7d95959..5b399a0a7 100644 --- a/packages/taler-wallet-core/src/instructedAmountConversion.ts +++ b/packages/taler-wallet-core/src/instructedAmountConversion.ts @@ -283,7 +283,7 @@ async function getAvailableDenoms( coinAvail.exchangeBaseUrl, coinAvail.denomPubHash, ]); - checkDbInvariant(!!denom); + checkDbInvariant(!!denom, `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`); if (denom.isRevoked || !denom.isOffered) { continue; } @@ -472,7 +472,7 @@ export async function getMaxDepositAmount( export function getMaxDepositAmountForAvailableCoins( denoms: AvailableCoins, currency: string, -) { +): AmountWithFee { const zero = Amounts.zeroOfCurrency(currency); if (!denoms.list.length) { // no coins in the database @@ -663,8 +663,13 @@ function rankDenominationForWithdrawals( //different exchanges may have different wireFee //ranking should take the relative contribution in the exchange //which is (value - denomFee / fixedFee) - const rate1 = Amounts.divmod(d1.value, d1.denomWithdraw).quotient; - const rate2 = Amounts.divmod(d2.value, d2.denomWithdraw).quotient; + + const rate1 = Amounts.isZero(d1.denomWithdraw) + ? Number.MIN_SAFE_INTEGER + : Amounts.divmod(d1.value, d1.denomWithdraw).quotient; + const rate2 = Amounts.isZero(d2.denomWithdraw) + ? Number.MIN_SAFE_INTEGER + : Amounts.divmod(d2.value, d2.denomWithdraw).quotient; const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; return ( contribCmp || @@ -719,8 +724,13 @@ function rankDenominationForDeposit( //different exchanges may have different wireFee //ranking should take the relative contribution in the exchange //which is (value - denomFee / fixedFee) - const rate1 = Amounts.divmod(d1.value, d1.denomDeposit).quotient; - const rate2 = Amounts.divmod(d2.value, d2.denomDeposit).quotient; + const rate1 = Amounts.isZero(d1.denomDeposit) + ? Number.MIN_SAFE_INTEGER + : Amounts.divmod(d1.value, d1.denomDeposit).quotient; + const rate2 = Amounts.isZero(d2.denomDeposit) + ? Number.MIN_SAFE_INTEGER + : Amounts.divmod(d2.value, d2.denomDeposit).quotient; + const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; return ( contribCmp || diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts index 090a11cf0..ee154252f 100644 --- a/packages/taler-wallet-core/src/pay-merchant.ts +++ b/packages/taler-wallet-core/src/pay-merchant.ts @@ -34,7 +34,6 @@ import { assertUnreachable, AsyncFlag, checkDbInvariant, - CheckPaymentResponse, CheckPayTemplateReponse, CheckPayTemplateRequest, codecForAbortResponse, @@ -342,7 +341,6 @@ export class PayMerchantTransactionContext implements TransactionContext { return; } await tx.purchases.put(purchase); - await tx.operationRetries.delete(this.taskId); const newTxState = computePayMerchantTransactionState(purchase); return { oldTxState, newTxState }; }, @@ -1028,11 +1026,17 @@ async function storeFirstPaySuccess( purchase.merchantPaySig = payResponse.sig; purchase.posConfirmation = payResponse.pos_confirmation; const dl = purchase.download; - checkDbInvariant(!!dl); + checkDbInvariant( + !!dl, + `purchase ${purchase.orderId} without ct downloaded`, + ); const contractTermsRecord = await tx.contractTerms.get( dl.contractTermsHash, ); - checkDbInvariant(!!contractTermsRecord); + checkDbInvariant( + !!contractTermsRecord, + `no contract terms found for purchase ${purchase.orderId}`, + ); const contractData = extractContractData( contractTermsRecord.contractTermsRaw, dl.contractTermsHash, @@ -1625,6 +1629,9 @@ export async function checkPayForTemplate( throw TalerError.fromUncheckedDetail(cfg.detail); } + // FIXME: Put body.currencies *and* body.currency in the set of + // supported currencies. + return { templateDetails, supportedCurrencies: Object.keys(cfg.body.currencies), @@ -2086,6 +2093,7 @@ export async function processPurchase( case PurchaseStatus.PendingPayingReplay: return processPurchasePay(wex, proposalId); case PurchaseStatus.PendingQueryingRefund: + case PurchaseStatus.FinalizingQueryingAutoRefund: return processPurchaseQueryRefund(wex, purchase); case PurchaseStatus.PendingQueryingAutoRefund: return processPurchaseAutoRefund(wex, purchase); @@ -2110,6 +2118,7 @@ export async function processPurchase( case PurchaseStatus.SuspendedPendingAcceptRefund: case PurchaseStatus.SuspendedQueryingAutoRefund: case PurchaseStatus.SuspendedQueryingRefund: + case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund: case PurchaseStatus.FailedAbort: case PurchaseStatus.FailedPaidByOther: return TaskRunResult.finished(); @@ -2155,7 +2164,7 @@ async function processPurchasePay( logger.trace(`paying with session ID ${sessionId}`); const payInfo = purchase.payInfo; - checkDbInvariant(!!payInfo, "payInfo"); + checkDbInvariant(!!payInfo, `purchase ${purchase.orderId} without payInfo`); const download = await expectProposalDownload(wex, purchase); @@ -2487,6 +2496,9 @@ const transitionSuspend: { [PurchaseStatus.PendingQueryingAutoRefund]: { next: PurchaseStatus.SuspendedQueryingAutoRefund, }, + [PurchaseStatus.FinalizingQueryingAutoRefund]: { + next: PurchaseStatus.SuspendedFinalizingQueryingAutoRefund, + }, }; const transitionResume: { @@ -2509,6 +2521,9 @@ const transitionResume: { [PurchaseStatus.SuspendedQueryingAutoRefund]: { next: PurchaseStatus.PendingQueryingAutoRefund, }, + [PurchaseStatus.SuspendedFinalizingQueryingAutoRefund]: { + next: PurchaseStatus.FinalizingQueryingAutoRefund, + }, }; export function computePayMerchantTransactionState( @@ -2637,6 +2652,16 @@ export function computePayMerchantTransactionState( major: TransactionMajorState.Failed, minor: TransactionMinorState.PaidByOther, }; + case PurchaseStatus.FinalizingQueryingAutoRefund: + return { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.AutoRefund, + }; + case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund: + return { + major: TransactionMajorState.SuspendedFinalizing, + minor: TransactionMinorState.AutoRefund, + }; default: assertUnreachable(purchaseRecord.purchaseStatus); } @@ -2648,21 +2673,45 @@ export function computePayMerchantTransactionActions( switch (purchaseRecord.purchaseStatus) { // Pending States case PurchaseStatus.PendingDownloadingProposal: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingPaying: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingPayingReplay: // Special "abort" since it goes back to "done". - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingQueryingAutoRefund: // Special "abort" since it goes back to "done". - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingQueryingRefund: // Special "abort" since it goes back to "done". - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingAcceptRefund: // Special "abort" since it goes back to "done". - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; // Suspended Pending States case PurchaseStatus.SuspendedDownloadingProposal: return [TransactionAction.Resume, TransactionAction.Abort]; @@ -2682,14 +2731,18 @@ export function computePayMerchantTransactionActions( return [TransactionAction.Resume, TransactionAction.Abort]; // Aborting States case PurchaseStatus.AbortingWithRefund: - return [TransactionAction.Fail, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Fail, + TransactionAction.Suspend, + ]; case PurchaseStatus.SuspendedAbortingWithRefund: return [TransactionAction.Fail, TransactionAction.Resume]; // Dialog States case PurchaseStatus.DialogProposed: - return []; + return [TransactionAction.Retry]; case PurchaseStatus.DialogShared: - return []; + return [TransactionAction.Retry]; // Final States case PurchaseStatus.AbortedProposalRefused: case PurchaseStatus.AbortedOrderDeleted: @@ -2707,6 +2760,14 @@ export function computePayMerchantTransactionActions( return [TransactionAction.Delete]; case PurchaseStatus.FailedPaidByOther: return [TransactionAction.Delete]; + case PurchaseStatus.FinalizingQueryingAutoRefund: + return [ + TransactionAction.Suspend, + TransactionAction.Retry, + TransactionAction.Delete, + ]; + case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund: + return [TransactionAction.Resume, TransactionAction.Delete]; default: assertUnreachable(purchaseRecord.purchaseStatus); } @@ -2909,8 +2970,12 @@ async function processPurchaseAutoRefund( logger.warn("purchase does not exist anymore"); return; } - if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) { - return; + switch (p.purchaseStatus) { + case PurchaseStatus.PendingQueryingAutoRefund: + case PurchaseStatus.FinalizingQueryingAutoRefund: + break; + default: + return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.Done; @@ -2956,8 +3021,12 @@ async function processPurchaseAutoRefund( logger.warn("purchase does not exist anymore"); return; } - if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) { - return; + switch (p.purchaseStatus) { + case PurchaseStatus.PendingQueryingAutoRefund: + case PurchaseStatus.FinalizingQueryingAutoRefund: + break; + default: + return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.PendingAcceptRefund; @@ -2997,7 +3066,7 @@ async function processPurchaseAbortingRefund( for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { const coinPub = payCoinSelection.coinPubs[i]; const coin = await tx.coins.get(coinPub); - checkDbInvariant(!!coin, "expected coin to be present"); + checkDbInvariant(!!coin, `coin not found for ${coinPub}`); abortingCoins.push({ coin_pub: coinPub, contribution: Amounts.stringify(payCoinSelection.coinContributions[i]), @@ -3501,7 +3570,8 @@ async function storeRefunds( if (isAborting) { myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded; } else if (shouldCheckAutoRefund) { - myPurchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund; + myPurchase.purchaseStatus = + PurchaseStatus.FinalizingQueryingAutoRefund; } else { myPurchase.purchaseStatus = PurchaseStatus.Done; } diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts index bfd39b657..a1729ced7 100644 --- a/packages/taler-wallet-core/src/pay-peer-common.ts +++ b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -140,10 +140,10 @@ export async function getMergeReserveInfo( { storeNames: ["exchanges", "reserves"] }, async (tx) => { const ex = await tx.exchanges.get(req.exchangeBaseUrl); - checkDbInvariant(!!ex); + checkDbInvariant(!!ex, `no exchange record for ${req.exchangeBaseUrl}`); if (ex.currentMergeReserveRowId != null) { const reserve = await tx.reserves.get(ex.currentMergeReserveRowId); - checkDbInvariant(!!reserve); + checkDbInvariant(!!reserve, `reserver ${ex.currentMergeReserveRowId} missing in db`); return reserve; } const reserve: ReserveRecord = { @@ -151,7 +151,7 @@ export async function getMergeReserveInfo( reservePub: newReservePair.pub, }; const insertResp = await tx.reserves.put(reserve); - checkDbInvariant(typeof insertResp.key === "number"); + checkDbInvariant(typeof insertResp.key === "number", `reserve key is not a number`); reserve.rowId = insertResp.key; ex.currentMergeReserveRowId = reserve.rowId; await tx.exchanges.put(ex); diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts index 840c244d0..3e7fdd36b 100644 --- a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts +++ b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -59,6 +59,7 @@ import { TombstoneTag, TransactionContext, constructTaskIdentifier, + requireExchangeTosAcceptedOrThrow, } from "./common.js"; import { KycPendingInfo, @@ -933,6 +934,11 @@ export async function checkPeerPullPaymentInitiation( Amounts.parseOrThrow(req.amount), undefined, ); + if (wi.selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to check pull payment from ${exchangeUrl}, can't select denominations for instructed amount (${req.amount}`, + ); + } logger.trace(`got withdrawal info`); @@ -1021,7 +1027,8 @@ export async function initiatePeerPullPayment( const exchangeBaseUrl = maybeExchangeBaseUrl; - await fetchFreshExchange(wex, exchangeBaseUrl); + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); + requireExchangeTosAcceptedOrThrow(exchange); const mergeReserveInfo = await getMergeReserveInfo(wex, { exchangeBaseUrl: exchangeBaseUrl, @@ -1039,7 +1046,10 @@ export async function initiatePeerPullPayment( const withdrawalGroupId = encodeCrock(getRandomBytes(32)); const mergeReserveRowId = mergeReserveInfo.rowId; - checkDbInvariant(!!mergeReserveRowId); + checkDbInvariant( + !!mergeReserveRowId, + `merge reserve for ${exchangeBaseUrl} without rowid`, + ); const contractEncNonce = encodeCrock(getRandomBytes(24)); @@ -1049,6 +1059,11 @@ export async function initiatePeerPullPayment( Amounts.parseOrThrow(req.partialContractTerms.amount), undefined, ); + if (wi.selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to initiate pull payment from ${exchangeBaseUrl}, can't select denominations for instructed amount (${req.partialContractTerms.amount}`, + ); + } const mergeTimestamp = TalerPreciseTimestamp.now(); @@ -1184,15 +1199,31 @@ export function computePeerPullCreditTransactionActions( ): TransactionAction[] { switch (pullCreditRecord.status) { case PeerPullPaymentCreditStatus.PendingCreatePurse: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPullPaymentCreditStatus.PendingMergeKycRequired: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPullPaymentCreditStatus.PendingReady: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPullPaymentCreditStatus.Done: return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.PendingWithdrawing: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPullPaymentCreditStatus.SuspendedCreatePurse: return [TransactionAction.Resume, TransactionAction.Abort]; case PeerPullPaymentCreditStatus.SuspendedReady: @@ -1204,7 +1235,11 @@ export function computePeerPullCreditTransactionActions( case PeerPullPaymentCreditStatus.Aborted: return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.AbortingDeletePurse: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPullPaymentCreditStatus.Failed: return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.Expired: diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts index 0355b58ad..e9be15026 100644 --- a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -1000,7 +1000,7 @@ export function computePeerPullDebitTransactionActions( ): TransactionAction[] { switch (pullDebitRecord.status) { case PeerPullDebitRecordStatus.DialogProposed: - return []; + return [TransactionAction.Retry, TransactionAction.Delete]; case PeerPullDebitRecordStatus.PendingDeposit: return [TransactionAction.Abort, TransactionAction.Suspend]; case PeerPullDebitRecordStatus.Done: diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts index 93f1a63a7..5a1bfbdbd 100644 --- a/packages/taler-wallet-core/src/pay-peer-push-credit.ts +++ b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -61,6 +61,7 @@ import { TombstoneTag, TransactionContext, constructTaskIdentifier, + requireExchangeTosAcceptedOrThrow, } from "./common.js"; import { KycPendingInfo, @@ -407,7 +408,8 @@ export async function preparePeerPushCredit( const exchangeBaseUrl = uri.exchangeBaseUrl; - await fetchFreshExchange(wex, exchangeBaseUrl); + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); + requireExchangeTosAcceptedOrThrow(exchange); const contractPriv = uri.contractPriv; const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); @@ -459,6 +461,12 @@ export async function preparePeerPushCredit( undefined, ); + if (wi.selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to prepare push credit from ${exchangeBaseUrl}, can't select denominations for instructed amount (${purseStatus.balance}`, + ); + } + const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["contractTerms", "peerPushCredit"] }, async (tx) => { @@ -872,7 +880,10 @@ export async function processPeerPushCredit( `processing peerPushCredit in state ${peerInc.status.toString(16)}`, ); - checkDbInvariant(!!contractTerms); + checkDbInvariant( + !!contractTerms, + `not contract terms for peer push ${peerPushCreditId}`, + ); switch (peerInc.status) { case PeerPushCreditStatus.PendingMergeKycRequired: { @@ -1011,15 +1022,27 @@ export function computePeerPushCreditTransactionActions( ): TransactionAction[] { switch (pushCreditRecord.status) { case PeerPushCreditStatus.DialogProposed: - return [TransactionAction.Delete]; + return [TransactionAction.Retry, TransactionAction.Delete]; case PeerPushCreditStatus.PendingMerge: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPushCreditStatus.Done: return [TransactionAction.Delete]; case PeerPushCreditStatus.PendingMergeKycRequired: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPushCreditStatus.PendingWithdrawing: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPushCreditStatus.SuspendedMerge: return [TransactionAction.Resume, TransactionAction.Abort]; case PeerPushCreditStatus.SuspendedMergeKycRequired: diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts index 6452407ff..f8e6adb3c 100644 --- a/packages/taler-wallet-core/src/pay-peer-push-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -406,7 +406,10 @@ async function handlePurseCreationConflict( const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount); const sel = peerPushInitiation.coinSel; - checkDbInvariant(!!sel); + checkDbInvariant( + !!sel, + `no coin selected for peer push initiation ${peerPushInitiation.pursePub}`, + ); const repair: PreviousPayCoins = []; @@ -1218,17 +1221,37 @@ export function computePeerPushDebitTransactionActions( ): TransactionAction[] { switch (ppiRecord.status) { case PeerPushDebitStatus.PendingCreatePurse: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPushDebitStatus.PendingReady: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPushDebitStatus.Aborted: return [TransactionAction.Delete]; case PeerPushDebitStatus.AbortingDeletePurse: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPushDebitStatus.AbortingRefreshDeleted: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPushDebitStatus.AbortingRefreshExpired: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPushDebitStatus.SuspendedAbortingRefreshExpired: return [TransactionAction.Resume, TransactionAction.Fail]; case PeerPushDebitStatus.SuspendedAbortingDeletePurse: diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts index 6a09f9a0e..be5731b0b 100644 --- a/packages/taler-wallet-core/src/recoup.ts +++ b/packages/taler-wallet-core/src/recoup.ts @@ -199,8 +199,8 @@ async function recoupRefreshCoin( revokedCoin.exchangeBaseUrl, revokedCoin.denomPubHash, ); - checkDbInvariant(!!oldCoinDenom); - checkDbInvariant(!!revokedCoinDenom); + checkDbInvariant(!!oldCoinDenom, `no denom for coin, hash ${oldCoin.denomPubHash}`); + checkDbInvariant(!!revokedCoinDenom, `no revoked denom for coin, hash ${revokedCoin.denomPubHash}`); revokedCoin.status = CoinStatus.Dormant; if (!revokedCoin.spendAllocation) { // We don't know what happened to this coin diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts index 7800967e6..05c65f6b6 100644 --- a/packages/taler-wallet-core/src/refresh.ts +++ b/packages/taler-wallet-core/src/refresh.ts @@ -29,7 +29,6 @@ import { Amounts, amountToPretty, assertUnreachable, - AsyncFlag, checkDbInvariant, codecForCoinHistoryResponse, codecForExchangeMeltResponse, @@ -68,12 +67,14 @@ import { WalletNotification, } from "@gnu-taler/taler-util"; import { + HttpResponse, readSuccessResponseJsonOrThrow, readTalerErrorResponse, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { constructTaskIdentifier, + genericWaitForState, makeCoinsVisible, PendingTaskType, TaskIdStr, @@ -386,7 +387,6 @@ async function getCoinAvailabilityForDenom( denom: DenominationInfo, ageRestriction: number, ): Promise<CoinAvailabilityRecord> { - checkDbInvariant(!!denom); let car = await tx.coinAvailability.get([ denom.exchangeBaseUrl, denom.denomPubHash, @@ -537,7 +537,10 @@ async function destroyRefreshSession( denom, oldCoin.maxAge, ); - checkDbInvariant(car.pendingRefreshOutputCount != null); + checkDbInvariant( + car.pendingRefreshOutputCount != null, + `no pendingRefreshOutputCount for denom ${dph}`, + ); car.pendingRefreshOutputCount = car.pendingRefreshOutputCount - refreshSession.newDenoms[i].count; await tx.coinAvailability.put(car); @@ -693,7 +696,7 @@ async function refreshMelt( switch (resp.status) { case HttpStatusCode.NotFound: { const errDetail = await readTalerErrorResponse(resp); - await handleRefreshMeltNotFound(ctx, coinIndex, errDetail); + await handleRefreshMeltNotFound(ctx, coinIndex, resp, errDetail); return; } case HttpStatusCode.Gone: { @@ -898,9 +901,18 @@ async function handleRefreshMeltConflict( async function handleRefreshMeltNotFound( ctx: RefreshTransactionContext, coinIndex: number, + resp: HttpResponse, errDetails: TalerErrorDetail, ): Promise<void> { - // FIXME: Validate the exchange's error response + // Make sure that we only act on a 404 that indicates a final problem + // with the coin. + switch (errDetails.code) { + case TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN: + case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN: + break; + default: + throwUnexpectedRequestError(resp, errDetails); + } await ctx.wex.db.runReadWriteTx( { storeNames: [ @@ -1242,7 +1254,10 @@ async function refreshReveal( coin.exchangeBaseUrl, coin.denomPubHash, ); - checkDbInvariant(!!denomInfo); + checkDbInvariant( + !!denomInfo, + `no denom with hash ${coin.denomPubHash}`, + ); const car = await getCoinAvailabilityForDenom( wex, tx, @@ -1252,6 +1267,7 @@ async function refreshReveal( checkDbInvariant( car.pendingRefreshOutputCount != null && car.pendingRefreshOutputCount > 0, + `no pendingRefreshOutputCount for denom ${coin.denomPubHash} age ${coin.maxAge}`, ); car.pendingRefreshOutputCount--; car.freshCoinCount++; @@ -1559,9 +1575,22 @@ async function applyRefreshToOldCoins( coin.denomPubHash, coin.maxAge, ]); - checkDbInvariant(!!coinAv); - checkDbInvariant(coinAv.freshCoinCount > 0); + checkDbInvariant( + !!coinAv, + `no denom info for ${coin.denomPubHash} age ${coin.maxAge}`, + ); + checkDbInvariant( + coinAv.freshCoinCount > 0, + `no fresh coins for ${coin.denomPubHash}`, + ); coinAv.freshCoinCount--; + if (coin.visible) { + if (!coinAv.visibleCoinCount) { + logger.error("coin availability inconsistent"); + } else { + coinAv.visibleCoinCount--; + } + } await tx.coinAvailability.put(coinAv); break; } @@ -1770,7 +1799,7 @@ export async function forceRefresh( ], }, async (tx) => { - let coinPubs: CoinRefreshRequest[] = []; + const coinPubs: CoinRefreshRequest[] = []; for (const c of req.refreshCoinSpecs) { const coin = await tx.coins.get(c.coinPub); if (!coin) { @@ -1782,7 +1811,7 @@ export async function forceRefresh( coin.exchangeBaseUrl, coin.denomPubHash, ); - checkDbInvariant(!!denom); + checkDbInvariant(!!denom, `no denom hash: ${coin.denomPubHash}`); coinPubs.push({ coinPub: c.coinPub, amount: c.amount ?? denom.value, @@ -1818,66 +1847,38 @@ export async function waitRefreshFinal( const ctx = new RefreshTransactionContext(wex, refreshGroupId); wex.taskScheduler.startShepherdTask(ctx.taskId); - // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax. - const refreshNotifFlag = new AsyncFlag(); - // Raise purchaseNotifFlag whenever we get a notification - // about our refresh. - const cancelNotif = wex.ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.TransactionStateTransition && - notif.transactionId === ctx.transactionId - ) { - refreshNotifFlag.raise(); - } - }); - const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { - cancelNotif(); - refreshNotifFlag.raise(); + await genericWaitForState(wex, { + async checkState(): Promise<boolean> { + // Check if refresh is final + const res = await ctx.wex.db.runReadOnlyTx( + { storeNames: ["refreshGroups"] }, + async (tx) => { + return { + rg: await tx.refreshGroups.get(ctx.refreshGroupId), + }; + }, + ); + const { rg } = res; + if (!rg) { + // Must've been deleted, we consider that final. + return true; + } + switch (rg.operationStatus) { + case RefreshOperationStatus.Failed: + case RefreshOperationStatus.Finished: + // Transaction is final + return true; + case RefreshOperationStatus.Pending: + case RefreshOperationStatus.Suspended: + break; + } + return false; + }, + filterNotification(notif): boolean { + return ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ); + }, }); - - try { - await internalWaitRefreshFinal(ctx, refreshNotifFlag); - } catch (e) { - unregisterOnCancelled(); - cancelNotif(); - } -} - -async function internalWaitRefreshFinal( - ctx: RefreshTransactionContext, - flag: AsyncFlag, -): Promise<void> { - while (true) { - if (ctx.wex.cancellationToken.isCancelled) { - throw Error("cancelled"); - } - - // Check if refresh is final - const res = await ctx.wex.db.runReadOnlyTx( - { storeNames: ["refreshGroups", "operationRetries"] }, - async (tx) => { - return { - rg: await tx.refreshGroups.get(ctx.refreshGroupId), - }; - }, - ); - const { rg } = res; - if (!rg) { - // Must've been deleted, we consider that final. - return; - } - switch (rg.operationStatus) { - case RefreshOperationStatus.Failed: - case RefreshOperationStatus.Finished: - // Transaction is final - return; - case RefreshOperationStatus.Pending: - case RefreshOperationStatus.Suspended: - break; - } - - // Wait for the next transition - await flag.wait(); - flag.reset(); - } } diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts index 3b160d97f..470f45aff 100644 --- a/packages/taler-wallet-core/src/shepherd.ts +++ b/packages/taler-wallet-core/src/shepherd.ts @@ -50,12 +50,13 @@ import { parseTaskIdentifier, } from "./common.js"; import { - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, OperationRetryRecord, WalletDbAllStoresReadOnlyTransaction, WalletDbReadOnlyTransaction, timestampAbsoluteFromDb, + timestampPreciseToDb, } from "./db.js"; import { computeDepositTransactionStatus, @@ -113,6 +114,8 @@ const logger = new Logger("shepherd.ts"); */ interface ShepherdInfo { cts: CancellationToken.Source; + latch?: Promise<void>; + stopped: boolean; } /** @@ -256,29 +259,36 @@ export class TaskSchedulerImpl implements TaskScheduler { async reload(): Promise<void> { await this.ensureRunning(); const tasksIds = [...this.sheps.keys()]; - logger.info(`reloading sheperd with ${tasksIds.length} tasks`); + logger.info(`reloading shepherd with ${tasksIds.length} tasks`); for (const taskId of tasksIds) { - this.stopShepherdTask(taskId); + await this.stopShepherdTask(taskId); } for (const taskId of tasksIds) { this.startShepherdTask(taskId); } } - private async internalStartShepherdTask(taskId: TaskIdStr): Promise<void> { logger.trace(`Starting to shepherd task ${taskId}`); const oldShep = this.sheps.get(taskId); if (oldShep) { - logger.trace(`Already have a shepherd for ${taskId}`); - return; + if (!oldShep.stopped) { + logger.trace(`Already have a shepherd for ${taskId}`); + return; + } + logger.trace( + `Waiting old task to complete the loop in cancel mode ${taskId}`, + ); + await oldShep.latch; } logger.trace(`Creating new shepherd for ${taskId}`); const newShep: ShepherdInfo = { cts: CancellationToken.create(), + stopped: false, }; this.sheps.set(taskId, newShep); try { - await this.internalShepherdTask(taskId, newShep); + newShep.latch = this.internalShepherdTask(taskId, newShep); + await newShep.latch; } finally { logger.trace(`Done shepherding ${taskId}`); this.sheps.delete(taskId); @@ -291,8 +301,8 @@ export class TaskSchedulerImpl implements TaskScheduler { const oldShep = this.sheps.get(taskId); if (oldShep) { logger.trace(`Cancelling old shepherd for ${taskId}`); - oldShep.cts.cancel(); - this.sheps.delete(taskId); + oldShep.cts.cancel(`stopping task ${taskId}`); + oldShep.stopped = true; this.iterCond.trigger(); } } @@ -306,6 +316,7 @@ export class TaskSchedulerImpl implements TaskScheduler { const maybeNotification = await this.ws.db.runAllStoresReadWriteTx( {}, async (tx) => { + logger.trace(`storing task [reset] for ${taskId}`); await tx.operationRetries.delete(taskId); return taskToRetryNotification(this.ws, tx, taskId, undefined); }, @@ -325,7 +336,13 @@ export class TaskSchedulerImpl implements TaskScheduler { try { await info.cts.token.racePromise(this.ws.timerGroup.resolveAfter(delay)); } catch (e) { - logger.info(`waiting for ${taskId} interrupted`); + if (e instanceof CancellationToken.CancellationError) { + logger.info( + `waiting for ${taskId} interrupted: ${e.message} ${j2s(e.reason)}`, + ); + } else { + logger.info(`waiting for ${taskId} interrupted: ${e}`); + } } } @@ -363,13 +380,14 @@ export class TaskSchedulerImpl implements TaskScheduler { try { res = await callOperationHandlerForTaskId(wex, taskId); } catch (e) { + logger.trace(`Shepherd error ${taskId} saving response ${e}`); res = { type: TaskRunResultType.Error, errorDetail: getErrorDetailFromException(e), }; } if (info.cts.token.isCancelled) { - logger.trace("task cancelled, not processing result"); + logger.trace(`task ${taskId} cancelled, not processing result`); return; } if (this.ws.stopped) { @@ -382,7 +400,9 @@ export class TaskSchedulerImpl implements TaskScheduler { }); switch (res.type) { case TaskRunResultType.Error: { - logger.trace(`Shepherd for ${taskId} got error result.`); + logger.trace( + `Shepherd for ${taskId} got error result: ${j2s(res.errorDetail)}`, + ); const retryRecord = await storePendingTaskError( this.ws, taskId, @@ -412,8 +432,13 @@ export class TaskSchedulerImpl implements TaskScheduler { } case TaskRunResultType.ScheduleLater: { logger.trace(`Shepherd for ${taskId} got schedule-later result.`); - await storeTaskProgress(this.ws, taskId); - const delay = AbsoluteTime.remaining(res.runAt); + const retryRecord = await storePendingTaskPending( + this.ws, + taskId, + res.runAt, + ); + const t = timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry); + const delay = AbsoluteTime.remaining(t); logger.trace(`Waiting for ${delay.d_ms} ms`); await this.wait(taskId, info, delay); break; @@ -451,7 +476,7 @@ async function storePendingTaskError( pendingTaskId: string, e: TalerErrorDetail, ): Promise<OperationRetryRecord> { - logger.info(`storing pending task error for ${pendingTaskId}`); + logger.trace(`storing task [pending] with ERROR for ${pendingTaskId}`); const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => { let retryRecord = await tx.operationRetries.get(pendingTaskId); if (!retryRecord) { @@ -483,6 +508,7 @@ async function storeTaskProgress( ws: InternalWalletState, pendingTaskId: string, ): Promise<void> { + logger.trace(`storing task [progress] for ${pendingTaskId}`); await ws.db.runReadWriteTx( { storeNames: ["operationRetries"] }, async (tx) => { @@ -494,7 +520,9 @@ async function storeTaskProgress( async function storePendingTaskPending( ws: InternalWalletState, pendingTaskId: string, + schedTime?: AbsoluteTime, ): Promise<OperationRetryRecord> { + logger.trace(`storing task [pending] for ${pendingTaskId}`); const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => { let retryRecord = await tx.operationRetries.get(pendingTaskId); let hadError = false; @@ -510,6 +538,11 @@ async function storePendingTaskPending( delete retryRecord.lastError; retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo); } + if (schedTime) { + retryRecord.retryInfo.nextRetry = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(schedTime), + ); + } await tx.operationRetries.put(retryRecord); let notification: WalletNotification | undefined = undefined; if (hadError) { @@ -535,6 +568,7 @@ async function storePendingTaskFinished( ws: InternalWalletState, pendingTaskId: string, ): Promise<void> { + logger.trace(`storing task [finished] for ${pendingTaskId}`); await ws.db.runReadWriteTx( { storeNames: ["operationRetries"] }, async (tx) => { @@ -978,8 +1012,8 @@ export async function getActiveTaskIds( }, async (tx) => { const active = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); // Withdrawals diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts index 9a9fb524f..7782d09ba 100644 --- a/packages/taler-wallet-core/src/transactions.ts +++ b/packages/taler-wallet-core/src/transactions.ts @@ -62,8 +62,8 @@ import { DenomLossEventRecord, DepositElementStatus, DepositGroupRecord, - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, OperationRetryRecord, PeerPullCreditRecord, PeerPullDebitRecordStatus, @@ -93,7 +93,6 @@ import { computeDenomLossTransactionStatus, DenomLossTransactionContext, ExchangeWireDetails, - fetchFreshExchange, getExchangeWireDetailsInTx, } from "./exchanges.js"; import { @@ -244,11 +243,14 @@ export async function getTransactionById( const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord); const ort = await tx.operationRetries.get(opId); - const exchangeDetails = await getExchangeWireDetailsInTx( - tx, - withdrawalGroupRecord.exchangeBaseUrl, - ); - if (!exchangeDetails) throw Error("not exchange details"); + const exchangeDetails = + withdrawalGroupRecord.exchangeBaseUrl === undefined + ? undefined + : await getExchangeWireDetailsInTx( + tx, + withdrawalGroupRecord.exchangeBaseUrl, + ); + // if (!exchangeDetails) throw Error("not exchange details"); if ( withdrawalGroupRecord.wgInfo.withdrawalType === @@ -260,7 +262,10 @@ export async function getTransactionById( ort, ); } - + checkDbInvariant( + exchangeDetails !== undefined, + "manual withdrawal without exchange", + ); return buildTransactionForManualWithdraw( withdrawalGroupRecord, exchangeDetails, @@ -405,7 +410,10 @@ export async function getTransactionById( const debit = await tx.peerPushDebit.get(parsedTx.pursePub); if (!debit) throw Error("not found"); const ct = await tx.contractTerms.get(debit.contractTermsHash); - checkDbInvariant(!!ct); + checkDbInvariant( + !!ct, + `no contract terms for p2p push ${parsedTx.pursePub}`, + ); return buildTransactionForPushPaymentDebit( debit, ct.contractTermsRaw, @@ -429,7 +437,10 @@ export async function getTransactionById( const pushInc = await tx.peerPushCredit.get(peerPushCreditId); if (!pushInc) throw Error("not found"); const ct = await tx.contractTerms.get(pushInc.contractTermsHash); - checkDbInvariant(!!ct); + checkDbInvariant( + !!ct, + `no contract terms for p2p push ${peerPushCreditId}`, + ); let wg: WithdrawalGroupRecord | undefined = undefined; let wgOrt: OperationRetryRecord | undefined = undefined; @@ -441,7 +452,7 @@ export async function getTransactionById( } } const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pushInc); - let pushIncOrt = await tx.operationRetries.get(pushIncOpId); + const pushIncOrt = await tx.operationRetries.get(pushIncOpId); return buildTransactionForPeerPushCredit( pushInc, @@ -469,7 +480,7 @@ export async function getTransactionById( const pushInc = await tx.peerPullCredit.get(pursePub); if (!pushInc) throw Error("not found"); const ct = await tx.contractTerms.get(pushInc.contractTermsHash); - checkDbInvariant(!!ct); + checkDbInvariant(!!ct, `no contract terms for p2p push ${pursePub}`); let wg: WithdrawalGroupRecord | undefined = undefined; let wgOrt: OperationRetryRecord | undefined = undefined; @@ -594,6 +605,7 @@ function buildTransactionForPeerPullCredit( const txState = computePeerPullCreditTransactionState(pullCredit); checkDbInvariant(wsr.instructedAmount !== undefined, "wg uninitialized"); checkDbInvariant(wsr.denomsSel !== undefined, "wg uninitialized"); + checkDbInvariant(wsr.exchangeBaseUrl !== undefined, "wg uninitialized"); return { type: TransactionType.PeerPullCredit, txState, @@ -668,6 +680,7 @@ function buildTransactionForPeerPushCredit( } checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized"); checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized"); + checkDbInvariant(wg.exchangeBaseUrl !== undefined, "wg uninitialized"); const txState = computePeerPushCreditTransactionState(pushInc); return { @@ -720,16 +733,21 @@ function buildTransactionForPeerPushCredit( function buildTransactionForBankIntegratedWithdraw( wg: WithdrawalGroupRecord, - exchangeDetails: ExchangeWireDetails, + exchangeDetails: ExchangeWireDetails | undefined, ort?: OperationRetryRecord, ): TransactionWithdrawal { if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { throw Error(""); } + const instructedCurrency = + wg.instructedAmount === undefined + ? undefined + : Amounts.currencyOf(wg.instructedAmount); + const currency = wg.wgInfo.bankInfo.currency ?? instructedCurrency; + checkDbInvariant(currency !== undefined, "wg uninitialized (missing currency)"); const txState = computeWithdrawalTransactionStatus(wg); - const zero = Amounts.stringify( - Amounts.zeroOfCurrency(exchangeDetails.currency), - ); + + const zero = Amounts.stringify(Amounts.zeroOfCurrency(currency)); return { type: TransactionType.Withdrawal, txState, @@ -785,6 +803,7 @@ function buildTransactionForManualWithdraw( checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized"); checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized"); + checkDbInvariant(wg.exchangeBaseUrl !== undefined, "wg uninitialized"); const exchangePaytoUris = augmentPaytoUrisForWithdrawal( plainPaytoUris, wg.reservePub, @@ -1035,8 +1054,14 @@ function buildTransactionForPurchase( })); const timestamp = purchaseRecord.timestampAccept; - checkDbInvariant(!!timestamp); - checkDbInvariant(!!purchaseRecord.payInfo); + checkDbInvariant( + !!timestamp, + `purchase ${purchaseRecord.orderId} without accepted time`, + ); + checkDbInvariant( + !!purchaseRecord.payInfo, + `purchase ${purchaseRecord.orderId} without payinfo`, + ); const txState = computePayMerchantTransactionState(purchaseRecord); return { @@ -1090,6 +1115,10 @@ export async function getWithdrawalTransactionByUri( if (!withdrawalGroupRecord) { return undefined; } + if (withdrawalGroupRecord.exchangeBaseUrl === undefined) { + // prepared and unconfirmed withdrawals are hidden + return undefined; + } const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord); const ort = await tx.operationRetries.get(opId); @@ -1176,7 +1205,7 @@ export async function getTransactions( return; } const ct = await tx.contractTerms.get(pi.contractTermsHash); - checkDbInvariant(!!ct); + checkDbInvariant(!!ct, `no contract terms for p2p push ${pi.pursePub}`); transactions.push( buildTransactionForPushPaymentDebit(pi, ct.contractTermsRaw), ); @@ -1250,9 +1279,9 @@ export async function getTransactions( } } const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pi); - let pushIncOrt = await tx.operationRetries.get(pushIncOpId); + const pushIncOrt = await tx.operationRetries.get(pushIncOpId); - checkDbInvariant(!!ct); + checkDbInvariant(!!ct, `no contract terms for p2p push ${pi.pursePub}`); transactions.push( buildTransactionForPeerPushCredit( pi, @@ -1284,9 +1313,9 @@ export async function getTransactions( } } const pushIncOpId = TaskIdentifiers.forPeerPullPaymentInitiation(pi); - let pushIncOrt = await tx.operationRetries.get(pushIncOpId); + const pushIncOrt = await tx.operationRetries.get(pushIncOpId); - checkDbInvariant(!!ct); + checkDbInvariant(!!ct, `no contract terms for p2p push ${pi.pursePub}`); transactions.push( buildTransactionForPeerPullCredit( pi, @@ -1772,7 +1801,11 @@ export async function retryTransaction( } } +/** + * Reset the task retry counter for all tasks. + */ export async function retryAll(wex: WalletExecutionContext): Promise<void> { + await wex.taskScheduler.ensureRunning(); const tasks = wex.taskScheduler.getActiveTasks(); for (const task of tasks) { await wex.taskScheduler.resetTaskRetries(task); @@ -1935,8 +1968,8 @@ async function iterRecordsForWithdrawal( let withdrawalGroupRecords: WithdrawalGroupRecord[]; if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); withdrawalGroupRecords = await tx.withdrawalGroups.indexes.byStatus.getAll(keyRange); @@ -1957,8 +1990,8 @@ async function iterRecordsForDeposit( let dgs: DepositGroupRecord[]; if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); dgs = await tx.depositGroups.indexes.byStatus.getAll(keyRange); } else { @@ -1978,8 +2011,8 @@ async function iterRecordsForDenomLoss( let dgs: DenomLossEventRecord[]; if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); dgs = await tx.denomLossEvents.indexes.byStatus.getAll(keyRange); } else { @@ -1998,8 +2031,8 @@ async function iterRecordsForRefund( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2014,8 +2047,8 @@ async function iterRecordsForPurchase( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2030,8 +2063,8 @@ async function iterRecordsForPeerPullCredit( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2046,8 +2079,8 @@ async function iterRecordsForPeerPullDebit( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2062,8 +2095,8 @@ async function iterRecordsForPeerPushDebit( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2078,8 +2111,8 @@ async function iterRecordsForPeerPushCredit( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { diff --git a/packages/taler-wallet-core/src/versions.ts b/packages/taler-wallet-core/src/versions.ts index d33a23cdd..8b4b24351 100644 --- a/packages/taler-wallet-core/src/versions.ts +++ b/packages/taler-wallet-core/src/versions.ts @@ -29,13 +29,6 @@ export const WALLET_EXCHANGE_PROTOCOL_VERSION = "17:0:0"; export const WALLET_MERCHANT_PROTOCOL_VERSION = "5:0:1"; /** - * Protocol version spoken with the bank (bank integration API). - * - * Uses libtool's current:revision:age versioning. - */ -export const WALLET_BANK_INTEGRATION_PROTOCOL_VERSION = "1:0:0"; - -/** * Protocol version spoken with the bank (corebank API). * * Uses libtool's current:revision:age versioning. @@ -52,7 +45,7 @@ export const WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION = "2:0:0"; /** * Libtool version of the wallet-core API. */ -export const WALLET_CORE_API_PROTOCOL_VERSION = "5:0:0"; +export const WALLET_CORE_API_PROTOCOL_VERSION = "7:0:0"; /** * Libtool rules: diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index 1bcab801c..aa88331ea 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -123,7 +123,6 @@ import { StartRefundQueryForUriResponse, StartRefundQueryRequest, StoredBackupList, - TalerMerchantApi, TestPayArgs, TestPayResult, TestingGetDenomStatsRequest, @@ -277,6 +276,7 @@ export enum WalletApiOperation { TestingGetDenomStats = "testingGetDenomStats", TestingPing = "testingPing", TestingGetReserveHistory = "testingGetReserveHistory", + TestingResetAllRetries = "testingResetAllRetries", } // group: Initialization @@ -1213,6 +1213,16 @@ export type TestingGetReserveHistoryOp = { }; /** + * Reset all task/transaction retries, + * resulting in immediate re-try of all operations. + */ +export type TestingResetAllRetriesOp = { + op: WalletApiOperation.TestingResetAllRetries; + request: EmptyObject; + response: EmptyObject; +}; + +/** * Get stats about an exchange denomination. */ export type TestingGetDenomStatsOp = { @@ -1356,6 +1366,7 @@ export type WalletOperations = { [WalletApiOperation.ConfirmWithdrawal]: ConfirmWithdrawalOp; [WalletApiOperation.CanonicalizeBaseUrl]: CanonicalizeBaseUrlOp; [WalletApiOperation.TestingGetReserveHistory]: TestingGetReserveHistoryOp; + [WalletApiOperation.TestingResetAllRetries]: TestingResetAllRetriesOp; [WalletApiOperation.HintNetworkAvailability]: HintNetworkAvailabilityOp; }; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 68da15410..7a69fcb21 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -56,6 +56,7 @@ import { PrepareWithdrawExchangeResponse, RecoverStoredBackupRequest, StoredBackupList, + TalerBankIntegrationHttpClient, TalerError, TalerErrorCode, TalerProtocolTimestamp, @@ -107,6 +108,7 @@ import { codecForGetExchangeTosRequest, codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, + codecForHintNetworkAvailabilityRequest, codecForImportDbRequest, codecForInitRequest, codecForInitiatePeerPullPaymentRequest, @@ -265,6 +267,7 @@ import { TaskScheduler, TaskSchedulerImpl, convertTaskToTransactionId, + getActiveTaskIds, listTaskForTransactionId, } from "./shepherd.js"; import { @@ -287,12 +290,12 @@ import { getWithdrawalTransactionByUri, parseTransactionIdentifier, resumeTransaction, + retryAll, retryTransaction, suspendTransaction, } from "./transactions.js"; import { WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, - WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_COREBANK_API_PROTOCOL_VERSION, WALLET_CORE_API_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, @@ -476,7 +479,10 @@ async function setCoinSuspended( c.denomPubHash, c.maxAge, ]); - checkDbInvariant(!!coinAvailability); + checkDbInvariant( + !!coinAvailability, + `no denom info for ${c.denomPubHash} age ${c.maxAge}`, + ); if (suspended) { if (c.status !== CoinStatus.Fresh) { return; @@ -721,12 +727,11 @@ async function dispatchRequestInternal( case WalletApiOperation.InitWallet: { const req = codecForInitRequest().decode(payload); - logger.info(`init request: ${j2s(req)}`); - - if (wex.ws.initCalled) { - logger.info("initializing wallet (repeat initialization)"); - } else { - logger.info("initializing wallet (first initialization)"); + if (logger.shouldLogTrace()) { + const initType = wex.ws.initCalled + ? "repeat initialization" + : "first initialization"; + logger.trace(`init request (${initType}): ${j2s(req)}`); } // Write to the DB to make sure that we're failing early in @@ -744,7 +749,6 @@ async function dispatchRequestInternal( innerError: getErrorDetailFromException(e), }); } - wex.ws.initWithConfig(applyRunConfigDefaults(req.config)); if (wex.ws.config.testing.skipDefaults) { @@ -757,8 +761,11 @@ async function dispatchRequestInternal( versionInfo: getVersion(wex), }; - // After initialization, task loop should run. - await wex.taskScheduler.ensureRunning(); + if (req.config?.lazyTaskLoop) { + logger.trace("lazily starting task loop"); + } else { + await wex.taskScheduler.ensureRunning(); + } wex.ws.initCalled = true; return resp; @@ -996,6 +1003,7 @@ async function dispatchRequestInternal( talerWithdrawUri: req.talerWithdrawUri, forcedDenomSel: req.forcedDenomSel, restrictAge: req.restrictAge, + amount: req.amount, }); } case WalletApiOperation.ConfirmWithdrawal: { @@ -1005,10 +1013,7 @@ async function dispatchRequestInternal( case WalletApiOperation.PrepareBankIntegratedWithdrawal: { const req = codecForPrepareBankIntegratedWithdrawalRequest().decode(payload); - return prepareBankIntegratedWithdrawal(wex, { - talerWithdrawUri: req.talerWithdrawUri, - selectedExchange: req.selectedExchange, - }); + return prepareBankIntegratedWithdrawal(wex, req); } case WalletApiOperation.GetExchangeTos: { const req = codecForGetExchangeTosRequest().decode(payload); @@ -1046,6 +1051,10 @@ async function dispatchRequestInternal( const req = codecForPrepareWithdrawExchangeRequest().decode(payload); return handlePrepareWithdrawExchange(wex, req); } + case WalletApiOperation.CheckPayForTemplate: { + const req = codecForCheckPayTemplateRequest().decode(payload); + return await checkPayForTemplate(wex, req); + } case WalletApiOperation.PreparePayForUri: { const req = codecForPreparePayRequest().decode(payload); return await preparePayForUri(wex, req.talerPayUri); @@ -1054,10 +1063,6 @@ async function dispatchRequestInternal( const req = codecForPreparePayTemplateRequest().decode(payload); return preparePayForTemplate(wex, req); } - case WalletApiOperation.CheckPayForTemplate: { - const req = codecForCheckPayTemplateRequest().decode(payload); - return checkPayForTemplate(wex, req); - } case WalletApiOperation.ConfirmPay: { const req = codecForConfirmPayRequest().decode(payload); let transactionId; @@ -1085,7 +1090,7 @@ async function dispatchRequestInternal( return {}; } case WalletApiOperation.GetActiveTasks: { - const allTasksId = wex.taskScheduler.getActiveTasks(); + const allTasksId = (await getActiveTaskIds(wex.ws)).taskIds; const tasksInfo = await Promise.all( allTasksId.map(async (id) => { @@ -1234,10 +1239,16 @@ async function dispatchRequestInternal( await loadBackupRecovery(wex, req); return {}; } - // case WalletApiOperation.GetPlanForOperation: { - // const req = codecForGetPlanForOperationRequest().decode(payload); - // return await getPlanForOperation(ws, req); - // } + case WalletApiOperation.HintNetworkAvailability: { + const req = codecForHintNetworkAvailabilityRequest().decode(payload); + if (req.isNetworkAvailable) { + await retryAll(wex); + } else { + // We're not doing anything right now, but we could stop showing + // certain errors! + } + return {}; + } case WalletApiOperation.ConvertDepositAmount: { const req = codecForConvertAmountRequest.decode(payload); return await convertDepositAmount(wex, req); @@ -1388,7 +1399,10 @@ async function dispatchRequestInternal( return; } wex.ws.exchangeCache.clear(); - checkDbInvariant(!!existingRec.id); + checkDbInvariant( + !!existingRec.id, + `no global exchange for ${j2s(key)}`, + ); await tx.globalCurrencyExchanges.delete(existingRec.id); }, ); @@ -1421,6 +1435,9 @@ async function dispatchRequestInternal( await waitTasksDone(wex); return {}; } + case WalletApiOperation.TestingResetAllRetries: + await retryAll(wex); + return {}; case WalletApiOperation.RemoveGlobalCurrencyAuditor: { const req = codecForRemoveGlobalCurrencyAuditorRequest().decode(payload); await wex.db.runReadWriteTx( @@ -1434,7 +1451,10 @@ async function dispatchRequestInternal( if (!existingRec) { return; } - checkDbInvariant(!!existingRec.id); + checkDbInvariant( + !!existingRec.id, + `no global currency for ${j2s(key)}`, + ); await tx.globalCurrencyAuditors.delete(existingRec.id); wex.ws.exchangeCache.clear(); }, @@ -1569,9 +1589,9 @@ export function getVersion(wex: WalletExecutionContext): WalletCoreVersion { exchange: WALLET_EXCHANGE_PROTOCOL_VERSION, merchant: WALLET_MERCHANT_PROTOCOL_VERSION, bankConversionApiRange: WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, - bankIntegrationApiRange: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + bankIntegrationApiRange: TalerBankIntegrationHttpClient.PROTOCOL_VERSION, corebankApiRange: WALLET_COREBANK_API_PROTOCOL_VERSION, - bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + bank: TalerBankIntegrationHttpClient.PROTOCOL_VERSION, devMode: wex.ws.config.testing.devModeActive, }; return result; @@ -1629,10 +1649,10 @@ async function handleCoreApiRequest( if (!ws.initCalled) { throw Error("init must be called first"); } - // Might be lazily initialized! - await ws.taskScheduler.ensureRunning(); } + await ws.ensureWalletDbOpen(); + let wex: WalletExecutionContext; let oc: ObservabilityContext; @@ -1832,7 +1852,7 @@ class WalletDbTriggerSpec implements TriggerSpec { if (info.mode !== "readwrite") { return; } - logger.info( + logger.trace( `in after commit callback for readwrite, modified ${j2s([ ...info.modifiedStores, ])}`, @@ -1924,8 +1944,6 @@ export class InternalWalletState { initWithConfig(newConfig: WalletRunConfig): void { this._config = newConfig; - logger.info(`setting new config to ${j2s(newConfig)}`); - this._http = this.httpFactory(newConfig); if (this.config.testing.devModeActive) { diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts index 4a7c7873c..8bc4aafd1 100644 --- a/packages/taler-wallet-core/src/withdraw.ts +++ b/packages/taler-wallet-core/src/withdraw.ts @@ -44,6 +44,8 @@ import { Duration, EddsaPrivateKeyString, ExchangeBatchWithdrawRequest, + ExchangeListItem, + ExchangeTosStatus, ExchangeUpdateStatus, ExchangeWireAccount, ExchangeWithdrawBatchResponse, @@ -114,8 +116,10 @@ import { TransitionResult, TransitionResultType, constructTaskIdentifier, + genericWaitForState, makeCoinAvailable, makeCoinsVisible, + requireExchangeTosAcceptedOrThrow, } from "./common.js"; import { EddsaKeypair } from "./crypto/cryptoImplementation.js"; import { @@ -149,6 +153,7 @@ import { getExchangePaytoUri, getExchangeWireDetailsInTx, listExchanges, + lookupExchangeByUri, markExchangeUsed, } from "./exchanges.js"; import { DbAccess } from "./query.js"; @@ -159,10 +164,7 @@ import { notifyTransition, parseTransactionIdentifier, } from "./transactions.js"; -import { - WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, - WALLET_EXCHANGE_PROTOCOL_VERSION, -} from "./versions.js"; +import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; /** @@ -343,9 +345,11 @@ export class WithdrawTransactionContext implements TransactionContext { "exchanges" as const, "exchangeDetails" as const, ]; - let stores = opts.extraStores + const stores = opts.extraStores ? [...baseStores, ...opts.extraStores] : baseStores; + + let errorThrown: Error | undefined; const transitionInfo = await this.wex.db.runReadWriteTx( { storeNames: stores }, async (tx) => { @@ -358,7 +362,17 @@ export class WithdrawTransactionContext implements TransactionContext { major: TransactionMajorState.None, }; } - const res = await f(wgRec, tx); + let res: TransitionResult<WithdrawalGroupRecord> | undefined; + try { + res = await f(wgRec, tx); + } catch (error) { + if (error instanceof Error) { + errorThrown = error; + } + return undefined; + } + + // const res = await f(wgRec, tx); switch (res.type) { case TransitionResultType.Transition: { await tx.withdrawalGroups.put(res.rec); @@ -383,6 +397,9 @@ export class WithdrawTransactionContext implements TransactionContext { } }, ); + if (errorThrown) { + throw errorThrown; + } notifyTransition(this.wex, this.transactionId, transitionInfo); return transitionInfo; } @@ -715,15 +732,35 @@ export function computeWithdrawalTransactionActions( case WithdrawalGroupStatus.Done: return [TransactionAction.Delete]; case WithdrawalGroupStatus.PendingRegisteringBank: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.PendingReady: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.PendingQueryingStatus: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.PendingWaitConfirmBank: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.AbortingBank: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case WithdrawalGroupStatus.SuspendedAbortingBank: return [TransactionAction.Resume, TransactionAction.Fail]; case WithdrawalGroupStatus.SuspendedQueryingStatus: @@ -735,9 +772,17 @@ export function computeWithdrawalTransactionActions( case WithdrawalGroupStatus.SuspendedReady: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.PendingAml: - return [TransactionAction.Resume, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Resume, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.PendingKyc: - return [TransactionAction.Resume, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Resume, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.SuspendedAml: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedKyc: @@ -842,7 +887,7 @@ export async function getBankWithdrawalInfo( TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE, { bankProtocolVersion: config.version, - walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + walletProtocolVersion: bankApi.PROTOCOL_VERSION, }, "bank integration protocol version not compatible with wallet", ); @@ -857,13 +902,48 @@ export async function getBankWithdrawalInfo( } const { body: status } = resp; + const maxAmount = + status.max_amount === undefined + ? undefined + : Amounts.parseOrThrow(status.max_amount); + + let amount: AmountJson | undefined; + let editableAmount = false; + if (status.amount !== undefined) { + amount = Amounts.parseOrThrow(status.amount); + } else { + amount = + status.suggested_amount === undefined + ? undefined + : Amounts.parseOrThrow(status.suggested_amount); + editableAmount = true; + } + + let wireFee: AmountJson | undefined; + if (status.card_fees) { + wireFee = Amounts.parseOrThrow(status.card_fees); + } + + let exchange: string | undefined = undefined; + let editableExchange = false; + if (status.required_exchange !== undefined) { + exchange = status.required_exchange; + } else { + exchange = status.suggested_exchange; + editableExchange = true; + } return { operationId: uriResult.withdrawalOperationId, apiBaseUrl: uriResult.bankIntegrationApiBaseUrl, - amount: Amounts.parseOrThrow(status.amount), + currency: config.currency, + amount, + wireFee, confirmTransferUrl: status.confirm_transfer_url, senderWire: status.sender_wire, - suggestedExchange: status.suggested_exchange, + exchange, + editableAmount, + editableExchange, + maxAmount, wireTypes: status.wire_types, status: status.status, }; @@ -917,6 +997,10 @@ async function processPlanchetGenerate( withdrawalGroup.denomsSel !== undefined, "can't process uninitialized exchange", ); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; let planchet = await wex.db.runReadOnlyTx( { storeNames: ["planchets"] }, @@ -958,7 +1042,7 @@ async function processPlanchetGenerate( return getDenomInfo(wex, tx, exchangeBaseUrl, denomPubHash); }, ); - checkDbInvariant(!!denom); + checkDbInvariant(!!denom, `no denom info for ${denomPubHash}`); const r = await wex.cryptoApi.createPlanchet({ denomPub: denom.denomPub, feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw), @@ -1121,6 +1205,10 @@ async function processPlanchetExchangeBatchRequest( logger.info( `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`, ); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] }; @@ -1256,6 +1344,10 @@ async function processPlanchetVerifyAndStoreCoin( resp: ExchangeWithdrawResponse, ): Promise<void> { const withdrawalGroup = wgContext.wgRecord; + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; logger.trace(`checking and storing planchet idx=${coinIdx}`); @@ -1505,6 +1597,10 @@ async function processQueryReserve( return TaskRunResult.backoff(); } checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); + checkDbInvariant( withdrawalGroup.denomsSel !== undefined, "can't process uninitialized exchange", ); @@ -1740,6 +1836,10 @@ async function redenominateWithdrawal( return; } checkDbInvariant( + wg.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); + checkDbInvariant( wg.denomsSel !== undefined, "can't process uninitialized exchange", ); @@ -1882,7 +1982,12 @@ async function processWithdrawalGroupPendingReady( withdrawalGroup.denomsSel !== undefined, "can't process uninitialized exchange", ); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + logger.trace(`updating exchange beofre processing wg`); await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl); if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { @@ -2162,14 +2267,6 @@ export async function getExchangeWithdrawalInfo( logger.trace("selection done"); - if (selectedDenoms.selectedDenoms.length === 0) { - throw Error( - `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify( - instructedAmount, - )}`, - ); - } - const exchangeWireAccounts: string[] = []; for (const account of exchange.wireInfo.accounts) { @@ -2248,38 +2345,52 @@ export async function getWithdrawalDetailsForUri( logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`); const info = await getBankWithdrawalInfo(wex.http, talerWithdrawUri); logger.trace(`got bank info`); - if (info.suggestedExchange) { + if (info.exchange) { try { // If the exchange entry doesn't exist yet, // it'll be created as an ephemeral entry. - await fetchFreshExchange(wex, info.suggestedExchange); + await fetchFreshExchange(wex, info.exchange); } catch (e) { // We still continued if it failed, as other exchanges might be available. // We don't want to fail if the bank-suggested exchange is broken/offline. logger.trace( - `querying bank-suggested exchange (${info.suggestedExchange}) failed`, + `querying bank-suggested exchange (${info.exchange}) failed`, ); } } - const currency = Amounts.currencyOf(info.amount); + const currency = info.currency; - const listExchangesResp = await listExchanges(wex); - const possibleExchanges = listExchangesResp.exchanges.filter((x) => { - return ( - x.currency === currency && - (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready || - x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate) - ); - }); + let possibleExchanges: ExchangeListItem[]; + if (!info.editableExchange && info.exchange !== undefined) { + const ex: ExchangeListItem = await lookupExchangeByUri(wex, { + exchangeBaseUrl: info.exchange, + }); + possibleExchanges = [ex]; + } else { + const listExchangesResp = await listExchanges(wex); + + possibleExchanges = listExchangesResp.exchanges.filter((x) => { + return ( + x.currency === currency && + (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready || + x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate) + ); + }); + } return { operationId: info.operationId, confirmTransferUrl: info.confirmTransferUrl, status: info.status, - amount: Amounts.stringify(info.amount), - defaultExchangeBaseUrl: info.suggestedExchange, + currency, + editableAmount: info.editableAmount, + editableExchange: info.editableExchange, + maxAmount: info.maxAmount ? Amounts.stringify(info.maxAmount) : undefined, + amount: info.amount ? Amounts.stringify(info.amount) : undefined, + defaultExchangeBaseUrl: info.exchange, possibleExchanges, + wireFee: info.wireFee ? Amounts.stringify(info.wireFee) : undefined, }; } @@ -2306,7 +2417,11 @@ export async function getFundingPaytoUris( withdrawalGroupId: string, ): Promise<string[]> { const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId); - checkDbInvariant(!!withdrawalGroup); + checkDbInvariant(!!withdrawalGroup, `no withdrawal for ${withdrawalGroupId}`); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); checkDbInvariant( withdrawalGroup.instructedAmount !== undefined, "can't get funding uri from uninitialized wg", @@ -2379,6 +2494,7 @@ export function getBankAbortUrl(talerWithdrawUri: string): string { async function registerReserveWithBank( wex: WalletExecutionContext, withdrawalGroupId: string, + isFlexibleAmount: boolean, ): Promise<void> { const withdrawalGroup = await wex.db.runReadOnlyTx( { storeNames: ["withdrawalGroups"] }, @@ -2407,7 +2523,11 @@ async function registerReserveWithBank( const reqBody = { reserve_pub: withdrawalGroup.reservePub, selected_exchange: bankInfo.exchangePaytoUri, - }; + } as any; + if (isFlexibleAmount) { + reqBody.amount = withdrawalGroup.instructedAmount; + } + logger.trace(`isFlexibleAmount: ${isFlexibleAmount}`); logger.info(`registering reserve with bank: ${j2s(reqBody)}`); const httpResp = await wex.http.fetch(bankStatusUrl, { method: "POST", @@ -2516,7 +2636,9 @@ async function processBankRegisterReserve( // FIXME: Put confirm transfer URL in the DB! - await registerReserveWithBank(wex, withdrawalGroupId); + const isFlexibleAmount = status.amount == null; + + await registerReserveWithBank(wex, withdrawalGroupId, isFlexibleAmount); return TaskRunResult.progress(); } @@ -2553,6 +2675,7 @@ async function processReserveBankStatus( uriResult.bankIntegrationApiBaseUrl, ); bankStatusUrl.searchParams.set("long_poll_ms", "30000"); + bankStatusUrl.searchParams.set("old_state", "selected"); logger.info(`long-polling for withdrawal operation at ${bankStatusUrl.href}`); const statusResp = await wex.http.fetch(bankStatusUrl.href, { @@ -2655,7 +2778,7 @@ export async function internalPrepareCreateWithdrawalGroup( args: { reserveStatus: WithdrawalGroupStatus; amount?: AmountJson; - exchangeBaseUrl: string; + exchangeBaseUrl: string | undefined; forcedWithdrawalGroupId?: string; forcedDenomSel?: ForcedDenomSel; reserveKeyPair?: EddsaKeypair; @@ -2696,7 +2819,7 @@ export async function internalPrepareCreateWithdrawalGroup( let initialDenomSel: DenomSelectionState | undefined; const denomSelUid = encodeCrock(getRandomBytes(16)); - if (amount !== undefined) { + if (amount !== undefined && exchangeBaseUrl !== undefined) { initialDenomSel = await getInitialDenomsSelection( wex, exchangeBaseUrl, @@ -2727,7 +2850,9 @@ export async function internalPrepareCreateWithdrawalGroup( wgInfo: args.wgInfo, }; - await fetchFreshExchange(wex, exchangeBaseUrl); + if (exchangeBaseUrl !== undefined) { + await fetchFreshExchange(wex, exchangeBaseUrl); + } const transactionId = constructTransactionIdentifier({ tag: TransactionType.Withdrawal, @@ -2737,12 +2862,13 @@ export async function internalPrepareCreateWithdrawalGroup( return { withdrawalGroup, transactionId, - creationInfo: !amount - ? undefined - : { - amount, - canonExchange: exchangeBaseUrl, - }, + creationInfo: + !amount || !exchangeBaseUrl + ? undefined + : { + amount, + canonExchange: exchangeBaseUrl, + }, }; } @@ -2772,8 +2898,8 @@ export async function internalPerformCreateWithdrawalGroup( if (existingWg) { return { withdrawalGroup: existingWg, - exchangeNotif: undefined, transitionInfo: undefined, + exchangeNotif: undefined, }; } await tx.withdrawalGroups.add(withdrawalGroup); @@ -2789,7 +2915,21 @@ export async function internalPerformCreateWithdrawalGroup( exchangeNotif: undefined, }; } - const exchange = await tx.exchanges.get(prep.creationInfo.canonExchange); + return internalPerformExchangeWasUsed( + wex, + tx, + prep.creationInfo.canonExchange, + withdrawalGroup, + ); +} + +export async function internalPerformExchangeWasUsed( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction<["exchanges"]>, + canonExchange: string, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<PerformCreateWithdrawalGroupResult> { + const exchange = await tx.exchanges.get(canonExchange); if (exchange) { exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now()); await tx.exchanges.put(exchange); @@ -2805,11 +2945,7 @@ export async function internalPerformCreateWithdrawalGroup( newTxState, }; - const exchangeUsedRes = await markExchangeUsed( - wex, - tx, - prep.creationInfo.canonExchange, - ); + const exchangeUsedRes = await markExchangeUsed(wex, tx, canonExchange); const ctx = new WithdrawTransactionContext( wex, @@ -2837,7 +2973,7 @@ export async function internalCreateWithdrawalGroup( wex: WalletExecutionContext, args: { reserveStatus: WithdrawalGroupStatus; - exchangeBaseUrl: string; + exchangeBaseUrl: string | undefined; amount?: AmountJson; forcedWithdrawalGroupId?: string; forcedDenomSel?: ForcedDenomSel; @@ -2883,7 +3019,6 @@ export async function prepareBankIntegratedWithdrawal( wex: WalletExecutionContext, req: { talerWithdrawUri: string; - selectedExchange?: string; }, ): Promise<PrepareBankIntegratedWithdrawalResponse> { const existingWithdrawalGroup = await wex.db.runReadOnlyTx( @@ -2912,12 +3047,6 @@ export async function prepareBankIntegratedWithdrawal( const info = await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri); - const exchangeBaseUrl = - req.selectedExchange ?? withdrawInfo.suggestedExchange; - if (!exchangeBaseUrl) { - return { info }; - } - /** * Withdrawal group without exchange and amount * this is an special case when the user haven't yet @@ -2926,7 +3055,7 @@ export async function prepareBankIntegratedWithdrawal( * same URI */ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { - exchangeBaseUrl, + exchangeBaseUrl: undefined, wgInfo: { withdrawalType: WithdrawalRecordType.BankIntegrated, bankInfo: { @@ -2935,6 +3064,7 @@ export async function prepareBankIntegratedWithdrawal( timestampBankConfirmed: undefined, timestampReserveInfoPosted: undefined, wireTypes: withdrawInfo.wireTypes, + currency: withdrawInfo.currency, }, }, reserveStatus: WithdrawalGroupStatus.DialogProposed, @@ -2957,6 +3087,9 @@ export async function confirmWithdrawal( req: ConfirmWithdrawalRequest, ): Promise<void> { const parsedTx = parseTransactionIdentifier(req.transactionId); + const selectedExchange = req.exchangeBaseUrl; + const instructedAmount = Amounts.parseOrThrow(req.amount); + if (parsedTx?.tag !== TransactionType.Withdrawal) { throw Error("invalid withdrawal transaction ID"); } @@ -2978,38 +3111,44 @@ export async function confirmWithdrawal( throw Error("not a bank integrated withdrawal"); } - const selectedExchange = req.exchangeBaseUrl; const exchange = await fetchFreshExchange(wex, selectedExchange); + requireExchangeTosAcceptedOrThrow(exchange); const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri; const confirmUrl = withdrawalGroup.wgInfo.bankInfo.confirmUrl; /** - * The only reasong this to be undefined is because it is an old wallet - * database before adding the wireType field was added + * The only reason this could be undefined is because it is an old wallet + * database before adding the prepareWithdrawal feature */ - let wtypes: string[]; - if (withdrawalGroup.wgInfo.bankInfo.wireTypes === undefined) { + let bankWireTypes: string[]; + let bankCurrency: string; + if ( + withdrawalGroup.wgInfo.bankInfo.wireTypes === undefined || + withdrawalGroup.wgInfo.bankInfo.currency === undefined + ) { const withdrawInfo = await getBankWithdrawalInfo( wex.http, talerWithdrawUri, ); - wtypes = withdrawInfo.wireTypes; + bankWireTypes = withdrawInfo.wireTypes; + bankCurrency = withdrawInfo.currency; } else { - wtypes = withdrawalGroup.wgInfo.bankInfo.wireTypes; + bankWireTypes = withdrawalGroup.wgInfo.bankInfo.wireTypes; + bankCurrency = withdrawalGroup.wgInfo.bankInfo.currency; } const exchangePaytoUri = await getExchangePaytoUri( wex, selectedExchange, - wtypes, + bankWireTypes, ); const withdrawalAccountList = await fetchWithdrawalAccountInfo( wex, { exchange, - instructedAmount: Amounts.parseOrThrow(req.amount), + instructedAmount, }, wex.cancellationToken, ); @@ -3020,23 +3159,34 @@ export async function confirmWithdrawal( ); const initalDenoms = await getInitialDenomsSelection( wex, - req.exchangeBaseUrl, - Amounts.parseOrThrow(req.amount), + exchange.exchangeBaseUrl, + instructedAmount, req.forcedDenomSel, ); - ctx.transition({}, async (rec) => { + let pending = false; + await ctx.transition({}, async (rec) => { if (!rec) { return TransitionResult.stay(); } switch (rec.status) { + case WithdrawalGroupStatus.PendingWaitConfirmBank: { + pending = true; + return TransitionResult.stay(); + } + case WithdrawalGroupStatus.AbortedOtherWallet: { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + ); + } case WithdrawalGroupStatus.DialogProposed: { - rec.exchangeBaseUrl = req.exchangeBaseUrl; + rec.exchangeBaseUrl = exchange.exchangeBaseUrl; rec.instructedAmount = req.amount; + rec.restrictAge = req.restrictAge; rec.denomsSel = initalDenoms; rec.rawWithdrawalAmount = initalDenoms.totalWithdrawCost; rec.effectiveWithdrawalAmount = initalDenoms.totalCoinValue; - rec.restrictAge = req.restrictAge; rec.wgInfo = { withdrawalType: WithdrawalRecordType.BankIntegrated, @@ -3047,20 +3197,50 @@ export async function confirmWithdrawal( confirmUrl: confirmUrl, timestampBankConfirmed: undefined, timestampReserveInfoPosted: undefined, - wireTypes: wtypes, + wireTypes: bankWireTypes, + currency: bankCurrency, }, }; - + pending = true; rec.status = WithdrawalGroupStatus.PendingRegisteringBank; return TransitionResult.transition(rec); } - default: - throw Error("unable to confirm withdrawal in current state"); + default: { + throw Error( + `unable to confirm withdrawal in current state: ${rec.status}`, + ); + } } }); await wex.taskScheduler.resetTaskRetries(ctx.taskId); - wex.taskScheduler.startShepherdTask(ctx.taskId); + + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: ctx.transactionId, + }); + + const res = await wex.db.runReadWriteTx( + { + storeNames: ["exchanges"], + }, + async (tx) => { + const r = await internalPerformExchangeWasUsed( + wex, + tx, + exchange.exchangeBaseUrl, + withdrawalGroup, + ); + return r; + }, + ); + if (res.exchangeNotif) { + wex.ws.notify(res.exchangeNotif); + } + + if (pending) { + await waitWithdrawalRegistered(wex, ctx); + } } /** @@ -3080,181 +3260,119 @@ export async function acceptWithdrawalFromUri( selectedExchange: string; forcedDenomSel?: ForcedDenomSel; restrictAge?: number; + amount?: AmountLike; }, ): Promise<AcceptWithdrawalResponse> { const selectedExchange = req.selectedExchange; logger.info( - `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`, - ); - const existingWithdrawalGroup = await wex.db.runReadOnlyTx( - { storeNames: ["withdrawalGroups"] }, - async (tx) => { - return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( - req.talerWithdrawUri, - ); - }, + `preparing withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`, ); - if (existingWithdrawalGroup) { - let url: string | undefined; - if ( - existingWithdrawalGroup.wgInfo.withdrawalType === - WithdrawalRecordType.BankIntegrated - ) { - url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl; + const p = await prepareBankIntegratedWithdrawal(wex, { + talerWithdrawUri: req.talerWithdrawUri, + }); + + let amount: AmountString; + if (p.info.amount == null) { + if (req.amount == null) { + throw Error( + "amount required, as withdrawal operation has flexible amount", + ); } - return { - reservePub: existingWithdrawalGroup.reservePub, - confirmTransferUrl: url, - transactionId: constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId, - }), - }; + amount = req.amount as AmountString; + } else { + if (req.amount != null && Amounts.cmp(req.amount, p.info.amount) != 0) { + throw Error( + "mismatched amount, amount is fixed by bank but client provided different amount", + ); + } + amount = p.info.amount; } - const exchange = await fetchFreshExchange(wex, selectedExchange); - const withdrawInfo = await getBankWithdrawalInfo( - wex.http, - req.talerWithdrawUri, - ); - const exchangePaytoUri = await getExchangePaytoUri( - wex, - selectedExchange, - withdrawInfo.wireTypes, - ); - - const withdrawalAccountList = await fetchWithdrawalAccountInfo( - wex, - { - exchange, - instructedAmount: withdrawInfo.amount, - }, - CancellationToken.CONTINUE, - ); - - const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { - amount: withdrawInfo.amount, - exchangeBaseUrl: req.selectedExchange, - wgInfo: { - withdrawalType: WithdrawalRecordType.BankIntegrated, - exchangeCreditAccounts: withdrawalAccountList, - bankInfo: { - exchangePaytoUri, - talerWithdrawUri: req.talerWithdrawUri, - confirmUrl: withdrawInfo.confirmTransferUrl, - timestampBankConfirmed: undefined, - timestampReserveInfoPosted: undefined, - wireTypes: withdrawInfo.wireTypes, - }, - }, + logger.info(`confirming withdrawal with tx ${p.transactionId}`); + await confirmWithdrawal(wex, { + amount: Amounts.stringify(amount), + exchangeBaseUrl: selectedExchange, + transactionId: p.transactionId, restrictAge: req.restrictAge, forcedDenomSel: req.forcedDenomSel, - reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank, }); - const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; - - const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); - - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: ctx.transactionId, - }); - - await waitWithdrawalRegistered(wex, ctx); + const newWithdrawralGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( + req.talerWithdrawUri, + ); + }, + ); - wex.taskScheduler.startShepherdTask(ctx.taskId); + checkDbInvariant( + newWithdrawralGroup !== undefined, + "withdrawal don't exist after confirm", + ); return { - reservePub: withdrawalGroup.reservePub, - confirmTransferUrl: withdrawInfo.confirmTransferUrl, - transactionId: ctx.transactionId, + reservePub: newWithdrawralGroup.reservePub, + confirmTransferUrl: p.info.confirmTransferUrl, + transactionId: p.transactionId, }; } -async function internalWaitWithdrawalRegistered( +async function waitWithdrawalRegistered( wex: WalletExecutionContext, ctx: WithdrawTransactionContext, - withdrawalNotifFlag: AsyncFlag, ): Promise<void> { - while (true) { - const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx( - { storeNames: ["withdrawalGroups", "operationRetries"] }, - async (tx) => { - return { - withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), - retryRec: await tx.operationRetries.get(ctx.taskId), - }; - }, - ); + await genericWaitForState(wex, { + async checkState(): Promise<boolean> { + const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups", "operationRetries"] }, + async (tx) => { + return { + withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), + retryRec: await tx.operationRetries.get(ctx.taskId), + }; + }, + ); - if (!withdrawalRec) { - throw Error("withdrawal not found anymore"); - } + if (!withdrawalRec) { + throw Error("withdrawal not found anymore"); + } - switch (withdrawalRec.status) { - case WithdrawalGroupStatus.FailedBankAborted: - throw TalerError.fromDetail( - TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, - {}, - ); - case WithdrawalGroupStatus.PendingKyc: - case WithdrawalGroupStatus.PendingAml: - case WithdrawalGroupStatus.PendingQueryingStatus: - case WithdrawalGroupStatus.PendingReady: - case WithdrawalGroupStatus.Done: - case WithdrawalGroupStatus.PendingWaitConfirmBank: - return; - case WithdrawalGroupStatus.PendingRegisteringBank: - break; - default: { - if (retryRec) { - if (retryRec.lastError) { - throw TalerError.fromUncheckedDetail(retryRec.lastError); - } else { - throw Error("withdrawal unexpectedly pending"); + switch (withdrawalRec.status) { + case WithdrawalGroupStatus.FailedBankAborted: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + ); + case WithdrawalGroupStatus.PendingKyc: + case WithdrawalGroupStatus.PendingAml: + case WithdrawalGroupStatus.PendingQueryingStatus: + case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + return true; + case WithdrawalGroupStatus.PendingRegisteringBank: + break; + default: { + if (retryRec) { + if (retryRec.lastError) { + throw TalerError.fromUncheckedDetail(retryRec.lastError); + } else { + throw Error("withdrawal unexpectedly pending"); + } } } } - } - - await withdrawalNotifFlag.wait(); - withdrawalNotifFlag.reset(); - } -} - -async function waitWithdrawalRegistered( - wex: WalletExecutionContext, - ctx: WithdrawTransactionContext, -): Promise<void> { - // FIXME: Doesn't support cancellation yet - // FIXME: We should use Symbol.dispose magic here for cleanup! - - const withdrawalNotifFlag = new AsyncFlag(); - // Raise exchangeNotifFlag whenever we get a notification - // about our exchange. - const cancelNotif = wex.ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.TransactionStateTransition && - notif.transactionId === ctx.transactionId - ) { - logger.info(`raising update notification: ${j2s(notif)}`); - withdrawalNotifFlag.raise(); - } + return false; + }, + filterNotification(notif) { + return ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ); + }, }); - - try { - const res = await internalWaitWithdrawalRegistered( - wex, - ctx, - withdrawalNotifFlag, - ); - logger.info("done waiting for ready exchange"); - return res; - } finally { - cancelNotif(); - } } async function fetchAccount( @@ -3422,7 +3540,7 @@ export async function createManualWithdrawal( ); const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { - amount: Amounts.jsonifyAmount(req.amount), + amount: amount, wgInfo: { withdrawalType: WithdrawalRecordType.BankManual, exchangeCreditAccounts: withdrawalAccountsList, @@ -3507,7 +3625,7 @@ async function internalWaitWithdrawalFinal( // Check if refresh is final const res = await ctx.wex.db.runReadOnlyTx( - { storeNames: ["withdrawalGroups", "operationRetries"] }, + { storeNames: ["withdrawalGroups"] }, async (tx) => { return { wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), @@ -3550,7 +3668,7 @@ export async function getWithdrawalDetailsForAmount( type: ObservabilityEventType.Message, contents: `Cancelling previous key ${clientCancelKey}`, }); - prevCts.cancel(); + prevCts.cancel(`getting details amount`); } else { wex.oc.observe({ type: ObservabilityEventType.Message, |