wallet-core: cache fresh coin count in DB

This commit is contained in:
Florian Dold 2022-09-14 20:34:37 +02:00
parent 9d044058e2
commit c021876b41
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
11 changed files with 197 additions and 129 deletions

View File

@ -529,6 +529,7 @@ export interface PlanchetCreationRequest {
export enum RefreshReason {
Manual = "manual",
PayMerchant = "pay-merchant",
PayDeposit = "pay-deposit",
PayPeerPush = "pay-peer-push",
PayPeerPull = "pay-peer-pull",
Refund = "refund",

View File

@ -314,6 +314,11 @@ export interface DenominationRecord {
* that includes this denomination.
*/
listIssueDate: TalerProtocolTimestamp;
/**
* Number of fresh coins of this denomination that are available.
*/
freshCoinCount?: number;
}
/**
@ -520,6 +525,13 @@ export enum CoinStatus {
* Withdrawn and never shown to anybody.
*/
Fresh = "fresh",
/**
* Fresh, but currently marked as "suspended", thus won't be used
* for spending. Used for testing.
*/
FreshSuspended = "fresh-suspended",
/**
* A coin that has been spent and refreshed.
*/
@ -605,11 +617,6 @@ export interface CoinRecord {
*/
exchangeBaseUrl: string;
/**
* The coin is currently suspended, and will not be used for payments.
*/
suspended: boolean;
/**
* Blinding key used when withdrawing the coin.
* Potentionally used again during payback.

View File

@ -413,7 +413,6 @@ export async function importBackup(
currentAmount: Amounts.parseOrThrow(backupCoin.current_amount),
denomSig: backupCoin.denom_sig,
coinPub: compCoin.coinPub,
suspended: false,
exchangeBaseUrl: backupExchangeDetails.base_url,
denomPubHash,
status: backupCoin.fresh

View File

@ -33,12 +33,11 @@ import {
getRandomBytes,
hashWire,
Logger,
NotificationType,
parsePaytoUri,
PayCoinSelection,
PrepareDepositRequest,
PrepareDepositResponse,
TalerErrorDetail,
RefreshReason,
TalerProtocolTimestamp,
TrackDepositGroupRequest,
TrackDepositGroupResponse,
@ -46,18 +45,15 @@ import {
} from "@gnu-taler/taler-util";
import {
DepositGroupRecord,
OperationAttemptErrorResult,
OperationAttemptResult,
OperationStatus,
} from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { selectPayCoins } from "../util/coinSelection.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { RetryInfo } from "../util/retries.js";
import { guardOperationException } from "./common.js";
import { spendCoins } from "../wallet.js";
import { getExchangeDetails } from "./exchanges.js";
import {
applyCoinSpend,
CoinSelectionRequest,
extractContractData,
generateDepositPermissions,
@ -525,12 +521,12 @@ export async function createDepositGroup(
x.refreshGroups,
])
.runReadWrite(async (tx) => {
await applyCoinSpend(
ws,
tx,
payCoinSel,
`deposit-group:${depositGroup.depositGroupId}`,
);
await spendCoins(ws, tx, {
allocationId: `deposit-group:${depositGroup.depositGroupId}`,
coinPubs: payCoinSel.coinPubs,
contributions: payCoinSel.coinContributions,
refreshReason: RefreshReason.PayDeposit,
});
await tx.depositGroups.put(depositGroup);
});

View File

@ -100,6 +100,7 @@ import {
} from "../util/http.js";
import { GetReadWriteAccess } from "../util/query.js";
import { RetryInfo, RetryTags, scheduleRetry } from "../util/retries.js";
import { spendCoins } from "../wallet.js";
import { getExchangeDetails } from "./exchanges.js";
import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
@ -156,9 +157,6 @@ export async function getTotalPaymentCost(
}
function isSpendableCoin(coin: CoinRecord, denom: DenominationRecord): boolean {
if (coin.suspended) {
return false;
}
if (denom.isRevoked) {
return false;
}
@ -347,65 +345,6 @@ export async function getCandidatePayCoins(
};
}
/**
* Apply a coin selection to the database. Marks coins as spent
* and creates a refresh session for the remaining amount.
*
* FIXME: This does not deal well with conflicting spends!
* When two payments are made in parallel, the same coin can be selected
* for two payments.
* However, this is a situation that can also happen via sync.
*/
export async function applyCoinSpend(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
refreshGroups: typeof WalletStoresV1.refreshGroups;
denominations: typeof WalletStoresV1.denominations;
}>,
coinSelection: PayCoinSelection,
allocationId: string,
): Promise<void> {
logger.info(`applying coin spend ${j2s(coinSelection)}`);
for (let i = 0; i < coinSelection.coinPubs.length; i++) {
const coin = await tx.coins.get(coinSelection.coinPubs[i]);
if (!coin) {
throw Error("coin allocated for payment doesn't exist anymore");
}
const contrib = coinSelection.coinContributions[i];
if (coin.status !== CoinStatus.Fresh) {
const alloc = coin.allocation;
if (!alloc) {
continue;
}
if (alloc.id !== allocationId) {
// FIXME: assign error code
throw Error("conflicting coin allocation (id)");
}
if (0 !== Amounts.cmp(alloc.amount, contrib)) {
// FIXME: assign error code
throw Error("conflicting coin allocation (contrib)");
}
continue;
}
coin.status = CoinStatus.Dormant;
coin.allocation = {
id: allocationId,
amount: Amounts.stringify(contrib),
};
const remaining = Amounts.sub(coin.currentAmount, contrib);
if (remaining.saturated) {
throw Error("not enough remaining balance on coin for payment");
}
coin.currentAmount = remaining.amount;
await tx.coins.put(coin);
}
const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({
coinPub: x,
}));
await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.PayMerchant);
}
/**
* Record all information that is necessary to
* pay for a proposal in the wallet's database.
@ -468,7 +407,12 @@ async function recordConfirmPay(
await tx.proposals.put(p);
}
await tx.purchases.put(t);
await applyCoinSpend(ws, tx, coinSelection, `proposal:${t.proposalId}`);
await spendCoins(ws, tx, {
allocationId: `proposal:${t.proposalId}`,
coinPubs: coinSelection.coinPubs,
contributions: coinSelection.coinContributions,
refreshReason: RefreshReason.PayMerchant,
});
});
ws.notify({
@ -1038,7 +982,12 @@ async function handleInsufficientFunds(
p.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
p.coinDepositPermissions = undefined;
await tx.purchases.put(p);
await applyCoinSpend(ws, tx, res, `proposal:${p.proposalId}`);
await spendCoins(ws, tx, {
allocationId: `proposal:${p.proposalId}`,
coinPubs: p.payCoinSelection.coinPubs,
contributions: p.payCoinSelection.coinContributions,
refreshReason: RefreshReason.PayMerchant,
});
});
}

View File

@ -75,6 +75,8 @@ import { internalCreateWithdrawalGroup } from "./withdraw.js";
import { GetReadOnlyAccess } from "../util/query.js";
import { createRefreshGroup } from "./refresh.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import { spendCoins } from "../wallet.js";
import { RetryTags } from "../util/retries.js";
const logger = new Logger("operations/peer-to-peer.ts");
@ -256,18 +258,14 @@ export async function initiatePeerToPeerPush(
return undefined;
}
const pubs: CoinPublicKey[] = [];
for (const c of sel.coins) {
const coin = await tx.coins.get(c.coinPub);
checkDbInvariant(!!coin);
coin.currentAmount = Amounts.sub(
coin.currentAmount,
Amounts.parseOrThrow(c.contribution),
).amount;
coin.status = CoinStatus.Dormant;
pubs.push({ coinPub: coin.coinPub });
await tx.coins.put(coin);
}
await spendCoins(ws, tx, {
allocationId: `peer-push:${pursePair.pub}`,
coinPubs: sel.coins.map((x) => x.coinPub),
contributions: sel.coins.map((x) =>
Amounts.parseOrThrow(x.contribution),
),
refreshReason: RefreshReason.PayPeerPush,
});
await tx.peerPushPaymentInitiations.add({
amount: Amounts.stringify(instructedAmount),
@ -284,8 +282,6 @@ export async function initiatePeerToPeerPush(
timestampCreated: TalerProtocolTimestamp.now(),
});
await createRefreshGroup(ws, tx, pubs, RefreshReason.PayPeerPush);
return sel;
});
logger.info(`selected p2p coins (push): ${j2s(coinSelRes)}`);
@ -588,20 +584,14 @@ export async function acceptPeerPullPayment(
return undefined;
}
const pubs: CoinPublicKey[] = [];
for (const c of sel.coins) {
const coin = await tx.coins.get(c.coinPub);
checkDbInvariant(!!coin);
coin.currentAmount = Amounts.sub(
coin.currentAmount,
Amounts.parseOrThrow(c.contribution),
).amount;
coin.status = CoinStatus.Dormant;
pubs.push({ coinPub: coin.coinPub });
await tx.coins.put(coin);
}
await createRefreshGroup(ws, tx, pubs, RefreshReason.PayPeerPull);
await spendCoins(ws, tx, {
allocationId: `peer-pull:${req.peerPullPaymentIncomingId}`,
coinPubs: sel.coins.map((x) => x.coinPub),
contributions: sel.coins.map((x) =>
Amounts.parseOrThrow(x.contribution),
),
refreshReason: RefreshReason.PayPeerPull,
});
const pi = await tx.peerPullPaymentIncoming.get(
req.peerPullPaymentIncomingId,

View File

@ -77,6 +77,7 @@ import {
import { checkDbInvariant } from "../util/invariants.js";
import { GetReadWriteAccess } from "../util/query.js";
import { RetryInfo, runOperationHandlerForResult } from "../util/retries.js";
import { makeCoinAvailable } from "../wallet.js";
import { guardOperationException } from "./common.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import {
@ -670,7 +671,6 @@ async function refreshReveal(
type: CoinSourceType.Refresh,
oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
},
suspended: false,
coinEvHash: pc.coinEvHash,
ageCommitmentProof: pc.ageCommitmentProof,
};
@ -680,7 +680,7 @@ async function refreshReveal(
}
await ws.db
.mktx((x) => [x.coins, x.refreshGroups])
.mktx((x) => [x.coins, x.denominations, x.refreshGroups])
.runReadWrite(async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId);
if (!rg) {
@ -694,7 +694,7 @@ async function refreshReveal(
rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
updateGroupStatus(rg);
for (const coin of coins) {
await tx.coins.put(coin);
await makeCoinAvailable(ws, tx, coin);
}
await tx.refreshGroups.put(rg);
});
@ -865,10 +865,22 @@ export async function createRefreshGroup(
!!denom,
"denomination for existing coin must be in database",
);
if (coin.status !== CoinStatus.Dormant) {
coin.status = CoinStatus.Dormant;
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
checkDbInvariant(!!denom);
checkDbInvariant(
denom.freshCoinCount != null && denom.freshCoinCount > 0,
);
denom.freshCoinCount--;
await tx.denominations.put(denom);
}
const refreshAmount = coin.currentAmount;
inputPerCoin.push(refreshAmount);
coin.currentAmount = Amounts.getZero(refreshAmount.currency);
coin.status = CoinStatus.Dormant;
await tx.coins.put(coin);
const denoms = await getDenoms(coin.exchangeBaseUrl);
const cost = getTotalRefreshCost(denoms, denom, refreshAmount);
@ -965,9 +977,6 @@ export async function autoRefresh(
if (coin.status !== CoinStatus.Fresh) {
continue;
}
if (coin.suspended) {
continue;
}
const denom = await tx.denominations.get([
exchangeBaseUrl,
coin.denomPubHash,

View File

@ -51,6 +51,7 @@ import {
readSuccessResponseJsonOrThrow,
} from "../util/http.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import { makeCoinAvailable } from "../wallet.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import {
getCandidateWithdrawalDenoms,
@ -310,13 +311,12 @@ export async function processTip(
denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: denomSigRsa.sig },
exchangeBaseUrl: tipRecord.exchangeBaseUrl,
status: CoinStatus.Fresh,
suspended: false,
coinEvHash: planchet.coinEvHash,
});
}
await ws.db
.mktx((x) => [x.coins, x.tips, x.withdrawalGroups])
.mktx((x) => [x.coins, x.denominations, x.tips])
.runReadWrite(async (tx) => {
const tr = await tx.tips.get(walletTipId);
if (!tr) {
@ -328,7 +328,7 @@ export async function processTip(
tr.pickedUpTimestamp = TalerProtocolTimestamp.now();
await tx.tips.put(tr);
for (const cr of newCoinRecords) {
await tx.coins.put(cr);
await makeCoinAvailable(ws, tx, cr);
}
});

View File

@ -93,11 +93,11 @@ import {
} from "../util/http.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import { DbAccess, GetReadOnlyAccess } from "../util/query.js";
import { RetryInfo } from "../util/retries.js";
import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION,
} from "../versions.js";
import { makeCoinAvailable } from "../wallet.js";
import {
getExchangeDetails,
getExchangePaytoUri,
@ -805,7 +805,6 @@ async function processPlanchetVerifyAndStoreCoin(
reservePub: planchet.reservePub,
withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
},
suspended: false,
ageCommitmentProof: planchet.ageCommitmentProof,
};
@ -815,7 +814,7 @@ async function processPlanchetVerifyAndStoreCoin(
// withdrawal succeeded. If so, mark the withdrawal
// group as finished.
const firstSuccess = await ws.db
.mktx((x) => [x.coins, x.withdrawalGroups, x.planchets])
.mktx((x) => [x.coins, x.denominations, x.withdrawalGroups, x.planchets])
.runReadWrite(async (tx) => {
const p = await tx.planchets.get(planchetCoinPub);
if (!p || p.withdrawalDone) {
@ -823,7 +822,7 @@ async function processPlanchetVerifyAndStoreCoin(
}
p.withdrawalDone = true;
await tx.planchets.put(p);
await tx.coins.add(coin);
await makeCoinAvailable(ws, tx, coin);
return true;
});

View File

@ -445,14 +445,15 @@ function runTx<Arg, Res>(
if (!gotFunResult) {
const msg =
"BUG: transaction closed before transaction function returned";
console.error(msg);
logger.error(msg);
logger.error(`${stack.stack}`);
reject(Error(msg));
}
resolve(funResult);
};
tx.onerror = () => {
logger.error("error in transaction");
logger.error(`${stack}`);
logger.error(`${stack.stack}`);
};
tx.onabort = () => {
let msg: string;

View File

@ -99,7 +99,9 @@ import {
} from "./crypto/workers/cryptoDispatcher.js";
import {
AuditorTrustRecord,
CoinRecord,
CoinSourceType,
CoinStatus,
exportDb,
importDb,
OperationAttemptResult,
@ -216,6 +218,7 @@ import {
HttpRequestLibrary,
readSuccessResponseJsonOrThrow,
} from "./util/http.js";
import { checkDbInvariant } from "./util/invariants.js";
import {
AsyncCondition,
OpenedPromise,
@ -787,21 +790,135 @@ async function getExchangeDetailedInfo(
};
}
export async function makeCoinAvailable(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
denominations: typeof WalletStoresV1.denominations;
}>,
coinRecord: CoinRecord,
): Promise<void> {
const denom = await tx.denominations.get([
coinRecord.exchangeBaseUrl,
coinRecord.denomPubHash,
]);
checkDbInvariant(!!denom);
if (!denom.freshCoinCount) {
denom.freshCoinCount = 0;
}
denom.freshCoinCount++;
await tx.coins.put(coinRecord);
await tx.denominations.put(denom);
}
export interface CoinsSpendInfo {
coinPubs: string[];
contributions: AmountJson[];
refreshReason: RefreshReason;
/**
* Identifier for what the coin has been spent for.
*/
allocationId: string;
}
export async function spendCoins(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
refreshGroups: typeof WalletStoresV1.refreshGroups;
denominations: typeof WalletStoresV1.denominations;
}>,
csi: CoinsSpendInfo,
): Promise<void> {
for (let i = 0; i < csi.coinPubs.length; i++) {
const coin = await tx.coins.get(csi.coinPubs[i]);
if (!coin) {
throw Error("coin allocated for payment doesn't exist anymore");
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
checkDbInvariant(!!denom);
const contrib = csi.contributions[i];
if (coin.status !== CoinStatus.Fresh) {
const alloc = coin.allocation;
if (!alloc) {
continue;
}
if (alloc.id !== csi.allocationId) {
// FIXME: assign error code
throw Error("conflicting coin allocation (id)");
}
if (0 !== Amounts.cmp(alloc.amount, contrib)) {
// FIXME: assign error code
throw Error("conflicting coin allocation (contrib)");
}
continue;
}
coin.status = CoinStatus.Dormant;
coin.allocation = {
id: csi.allocationId,
amount: Amounts.stringify(contrib),
};
const remaining = Amounts.sub(coin.currentAmount, contrib);
if (remaining.saturated) {
throw Error("not enough remaining balance on coin for payment");
}
coin.currentAmount = remaining.amount;
checkDbInvariant(!!denom);
if (denom.freshCoinCount == null || denom.freshCoinCount === 0) {
throw Error(`invalid coin count ${denom.freshCoinCount} in DB`);
}
denom.freshCoinCount--;
await tx.coins.put(coin);
await tx.denominations.put(denom);
}
const refreshCoinPubs = csi.coinPubs.map((x) => ({
coinPub: x,
}));
await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.PayMerchant);
}
async function setCoinSuspended(
ws: InternalWalletState,
coinPub: string,
suspended: boolean,
): Promise<void> {
await ws.db
.mktx((x) => [x.coins])
.mktx((x) => [x.coins, x.denominations])
.runReadWrite(async (tx) => {
const c = await tx.coins.get(coinPub);
if (!c) {
logger.warn(`coin ${coinPub} not found, won't suspend`);
return;
}
c.suspended = suspended;
const denom = await tx.denominations.get([
c.exchangeBaseUrl,
c.denomPubHash,
]);
checkDbInvariant(!!denom);
if (suspended) {
if (c.status !== CoinStatus.Fresh) {
return;
}
if (denom.freshCoinCount == null || denom.freshCoinCount === 0) {
throw Error(`invalid coin count ${denom.freshCoinCount} in DB`);
}
denom.freshCoinCount--;
c.status = CoinStatus.FreshSuspended;
} else {
if (c.status == CoinStatus.Dormant) {
return;
}
if (denom.freshCoinCount == null) {
denom.freshCoinCount = 0;
}
denom.freshCoinCount++;
c.status = CoinStatus.Fresh;
}
await tx.coins.put(c);
await tx.denominations.put(denom);
});
}
@ -857,7 +974,7 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {
refresh_parent_coin_pub: refreshParentCoinPub,
remaining_value: Amounts.stringify(c.currentAmount),
withdrawal_reserve_pub: withdrawalReservePub,
coin_suspended: c.suspended,
coin_suspended: c.status === CoinStatus.FreshSuspended,
ageCommitmentProof: c.ageCommitmentProof,
});
}