From 91be5b89cd92c53d6aa2f68247f9626c8bc8f64a Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Wed, 6 Mar 2024 14:17:31 +0100 Subject: towards refactoring coin selection --- packages/taler-wallet-core/src/balance.ts | 257 +++-- .../taler-wallet-core/src/coinSelection.test.ts | 101 +- packages/taler-wallet-core/src/coinSelection.ts | 1202 ++++++++++---------- packages/taler-wallet-core/src/deposits.ts | 22 +- packages/taler-wallet-core/src/pay-merchant.ts | 32 +- 5 files changed, 811 insertions(+), 803 deletions(-) (limited to 'packages/taler-wallet-core') diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts index 6dc0783c0..a77358363 100644 --- a/packages/taler-wallet-core/src/balance.ts +++ b/packages/taler-wallet-core/src/balance.ts @@ -468,12 +468,16 @@ export interface MerchantPaymentBalanceDetails { balanceAvailable: AmountJson; } -export interface MerchantPaymentRestrictionsForBalance { +export interface PaymentRestrictionsForBalance { currency: string; minAge: number; - acceptedExchanges: AllowedExchangeInfo[]; - acceptedAuditors: AllowedAuditorInfo[]; - acceptedWireMethods: string[]; + restrictExchanges: + | { + exchanges: AllowedExchangeInfo[]; + auditors: AllowedAuditorInfo[]; + } + | undefined; + restrictWireMethods: string[] | undefined; } export interface AcceptableExchanges { @@ -492,69 +496,73 @@ export interface AcceptableExchanges { /** * Get all exchanges that are acceptable for a particular payment. */ -export async function getAcceptableExchangeBaseUrls( +async function getAcceptableExchangeBaseUrlsInTx( wex: WalletExecutionContext, - req: MerchantPaymentRestrictionsForBalance, + tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>, + req: PaymentRestrictionsForBalance, ): Promise { const acceptableExchangeUrls = new Set(); const depositableExchangeUrls = new Set(); - await wex.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => { - // FIXME: We should have a DB index to look up all exchanges - // for a particular auditor ... + // FIXME: We should have a DB index to look up all exchanges + // for a particular auditor ... - const canonExchanges = new Set(); - const canonAuditors = new Set(); + const canonExchanges = new Set(); + const canonAuditors = new Set(); - for (const exchangeHandle of req.acceptedExchanges) { + if (req.restrictExchanges) { + for (const exchangeHandle of req.restrictExchanges.exchanges) { const normUrl = canonicalizeBaseUrl(exchangeHandle.exchangeBaseUrl); canonExchanges.add(normUrl); } - for (const auditorHandle of req.acceptedAuditors) { + for (const auditorHandle of req.restrictExchanges.auditors) { const normUrl = canonicalizeBaseUrl(auditorHandle.auditorBaseUrl); canonAuditors.add(normUrl); } + } - await tx.exchanges.iter().forEachAsync(async (exchange) => { - const dp = exchange.detailsPointer; - if (!dp) { - return; - } - const { currency, masterPublicKey } = dp; - const exchangeDetails = await tx.exchangeDetails.indexes.byPointer.get([ - exchange.baseUrl, - currency, - masterPublicKey, - ]); - if (!exchangeDetails) { - return; - } + await tx.exchanges.iter().forEachAsync(async (exchange) => { + const dp = exchange.detailsPointer; + if (!dp) { + return; + } + const { currency, masterPublicKey } = dp; + const exchangeDetails = await tx.exchangeDetails.indexes.byPointer.get([ + exchange.baseUrl, + currency, + masterPublicKey, + ]); + if (!exchangeDetails) { + return; + } - let acceptable = false; + let acceptable = false; - if (canonExchanges.has(exchange.baseUrl)) { + if (canonExchanges.has(exchange.baseUrl)) { + acceptableExchangeUrls.add(exchange.baseUrl); + acceptable = true; + } + for (const exchangeAuditor of exchangeDetails.auditors) { + if (canonAuditors.has(exchangeAuditor.auditor_url)) { acceptableExchangeUrls.add(exchange.baseUrl); acceptable = true; + break; } - for (const exchangeAuditor of exchangeDetails.auditors) { - if (canonAuditors.has(exchangeAuditor.auditor_url)) { - acceptableExchangeUrls.add(exchange.baseUrl); - acceptable = true; - break; - } - } + } - if (!acceptable) { - return; - } - // FIXME: Also consider exchange and auditor public key - // instead of just base URLs? + if (!acceptable) { + return; + } + // FIXME: Also consider exchange and auditor public key + // instead of just base URLs? + + let wireMethodSupported = false; - let wireMethodSupported = false; + if (req.restrictWireMethods) { for (const acc of exchangeDetails.wireInfo.accounts) { const pp = parsePaytoUri(acc.payto_uri); checkLogicInvariant(!!pp); - for (const wm of req.acceptedWireMethods) { + for (const wm of req.restrictWireMethods) { if (pp.targetType === wm) { wireMethodSupported = true; break; @@ -564,12 +572,14 @@ export async function getAcceptableExchangeBaseUrls( } } } + } else { + wireMethodSupported = true; + } - acceptableExchangeUrls.add(exchange.baseUrl); - if (wireMethodSupported) { - depositableExchangeUrls.add(exchange.baseUrl); - } - }); + acceptableExchangeUrls.add(exchange.baseUrl); + if (wireMethodSupported) { + depositableExchangeUrls.add(exchange.baseUrl); + } }); return { acceptableExchanges: [...acceptableExchangeUrls], @@ -606,9 +616,24 @@ export interface MerchantPaymentBalanceDetails { export async function getMerchantPaymentBalanceDetails( wex: WalletExecutionContext, - req: MerchantPaymentRestrictionsForBalance, + req: PaymentRestrictionsForBalance, ): Promise { - const acceptability = await getAcceptableExchangeBaseUrls(wex, req); + return await wex.db.runReadOnlyTx( + ["coinAvailability", "refreshGroups", "exchanges", "exchangeDetails"], + async (tx) => { + return getMerchantPaymentBalanceDetailsInTx(wex, tx, req); + }, + ); +} + +export async function getMerchantPaymentBalanceDetailsInTx( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + ["coinAvailability", "refreshGroups", "exchanges", "exchangeDetails"] + >, + req: PaymentRestrictionsForBalance, +): Promise { + const acceptability = await getAcceptableExchangeBaseUrlsInTx(wex, tx, req); const d: MerchantPaymentBalanceDetails = { balanceAvailable: Amounts.zeroOfCurrency(req.currency), @@ -618,53 +643,46 @@ export async function getMerchantPaymentBalanceDetails( balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency), }; - await wex.db.runReadOnlyTx( - ["coinAvailability", "refreshGroups"], - async (tx) => { - await tx.coinAvailability.iter().forEach((ca) => { - if (ca.currency != req.currency) { - return; - } - const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value); - const coinAmount: AmountJson = Amounts.mult( - singleCoinAmount, - ca.freshCoinCount, + await tx.coinAvailability.iter().forEach((ca) => { + if (ca.currency != req.currency) { + return; + } + const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value); + const coinAmount: AmountJson = Amounts.mult( + singleCoinAmount, + ca.freshCoinCount, + ).amount; + d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount; + d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount; + if (ca.maxAge === 0 || ca.maxAge > req.minAge) { + d.balanceAgeAcceptable = Amounts.add( + d.balanceAgeAcceptable, + coinAmount, + ).amount; + if (acceptability.acceptableExchanges.includes(ca.exchangeBaseUrl)) { + d.balanceMerchantAcceptable = Amounts.add( + d.balanceMerchantAcceptable, + coinAmount, ).amount; - d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount; - d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount; - if (ca.maxAge === 0 || ca.maxAge > req.minAge) { - d.balanceAgeAcceptable = Amounts.add( - d.balanceAgeAcceptable, + if (acceptability.depositableExchanges.includes(ca.exchangeBaseUrl)) { + d.balanceMerchantDepositable = Amounts.add( + d.balanceMerchantDepositable, coinAmount, ).amount; - if (acceptability.acceptableExchanges.includes(ca.exchangeBaseUrl)) { - d.balanceMerchantAcceptable = Amounts.add( - d.balanceMerchantAcceptable, - coinAmount, - ).amount; - if ( - acceptability.depositableExchanges.includes(ca.exchangeBaseUrl) - ) { - d.balanceMerchantDepositable = Amounts.add( - d.balanceMerchantDepositable, - coinAmount, - ).amount; - } - } } - }); + } + } + }); - await tx.refreshGroups.iter().forEach((r) => { - if (r.currency != req.currency) { - return; - } - d.balanceAvailable = Amounts.add( - d.balanceAvailable, - computeRefreshGroupAvailableAmount(r), - ).amount; - }); - }, - ); + await tx.refreshGroups.iter().forEach((r) => { + if (r.currency != req.currency) { + return; + } + d.balanceAvailable = Amounts.add( + d.balanceAvailable, + computeRefreshGroupAvailableAmount(r), + ).amount; + }); return d; } @@ -697,9 +715,11 @@ export async function getBalanceDetail( return await getMerchantPaymentBalanceDetails(wex, { currency: req.currency, - acceptedAuditors: [], - acceptedExchanges: exchanges, - acceptedWireMethods: wires, + restrictExchanges: { + auditors: [], + exchanges, + }, + restrictWireMethods: wires, minAge: 0, }); } @@ -763,3 +783,50 @@ export async function getPeerPaymentBalanceDetailsInTx( balanceMaterial, }; } + +/** + * Get information about the balance at a given exchange + * with certain restrictions. + */ +export async function getExchangePaymentBalanceDetailsInTx( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["coinAvailability", "refreshGroups"]>, + req: PeerPaymentRestrictionsForBalance, +): Promise { + let balanceAvailable = Amounts.zeroOfCurrency(req.currency); + let balanceMaterial = Amounts.zeroOfCurrency(req.currency); + + await tx.coinAvailability.iter().forEach((ca) => { + if (ca.currency != req.currency) { + return; + } + if ( + req.restrictExchangeTo && + req.restrictExchangeTo !== ca.exchangeBaseUrl + ) { + return; + } + const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value); + const coinAmount: AmountJson = Amounts.mult( + singleCoinAmount, + ca.freshCoinCount, + ).amount; + balanceAvailable = Amounts.add(balanceAvailable, coinAmount).amount; + balanceMaterial = Amounts.add(balanceMaterial, coinAmount).amount; + }); + + await tx.refreshGroups.iter().forEach((r) => { + if (r.currency != req.currency) { + return; + } + balanceAvailable = Amounts.add( + balanceAvailable, + computeRefreshGroupAvailableAmount(r), + ).amount; + }); + + return { + balanceAvailable, + balanceMaterial, + }; +} diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts index 4fac244fc..6eae9deaa 100644 --- a/packages/taler-wallet-core/src/coinSelection.test.ts +++ b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -24,8 +24,8 @@ import { import test from "ava"; import { AvailableDenom, - PeerCoinSelectionTally, - testing_greedySelectPeer, + CoinSelectionTally, + emptyTallyForPeerPayment, testing_selectGreedy, } from "./coinSelection.js"; @@ -42,12 +42,13 @@ const inThePast = AbsoluteTime.toProtocolTimestamp( test("p2p: should select the coin", (t) => { const instructedAmount = Amounts.parseOrThrow("LOCAL:2"); - const tally = { - amountRemaining: instructedAmount, - depositFeesAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - lastDepositFee: Amounts.zeroOfCurrency(instructedAmount.currency), - } satisfies PeerCoinSelectionTally; - const coins = testing_greedySelectPeer( + const tally = emptyTallyForPeerPayment(instructedAmount); + t.log(`tally before: ${j2s(tally)}`); + const coins = testing_selectGreedy( + { + wireFeeAmortization: 1, + wireFeesPerExchange: {}, + }, createCandidates([ { amount: "LOCAL:10" as AmountString, @@ -59,7 +60,8 @@ test("p2p: should select the coin", (t) => { tally, ); - t.log(j2s(coins)); + t.log(`coins: ${j2s(coins)}`); + t.log(`tally: ${j2s(tally)}`); t.assert(coins != null); @@ -73,22 +75,16 @@ test("p2p: should select the coin", (t) => { expireWithdraw: inTheDistantFuture, }, }); - - t.deepEqual(tally, { - amountRemaining: Amounts.parseOrThrow("LOCAL:0"), - depositFeesAcc: Amounts.parseOrThrow("LOCAL:0.1"), - lastDepositFee: Amounts.parseOrThrow("LOCAL:0.1"), - }); }); test("p2p: should select 3 coins", (t) => { const instructedAmount = Amounts.parseOrThrow("LOCAL:20"); - const tally = { - amountRemaining: instructedAmount, - depositFeesAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - lastDepositFee: Amounts.zeroOfCurrency(instructedAmount.currency), - } satisfies PeerCoinSelectionTally; - const coins = testing_greedySelectPeer( + const tally = emptyTallyForPeerPayment(instructedAmount); + const coins = testing_selectGreedy( + { + wireFeeAmortization: 1, + wireFeesPerExchange: {}, + }, createCandidates([ { amount: "LOCAL:10" as AmountString, @@ -114,22 +110,16 @@ test("p2p: should select 3 coins", (t) => { expireWithdraw: inTheDistantFuture, }, }); - - t.deepEqual(tally, { - amountRemaining: Amounts.parseOrThrow("LOCAL:0"), - depositFeesAcc: Amounts.parseOrThrow("LOCAL:0.3"), - lastDepositFee: Amounts.parseOrThrow("LOCAL:0.1"), - }); }); test("p2p: can't select since the instructed amount is too high", (t) => { const instructedAmount = Amounts.parseOrThrow("LOCAL:60"); - const tally = { - amountRemaining: instructedAmount, - depositFeesAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - lastDepositFee: Amounts.zeroOfCurrency(instructedAmount.currency), - } satisfies PeerCoinSelectionTally; - const coins = testing_greedySelectPeer( + const tally = emptyTallyForPeerPayment(instructedAmount); + const coins = testing_selectGreedy( + { + wireFeeAmortization: 1, + wireFeesPerExchange: {}, + }, createCandidates([ { amount: "LOCAL:10" as AmountString, @@ -142,12 +132,6 @@ test("p2p: can't select since the instructed amount is too high", (t) => { ); t.is(coins, undefined); - - t.deepEqual(tally, { - amountRemaining: Amounts.parseOrThrow("LOCAL:10.5"), - depositFeesAcc: Amounts.parseOrThrow("LOCAL:0.5"), - lastDepositFee: Amounts.parseOrThrow("LOCAL:0.1"), - }); }); test("pay: select one coin to pay with fee", (t) => { @@ -162,22 +146,11 @@ test("pay: select one coin to pay with fee", (t) => { customerWireFees: zero, wireFeeCoveredForExchange: new Set(), lastDepositFee: zero, - }; + } satisfies CoinSelectionTally; const coins = testing_selectGreedy( { - auditors: [], - exchanges: [ - { - exchangeBaseUrl: "http://exchange.localhost/", - exchangePub: "E5M8CGRDHXF1RCVP3B8TQCTDYNQ7T4XHWR5SVEQRGVVMVME41VJ0", - }, - ], - contractTermsAmount: payment, - depositFeeLimit: zero, wireFeeAmortization: 1, - wireFeeLimit: zero, - prevPayCoins: [], - wireMethod: "x-taler-bank", + wireFeesPerExchange: { "http://exchange.localhost/": exchangeWireFee }, }, createCandidates([ { @@ -187,7 +160,6 @@ test("pay: select one coin to pay with fee", (t) => { fromExchange: "http://exchange.localhost/", }, ]), - { "http://exchange.localhost/": exchangeWireFee }, tally, ); @@ -203,13 +175,13 @@ test("pay: select one coin to pay with fee", (t) => { }); t.deepEqual(tally, { - amountPayRemaining: Amounts.parseOrThrow("LOCAL:2"), + amountPayRemaining: Amounts.parseOrThrow("LOCAL:0"), amountWireFeeLimitRemaining: zero, amountDepositFeeLimitRemaining: zero, - customerDepositFees: zero, + customerDepositFees: Amounts.parse("LOCAL:0.1"), customerWireFees: zero, - wireFeeCoveredForExchange: new Set(), - lastDepositFee: zero, + wireFeeCoveredForExchange: new Set(["http://exchange.localhost/"]), + lastDepositFee: Amounts.parse("LOCAL:0.1"), }); }); @@ -309,11 +281,14 @@ test("p2p: regression STATER", (t) => { }, ]; const instructedAmount = Amounts.parseOrThrow("STATER:1"); - const tally = { - amountRemaining: instructedAmount, - depositFeesAcc: Amounts.parseOrThrow("STATER:0"), - lastDepositFee: Amounts.parseOrThrow("STATER:0"), - } satisfies PeerCoinSelectionTally; - const res = testing_greedySelectPeer(candidates as any, tally); + const tally = emptyTallyForPeerPayment(instructedAmount); + const res = testing_selectGreedy( + { + wireFeeAmortization: 1, + wireFeesPerExchange: {}, + }, + candidates as any, + tally, + ); t.assert(!!res); }); diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts index 3ece5546c..c44ca3d17 100644 --- a/packages/taler-wallet-core/src/coinSelection.ts +++ b/packages/taler-wallet-core/src/coinSelection.ts @@ -39,7 +39,6 @@ import { CoinPublicKeyString, CoinStatus, DenominationInfo, - DenominationPubKey, DenomSelectionState, Duration, ForcedCoinSel, @@ -50,62 +49,25 @@ import { parsePaytoUri, PayCoinSelection, PayMerchantInsufficientBalanceDetails, - PayPeerInsufficientBalanceDetails, strcmp, TalerProtocolTimestamp, UnblindedSignature, } from "@gnu-taler/taler-util"; import { - getMerchantPaymentBalanceDetails, - getPeerPaymentBalanceDetailsInTx, + getExchangePaymentBalanceDetailsInTx, + getMerchantPaymentBalanceDetailsInTx, } from "./balance.js"; import { getAutoRefreshExecuteThreshold } from "./common.js"; import { DenominationRecord, WalletDbReadOnlyTransaction } from "./db.js"; import { isWithdrawableDenom } from "./denominations.js"; -import { getExchangeWireDetailsInTx } from "./exchanges.js"; +import { + ExchangeWireDetails, + getExchangeWireDetailsInTx, +} from "./exchanges.js"; import { getDenomInfo, WalletExecutionContext } from "./wallet.js"; const logger = new Logger("coinSelection.ts"); -/** - * Structure to describe a coin that is available to be - * used in a payment. - */ -export interface AvailableCoinInfo { - /** - * Public key of the coin. - */ - coinPub: string; - - /** - * Coin's denomination public key. - * - * FIXME: We should only need the denomPubHash here, if at all. - */ - denomPub: DenominationPubKey; - - /** - * Full value of the coin. - */ - value: AmountJson; - - /** - * Amount still remaining (typically the full amount, - * as coins are always refreshed after use.) - */ - availableAmount: AmountJson; - - /** - * Deposit fee for the coin. - */ - feeDeposit: AmountJson; - - exchangeBaseUrl: string; - - maxAge: number; - ageCommitmentProof?: AgeCommitmentProof; -} - export type PreviousPayCoins = { coinPub: string; contribution: AmountJson; @@ -113,19 +75,9 @@ export type PreviousPayCoins = { exchangeBaseUrl: string; }[]; -export interface CoinCandidateSelection { - candidateCoins: AvailableCoinInfo[]; - wireFeesPerExchange: Record; -} - -export interface SelectPayCoinRequest { - candidates: CoinCandidateSelection; - contractTermsAmount: AmountJson; - depositFeeLimit: AmountJson; - wireFeeLimit: AmountJson; - wireFeeAmortization: number; - prevPayCoins?: PreviousPayCoins; - requiredMinimumAge?: number; +export interface ExchangeRestrictionSpec { + exchanges: AllowedExchangeInfo[]; + auditors: AllowedAuditorInfo[]; } export interface CoinSelectionTally { @@ -159,26 +111,20 @@ export interface CoinSelectionTally { * Account for the fees of spending a coin. */ function tallyFees( - tally: Readonly, + tally: CoinSelectionTally, wireFeesPerExchange: Record, wireFeeAmortization: number, exchangeBaseUrl: string, feeDeposit: AmountJson, -): CoinSelectionTally { +): void { const currency = tally.amountPayRemaining.currency; - let amountWireFeeLimitRemaining = tally.amountWireFeeLimitRemaining; - let amountDepositFeeLimitRemaining = tally.amountDepositFeeLimitRemaining; - let customerDepositFees = tally.customerDepositFees; - let customerWireFees = tally.customerWireFees; - let amountPayRemaining = tally.amountPayRemaining; - const wireFeeCoveredForExchange = new Set(tally.wireFeeCoveredForExchange); if (!tally.wireFeeCoveredForExchange.has(exchangeBaseUrl)) { const wf = wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.zeroOfCurrency(currency); - const wfForgiven = Amounts.min(amountWireFeeLimitRemaining, wf); - amountWireFeeLimitRemaining = Amounts.sub( - amountWireFeeLimitRemaining, + const wfForgiven = Amounts.min(tally.amountWireFeeLimitRemaining, wf); + tally.amountWireFeeLimitRemaining = Amounts.sub( + tally.amountWireFeeLimitRemaining, wfForgiven, ).amount; // The remaining, amortized amount needs to be paid by the @@ -187,45 +133,48 @@ function tallyFees( Amounts.sub(wf, wfForgiven).amount, wireFeeAmortization, ); - // This is the amount forgiven via the deposit fee allowance. const wfDepositForgiven = Amounts.min( - amountDepositFeeLimitRemaining, + tally.amountDepositFeeLimitRemaining, wfRemaining, ); - amountDepositFeeLimitRemaining = Amounts.sub( - amountDepositFeeLimitRemaining, + tally.amountDepositFeeLimitRemaining = Amounts.sub( + tally.amountDepositFeeLimitRemaining, wfDepositForgiven, ).amount; - wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount; - customerWireFees = Amounts.add(customerWireFees, wfRemaining).amount; - amountPayRemaining = Amounts.add(amountPayRemaining, wfRemaining).amount; - - wireFeeCoveredForExchange.add(exchangeBaseUrl); + tally.customerWireFees = Amounts.add( + tally.customerWireFees, + wfRemaining, + ).amount; + tally.amountPayRemaining = Amounts.add( + tally.amountPayRemaining, + wfRemaining, + ).amount; + tally.wireFeeCoveredForExchange.add(exchangeBaseUrl); } - const dfForgiven = Amounts.min(feeDeposit, amountDepositFeeLimitRemaining); + const dfForgiven = Amounts.min( + feeDeposit, + tally.amountDepositFeeLimitRemaining, + ); - amountDepositFeeLimitRemaining = Amounts.sub( - amountDepositFeeLimitRemaining, + tally.amountDepositFeeLimitRemaining = Amounts.sub( + tally.amountDepositFeeLimitRemaining, dfForgiven, ).amount; // How much does the user spend on deposit fees for this coin? const dfRemaining = Amounts.sub(feeDeposit, dfForgiven).amount; - customerDepositFees = Amounts.add(customerDepositFees, dfRemaining).amount; - amountPayRemaining = Amounts.add(amountPayRemaining, dfRemaining).amount; - - return { - amountDepositFeeLimitRemaining, - amountPayRemaining, - amountWireFeeLimitRemaining, - customerDepositFees, - customerWireFees, - wireFeeCoveredForExchange, - lastDepositFee: feeDeposit, - }; + tally.customerDepositFees = Amounts.add( + tally.customerDepositFees, + dfRemaining, + ).amount; + tally.amountPayRemaining = Amounts.add( + tally.amountPayRemaining, + dfRemaining, + ).amount; + tally.lastDepositFee = feeDeposit; } export type SelectPayCoinsResult = @@ -236,16 +185,13 @@ export type SelectPayCoinsResult = | { type: "success"; coinSel: PayCoinSelection }; /** - * Given a list of candidate coins, select coins to spend under the merchant's - * constraints. + * Select coins to spend under the merchant's constraints. * * The prevPayCoins can be specified to "repair" a coin selection * by adding additional coins, after a broken (e.g. double-spent) coin * has been removed from the selection. - * - * This function is only exported for the sake of unit tests. */ -export async function selectPayCoinsNew( +export async function selectPayCoins( wex: WalletExecutionContext, req: SelectPayCoinRequestNg, ): Promise { @@ -256,141 +202,203 @@ export async function selectPayCoinsNew( wireFeeAmortization, } = req; - // FIXME: Why don't we do this in a transaction? - const [candidateDenoms, wireFeesPerExchange] = - await selectPayMerchantCandidates(wex, req); + return await wex.db.runReadOnlyTx( + [ + "coinAvailability", + "denominations", + "refreshGroups", + "exchanges", + "exchangeDetails", + "coins", + ], + async (tx) => { + const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates( + wex, + tx, + { + restrictExchanges: req.restrictExchanges, + instructedAmount: req.contractTermsAmount, + restrictWireMethod: req.restrictWireMethod, + depositPaytoUri: req.depositPaytoUri, + requiredMinimumAge: req.requiredMinimumAge, + }, + ); - const coinPubs: string[] = []; - const coinContributions: AmountJson[] = []; - const currency = contractTermsAmount.currency; + const coinPubs: string[] = []; + const coinContributions: AmountJson[] = []; + const currency = contractTermsAmount.currency; + + let tally: CoinSelectionTally = { + amountPayRemaining: contractTermsAmount, + amountWireFeeLimitRemaining: wireFeeLimit, + amountDepositFeeLimitRemaining: depositFeeLimit, + customerDepositFees: Amounts.zeroOfCurrency(currency), + customerWireFees: Amounts.zeroOfCurrency(currency), + wireFeeCoveredForExchange: new Set(), + lastDepositFee: Amounts.zeroOfCurrency(currency), + }; - let tally: CoinSelectionTally = { - amountPayRemaining: contractTermsAmount, - amountWireFeeLimitRemaining: wireFeeLimit, - amountDepositFeeLimitRemaining: depositFeeLimit, - customerDepositFees: Amounts.zeroOfCurrency(currency), - customerWireFees: Amounts.zeroOfCurrency(currency), - wireFeeCoveredForExchange: new Set(), - lastDepositFee: Amounts.zeroOfCurrency(currency), - }; + const prevPayCoins = req.prevPayCoins ?? []; - const prevPayCoins = req.prevPayCoins ?? []; + // Look at existing pay coin selection and tally up + for (const prev of prevPayCoins) { + tallyFees( + tally, + wireFeesPerExchange, + wireFeeAmortization, + prev.exchangeBaseUrl, + prev.feeDeposit, + ); + tally.amountPayRemaining = Amounts.sub( + tally.amountPayRemaining, + prev.contribution, + ).amount; - // Look at existing pay coin selection and tally up - for (const prev of prevPayCoins) { - tally = tallyFees( - tally, - wireFeesPerExchange, - wireFeeAmortization, - prev.exchangeBaseUrl, - prev.feeDeposit, - ); - tally.amountPayRemaining = Amounts.sub( - tally.amountPayRemaining, - prev.contribution, - ).amount; + coinPubs.push(prev.coinPub); + coinContributions.push(prev.contribution); + } - coinPubs.push(prev.coinPub); - coinContributions.push(prev.contribution); - } + let selectedDenom: SelResult | undefined; + if (req.forcedSelection) { + selectedDenom = selectForced(req, candidateDenoms); + } else { + // FIXME: Here, we should select coins in a smarter way. + // Instead of always spending the next-largest coin, + // we should try to find the smallest coin that covers the + // amount. + selectedDenom = selectGreedy( + { + wireFeeAmortization: req.wireFeeAmortization, + wireFeesPerExchange: wireFeesPerExchange, + }, + candidateDenoms, + tally, + ); + } - let selectedDenom: SelResult | undefined; - if (req.forcedSelection) { - selectedDenom = selectForced(req, candidateDenoms); - } else { - // FIXME: Here, we should select coins in a smarter way. - // Instead of always spending the next-largest coin, - // we should try to find the smallest coin that covers the - // amount. - selectedDenom = selectGreedy( - req, - candidateDenoms, - wireFeesPerExchange, - tally, - ); - } + if (!selectedDenom) { + return { + type: "failure", + insufficientBalanceDetails: await reportInsufficientBalanceDetails( + wex, + tx, + { + restrictExchanges: req.restrictExchanges, + instructedAmount: req.contractTermsAmount, + requiredMinimumAge: req.requiredMinimumAge, + wireMethod: req.restrictWireMethod, + }, + ), + } satisfies SelectPayCoinsResult; + } - if (!selectedDenom) { - const details = await getMerchantPaymentBalanceDetails(wex, { - acceptedAuditors: req.auditors, - acceptedExchanges: req.exchanges, - acceptedWireMethods: [req.wireMethod], - currency: Amounts.currencyOf(req.contractTermsAmount), - minAge: req.requiredMinimumAge ?? 0, - }); - let feeGapEstimate: AmountJson; - if ( - Amounts.cmp( - details.balanceMerchantDepositable, - req.contractTermsAmount, - ) >= 0 - ) { - // FIXME: We can probably give a better estimate. - feeGapEstimate = Amounts.add( - tally.amountPayRemaining, - tally.lastDepositFee, - ).amount; - } else { - feeGapEstimate = Amounts.zeroOfAmount(req.contractTermsAmount); + const finalSel = selectedDenom; + + logger.trace(`coin selection request ${j2s(req)}`); + logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`); + + for (const dph of Object.keys(finalSel)) { + const selInfo = finalSel[dph]; + const numRequested = selInfo.contributions.length; + const query = [ + selInfo.exchangeBaseUrl, + selInfo.denomPubHash, + selInfo.maxAge, + CoinStatus.Fresh, + ]; + logger.trace(`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}/${numRequested})`, + ); + } + coinPubs.push(...coins.map((x) => x.coinPub)); + coinContributions.push(...selInfo.contributions); + } + + return { + type: "success", + coinSel: { + paymentAmount: Amounts.stringify(contractTermsAmount), + coinContributions: coinContributions.map((x) => Amounts.stringify(x)), + coinPubs, + customerDepositFees: Amounts.stringify(tally.customerDepositFees), + customerWireFees: Amounts.stringify(tally.customerWireFees), + }, + }; + }, + ); +} + +interface ReportInsufficientBalanceRequest { + instructedAmount: AmountJson; + requiredMinimumAge: number | undefined; + restrictExchanges: ExchangeRestrictionSpec | undefined; + wireMethod: string | undefined; +} + +export async function reportInsufficientBalanceDetails( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + ["coinAvailability", "exchanges", "exchangeDetails", "refreshGroups"] + >, + req: ReportInsufficientBalanceRequest, +): Promise { + const currency = Amounts.currencyOf(req.instructedAmount); + const details = await getMerchantPaymentBalanceDetailsInTx(wex, tx, { + restrictExchanges: req.restrictExchanges, + restrictWireMethods: req.wireMethod ? [req.wireMethod] : [], + currency: Amounts.currencyOf(req.instructedAmount), + minAge: req.requiredMinimumAge ?? 0, + }); + let feeGapEstimate: AmountJson; + + // FIXME: need fee gap estimate + // FIXME: We can probably give a better estimate. + // feeGapEstimate = Amounts.add( + // tally.amountPayRemaining, + // tally.lastDepositFee, + // ).amount; + + feeGapEstimate = Amounts.zeroOfAmount(req.instructedAmount); + + const perExchange: PayMerchantInsufficientBalanceDetails["perExchange"] = {}; + + const exchanges = await tx.exchanges.iter().toArray(); + + for (const exch of exchanges) { + if (exch.detailsPointer?.currency !== currency) { + continue; } - return { - type: "failure", - insufficientBalanceDetails: { - amountRequested: Amounts.stringify(req.contractTermsAmount), - balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable), - balanceAvailable: Amounts.stringify(details.balanceAvailable), - balanceMaterial: Amounts.stringify(details.balanceMaterial), - balanceMerchantAcceptable: Amounts.stringify( - details.balanceMerchantAcceptable, - ), - balanceMerchantDepositable: Amounts.stringify( - details.balanceMerchantDepositable, - ), - feeGapEstimate: Amounts.stringify(feeGapEstimate), - }, + const infoExchange = await getExchangePaymentBalanceDetailsInTx(wex, tx, { + currency, + restrictExchangeTo: exch.baseUrl, + }); + perExchange[exch.baseUrl] = { + balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable), + balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial), + feeGapEstimate: Amounts.stringify(Amounts.zeroOfCurrency(currency)), }; } - const finalSel = selectedDenom; - - logger.trace(`coin selection request ${j2s(req)}`); - logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`); - - await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => { - for (const dph of Object.keys(finalSel)) { - const selInfo = finalSel[dph]; - const numRequested = selInfo.contributions.length; - const query = [ - selInfo.exchangeBaseUrl, - selInfo.denomPubHash, - selInfo.maxAge, - CoinStatus.Fresh, - ]; - logger.trace(`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}/${numRequested})`, - ); - } - coinPubs.push(...coins.map((x) => x.coinPub)); - coinContributions.push(...selInfo.contributions); - } - }); - return { - type: "success", - coinSel: { - paymentAmount: Amounts.stringify(contractTermsAmount), - coinContributions: coinContributions.map((x) => Amounts.stringify(x)), - coinPubs, - customerDepositFees: Amounts.stringify(tally.customerDepositFees), - customerWireFees: Amounts.stringify(tally.customerWireFees), - }, + amountRequested: Amounts.stringify(req.instructedAmount), + balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable), + balanceAvailable: Amounts.stringify(details.balanceAvailable), + balanceMaterial: Amounts.stringify(details.balanceMaterial), + balanceMerchantAcceptable: Amounts.stringify( + details.balanceMerchantAcceptable, + ), + balanceMerchantDepositable: Amounts.stringify( + details.balanceMerchantDepositable, + ), + feeGapEstimate: Amounts.stringify(feeGapEstimate), + perExchange, }; } @@ -426,10 +434,14 @@ export function testing_selectGreedy( return selectGreedy(...args); } +export interface SelectGreedyRequest { + wireFeeAmortization: number; + wireFeesPerExchange: Record; +} + function selectGreedy( - req: SelectPayCoinRequestNg, + req: SelectGreedyRequest, candidateDenoms: AvailableDenom[], - wireFeesPerExchange: Record, tally: CoinSelectionTally, ): SelResult | undefined { const { wireFeeAmortization } = req; @@ -449,9 +461,9 @@ function selectGreedy( i < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining); i++ ) { - tally = tallyFees( + tallyFees( tally, - wireFeesPerExchange, + req.wireFeesPerExchange, wireFeeAmortization, denom.exchangeBaseUrl, Amounts.parseOrThrow(denom.feeDeposit), @@ -491,6 +503,7 @@ function selectGreedy( selectedDenom[avKey] = sd; } } + logger.info(`greedy tally: ${j2s(tally)}`); return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined; } @@ -566,9 +579,8 @@ export function checkAccountRestriction( } export interface SelectPayCoinRequestNg { - exchanges: AllowedExchangeInfo[]; - auditors: AllowedAuditorInfo[]; - wireMethod: string; + restrictExchanges: ExchangeRestrictionSpec | undefined; + restrictWireMethod: string; contractTermsAmount: AmountJson; depositFeeLimit: AmountJson; wireFeeLimit: AmountJson; @@ -592,137 +604,183 @@ export type AvailableDenom = DenominationInfo & { numAvailable: number; }; -async function selectPayMerchantCandidates( +function findMatchingWire( + wireMethod: string, + depositPaytoUri: string | undefined, + exchangeWireDetails: ExchangeWireDetails, +): { wireFee: AmountJson } | undefined { + for (const acc of exchangeWireDetails.wireInfo.accounts) { + const pp = parsePaytoUri(acc.payto_uri); + checkLogicInvariant(!!pp); + if (pp.targetType !== wireMethod) { + continue; + } + const wireFeeStr = exchangeWireDetails.wireInfo.feesForType[ + wireMethod + ]?.find((x) => { + return AbsoluteTime.isBetween( + AbsoluteTime.now(), + AbsoluteTime.fromProtocolTimestamp(x.startStamp), + AbsoluteTime.fromProtocolTimestamp(x.endStamp), + ); + })?.wireFee; + + if (!wireFeeStr) { + continue; + } + + let debitAccountCheckOk = false; + if (depositPaytoUri) { + // FIXME: We should somehow propagate the hint here! + const checkResult = checkAccountRestriction( + depositPaytoUri, + acc.debit_restrictions, + ); + if (checkResult.ok) { + debitAccountCheckOk = true; + } + } else { + debitAccountCheckOk = true; + } + + if (!debitAccountCheckOk) { + continue; + } + + return { + wireFee: Amounts.parseOrThrow(wireFeeStr), + }; + } + return undefined; +} + +function checkExchangeAccepted( + exchangeDetails: ExchangeWireDetails, + exchangeRestrictions: ExchangeRestrictionSpec | undefined, +): boolean { + if (!exchangeRestrictions) { + return true; + } + let accepted = false; + for (const allowedExchange of exchangeRestrictions.exchanges) { + if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) { + accepted = true; + break; + } + } + for (const allowedAuditor of exchangeRestrictions.auditors) { + for (const providedAuditor of exchangeDetails.auditors) { + if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) { + accepted = true; + break; + } + } + } + return accepted; +} + +interface SelectPayCandidatesRequest { + instructedAmount: AmountJson; + restrictWireMethod: string | undefined; + depositPaytoUri?: string; + restrictExchanges: + | { + exchanges: AllowedExchangeInfo[]; + auditors: AllowedAuditorInfo[]; + } + | undefined; + requiredMinimumAge?: number; +} + +async function selectPayCandidates( wex: WalletExecutionContext, - req: SelectPayCoinRequestNg, + tx: WalletDbReadOnlyTransaction< + ["exchanges", "coinAvailability", "exchangeDetails", "denominations"] + >, + req: SelectPayCandidatesRequest, ): Promise<[AvailableDenom[], Record]> { - return await wex.db.runReadOnlyTx( - ["exchanges", "exchangeDetails", "denominations", "coinAvailability"], - async (tx) => { - // FIXME: Use the existing helper (from balance.ts) to - // get acceptable exchanges. - const denoms: AvailableDenom[] = []; - const exchanges = await tx.exchanges.iter().toArray(); - const wfPerExchange: Record = {}; - loopExchange: for (const exchange of exchanges) { - const exchangeDetails = await getExchangeWireDetailsInTx( - tx, - exchange.baseUrl, - ); - // 1.- exchange has same currency - if (exchangeDetails?.currency !== req.contractTermsAmount.currency) { - continue; - } - let wireMethodFee: string | undefined; - // 2.- exchange supports wire method - loopWireAccount: for (const acc of exchangeDetails.wireInfo.accounts) { - const pp = parsePaytoUri(acc.payto_uri); - checkLogicInvariant(!!pp); - if (pp.targetType !== req.wireMethod) { - continue; - } - const wireFeeStr = exchangeDetails.wireInfo.feesForType[ - req.wireMethod - ]?.find((x) => { - return AbsoluteTime.isBetween( - AbsoluteTime.now(), - AbsoluteTime.fromProtocolTimestamp(x.startStamp), - AbsoluteTime.fromProtocolTimestamp(x.endStamp), - ); - })?.wireFee; - let debitAccountCheckOk = false; - if (req.depositPaytoUri) { - // FIXME: We should somehow propagate the hint here! - const checkResult = checkAccountRestriction( - req.depositPaytoUri, - acc.debit_restrictions, - ); - if (checkResult.ok) { - debitAccountCheckOk = true; - } - } else { - debitAccountCheckOk = true; - } - - if (wireFeeStr) { - wireMethodFee = wireFeeStr; - break loopWireAccount; - } - } - if (!wireMethodFee) { - continue; - } - wfPerExchange[exchange.baseUrl] = Amounts.parseOrThrow(wireMethodFee); - - // 3.- exchange is trusted in the exchange list or auditor list - let accepted = false; - for (const allowedExchange of req.exchanges) { - if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) { - accepted = true; - break; - } - } - for (const allowedAuditor of req.auditors) { - for (const providedAuditor of exchangeDetails.auditors) { - if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) { - accepted = true; - break; - } - } - } - if (!accepted) { - continue; - } - // 4.- filter coins restricted by age - let ageLower = 0; - let ageUpper = AgeRestriction.AGE_UNRESTRICTED; - if (req.requiredMinimumAge) { - ageLower = req.requiredMinimumAge; - } - const myExchangeCoins = - await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( - GlobalIDB.KeyRange.bound( - [exchangeDetails.exchangeBaseUrl, ageLower, 1], - [ - exchangeDetails.exchangeBaseUrl, - ageUpper, - Number.MAX_SAFE_INTEGER, - ], - ), - ); - // 5.- save denoms with how many coins are available - // FIXME: Check that the individual denomination is audited! - // FIXME: Should we exclude denominations that are - // not spendable anymore? - for (const coinAvail of myExchangeCoins) { - const denom = await tx.denominations.get([ - coinAvail.exchangeBaseUrl, - coinAvail.denomPubHash, - ]); - checkDbInvariant(!!denom); - if (denom.isRevoked || !denom.isOffered) { - continue; - } - denoms.push({ - ...DenominationRecord.toDenomInfo(denom), - numAvailable: coinAvail.freshCoinCount ?? 0, - maxAge: coinAvail.maxAge, - }); - } + // FIXME: Use the existing helper (from balance.ts) to + // get acceptable exchanges. + const denoms: AvailableDenom[] = []; + const exchanges = await tx.exchanges.iter().toArray(); + const wfPerExchange: Record = {}; + for (const exchange of exchanges) { + const exchangeDetails = await getExchangeWireDetailsInTx( + tx, + exchange.baseUrl, + ); + // 1. exchange has same currency + if (exchangeDetails?.currency !== req.instructedAmount.currency) { + continue; + } + + // 2. Exchange supports wire method (only for pay/deposit) + if (req.restrictWireMethod) { + const wire = findMatchingWire( + req.restrictWireMethod, + req.depositPaytoUri, + exchangeDetails, + ); + if (!wire) { + continue; } - logger.info(`available denoms ${j2s(denoms)}`); - // Sort by available amount (descending), deposit fee (ascending) and - // denomPub (ascending) if deposit fee is the same - // (to guarantee deterministic results) - denoms.sort( - (o1, o2) => - -Amounts.cmp(o1.value, o2.value) || - Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || - strcmp(o1.denomPubHash, o2.denomPubHash), + } + + // 3. exchange is trusted in the exchange list or auditor list + let accepted = checkExchangeAccepted( + exchangeDetails, + req.restrictExchanges, + ); + if (!accepted) { + continue; + } + + // 4. filter coins restricted by age + let ageLower = 0; + let ageUpper = AgeRestriction.AGE_UNRESTRICTED; + if (req.requiredMinimumAge) { + ageLower = req.requiredMinimumAge; + } + + const myExchangeCoins = + await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( + GlobalIDB.KeyRange.bound( + [exchangeDetails.exchangeBaseUrl, ageLower, 1], + [exchangeDetails.exchangeBaseUrl, ageUpper, Number.MAX_SAFE_INTEGER], + ), ); - return [denoms, wfPerExchange]; - }, + + // 5. save denoms with how many coins are available + // FIXME: Check that the individual denomination is audited! + // FIXME: Should we exclude denominations that are + // not spendable anymore? + for (const coinAvail of myExchangeCoins) { + const denom = await tx.denominations.get([ + coinAvail.exchangeBaseUrl, + coinAvail.denomPubHash, + ]); + checkDbInvariant(!!denom); + if (denom.isRevoked || !denom.isOffered) { + continue; + } + denoms.push({ + ...DenominationRecord.toDenomInfo(denom), + numAvailable: coinAvail.freshCoinCount ?? 0, + maxAge: coinAvail.maxAge, + }); + } + } + logger.info(`available denoms ${j2s(denoms)}`); + // Sort by available amount (descending), deposit fee (ascending) and + // denomPub (ascending) if deposit fee is the same + // (to guarantee deterministic results) + denoms.sort( + (o1, o2) => + -Amounts.cmp(o1.value, o2.value) || + Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || + strcmp(o1.denomPubHash, o2.denomPubHash), ); + return [denoms, wfPerExchange]; } /** @@ -882,7 +940,7 @@ export type SelectPeerCoinsResult = | { type: "success"; result: PeerCoinSelectionDetails } | { type: "failure"; - insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; + insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails; }; export interface PeerCoinRepair { @@ -901,134 +959,134 @@ export interface PeerCoinSelectionRequest { repair?: PeerCoinRepair; } -/** - * Get coin availability information for a certain exchange. - */ -async function selectPayPeerCandidatesForExchange( - wex: WalletExecutionContext, - tx: WalletDbReadOnlyTransaction<["coinAvailability", "denominations"]>, +async function assemblePeerCoinSelectionDetails( + tx: WalletDbReadOnlyTransaction<["coins"]>, exchangeBaseUrl: string, -): Promise { - const denoms: AvailableDenom[] = []; - - let ageLower = 0; - let ageUpper = AgeRestriction.AGE_UNRESTRICTED; - const myExchangeCoins = - await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( - GlobalIDB.KeyRange.bound( - [exchangeBaseUrl, ageLower, 1], - [exchangeBaseUrl, ageUpper, Number.MAX_SAFE_INTEGER], + selectedDenom: SelResult, + resCoins: ResCoin[], + tally: CoinSelectionTally, +): Promise { + let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never(); + for (const dph of Object.keys(selectedDenom)) { + const selInfo = selectedDenom[dph]; + // Compute earliest time that a selected denom + // would have its coins auto-refreshed. + minAutorefreshExecuteThreshold = TalerProtocolTimestamp.min( + minAutorefreshExecuteThreshold, + AbsoluteTime.toProtocolTimestamp( + getAutoRefreshExecuteThreshold({ + stampExpireDeposit: selInfo.expireDeposit, + stampExpireWithdraw: selInfo.expireWithdraw, + }), ), ); - - for (const coinAvail of myExchangeCoins) { - if (coinAvail.freshCoinCount <= 0) { - continue; + 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}/${numRequested})`, + ); } - const denom = await tx.denominations.get([ - coinAvail.exchangeBaseUrl, - coinAvail.denomPubHash, - ]); - checkDbInvariant(!!denom); - if (denom.isRevoked || !denom.isOffered) { - continue; + for (let i = 0; i < selInfo.contributions.length; i++) { + resCoins.push({ + coinPriv: coins[i].coinPriv, + coinPub: coins[i].coinPub, + contribution: Amounts.stringify(selInfo.contributions[i]), + ageCommitmentProof: coins[i].ageCommitmentProof, + denomPubHash: selInfo.denomPubHash, + denomSig: coins[i].denomSig, + }); } - denoms.push({ - ...DenominationRecord.toDenomInfo(denom), - numAvailable: coinAvail.freshCoinCount ?? 0, - maxAge: coinAvail.maxAge, - }); } - // Sort by available amount (descending), deposit fee (ascending) and - // denomPub (ascending) if deposit fee is the same - // (to guarantee deterministic results) - denoms.sort( - (o1, o2) => - -Amounts.cmp(o1.value, o2.value) || - Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || - strcmp(o1.denomPubHash, o2.denomPubHash), - ); - - return denoms; -} -export interface PeerCoinSelectionTally { - amountRemaining: AmountJson; - depositFeesAcc: AmountJson; - lastDepositFee: AmountJson; -} - -/** - * exporting for testing - */ -export function testing_greedySelectPeer( - ...args: Parameters -): ReturnType { - return greedySelectPeer(...args); + return { + exchangeBaseUrl, + coins: resCoins, + depositFees: tally.customerDepositFees, + maxExpirationDate: minAutorefreshExecuteThreshold, + }; } -function greedySelectPeer( - candidates: AvailableDenom[], - tally: PeerCoinSelectionTally, -): SelResult | undefined { - const selectedDenom: SelResult = {}; - for (const denom of candidates) { - const contributions: AmountJson[] = []; - const feeDeposit = Amounts.parseOrThrow(denom.feeDeposit); - for ( - let i = 0; - i < denom.numAvailable && Amounts.isNonZero(tally.amountRemaining); - i++ - ) { - tally.depositFeesAcc = Amounts.add( - tally.depositFeesAcc, - feeDeposit, - ).amount; - tally.amountRemaining = Amounts.add( - tally.amountRemaining, - feeDeposit, - ).amount; - tally.lastDepositFee = feeDeposit; - - const coinSpend = Amounts.max( - Amounts.min(tally.amountRemaining, denom.value), - denom.feeDeposit, +async function maybeRepairPeerCoinSelection( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>, + exchangeBaseUrl: string, + tally: CoinSelectionTally, + repair: PeerCoinRepair | undefined, +): Promise { + const resCoins: ResCoin[] = []; + + if (repair && repair.exchangeBaseUrl === exchangeBaseUrl) { + for (let i = 0; i < repair.coinPubs.length; i++) { + const contrib = repair.contribs[i]; + const coin = await tx.coins.get(repair.coinPubs[i]); + if (!coin) { + throw Error("repair not possible, coin not found"); + } + const denom = await getDenomInfo( + wex, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, ); - - tally.amountRemaining = Amounts.sub( - tally.amountRemaining, - coinSpend, + checkDbInvariant(!!denom); + resCoins.push({ + coinPriv: coin.coinPriv, + coinPub: coin.coinPub, + contribution: Amounts.stringify(contrib), + denomPubHash: coin.denomPubHash, + denomSig: coin.denomSig, + ageCommitmentProof: coin.ageCommitmentProof, + }); + const depositFee = Amounts.parseOrThrow(denom.feeDeposit); + tally.lastDepositFee = depositFee; + tally.amountPayRemaining = Amounts.sub( + tally.amountPayRemaining, + Amounts.sub(contrib, depositFee).amount, + ).amount; + tally.customerDepositFees = Amounts.add( + tally.customerDepositFees, + depositFee, ).amount; - - contributions.push(coinSpend); - } - if (contributions.length > 0) { - const avKey = makeAvailabilityKey( - denom.exchangeBaseUrl, - denom.denomPubHash, - denom.maxAge, - ); - let sd = selectedDenom[avKey]; - if (!sd) { - sd = { - contributions: [], - denomPubHash: denom.denomPubHash, - exchangeBaseUrl: denom.exchangeBaseUrl, - maxAge: denom.maxAge, - expireDeposit: denom.stampExpireDeposit, - expireWithdraw: denom.stampExpireWithdraw, - }; - } - sd.contributions.push(...contributions); - selectedDenom[avKey] = sd; } } + return resCoins; +} - if (Amounts.isZero(tally.amountRemaining)) { - return selectedDenom; - } +interface ResCoin { + coinPub: string; + coinPriv: string; + contribution: AmountString; + denomPubHash: string; + denomSig: UnblindedSignature; + ageCommitmentProof: AgeCommitmentProof | undefined; +} - return undefined; +export function emptyTallyForPeerPayment( + instructedAmount: AmountJson, +): CoinSelectionTally { + const currency = instructedAmount.currency; + const zero = Amounts.zeroOfCurrency(currency); + return { + amountPayRemaining: instructedAmount, + customerDepositFees: zero, + lastDepositFee: zero, + amountDepositFeeLimitRemaining: zero, + amountWireFeeLimitRemaining: zero, + customerWireFees: zero, + wireFeeCoveredForExchange: new Set(), + }; } export async function selectPeerCoins( @@ -1041,6 +1099,7 @@ export async function selectPeerCoins( // one coin to spend. throw new Error("amount of zero not allowed"); } + return await wex.db.runReadWriteTx( [ "exchanges", @@ -1049,72 +1108,40 @@ export async function selectPeerCoins( "coinAvailability", "denominations", "refreshGroups", - "peerPushDebit", + "exchangeDetails", ], - async (tx) => { + async (tx): Promise => { const exchanges = await tx.exchanges.iter().toArray(); - const exchangeFeeGap: { [url: string]: AmountJson } = {}; const currency = Amounts.currencyOf(instructedAmount); for (const exch of exchanges) { if (exch.detailsPointer?.currency !== currency) { continue; } - const candidates = await selectPayPeerCandidatesForExchange( + const candidatesRes = await selectPayCandidates(wex, tx, { + instructedAmount, + restrictExchanges: { + auditors: [], + exchanges: [ + { + exchangeBaseUrl: exch.baseUrl, + exchangePub: exch.detailsPointer.masterPublicKey, + }, + ], + }, + restrictWireMethod: undefined, + }); + const candidates = candidatesRes[0]; + if (logger.shouldLogTrace()) { + logger.trace(`peer payment candidate coins: ${j2s(candidates)}`); + } + const tally = emptyTallyForPeerPayment(req.instructedAmount); + const resCoins: ResCoin[] = await maybeRepairPeerCoinSelection( wex, tx, exch.baseUrl, + tally, + req.repair, ); - if (logger.shouldLogTrace()) { - logger.trace(`peer payment candidate coins: ${j2s(candidates)}`); - } - const tally: PeerCoinSelectionTally = { - amountRemaining: Amounts.parseOrThrow(instructedAmount), - depositFeesAcc: Amounts.zeroOfCurrency(currency), - lastDepositFee: Amounts.zeroOfCurrency(currency), - }; - const resCoins: { - coinPub: string; - coinPriv: string; - contribution: AmountString; - denomPubHash: string; - denomSig: UnblindedSignature; - ageCommitmentProof: AgeCommitmentProof | undefined; - }[] = []; - - if (req.repair && req.repair.exchangeBaseUrl === exch.baseUrl) { - for (let i = 0; i < req.repair.coinPubs.length; i++) { - const contrib = req.repair.contribs[i]; - const coin = await tx.coins.get(req.repair.coinPubs[i]); - if (!coin) { - throw Error("repair not possible, coin not found"); - } - const denom = await getDenomInfo( - wex, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - checkDbInvariant(!!denom); - resCoins.push({ - coinPriv: coin.coinPriv, - coinPub: coin.coinPub, - contribution: Amounts.stringify(contrib), - denomPubHash: coin.denomPubHash, - denomSig: coin.denomSig, - ageCommitmentProof: coin.ageCommitmentProof, - }); - const depositFee = Amounts.parseOrThrow(denom.feeDeposit); - tally.lastDepositFee = depositFee; - tally.amountRemaining = Amounts.sub( - tally.amountRemaining, - Amounts.sub(contrib, depositFee).amount, - ).amount; - tally.depositFeesAcc = Amounts.add( - tally.depositFeesAcc, - depositFee, - ).amount; - } - } if (logger.shouldLogTrace()) { logger.trace(`candidates: ${j2s(candidates)}`); @@ -1122,113 +1149,42 @@ export async function selectPeerCoins( logger.trace(`tally: ${j2s(tally)}`); } - const selectedDenom = greedySelectPeer(candidates, tally); + const selectedDenom = selectGreedy( + { + wireFeeAmortization: 1, + wireFeesPerExchange: {}, + }, + candidates, + tally, + ); if (selectedDenom) { - let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never(); - for (const dph of Object.keys(selectedDenom)) { - const selInfo = selectedDenom[dph]; - // Compute earliest time that a selected denom - // would have its coins auto-refreshed. - minAutorefreshExecuteThreshold = TalerProtocolTimestamp.min( - minAutorefreshExecuteThreshold, - AbsoluteTime.toProtocolTimestamp( - getAutoRefreshExecuteThreshold({ - stampExpireDeposit: selInfo.expireDeposit, - stampExpireWithdraw: selInfo.expireWithdraw, - }), - ), - ); - 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}/${numRequested})`, - ); - } - for (let i = 0; i < selInfo.contributions.length; i++) { - resCoins.push({ - coinPriv: coins[i].coinPriv, - coinPub: coins[i].coinPub, - contribution: Amounts.stringify(selInfo.contributions[i]), - ageCommitmentProof: coins[i].ageCommitmentProof, - denomPubHash: selInfo.denomPubHash, - denomSig: coins[i].denomSig, - }); - } - } - - const res: PeerCoinSelectionDetails = { - exchangeBaseUrl: exch.baseUrl, - coins: resCoins, - depositFees: tally.depositFeesAcc, - maxExpirationDate: minAutorefreshExecuteThreshold, + return { + type: "success", + result: await assemblePeerCoinSelectionDetails( + tx, + exch.baseUrl, + selectedDenom, + resCoins, + tally, + ), }; - return { type: "success", result: res }; - } - - exchangeFeeGap[exch.baseUrl] = Amounts.add( - tally.lastDepositFee, - tally.amountRemaining, - ).amount; - - continue; - } - - // We were unable to select coins. - // Now we need to produce error details. - - const infoGeneral = await getPeerPaymentBalanceDetailsInTx(wex, tx, { - currency, - }); - - const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {}; - - let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency); - - for (const exch of exchanges) { - if (exch.detailsPointer?.currency !== currency) { - continue; - } - const infoExchange = await getPeerPaymentBalanceDetailsInTx(wex, tx, { - currency, - restrictExchangeTo: exch.baseUrl, - }); - let gap = - exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency); - if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) { - // Show fee gap only if we should've been able to pay with the material amount - gap = Amounts.zeroOfCurrency(currency); } - perExchange[exch.baseUrl] = { - balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable), - balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial), - feeGapEstimate: Amounts.stringify(gap), - }; - - maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap); } - - const errDetails: PayPeerInsufficientBalanceDetails = { - amountRequested: Amounts.stringify(instructedAmount), - balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable), - balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial), - feeGapEstimate: Amounts.stringify(maxFeeGapEstimate), - perExchange, + const insufficientBalanceDetails = await reportInsufficientBalanceDetails( + wex, + tx, + { + restrictExchanges: undefined, + instructedAmount: req.instructedAmount, + requiredMinimumAge: undefined, + wireMethod: undefined, + }, + ); + return { + type: "failure", + insufficientBalanceDetails, }; - - return { type: "failure", insufficientBalanceDetails: errDetails }; }, ); } diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts index 960b123c6..2e28ba9b7 100644 --- a/packages/taler-wallet-core/src/deposits.ts +++ b/packages/taler-wallet-core/src/deposits.ts @@ -72,7 +72,7 @@ import { stringToBytes, } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; -import { selectPayCoinsNew } from "./coinSelection.js"; +import { selectPayCoins } from "./coinSelection.js"; import { PendingTaskType, TaskIdStr, @@ -1219,10 +1219,12 @@ export async function prepareDepositGroup( "", ); - const payCoinSel = await selectPayCoinsNew(wex, { - auditors: [], - exchanges: contractData.allowedExchanges, - wireMethod: contractData.wireMethod, + const payCoinSel = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), wireFeeAmortization: contractData.wireFeeAmortization ?? 1, @@ -1338,10 +1340,12 @@ export async function createDepositGroup( "", ); - const payCoinSel = await selectPayCoinsNew(wex, { - auditors: [], - exchanges: contractData.allowedExchanges, - wireMethod: contractData.wireMethod, + const payCoinSel = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), wireFeeAmortization: contractData.wireFeeAmortization ?? 1, diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts index a3623e6d2..ed58dc404 100644 --- a/packages/taler-wallet-core/src/pay-merchant.ts +++ b/packages/taler-wallet-core/src/pay-merchant.ts @@ -96,7 +96,7 @@ import { readUnexpectedResponseDetails, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; -import { PreviousPayCoins, selectPayCoinsNew } from "./coinSelection.js"; +import { PreviousPayCoins, selectPayCoins } from "./coinSelection.js"; import { constructTaskIdentifier, PendingTaskType, @@ -1161,10 +1161,12 @@ async function handleInsufficientFunds( } }); - const res = await selectPayCoinsNew(wex, { - auditors: [], - exchanges: contractData.allowedExchanges, - wireMethod: contractData.wireMethod, + const res = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), wireFeeAmortization: contractData.wireFeeAmortization ?? 1, @@ -1285,16 +1287,18 @@ async function checkPaymentByProposalId( purchase.purchaseStatus === PurchaseStatus.DialogShared ) { // If not already paid, check if we could pay for it. - const res = await selectPayCoinsNew(wex, { - auditors: [], - exchanges: contractData.allowedExchanges, + const res = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), wireFeeAmortization: contractData.wireFeeAmortization ?? 1, wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), prevPayCoins: [], requiredMinimumAge: contractData.minimumAge, - wireMethod: contractData.wireMethod, + restrictWireMethod: contractData.wireMethod, }); if (res.type !== "success") { @@ -1820,10 +1824,12 @@ export async function confirmPay( const contractData = d.contractData; - const selectCoinsResult = await selectPayCoinsNew(wex, { - auditors: [], - exchanges: contractData.allowedExchanges, - wireMethod: contractData.wireMethod, + const selectCoinsResult = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), wireFeeAmortization: contractData.wireFeeAmortization ?? 1, -- cgit v1.2.3