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, ObjectStoreRecord,
MemoryBackendDump, MemoryBackendDump,
} from "./MemoryBackend"; } from "./MemoryBackend";
import { Event } from "./idbtypes"; import { Event, IDBKeyRange } from "./idbtypes";
import { import {
BridgeIDBCursor, BridgeIDBCursor,
BridgeIDBDatabase, BridgeIDBDatabase,
@ -89,6 +89,17 @@ export type { AccessStats } from "./MemoryBackend";
delete Object.prototype.__magic__; 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 * Populate the global name space such that the given IndexedDB factory is made
* available globally. * available globally.

View File

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

View File

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

View File

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

View File

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

View File

@ -319,11 +319,6 @@ export interface DenominationRecord {
* that includes this denomination. * that includes this denomination.
*/ */
listIssueDate: TalerProtocolTimestamp; listIssueDate: TalerProtocolTimestamp;
/**
* Number of fresh coins of this denomination that are available.
*/
freshCoinCount?: number;
} }
export namespace DenominationRecord { export namespace DenominationRecord {
@ -546,6 +541,8 @@ export interface PlanchetRecord {
coinEvHash: string; coinEvHash: string;
maxAge: number;
ageCommitmentProof?: AgeCommitmentProof; ageCommitmentProof?: AgeCommitmentProof;
} }
@ -674,6 +671,8 @@ export interface CoinRecord {
*/ */
allocation?: CoinAllocation; allocation?: CoinAllocation;
maxAge: number;
ageCommitmentProof?: AgeCommitmentProof; ageCommitmentProof?: AgeCommitmentProof;
} }
@ -1770,7 +1769,45 @@ export interface OperationAttemptLongpollResult {
type: OperationAttemptResultType.Longpoll; 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 = { export const WalletStoresV1 = {
coinAvailability: describeStore(
"coinAvailability",
describeContents<CoinAvailabilityRecord>({
keyPath: ["exchangeBaseUrl", "denomPubHash", "maxAge"],
}),
{
byExchangeAgeAvailability: describeIndex("byExchangeAgeAvailability", [
"exchangeBaseUrl",
"maxAge",
"freshCoinCount",
]),
},
),
coins: describeStore( coins: describeStore(
"coins", "coins",
describeContents<CoinRecord>({ describeContents<CoinRecord>({
@ -1779,10 +1816,10 @@ export const WalletStoresV1 = {
{ {
byBaseUrl: describeIndex("byBaseUrl", "exchangeBaseUrl"), byBaseUrl: describeIndex("byBaseUrl", "exchangeBaseUrl"),
byDenomPubHash: describeIndex("byDenomPubHash", "denomPubHash"), byDenomPubHash: describeIndex("byDenomPubHash", "denomPubHash"),
byDenomPubHashAndStatus: describeIndex("byDenomPubHashAndStatus", [ byExchangeDenomPubHashAndAgeAndStatus: describeIndex(
"denomPubHash", "byExchangeDenomPubHashAndAgeAndStatus",
"status", ["exchangeBaseUrl", "denomPubHash", "maxAge", "status"],
]), ),
byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"), byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"),
}, },
), ),

View File

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@
/** /**
* Imports. * Imports.
*/ */
import { BridgeIDBKeyRange, GlobalIDB } from "@gnu-taler/idb-bridge";
import { import {
AbsoluteTime, AbsoluteTime,
AgeRestriction, AgeRestriction,
@ -102,7 +103,7 @@ import {
readUnexpectedResponseDetails, readUnexpectedResponseDetails,
throwUnexpectedRequestError, throwUnexpectedRequestError,
} from "../util/http.js"; } from "../util/http.js";
import { checkLogicInvariant } from "../util/invariants.js"; import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import { GetReadWriteAccess } from "../util/query.js"; import { GetReadWriteAccess } from "../util/query.js";
import { RetryInfo, RetryTags, scheduleRetry } from "../util/retries.js"; import { RetryInfo, RetryTags, scheduleRetry } from "../util/retries.js";
import { spendCoins } from "../wallet.js"; import { spendCoins } from "../wallet.js";
@ -215,149 +216,6 @@ export interface CoinSelectionRequest {
minimumAge?: number; 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 * Record all information that is necessary to
* pay for a proposal in the wallet's database. * pay for a proposal in the wallet's database.
@ -412,6 +270,7 @@ async function recordConfirmPay(
x.coins, x.coins,
x.refreshGroups, x.refreshGroups,
x.denominations, x.denominations,
x.coinAvailability,
]) ])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const p = await tx.proposals.get(proposal.proposalId); const p = await tx.proposals.get(proposal.proposalId);
@ -976,7 +835,13 @@ async function handleInsufficientFunds(
logger.trace("re-selected coins"); logger.trace("re-selected coins");
await ws.db 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) => { .runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId); const p = await tx.purchases.get(proposalId);
if (!p) { if (!p) {
@ -1029,6 +894,7 @@ export interface SelectPayCoinRequestNg {
} }
export type AvailableDenom = DenominationInfo & { export type AvailableDenom = DenominationInfo & {
maxAge: number;
numAvailable: number; numAvailable: number;
}; };
@ -1037,7 +903,12 @@ async function selectCandidates(
req: SelectPayCoinRequestNg, req: SelectPayCoinRequestNg,
): Promise<[AvailableDenom[], Record<string, AmountJson>]> { ): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
return await ws.db 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) => { .runReadOnly(async (tx) => {
const denoms: AvailableDenom[] = []; const denoms: AvailableDenom[] = [];
const exchanges = await tx.exchanges.iter().toArray(); const exchanges = await tx.exchanges.iter().toArray();
@ -1065,17 +936,35 @@ async function selectCandidates(
if (!accepted) { if (!accepted) {
continue; continue;
} }
// FIXME: Do this query more efficiently via indexing let ageLower = 0;
const exchangeDenoms = await tx.denominations.indexes.byExchangeBaseUrl let ageUpper = Number.MAX_SAFE_INTEGER;
.iter(exchangeDetails.exchangeBaseUrl) if (req.requiredMinimumAge) {
.filter((x) => x.freshCoinCount != null && x.freshCoinCount > 0); 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: Check that the individual denomination is audited!
// FIXME: Should we exclude denominations that are // FIXME: Should we exclude denominations that are
// not spendable anymore? // 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({ denoms.push({
...DenominationRecord.toDenomInfo(denom), ...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. * Selection result.
*/ */
interface SelResult { interface SelResult {
/** /**
* Map from denomination public key hashes * Map from an availability key
* to an array of contributions. * to an array of contributions.
*/ */
[dph: string]: AmountJson[]; [avKey: string]: {
exchangeBaseUrl: string;
denomPubHash: string;
maxAge: number;
contributions: AmountJson[];
};
} }
export function selectGreedy( export function selectGreedy(
@ -1146,7 +1048,22 @@ export function selectGreedy(
} }
if (contributions.length) { 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)) { if (Amounts.isZero(tally.amountPayRemaining)) {
@ -1173,9 +1090,22 @@ export function selectForced(
} }
if (Amounts.cmp(aci.value, forcedCoin.value) === 0) { if (Amounts.cmp(aci.value, forcedCoin.value) === 0) {
aci.numAvailable--; aci.numAvailable--;
const contributions = selectedDenom[aci.denomPubHash] ?? []; const avKey = makeAvailabilityKey(
contributions.push(Amounts.parseOrThrow(forcedCoin.value)); aci.exchangeBaseUrl,
selectedDenom[aci.denomPubHash] = contributions; 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; found = true;
break; break;
} }
@ -1273,18 +1203,27 @@ export async function selectPayCoinsNew(
.mktx((x) => [x.coins, x.denominations]) .mktx((x) => [x.coins, x.denominations])
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
for (const dph of Object.keys(finalSel)) { for (const dph of Object.keys(finalSel)) {
const contributions = finalSel[dph]; const selInfo = finalSel[dph];
const coins = await tx.coins.indexes.byDenomPubHashAndStatus.getAll( const numRequested = selInfo.contributions.length;
[dph, CoinStatus.Fresh], const query = [
contributions.length, selInfo.exchangeBaseUrl,
); selInfo.denomPubHash,
if (coins.length != contributions.length) { 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( 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)); 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; let wireInfoHash: string;
wireInfoHash = contractData.wireInfoHash; wireInfoHash = contractData.wireInfoHash;
logger.trace( logger.trace(
`signing deposit permission for coin with acp=${j2s( `signing deposit permission for coin with ageRestriction=${j2s(
coin.ageCommitmentProof, coin.ageCommitmentProof,
)}`, )}`,
); );

View File

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

View File

@ -392,7 +392,13 @@ export async function processRecoupGroupHandler(
} }
await ws.db 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) => { .runReadWrite(async (tx) => {
const rg2 = await tx.recoupGroups.get(recoupGroupId); const rg2 = await tx.recoupGroups.get(recoupGroupId);
if (!rg2) { if (!rg2) {

View File

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

View File

@ -336,7 +336,13 @@ async function acceptRefunds(
const now = TalerProtocolTimestamp.now(); const now = TalerProtocolTimestamp.now();
await ws.db 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) => { .runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId); const p = await tx.purchases.get(proposalId);
if (!p) { if (!p) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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