summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2023-06-26 19:27:34 +0200
committerFlorian Dold <florian@dold.me>2023-06-26 19:27:42 +0200
commita844136489611525726c117cb28086b854bee5c0 (patch)
treebb3384fab39600c35675cd8e36caac9246ef9782
parent2779086a32a62d6d16b7813c2ca4944dc02c4d93 (diff)
downloadwallet-core-a844136489611525726c117cb28086b854bee5c0.tar.gz
wallet-core-a844136489611525726c117cb28086b854bee5c0.tar.bz2
wallet-core-a844136489611525726c117cb28086b854bee5c0.zip
wallet-core: make changes to available amount atomic
W.r.t. transactions
-rw-r--r--packages/taler-wallet-core/src/db.ts37
-rw-r--r--packages/taler-wallet-core/src/operations/balance.ts60
-rw-r--r--packages/taler-wallet-core/src/operations/common.ts39
-rw-r--r--packages/taler-wallet-core/src/operations/refresh.ts76
-rw-r--r--packages/taler-wallet-core/src/operations/tip.ts4
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.ts21
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.ts2
7 files changed, 187 insertions, 52 deletions
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.
@@ -724,6 +724,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.
*/
coinPub: string;
@@ -769,6 +777,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.
*
* Used 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<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);
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,