aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2022-09-16 16:20:47 +0200
committerFlorian Dold <florian@dold.me>2022-09-16 16:32:21 +0200
commitb91caf977fad8da11e523ca3a39064dd86e04c64 (patch)
tree732e1543d2555094d7f9a9ca242309847c1a33a3
parent2747bc260bc05418974570d04d7f999dfc988cda (diff)
wallet-core: support age restrictions in new coin selection
-rw-r--r--packages/idb-bridge/src/index.ts13
-rw-r--r--packages/taler-util/src/talerCrypto.ts5
-rw-r--r--packages/taler-util/src/walletTypes.ts1
-rw-r--r--packages/taler-wallet-core/src/crypto/cryptoImplementation.ts1
-rw-r--r--packages/taler-wallet-core/src/crypto/cryptoTypes.ts1
-rw-r--r--packages/taler-wallet-core/src/db.ts55
-rw-r--r--packages/taler-wallet-core/src/dbless.ts4
-rw-r--r--packages/taler-wallet-core/src/operations/backup/import.ts3
-rw-r--r--packages/taler-wallet-core/src/operations/deposits.ts60
-rw-r--r--packages/taler-wallet-core/src/operations/pay.ts273
-rw-r--r--packages/taler-wallet-core/src/operations/peer-to-peer.ts6
-rw-r--r--packages/taler-wallet-core/src/operations/recoup.ts8
-rw-r--r--packages/taler-wallet-core/src/operations/refresh.ts34
-rw-r--r--packages/taler-wallet-core/src/operations/refund.ts8
-rw-r--r--packages/taler-wallet-core/src/operations/tip.ts4
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.ts11
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.test.ts17
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.ts1
-rw-r--r--packages/taler-wallet-core/src/util/query.ts15
-rw-r--r--packages/taler-wallet-core/src/wallet.ts74
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,
@@ -90,6 +90,17 @@ export type { AccessStats } from "./MemoryBackend";
})();
/**
+ * 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<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"),
},
),
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";
@@ -216,149 +217,6 @@ export interface CoinSelectionRequest {
}
/**
- * 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,
)}`,
);
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<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> = {
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<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,