From b91caf977fad8da11e523ca3a39064dd86e04c64 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Fri, 16 Sep 2022 16:20:47 +0200 Subject: [PATCH] wallet-core: support age restrictions in new coin selection --- packages/idb-bridge/src/index.ts | 13 +- packages/taler-util/src/talerCrypto.ts | 5 + packages/taler-util/src/walletTypes.ts | 1 + .../src/crypto/cryptoImplementation.ts | 1 + .../src/crypto/cryptoTypes.ts | 1 + packages/taler-wallet-core/src/db.ts | 55 +++- packages/taler-wallet-core/src/dbless.ts | 4 + .../src/operations/backup/import.ts | 3 + .../src/operations/deposits.ts | 60 +--- .../taler-wallet-core/src/operations/pay.ts | 273 +++++++----------- .../src/operations/peer-to-peer.ts | 6 +- .../src/operations/recoup.ts | 8 +- .../src/operations/refresh.ts | 34 ++- .../src/operations/refund.ts | 8 +- .../taler-wallet-core/src/operations/tip.ts | 4 +- .../src/operations/withdraw.ts | 11 +- .../src/util/coinSelection.test.ts | 17 +- .../src/util/coinSelection.ts | 1 + packages/taler-wallet-core/src/util/query.ts | 15 +- packages/taler-wallet-core/src/wallet.ts | 74 +++-- 20 files changed, 327 insertions(+), 267 deletions(-) diff --git a/packages/idb-bridge/src/index.ts b/packages/idb-bridge/src/index.ts index c4dbb8281..825d41f5e 100644 --- a/packages/idb-bridge/src/index.ts +++ b/packages/idb-bridge/src/index.ts @@ -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. diff --git a/packages/taler-util/src/talerCrypto.ts b/packages/taler-util/src/talerCrypto.ts index 8d2e41793..c9eeb0584 100644 --- a/packages/taler-util/src/talerCrypto.ts +++ b/packages/taler-util/src/talerCrypto.ts @@ -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) { diff --git a/packages/taler-util/src/walletTypes.ts b/packages/taler-util/src/walletTypes.ts index c3e5c6ed0..6dcaac78d 100644 --- a/packages/taler-util/src/walletTypes.ts +++ b/packages/taler-util/src/walletTypes.ts @@ -1226,6 +1226,7 @@ export interface RefreshPlanchetInfo { */ blindingKey: string; + maxAge: number; ageCommitmentProof?: AgeCommitmentProof; } diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts index 9eaf1d91e..8b2bcab32 100644 --- a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts +++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -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); diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts index 6e0e01627..4c75aa91e 100644 --- a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts +++ b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts @@ -61,6 +61,7 @@ export interface DeriveRefreshSessionRequest { meltCoinPub: string; meltCoinPriv: string; meltCoinDenomPubHash: string; + meltCoinMaxAge: number; meltCoinAgeCommitmentProof?: AgeCommitmentProof; newCoinDenoms: RefreshNewDenomInfo[]; feeRefresh: AmountJson; diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 760234941..6466edf5a 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -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({ + keyPath: ["exchangeBaseUrl", "denomPubHash", "maxAge"], + }), + { + byExchangeAgeAvailability: describeIndex("byExchangeAgeAvailability", [ + "exchangeBaseUrl", + "maxAge", + "freshCoinCount", + ]), + }, + ), coins: describeStore( "coins", describeContents({ @@ -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"), }, ), diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts index 652ba8f53..ff7870435 100644 --- a/packages/taler-wallet-core/src/dbless.ts +++ b/packages/taler-wallet-core/src/dbless.ts @@ -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 = { diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index 53dc50f3b..be09952cd 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -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, }); } } diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts index 9747f21a3..22ec5f0a5 100644 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -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, { diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index af6ff507f..ab59fff87 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -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 { - const candidateCoins: AvailableCoinInfo[] = []; - const wireFeesPerExchange: Record = {}; - - 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(); - 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]> { 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, )}`, ); diff --git a/packages/taler-wallet-core/src/operations/peer-to-peer.ts b/packages/taler-wallet-core/src/operations/peer-to-peer.ts index e71e8a709..ffbc1fc97 100644 --- a/packages/taler-wallet-core/src/operations/peer-to-peer.ts +++ b/packages/taler-wallet-core/src/operations/peer-to-peer.ts @@ -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); diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts index 100bbc074..bd598511a 100644 --- a/packages/taler-wallet-core/src/operations/recoup.ts +++ b/packages/taler-wallet-core/src/operations/recoup.ts @@ -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) { diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index 2d9ad2c05..e968ec020 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -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) { diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts index 644b07ef1..bdcdac943 100644 --- a/packages/taler-wallet-core/src/operations/refund.ts +++ b/packages/taler-wallet-core/src/operations/refund.ts @@ -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) { diff --git a/packages/taler-wallet-core/src/operations/tip.ts b/packages/taler-wallet-core/src/operations/tip.ts index eef151cf2..9f96b7a7d 100644 --- a/packages/taler-wallet-core/src/operations/tip.ts +++ b/packages/taler-wallet-core/src/operations/tip.ts @@ -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) { diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index f2152ccbc..cb0b55faf 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -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) { diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/util/coinSelection.test.ts index 3c6ad0d82..fe9672116 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.test.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.test.ts @@ -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(); diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts index 9622b3a76..d2f12baf5 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -72,6 +72,7 @@ export interface AvailableCoinInfo { exchangeBaseUrl: string; + maxAge: number; ageCommitmentProof?: AgeCommitmentProof; } diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/util/query.ts index 17b713659..8b8c30f35 100644 --- a/packages/taler-wallet-core/src/util/query.ts +++ b/packages/taler-wallet-core/src/util/query.ts @@ -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 { - iter(query?: IDBValidKey): ResultStream; + iter(query?: IDBKeyRange | IDBValidKey): ResultStream; get(query: IDBValidKey): Promise; - getAll(query: IDBValidKey, count?: number): Promise; + getAll( + query: IDBKeyRange | IDBValidKey, + count?: number, + ): Promise; } type GetIndexReadOnlyAccess = { @@ -319,9 +323,12 @@ type GetIndexReadOnlyAccess = { }; interface IndexReadWriteAccessor { - iter(query: IDBValidKey): ResultStream; + iter(query: IDBKeyRange | IDBValidKey): ResultStream; get(query: IDBValidKey): Promise; - getAll(query: IDBValidKey, count?: number): Promise; + getAll( + query: IDBKeyRange | IDBValidKey, + count?: number, + ): Promise; } type GetIndexReadWriteAccess = { diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 4751f7976..812106c7a 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -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 { 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,