diff options
author | Florian Dold <florian@dold.me> | 2023-06-26 19:27:34 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2023-06-26 19:27:42 +0200 |
commit | a844136489611525726c117cb28086b854bee5c0 (patch) | |
tree | bb3384fab39600c35675cd8e36caac9246ef9782 /packages/taler-wallet-core/src/operations | |
parent | 2779086a32a62d6d16b7813c2ca4944dc02c4d93 (diff) | |
download | wallet-core-a844136489611525726c117cb28086b854bee5c0.tar.xz |
wallet-core: make changes to available amount atomic
W.r.t. transactions
Diffstat (limited to 'packages/taler-wallet-core/src/operations')
5 files changed, 153 insertions, 47 deletions
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<void> { + 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); |