From a844136489611525726c117cb28086b854bee5c0 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 26 Jun 2023 19:27:34 +0200 Subject: wallet-core: make changes to available amount atomic W.r.t. transactions --- packages/taler-wallet-core/src/db.ts | 37 ++++++++++- .../taler-wallet-core/src/operations/balance.ts | 60 +++++++++++++---- .../taler-wallet-core/src/operations/common.ts | 39 +++++++++++ .../taler-wallet-core/src/operations/refresh.ts | 76 +++++++++++++++------- packages/taler-wallet-core/src/operations/tip.ts | 4 +- .../taler-wallet-core/src/operations/withdraw.ts | 21 ++++-- .../taler-wallet-core/src/util/coinSelection.ts | 2 - 7 files changed, 187 insertions(+), 52 deletions(-) (limited to 'packages/taler-wallet-core/src') diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index ab2e95c23..89bb8b053 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -119,7 +119,7 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; * backwards-compatible way or object stores and indices * are added. */ -export const WALLET_DB_MINOR_VERSION = 8; +export const WALLET_DB_MINOR_VERSION = 9; /** * Ranges for operation status fields. @@ -723,6 +723,14 @@ export interface CoinRecord { */ coinSource: CoinSource; + /** + * Source transaction ID of the coin. + * + * Used to make the coin visible after the transaction + * has entered a final state. + */ + sourceTransactionId?: string; + /** * Public key of the coin. */ @@ -768,6 +776,14 @@ export interface CoinRecord { */ status: CoinStatus; + /** + * Non-zero for visible. + * + * A coin is visible when it is fresh and the + * source transaction is in a final state. + */ + visible?: number; + /** * Information about what the coin has been allocated for. * @@ -894,7 +910,7 @@ export enum RefreshCoinStatus { * The refresh for this coin has been frozen, because of a permanent error. * More info in lastErrorPerCoin. */ - Frozen = OperationStatusRange.DORMANT_START + 1, + Failed = OperationStatusRange.DORMANT_START + 1, } export enum OperationStatus { @@ -1748,7 +1764,6 @@ export interface DepositKycInfo { exchangeBaseUrl: string; } - /** * Record for a deposits that the wallet observed * as a result of double spending, but which is not @@ -2132,6 +2147,15 @@ export interface CoinAvailabilityRecord { * Number of fresh coins of this denomination that are available. */ freshCoinCount: number; + + /** + * Number of fresh coins that are available + * and visible, i.e. the source transaction is in + * a final state. + * + * (Optional for backwards compatibility, defaults to 0.) + */ + visibleCoinCount?: number; } export interface ContractTermsRecord { @@ -2318,6 +2342,13 @@ export const WalletStoresV1 = { ["exchangeBaseUrl", "denomPubHash", "maxAge", "status"], ), byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"), + bySourceTransactionId: describeIndex( + "bySourceTransactionId", + "sourceTransactionId", + { + versionAdded: 9, + }, + ), }, ), reserves: describeStore( diff --git a/packages/taler-wallet-core/src/operations/balance.ts b/packages/taler-wallet-core/src/operations/balance.ts index 3ab6649d7..af88181c0 100644 --- a/packages/taler-wallet-core/src/operations/balance.ts +++ b/packages/taler-wallet-core/src/operations/balance.ts @@ -20,14 +20,17 @@ * There are multiple definition of the wallet's balance. * We use the following terminology: * - * - "available": Balance that the wallet believes will certainly be available - * for spending, modulo any failures of the exchange or double spending issues. - * This includes available coins *not* allocated to any - * spending/refresh/... operation. Pending withdrawals are *not* counted - * towards this balance, because they are not certain to succeed. - * Pending refreshes *are* counted towards this balance. - * This balance type is nice to show to the user, because it does not - * temporarily decrease after payment when we are waiting for refreshes + * - "available": Balance that is available + * for spending from transactions in their final state and + * expected to be available from pending refreshes. + * + * - "pending-incoming": Expected (positive!) delta + * to the available balance that we expect to have + * after pending operations reach the "done" state. + * + * - "pending-outgoing": Amount that is currently allocated + * to be spent, but the spend operation could still be aborted + * and part of the pending-outgoing amount could be recovered. * * - "material": Balance that the wallet believes it could spend *right now*, * without waiting for any operations to complete. @@ -61,11 +64,13 @@ import { AllowedExchangeInfo, RefreshGroupRecord, WalletStoresV1, + WithdrawalGroupStatus, } from "../db.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { checkLogicInvariant } from "../util/invariants.js"; import { GetReadOnlyAccess } from "../util/query.js"; import { getExchangeDetails } from "./exchanges.js"; +import { assertUnreachable } from "../util/assertUnreachable.js"; /** * Logger. @@ -133,7 +138,8 @@ export async function getBalancesInsideTransaction( await tx.coinAvailability.iter().forEach((ca) => { const b = initBalance(ca.currency); - for (let i = 0; i < ca.freshCoinCount; i++) { + const count = ca.visibleCoinCount ?? 0; + for (let i = 0; i < count; i++) { b.available = Amounts.add(b.available, { currency: ca.currency, fraction: ca.amountFrac, @@ -150,14 +156,40 @@ export async function getBalancesInsideTransaction( ).amount; }); - await tx.withdrawalGroups.iter().forEach((wds) => { - if (wds.timestampFinish) { - return; + // FIXME: Use indexing to filter out final transactions. + await tx.withdrawalGroups.iter().forEach((wgRecord) => { + switch (wgRecord.status) { + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.FailedBankAborted: + case WithdrawalGroupStatus.Finished: + // Does not count as pendingIncoming + return; + case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.AbortingBank: + case WithdrawalGroupStatus.PendingAml: + case WithdrawalGroupStatus.PendingKyc: + case WithdrawalGroupStatus.PendingQueryingStatus: + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + case WithdrawalGroupStatus.SuspendedReady: + case WithdrawalGroupStatus.SuspendedRegisteringBank: + case WithdrawalGroupStatus.SuspendedKyc: + case WithdrawalGroupStatus.SuspendedAbortingBank: + case WithdrawalGroupStatus.SuspendedAml: + case WithdrawalGroupStatus.PendingRegisteringBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + case WithdrawalGroupStatus.SuspendedQueryingStatus: + break; + default: + assertUnreachable(wgRecord.status); } - const b = initBalance(Amounts.currencyOf(wds.denomsSel.totalWithdrawCost)); + const b = initBalance( + Amounts.currencyOf(wgRecord.denomsSel.totalWithdrawCost), + ); b.pendingIncoming = Amounts.add( b.pendingIncoming, - wds.denomsSel.totalCoinValue, + wgRecord.denomsSel.totalCoinValue, ).amount; }); diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/operations/common.ts index 37f1a2f22..52e4c4b53 100644 --- a/packages/taler-wallet-core/src/operations/common.ts +++ b/packages/taler-wallet-core/src/operations/common.ts @@ -81,6 +81,38 @@ export interface CoinsSpendInfo { allocationId: TransactionIdStr; } +export async function makeCoinsVisible( + ws: InternalWalletState, + tx: GetReadWriteAccess<{ + coins: typeof WalletStoresV1.coins; + coinAvailability: typeof WalletStoresV1.coinAvailability; + }>, + transactionId: string, +): Promise { + const coins = await tx.coins.indexes.bySourceTransactionId.getAll( + transactionId, + ); + for (const coinRecord of coins) { + if (!coinRecord.visible) { + coinRecord.visible = 1; + await tx.coins.put(coinRecord); + const ageRestriction = coinRecord.maxAge; + const car = await tx.coinAvailability.get([ + coinRecord.exchangeBaseUrl, + coinRecord.denomPubHash, + ageRestriction, + ]); + if (!car) { + logger.error("missing coin availability record"); + continue; + } + const visCount = car.visibleCoinCount ?? 0; + car.visibleCoinCount = visCount + 1; + await tx.coinAvailability.put(car); + } + } +} + export async function makeCoinAvailable( ws: InternalWalletState, tx: GetReadWriteAccess<{ @@ -195,6 +227,13 @@ export async function spendCoins( ); } coinAvailability.freshCoinCount--; + if (coin.visible) { + if (!coinAvailability.visibleCoinCount) { + logger.error("coin availability inconsistent"); + } else { + coinAvailability.visibleCoinCount--; + } + } await tx.coins.put(coin); await tx.coinAvailability.put(coinAvailability); } diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index 9c9ad8bbd..b91872735 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -46,16 +46,20 @@ import { NotificationType, RefreshGroupId, RefreshReason, + TalerError, TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, - TalerProtocolTimestamp, TransactionAction, TransactionMajorState, TransactionState, TransactionType, URL, } from "@gnu-taler/taler-util"; +import { + readSuccessResponseJsonOrThrow, + readUnexpectedResponseDetails, +} from "@gnu-taler/taler-util/http"; import { TalerCryptoInterface } from "../crypto/cryptoImplementation.js"; import { DerivedRefreshSession, @@ -72,25 +76,23 @@ import { RefreshReasonDetails, WalletStoresV1, } from "../db.js"; -import { TalerError } from "@gnu-taler/taler-util"; +import { isWithdrawableDenom, PendingTaskType } from "../index.js"; import { EXCHANGE_COINS_LOCK, InternalWalletState, } from "../internal-wallet-state.js"; import { assertUnreachable } from "../util/assertUnreachable.js"; -import { - readSuccessResponseJsonOrThrow, - readUnexpectedResponseDetails, -} from "@gnu-taler/taler-util/http"; +import { selectWithdrawalDenominations } from "../util/coinSelection.js"; import { checkDbInvariant } from "../util/invariants.js"; import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js"; -import { constructTaskIdentifier, makeCoinAvailable, OperationAttemptResult, OperationAttemptResultType } from "./common.js"; -import { updateExchangeFromUrl } from "./exchanges.js"; -import { selectWithdrawalDenominations } from "../util/coinSelection.js"; import { - isWithdrawableDenom, - PendingTaskType, -} from "../index.js"; + constructTaskIdentifier, + makeCoinAvailable, + makeCoinsVisible, + OperationAttemptResult, + OperationAttemptResultType, +} from "./common.js"; +import { updateExchangeFromUrl } from "./exchanges.js"; import { constructTransactionIdentifier, notifyTransition, @@ -144,24 +146,26 @@ export function getTotalRefreshCost( return totalCost; } -function updateGroupStatus(rg: RefreshGroupRecord): void { - const allDone = fnutil.all( +function updateGroupStatus(rg: RefreshGroupRecord): { final: boolean } { + const allFinal = fnutil.all( rg.statusPerCoin, - (x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Frozen, + (x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Failed, ); - const anyFrozen = fnutil.any( + const anyFailed = fnutil.any( rg.statusPerCoin, - (x) => x === RefreshCoinStatus.Frozen, + (x) => x === RefreshCoinStatus.Failed, ); - if (allDone) { - if (anyFrozen) { + if (allFinal) { + if (anyFailed) { rg.timestampFinished = TalerPreciseTimestamp.now(); rg.operationStatus = RefreshOperationStatus.Failed; } else { rg.timestampFinished = TalerPreciseTimestamp.now(); rg.operationStatus = RefreshOperationStatus.Finished; } + return { final: true }; } + return { final: false }; } /** @@ -248,22 +252,30 @@ async function refreshCreateSession( ws.config.testing.denomselAllowLate, ); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId, + }); + if (newCoinDenoms.selectedDenoms.length === 0) { logger.trace( `not refreshing, available amount ${amountToPretty( availableAmount, )} too small`, ); + // FIXME: State transition notification missing. await ws.db - .mktx((x) => [x.coins, x.refreshGroups]) + .mktx((x) => [x.coins, x.coinAvailability, x.refreshGroups]) .runReadWrite(async (tx) => { const rg = await tx.refreshGroups.get(refreshGroupId); if (!rg) { return; } rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; - updateGroupStatus(rg); - + const updateRes = updateGroupStatus(rg); + if (updateRes.final) { + await makeCoinsVisible(ws, tx, transactionId); + } await tx.refreshGroups.put(rg); }); return; @@ -418,10 +430,15 @@ async function refreshMelt( }); }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId, + }); + if (resp.status === HttpStatusCode.NotFound) { const errDetails = await readUnexpectedResponseDetails(resp); await ws.db - .mktx((x) => [x.refreshGroups]) + .mktx((x) => [x.refreshGroups, x.coins, x.coinAvailability]) .runReadWrite(async (tx) => { const rg = await tx.refreshGroups.get(refreshGroupId); if (!rg) { @@ -433,9 +450,12 @@ async function refreshMelt( if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) { return; } - rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Frozen; + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; rg.lastErrorPerCoin[coinIndex] = errDetails; - updateGroupStatus(rg); + const updateRes = updateGroupStatus(rg); + if (updateRes.final) { + await makeCoinsVisible(ws, tx, transactionId); + } await tx.refreshGroups.put(rg); }); return; @@ -672,6 +692,11 @@ async function refreshReveal( const coins: CoinRecord[] = []; + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId, + }); + for (let i = 0; i < refreshSession.newDenoms.length; i++) { const ncd = newCoinDenoms[i]; for (let j = 0; j < refreshSession.newDenoms[i].count; j++) { @@ -701,6 +726,7 @@ async function refreshReveal( refreshGroupId, oldCoinPub: refreshGroup.oldCoinPubs[coinIndex], }, + sourceTransactionId: transactionId, coinEvHash: pc.coinEvHash, maxAge: pc.maxAge, ageCommitmentProof: pc.ageCommitmentProof, diff --git a/packages/taler-wallet-core/src/operations/tip.ts b/packages/taler-wallet-core/src/operations/tip.ts index b43fd2e8a..1b40e36f1 100644 --- a/packages/taler-wallet-core/src/operations/tip.ts +++ b/packages/taler-wallet-core/src/operations/tip.ts @@ -57,7 +57,7 @@ import { readSuccessResponseJsonOrThrow, } from "@gnu-taler/taler-util/http"; import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; -import { constructTaskIdentifier, makeCoinAvailable, OperationAttemptResult, OperationAttemptResultType } from "./common.js"; +import { constructTaskIdentifier, makeCoinAvailable, makeCoinsVisible, OperationAttemptResult, OperationAttemptResultType } from "./common.js"; import { updateExchangeFromUrl } from "./exchanges.js"; import { getCandidateWithdrawalDenoms, @@ -387,6 +387,7 @@ export async function processTip( coinIndex: i, walletTipId: walletTipId, }, + sourceTransactionId: transactionId, denomPubHash: denom.denomPubHash, denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: denomSigRsa.sig }, exchangeBaseUrl: tipRecord.exchangeBaseUrl, @@ -416,6 +417,7 @@ export async function processTip( for (const cr of newCoinRecords) { await makeCoinAvailable(ws, tx, cr); } + await makeCoinsVisible(ws, tx, transactionId); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index 8eb7f6457..e3897f84e 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -97,6 +97,7 @@ import { TaskIdentifiers, constructTaskIdentifier, makeCoinAvailable, + makeCoinsVisible, makeExchangeListItem, runLongpollAsync, } from "../operations/common.js"; @@ -1029,6 +1030,11 @@ async function processPlanchetVerifyAndStoreCoin( return; } + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId, + }); + const { planchet, denomInfo } = d; const planchetDenomPub = denomInfo.denomPub; @@ -1099,6 +1105,7 @@ async function processPlanchetVerifyAndStoreCoin( reservePub: withdrawalGroup.reservePub, withdrawalGroupId: withdrawalGroup.withdrawalGroupId, }, + sourceTransactionId: transactionId, maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED, ageCommitmentProof: planchet.ageCommitmentProof, spendAllocation: undefined, @@ -1111,7 +1118,7 @@ async function processPlanchetVerifyAndStoreCoin( // Check if this is the first time that the whole // withdrawal succeeded. If so, mark the withdrawal // group as finished. - const firstSuccess = await ws.db + const success = await ws.db .mktx((x) => [ x.coins, x.denominations, @@ -1130,7 +1137,9 @@ async function processPlanchetVerifyAndStoreCoin( return true; }); - ws.notify({ type: NotificationType.BalanceChange }); + if (success) { + ws.notify({ type: NotificationType.BalanceChange }); + } } /** @@ -1495,10 +1504,7 @@ async function processWithdrawalGroupPendingReady( }; }); notifyTransition(ws, transactionId, transitionInfo); - return { - type: OperationAttemptResultType.Finished, - result: undefined, - }; + return OperationAttemptResult.finishedEmpty(); } const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms @@ -1563,7 +1569,7 @@ async function processWithdrawalGroupPendingReady( const maxReportedErrors = 5; const res = await ws.db - .mktx((x) => [x.coins, x.withdrawalGroups, x.planchets]) + .mktx((x) => [x.coins, x.coinAvailability, x.withdrawalGroups, x.planchets]) .runReadWrite(async (tx) => { const wg = await tx.withdrawalGroups.get(withdrawalGroupId); if (!wg) { @@ -1588,6 +1594,7 @@ async function processWithdrawalGroupPendingReady( if (wg.timestampFinish === undefined && numFinished === numTotalCoins) { wg.timestampFinish = TalerPreciseTimestamp.now(); wg.status = WithdrawalGroupStatus.Finished; + await makeCoinsVisible(ws, tx, transactionId); } const newTxState = computeWithdrawalTransactionStatus(wg); diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts index 26dc0dedc..d3c6ffc67 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -31,7 +31,6 @@ import { AmountJson, AmountResponse, Amounts, - AmountString, CoinStatus, ConvertAmountRequest, DenominationInfo, @@ -42,7 +41,6 @@ import { ForcedDenomSel, GetAmountRequest, GetPlanForOperationRequest, - GetPlanForOperationResponse, j2s, Logger, parsePaytoUri, -- cgit v1.2.3