wallet-core: support age restrictions in new coin selection

This commit is contained in:
Florian Dold 2022-09-16 16:20:47 +02:00
parent 2747bc260b
commit b91caf977f
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
20 changed files with 327 additions and 267 deletions

View File

@ -20,7 +20,7 @@ import {
ObjectStoreRecord,
MemoryBackendDump,
} from "./MemoryBackend";
import { Event } from "./idbtypes";
import { Event, IDBKeyRange } from "./idbtypes";
import {
BridgeIDBCursor,
BridgeIDBDatabase,
@ -89,6 +89,17 @@ export type { AccessStats } from "./MemoryBackend";
delete Object.prototype.__magic__;
})();
/**
* Global indexeddb objects, either from the native or bridge-idb
* implementation, depending on what is availabe in
* the global environment.
*/
export const GlobalIDB: {
KeyRange: typeof BridgeIDBKeyRange;
} = {
KeyRange: (globalThis as any).IDBKeyRange ?? BridgeIDBKeyRange,
};
/**
* Populate the global name space such that the given IndexedDB factory is made
* available globally.

View File

@ -988,6 +988,11 @@ function invariant(cond: boolean): asserts cond {
}
export namespace AgeRestriction {
/**
* Smallest age value that the protocol considers "unrestricted".
*/
export const AGE_UNRESTRICTED = 32;
export function hashCommitment(ac: AgeCommitment): HashCodeString {
const hc = new nacl.HashState();
for (const pub of ac.publicKeys) {

View File

@ -1226,6 +1226,7 @@ export interface RefreshPlanchetInfo {
*/
blindingKey: string;
maxAge: number;
ageCommitmentProof?: AgeCommitmentProof;
}

View File

@ -1213,6 +1213,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
coinPriv: encodeCrock(coinPriv),
coinPub: encodeCrock(coinPub),
coinEvHash: encodeCrock(coinEvHash),
maxAge: req.meltCoinMaxAge,
ageCommitmentProof: newAc,
};
planchets.push(planchet);

View File

@ -61,6 +61,7 @@ export interface DeriveRefreshSessionRequest {
meltCoinPub: string;
meltCoinPriv: string;
meltCoinDenomPubHash: string;
meltCoinMaxAge: number;
meltCoinAgeCommitmentProof?: AgeCommitmentProof;
newCoinDenoms: RefreshNewDenomInfo[];
feeRefresh: AmountJson;

View File

@ -319,11 +319,6 @@ export interface DenominationRecord {
* that includes this denomination.
*/
listIssueDate: TalerProtocolTimestamp;
/**
* Number of fresh coins of this denomination that are available.
*/
freshCoinCount?: number;
}
export namespace DenominationRecord {
@ -546,6 +541,8 @@ export interface PlanchetRecord {
coinEvHash: string;
maxAge: number;
ageCommitmentProof?: AgeCommitmentProof;
}
@ -674,6 +671,8 @@ export interface CoinRecord {
*/
allocation?: CoinAllocation;
maxAge: number;
ageCommitmentProof?: AgeCommitmentProof;
}
@ -1770,7 +1769,45 @@ export interface OperationAttemptLongpollResult {
type: OperationAttemptResultType.Longpoll;
}
/**
* Availability of coins of a given denomination (and age restriction!).
*
* We can't store this information with the denomination record, as one denomination
* can be withdrawn with multiple age restrictions.
*/
export interface CoinAvailabilityRecord {
currency: string;
amountVal: number;
amountFrac: number;
denomPubHash: string;
exchangeBaseUrl: string;
/**
* Age restriction on the coin, or 0 for no age restriction (or
* denomination without age restriction support).
*/
maxAge: number;
/**
* Number of fresh coins of this denomination that are available.
*/
freshCoinCount: number;
}
export const WalletStoresV1 = {
coinAvailability: describeStore(
"coinAvailability",
describeContents<CoinAvailabilityRecord>({
keyPath: ["exchangeBaseUrl", "denomPubHash", "maxAge"],
}),
{
byExchangeAgeAvailability: describeIndex("byExchangeAgeAvailability", [
"exchangeBaseUrl",
"maxAge",
"freshCoinCount",
]),
},
),
coins: describeStore(
"coins",
describeContents<CoinRecord>({
@ -1779,10 +1816,10 @@ export const WalletStoresV1 = {
{
byBaseUrl: describeIndex("byBaseUrl", "exchangeBaseUrl"),
byDenomPubHash: describeIndex("byDenomPubHash", "denomPubHash"),
byDenomPubHashAndStatus: describeIndex("byDenomPubHashAndStatus", [
"denomPubHash",
"status",
]),
byExchangeDenomPubHashAndAgeAndStatus: describeIndex(
"byExchangeDenomPubHashAndAgeAndStatus",
["exchangeBaseUrl", "denomPubHash", "maxAge", "status"],
),
byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"),
},
),

View File

@ -49,6 +49,7 @@ import {
BankWithdrawDetails,
parseWithdrawUri,
AmountJson,
AgeRestriction,
} from "@gnu-taler/taler-util";
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import { DenominationRecord } from "./db.js";
@ -86,6 +87,7 @@ export interface CoinInfo {
denomPubHash: string;
feeDeposit: string;
feeRefresh: string;
maxAge: number;
}
/**
@ -200,6 +202,7 @@ export async function withdrawCoin(args: {
feeDeposit: Amounts.stringify(denom.fees.feeDeposit),
feeRefresh: Amounts.stringify(denom.fees.feeRefresh),
exchangeBaseUrl: args.exchangeBaseUrl,
maxAge: AgeRestriction.AGE_UNRESTRICTED,
};
}
@ -298,6 +301,7 @@ export async function refreshCoin(req: {
value: x.amountVal,
},
})),
meltCoinMaxAge: oldCoin.maxAge,
});
const meltReqBody: ExchangeMeltRequest = {

View File

@ -15,6 +15,7 @@
*/
import {
AgeRestriction,
AmountJson,
Amounts,
BackupCoinSourceType,
@ -436,6 +437,8 @@ export async function importBackup(
? CoinStatus.Fresh
: CoinStatus.Dormant,
coinSource,
// FIXME!
maxAge: AgeRestriction.AGE_UNRESTRICTED,
});
}
}

View File

@ -51,16 +51,14 @@ import {
OperationStatus,
} from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { selectPayCoinsLegacy } from "../util/coinSelection.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { spendCoins } from "../wallet.js";
import { getExchangeDetails } from "./exchanges.js";
import {
CoinSelectionRequest,
extractContractData,
generateDepositPermissions,
getCandidatePayCoins,
getTotalPaymentCost,
selectPayCoinsNew,
} from "./pay.js";
import { getTotalRefreshCost } from "./refresh.js";
import { makeEventId } from "./transactions.js";
@ -255,28 +253,17 @@ export async function getFeeForDeposit(
}
});
const csr: CoinSelectionRequest = {
allowedAuditors: [],
allowedExchanges: Object.values(exchangeInfos).map((v) => ({
const payCoinSel = await selectPayCoinsNew(ws, {
auditors: [],
exchanges: Object.values(exchangeInfos).map((v) => ({
exchangeBaseUrl: v.url,
exchangePub: v.master_pub,
})),
amount: Amounts.parseOrThrow(req.amount),
maxDepositFee: Amounts.parseOrThrow(req.amount),
maxWireFee: Amounts.parseOrThrow(req.amount),
timestamp: TalerProtocolTimestamp.now(),
wireFeeAmortization: 1,
wireMethod: p.targetType,
};
const candidates = await getCandidatePayCoins(ws, csr);
const payCoinSel = selectPayCoinsLegacy({
candidates,
contractTermsAmount: csr.amount,
depositFeeLimit: csr.maxDepositFee,
wireFeeAmortization: csr.wireFeeAmortization,
wireFeeLimit: csr.maxWireFee,
contractTermsAmount: Amounts.parseOrThrow(req.amount),
depositFeeLimit: Amounts.parseOrThrow(req.amount),
wireFeeAmortization: 1,
wireFeeLimit: Amounts.parseOrThrow(req.amount),
prevPayCoins: [],
});
@ -356,19 +343,10 @@ export async function prepareDepositGroup(
"",
);
const candidates = await getCandidatePayCoins(ws, {
allowedAuditors: contractData.allowedAuditors,
allowedExchanges: contractData.allowedExchanges,
amount: contractData.amount,
maxDepositFee: contractData.maxDepositFee,
maxWireFee: contractData.maxWireFee,
timestamp: contractData.timestamp,
wireFeeAmortization: contractData.wireFeeAmortization,
const payCoinSel = await selectPayCoinsNew(ws, {
auditors: contractData.allowedAuditors,
exchanges: contractData.allowedExchanges,
wireMethod: contractData.wireMethod,
});
const payCoinSel = selectPayCoinsLegacy({
candidates,
contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee,
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
@ -459,19 +437,10 @@ export async function createDepositGroup(
"",
);
const candidates = await getCandidatePayCoins(ws, {
allowedAuditors: contractData.allowedAuditors,
allowedExchanges: contractData.allowedExchanges,
amount: contractData.amount,
maxDepositFee: contractData.maxDepositFee,
maxWireFee: contractData.maxWireFee,
timestamp: contractData.timestamp,
wireFeeAmortization: contractData.wireFeeAmortization,
const payCoinSel = await selectPayCoinsNew(ws, {
auditors: contractData.allowedAuditors,
exchanges: contractData.allowedExchanges,
wireMethod: contractData.wireMethod,
});
const payCoinSel = selectPayCoinsLegacy({
candidates,
contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee,
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
@ -522,6 +491,7 @@ export async function createDepositGroup(
x.recoupGroups,
x.denominations,
x.refreshGroups,
x.coinAvailability,
])
.runReadWrite(async (tx) => {
await spendCoins(ws, tx, {

View File

@ -24,6 +24,7 @@
/**
* Imports.
*/
import { BridgeIDBKeyRange, GlobalIDB } from "@gnu-taler/idb-bridge";
import {
AbsoluteTime,
AgeRestriction,
@ -102,7 +103,7 @@ import {
readUnexpectedResponseDetails,
throwUnexpectedRequestError,
} from "../util/http.js";
import { checkLogicInvariant } from "../util/invariants.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import { GetReadWriteAccess } from "../util/query.js";
import { RetryInfo, RetryTags, scheduleRetry } from "../util/retries.js";
import { spendCoins } from "../wallet.js";
@ -215,149 +216,6 @@ export interface CoinSelectionRequest {
minimumAge?: number;
}
/**
* Get candidate coins. From these candidate coins,
* the actual contributions will be computed later.
*
* The resulting candidate coin list is sorted deterministically.
*
* TODO: Exclude more coins:
* - when we already have a coin with more remaining amount than
* the payment amount, coins with even higher amounts can be skipped.
*/
export async function getCandidatePayCoins(
ws: InternalWalletState,
req: CoinSelectionRequest,
): Promise<CoinCandidateSelection> {
const candidateCoins: AvailableCoinInfo[] = [];
const wireFeesPerExchange: Record<string, AmountJson> = {};
await ws.db
.mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations, x.coins])
.runReadOnly(async (tx) => {
const exchanges = await tx.exchanges.iter().toArray();
for (const exchange of exchanges) {
let isOkay = false;
const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl);
if (!exchangeDetails) {
continue;
}
const exchangeFees = exchangeDetails.wireInfo;
if (!exchangeFees) {
continue;
}
const wireTypes = new Set<string>();
for (const acc of exchangeDetails.wireInfo.accounts) {
const p = parsePaytoUri(acc.payto_uri);
if (p) {
wireTypes.add(p.targetType);
}
}
if (!wireTypes.has(req.wireMethod)) {
// Exchange can't be used, because it doesn't support
// the wire type that the merchant requested.
continue;
}
// is the exchange explicitly allowed?
for (const allowedExchange of req.allowedExchanges) {
if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
isOkay = true;
break;
}
}
// is the exchange allowed because of one of its auditors?
if (!isOkay) {
for (const allowedAuditor of req.allowedAuditors) {
for (const auditor of exchangeDetails.auditors) {
if (auditor.auditor_pub === allowedAuditor.auditorPub) {
isOkay = true;
break;
}
}
if (isOkay) {
break;
}
}
}
if (!isOkay) {
continue;
}
const coins = await tx.coins.indexes.byBaseUrl
.iter(exchange.baseUrl)
.toArray();
if (!coins || coins.length === 0) {
continue;
}
// Denomination of the first coin, we assume that all other
// coins have the same currency
const firstDenom = await ws.getDenomInfo(
ws,
tx,
exchange.baseUrl,
coins[0].denomPubHash,
);
if (!firstDenom) {
throw Error("db inconsistent");
}
const currency = firstDenom.value.currency;
for (const coin of coins) {
const denom = await tx.denominations.get([
exchange.baseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error("db inconsistent");
}
if (denom.currency !== currency) {
logger.warn(
`same pubkey for different currencies at exchange ${exchange.baseUrl}`,
);
continue;
}
if (!isSpendableCoin(coin, denom)) {
continue;
}
candidateCoins.push({
availableAmount: coin.currentAmount,
value: DenominationRecord.getValue(denom),
coinPub: coin.coinPub,
denomPub: denom.denomPub,
feeDeposit: denom.fees.feeDeposit,
exchangeBaseUrl: denom.exchangeBaseUrl,
ageCommitmentProof: coin.ageCommitmentProof,
});
}
let wireFee: AmountJson | undefined;
for (const fee of exchangeFees.feesForType[req.wireMethod] || []) {
if (
fee.startStamp <= req.timestamp &&
fee.endStamp >= req.timestamp
) {
wireFee = fee.wireFee;
break;
}
}
if (wireFee) {
wireFeesPerExchange[exchange.baseUrl] = wireFee;
}
}
});
return {
candidateCoins,
wireFeesPerExchange,
};
}
/**
* Record all information that is necessary to
* pay for a proposal in the wallet's database.
@ -412,6 +270,7 @@ async function recordConfirmPay(
x.coins,
x.refreshGroups,
x.denominations,
x.coinAvailability,
])
.runReadWrite(async (tx) => {
const p = await tx.proposals.get(proposal.proposalId);
@ -976,7 +835,13 @@ async function handleInsufficientFunds(
logger.trace("re-selected coins");
await ws.db
.mktx((x) => [x.purchases, x.coins, x.denominations, x.refreshGroups])
.mktx((x) => [
x.purchases,
x.coins,
x.coinAvailability,
x.denominations,
x.refreshGroups,
])
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
@ -1029,6 +894,7 @@ export interface SelectPayCoinRequestNg {
}
export type AvailableDenom = DenominationInfo & {
maxAge: number;
numAvailable: number;
};
@ -1037,7 +903,12 @@ async function selectCandidates(
req: SelectPayCoinRequestNg,
): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
return await ws.db
.mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations])
.mktx((x) => [
x.exchanges,
x.exchangeDetails,
x.denominations,
x.coinAvailability,
])
.runReadOnly(async (tx) => {
const denoms: AvailableDenom[] = [];
const exchanges = await tx.exchanges.iter().toArray();
@ -1065,17 +936,35 @@ async function selectCandidates(
if (!accepted) {
continue;
}
// FIXME: Do this query more efficiently via indexing
const exchangeDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(exchangeDetails.exchangeBaseUrl)
.filter((x) => x.freshCoinCount != null && x.freshCoinCount > 0);
let ageLower = 0;
let ageUpper = Number.MAX_SAFE_INTEGER;
if (req.requiredMinimumAge) {
ageLower = req.requiredMinimumAge;
}
const myExchangeDenoms =
await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
GlobalIDB.KeyRange.bound(
[exchangeDetails.exchangeBaseUrl, ageLower, 1],
[
exchangeDetails.exchangeBaseUrl,
ageUpper,
Number.MAX_SAFE_INTEGER,
],
),
);
// FIXME: Check that the individual denomination is audited!
// FIXME: Should we exclude denominations that are
// not spendable anymore?
for (const denom of exchangeDenoms) {
for (const denomAvail of myExchangeDenoms) {
const denom = await tx.denominations.get([
denomAvail.exchangeBaseUrl,
denomAvail.denomPubHash,
]);
checkDbInvariant(!!denom);
denoms.push({
...DenominationRecord.toDenomInfo(denom),
numAvailable: denom.freshCoinCount ?? 0,
numAvailable: denomAvail.freshCoinCount ?? 0,
maxAge: denomAvail.maxAge,
});
}
}
@ -1092,15 +981,28 @@ async function selectCandidates(
});
}
function makeAvailabilityKey(
exchangeBaseUrl: string,
denomPubHash: string,
maxAge: number,
): string {
return `${denomPubHash};${maxAge};${exchangeBaseUrl}`;
}
/**
* Selection result.
*/
interface SelResult {
/**
* Map from denomination public key hashes
* Map from an availability key
* to an array of contributions.
*/
[dph: string]: AmountJson[];
[avKey: string]: {
exchangeBaseUrl: string;
denomPubHash: string;
maxAge: number;
contributions: AmountJson[];
};
}
export function selectGreedy(
@ -1146,7 +1048,22 @@ export function selectGreedy(
}
if (contributions.length) {
selectedDenom[aci.denomPubHash] = contributions;
const avKey = makeAvailabilityKey(
aci.exchangeBaseUrl,
aci.denomPubHash,
aci.maxAge,
);
let sd = selectedDenom[avKey];
if (!sd) {
sd = {
contributions: [],
denomPubHash: aci.denomPubHash,
exchangeBaseUrl: aci.exchangeBaseUrl,
maxAge: aci.maxAge,
};
}
sd.contributions.push(...contributions);
selectedDenom[avKey] = sd;
}
if (Amounts.isZero(tally.amountPayRemaining)) {
@ -1173,9 +1090,22 @@ export function selectForced(
}
if (Amounts.cmp(aci.value, forcedCoin.value) === 0) {
aci.numAvailable--;
const contributions = selectedDenom[aci.denomPubHash] ?? [];
contributions.push(Amounts.parseOrThrow(forcedCoin.value));
selectedDenom[aci.denomPubHash] = contributions;
const avKey = makeAvailabilityKey(
aci.exchangeBaseUrl,
aci.denomPubHash,
aci.maxAge,
);
let sd = selectedDenom[avKey];
if (!sd) {
sd = {
contributions: [],
denomPubHash: aci.denomPubHash,
exchangeBaseUrl: aci.exchangeBaseUrl,
maxAge: aci.maxAge,
};
}
sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value));
selectedDenom[avKey] = sd;
found = true;
break;
}
@ -1273,18 +1203,27 @@ export async function selectPayCoinsNew(
.mktx((x) => [x.coins, x.denominations])
.runReadOnly(async (tx) => {
for (const dph of Object.keys(finalSel)) {
const contributions = finalSel[dph];
const coins = await tx.coins.indexes.byDenomPubHashAndStatus.getAll(
[dph, CoinStatus.Fresh],
contributions.length,
);
if (coins.length != contributions.length) {
const selInfo = finalSel[dph];
const numRequested = selInfo.contributions.length;
const query = [
selInfo.exchangeBaseUrl,
selInfo.denomPubHash,
selInfo.maxAge,
CoinStatus.Fresh,
];
logger.info(`query: ${j2s(query)}`);
const coins =
await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll(
query,
numRequested,
);
if (coins.length != numRequested) {
throw Error(
`coin selection failed (not available anymore, got only ${coins.length}/${contributions.length})`,
`coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`,
);
}
coinPubs.push(...coins.map((x) => x.coinPub));
coinContributions.push(...contributions);
coinContributions.push(...selInfo.contributions);
}
});
@ -1535,7 +1474,7 @@ export async function generateDepositPermissions(
let wireInfoHash: string;
wireInfoHash = contractData.wireInfoHash;
logger.trace(
`signing deposit permission for coin with acp=${j2s(
`signing deposit permission for coin with ageRestriction=${j2s(
coin.ageCommitmentProof,
)}`,
);

View File

@ -118,7 +118,8 @@ interface CoinInfo {
denomSig: UnblindedSignature;
ageCommitmentProof: AgeCommitmentProof | undefined;
maxAge: number;
ageCommitmentProof?: AgeCommitmentProof;
}
export async function selectPeerCoins(
@ -156,6 +157,7 @@ export async function selectPeerCoins(
denomPubHash: denom.denomPubHash,
coinPriv: coin.coinPriv,
denomSig: coin.denomSig,
maxAge: coin.maxAge,
ageCommitmentProof: coin.ageCommitmentProof,
});
}
@ -245,6 +247,7 @@ export async function initiatePeerToPeerPush(
.mktx((x) => [
x.exchanges,
x.coins,
x.coinAvailability,
x.denominations,
x.refreshGroups,
x.peerPullPaymentInitiations,
@ -583,6 +586,7 @@ export async function acceptPeerPullPayment(
x.denominations,
x.refreshGroups,
x.peerPullPaymentIncoming,
x.coinAvailability,
])
.runReadWrite(async (tx) => {
const sel = await selectPeerCoins(ws, tx, instructedAmount);

View File

@ -392,7 +392,13 @@ export async function processRecoupGroupHandler(
}
await ws.db
.mktx((x) => [x.recoupGroups, x.denominations, x.refreshGroups, x.coins])
.mktx((x) => [
x.recoupGroups,
x.coinAvailability,
x.denominations,
x.refreshGroups,
x.coins,
])
.runReadWrite(async (tx) => {
const rg2 = await tx.recoupGroups.get(recoupGroupId);
if (!rg2) {

View File

@ -77,7 +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 { makeCoinAvailable, Wallet } from "../wallet.js";
import { guardOperationException } from "./common.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import {
@ -368,6 +368,7 @@ async function refreshMelt(
meltCoinPriv: oldCoin.coinPriv,
meltCoinPub: oldCoin.coinPub,
feeRefresh: oldDenom.feeRefresh,
meltCoinMaxAge: oldCoin.maxAge,
meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
newCoinDenoms,
sessionSecretSeed: refreshSession.sessionSecretSeed,
@ -614,6 +615,7 @@ async function refreshReveal(
meltCoinPub: oldCoin.coinPub,
feeRefresh: oldDenom.feeRefresh,
newCoinDenoms,
meltCoinMaxAge: oldCoin.maxAge,
meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
sessionSecretSeed: refreshSession.sessionSecretSeed,
});
@ -676,6 +678,7 @@ async function refreshReveal(
oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
},
coinEvHash: pc.coinEvHash,
maxAge: pc.maxAge,
ageCommitmentProof: pc.ageCommitmentProof,
};
@ -684,7 +687,12 @@ async function refreshReveal(
}
await ws.db
.mktx((x) => [x.coins, x.denominations, x.refreshGroups])
.mktx((x) => [
x.coins,
x.denominations,
x.coinAvailability,
x.refreshGroups,
])
.runReadWrite(async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId);
if (!rg) {
@ -830,6 +838,7 @@ export async function createRefreshGroup(
denominations: typeof WalletStoresV1.denominations;
coins: typeof WalletStoresV1.coins;
refreshGroups: typeof WalletStoresV1.refreshGroups;
coinAvailability: typeof WalletStoresV1.coinAvailability;
}>,
oldCoinPubs: CoinPublicKey[],
reason: RefreshReason,
@ -871,16 +880,15 @@ export async function createRefreshGroup(
);
if (coin.status !== CoinStatus.Dormant) {
coin.status = CoinStatus.Dormant;
const denom = await tx.denominations.get([
const coinAv = await tx.coinAvailability.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
coin.maxAge,
]);
checkDbInvariant(!!denom);
checkDbInvariant(
denom.freshCoinCount != null && denom.freshCoinCount > 0,
);
denom.freshCoinCount--;
await tx.denominations.put(denom);
checkDbInvariant(!!coinAv);
checkDbInvariant(coinAv.freshCoinCount > 0);
coinAv.freshCoinCount--;
await tx.coinAvailability.put(coinAv);
}
const refreshAmount = coin.currentAmount;
inputPerCoin.push(refreshAmount);
@ -967,7 +975,13 @@ export async function autoRefresh(
durationFromSpec({ days: 1 }),
);
await ws.db
.mktx((x) => [x.coins, x.denominations, x.refreshGroups, x.exchanges])
.mktx((x) => [
x.coins,
x.denominations,
x.coinAvailability,
x.refreshGroups,
x.exchanges,
])
.runReadWrite(async (tx) => {
const exchange = await tx.exchanges.get(exchangeBaseUrl);
if (!exchange) {

View File

@ -336,7 +336,13 @@ async function acceptRefunds(
const now = TalerProtocolTimestamp.now();
await ws.db
.mktx((x) => [x.purchases, x.coins, x.denominations, x.refreshGroups])
.mktx((x) => [
x.purchases,
x.coins,
x.coinAvailability,
x.denominations,
x.refreshGroups,
])
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {

View File

@ -18,6 +18,7 @@
* Imports.
*/
import {
AgeRestriction,
AcceptTipResponse,
Amounts,
BlindedDenominationSignature,
@ -315,11 +316,12 @@ export async function processTip(
exchangeBaseUrl: tipRecord.exchangeBaseUrl,
status: CoinStatus.Fresh,
coinEvHash: planchet.coinEvHash,
maxAge: AgeRestriction.AGE_UNRESTRICTED,
});
}
await ws.db
.mktx((x) => [x.coins, x.denominations, x.tips])
.mktx((x) => [x.coins, x.coinAvailability, x.denominations, x.tips])
.runReadWrite(async (tx) => {
const tr = await tx.tips.get(walletTipId);
if (!tr) {

View File

@ -22,6 +22,7 @@ import {
AcceptManualWithdrawalResult,
AcceptWithdrawalResponse,
addPaytoQueryParams,
AgeRestriction,
AmountJson,
AmountLike,
Amounts,
@ -510,6 +511,7 @@ async function processPlanchetGenerate(
withdrawalDone: false,
withdrawSig: r.withdrawSig,
withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
ageCommitmentProof: r.ageCommitmentProof,
lastError: undefined,
};
@ -823,6 +825,7 @@ async function processPlanchetVerifyAndStoreCoin(
reservePub: planchet.reservePub,
withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
},
maxAge: planchet.maxAge,
ageCommitmentProof: planchet.ageCommitmentProof,
};
@ -832,7 +835,13 @@ async function processPlanchetVerifyAndStoreCoin(
// withdrawal succeeded. If so, mark the withdrawal
// group as finished.
const firstSuccess = await ws.db
.mktx((x) => [x.coins, x.denominations, x.withdrawalGroups, x.planchets])
.mktx((x) => [
x.coins,
x.denominations,
x.coinAvailability,
x.withdrawalGroups,
x.planchets,
])
.runReadWrite(async (tx) => {
const p = await tx.planchets.get(planchetCoinPub);
if (!p || p.withdrawalDone) {

View File

@ -18,7 +18,12 @@
* Imports.
*/
import test from "ava";
import { AmountJson, Amounts, DenomKeyType } from "@gnu-taler/taler-util";
import {
AgeRestriction,
AmountJson,
Amounts,
DenomKeyType,
} from "@gnu-taler/taler-util";
import { AvailableCoinInfo, selectPayCoinsLegacy } from "./coinSelection.js";
function a(x: string): AmountJson {
@ -41,10 +46,14 @@ function fakeAci(current: string, feeDeposit: string): AvailableCoinInfo {
},
feeDeposit: a(feeDeposit),
exchangeBaseUrl: "https://example.com/",
maxAge: AgeRestriction.AGE_UNRESTRICTED,
};
}
function fakeAciWithAgeRestriction(current: string, feeDeposit: string): AvailableCoinInfo {
function fakeAciWithAgeRestriction(
current: string,
feeDeposit: string,
): AvailableCoinInfo {
return {
value: a(current),
availableAmount: a(current),
@ -56,6 +65,7 @@ function fakeAciWithAgeRestriction(current: string, feeDeposit: string): Availab
},
feeDeposit: a(feeDeposit),
exchangeBaseUrl: "https://example.com/",
maxAge: AgeRestriction.AGE_UNRESTRICTED,
};
}
@ -284,7 +294,6 @@ test("coin selection 9", (t) => {
t.pass();
});
test("it should be able to use unrestricted coins for age restricted contract", (t) => {
const acis: AvailableCoinInfo[] = [
fakeAciWithAgeRestriction("EUR:1.0", "EUR:0.2"),
@ -299,7 +308,7 @@ test("it should be able to use unrestricted coins for age restricted contract",
depositFeeLimit: a("EUR:0.4"),
wireFeeLimit: a("EUR:0"),
wireFeeAmortization: 1,
requiredMinimumAge: 13
requiredMinimumAge: 13,
});
if (!res) {
t.fail();

View File

@ -72,6 +72,7 @@ export interface AvailableCoinInfo {
exchangeBaseUrl: string;
maxAge: number;
ageCommitmentProof?: AgeCommitmentProof;
}

View File

@ -33,6 +33,7 @@ import {
IDBVersionChangeEvent,
IDBCursor,
IDBKeyPath,
IDBKeyRange,
} from "@gnu-taler/idb-bridge";
import { Logger } from "@gnu-taler/taler-util";
import { performanceNow } from "./timer.js";
@ -309,9 +310,12 @@ export function describeIndex(
}
interface IndexReadOnlyAccessor<RecordType> {
iter(query?: IDBValidKey): ResultStream<RecordType>;
iter(query?: IDBKeyRange | IDBValidKey): ResultStream<RecordType>;
get(query: IDBValidKey): Promise<RecordType | undefined>;
getAll(query: IDBValidKey, count?: number): Promise<RecordType[]>;
getAll(
query: IDBKeyRange | IDBValidKey,
count?: number,
): Promise<RecordType[]>;
}
type GetIndexReadOnlyAccess<RecordType, IndexMap> = {
@ -319,9 +323,12 @@ type GetIndexReadOnlyAccess<RecordType, IndexMap> = {
};
interface IndexReadWriteAccessor<RecordType> {
iter(query: IDBValidKey): ResultStream<RecordType>;
iter(query: IDBKeyRange | IDBValidKey): ResultStream<RecordType>;
get(query: IDBValidKey): Promise<RecordType | undefined>;
getAll(query: IDBValidKey, count?: number): Promise<RecordType[]>;
getAll(
query: IDBKeyRange | IDBValidKey,
count?: number,
): Promise<RecordType[]>;
}
type GetIndexReadWriteAccess<RecordType, IndexMap> = {

View File

@ -802,6 +802,7 @@ export async function makeCoinAvailable(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
coinAvailability: typeof WalletStoresV1.coinAvailability;
denominations: typeof WalletStoresV1.denominations;
}>,
coinRecord: CoinRecord,
@ -811,12 +812,26 @@ export async function makeCoinAvailable(
coinRecord.denomPubHash,
]);
checkDbInvariant(!!denom);
if (!denom.freshCoinCount) {
denom.freshCoinCount = 0;
const ageRestriction = coinRecord.maxAge;
let car = await tx.coinAvailability.get([
coinRecord.exchangeBaseUrl,
coinRecord.denomPubHash,
ageRestriction,
]);
if (!car) {
car = {
maxAge: ageRestriction,
amountFrac: denom.amountFrac,
amountVal: denom.amountVal,
currency: denom.currency,
denomPubHash: denom.denomPubHash,
exchangeBaseUrl: denom.exchangeBaseUrl,
freshCoinCount: 0,
};
}
denom.freshCoinCount++;
car.freshCoinCount++;
await tx.coins.put(coinRecord);
await tx.denominations.put(denom);
await tx.coinAvailability.put(car);
}
export interface CoinsSpendInfo {
@ -833,6 +848,7 @@ export async function spendCoins(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
coinAvailability: typeof WalletStoresV1.coinAvailability;
refreshGroups: typeof WalletStoresV1.refreshGroups;
denominations: typeof WalletStoresV1.denominations;
}>,
@ -843,11 +859,12 @@ export async function spendCoins(
if (!coin) {
throw Error("coin allocated for payment doesn't exist anymore");
}
const denom = await tx.denominations.get([
const coinAvailability = await tx.coinAvailability.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
coin.maxAge,
]);
checkDbInvariant(!!denom);
checkDbInvariant(!!coinAvailability);
const contrib = csi.contributions[i];
if (coin.status !== CoinStatus.Fresh) {
const alloc = coin.allocation;
@ -874,13 +891,15 @@ export async function spendCoins(
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`);
checkDbInvariant(!!coinAvailability);
if (coinAvailability.freshCoinCount === 0) {
throw Error(
`invalid coin count ${coinAvailability.freshCoinCount} in DB`,
);
}
denom.freshCoinCount--;
coinAvailability.freshCoinCount--;
await tx.coins.put(coin);
await tx.denominations.put(denom);
await tx.coinAvailability.put(coinAvailability);
}
const refreshCoinPubs = csi.coinPubs.map((x) => ({
coinPub: x,
@ -894,39 +913,45 @@ async function setCoinSuspended(
suspended: boolean,
): Promise<void> {
await ws.db
.mktx((x) => [x.coins, x.denominations])
.mktx((x) => [x.coins, x.coinAvailability])
.runReadWrite(async (tx) => {
const c = await tx.coins.get(coinPub);
if (!c) {
logger.warn(`coin ${coinPub} not found, won't suspend`);
return;
}
const denom = await tx.denominations.get([
const coinAvailability = await tx.coinAvailability.get([
c.exchangeBaseUrl,
c.denomPubHash,
c.maxAge,
]);
checkDbInvariant(!!denom);
checkDbInvariant(!!coinAvailability);
if (suspended) {
if (c.status !== CoinStatus.Fresh) {
return;
}
if (denom.freshCoinCount == null || denom.freshCoinCount === 0) {
throw Error(`invalid coin count ${denom.freshCoinCount} in DB`);
if (
coinAvailability.freshCoinCount == null ||
coinAvailability.freshCoinCount === 0
) {
throw Error(
`invalid coin count ${coinAvailability.freshCoinCount} in DB`,
);
}
denom.freshCoinCount--;
coinAvailability.freshCoinCount--;
c.status = CoinStatus.FreshSuspended;
} else {
if (c.status == CoinStatus.Dormant) {
return;
}
if (denom.freshCoinCount == null) {
denom.freshCoinCount = 0;
if (coinAvailability.freshCoinCount == null) {
coinAvailability.freshCoinCount = 0;
}
denom.freshCoinCount++;
coinAvailability.freshCoinCount++;
c.status = CoinStatus.Fresh;
}
await tx.coins.put(c);
await tx.denominations.put(denom);
await tx.coinAvailability.put(coinAvailability);
});
}
@ -1195,7 +1220,12 @@ async function dispatchRequestInternal(
const req = codecForForceRefreshRequest().decode(payload);
const coinPubs = req.coinPubList.map((x) => ({ coinPub: x }));
const refreshGroupId = await ws.db
.mktx((x) => [x.refreshGroups, x.denominations, x.coins])
.mktx((x) => [
x.refreshGroups,
x.coinAvailability,
x.denominations,
x.coins,
])
.runReadWrite(async (tx) => {
return await createRefreshGroup(
ws,