diff options
Diffstat (limited to 'packages/taler-wallet-core/src/coinSelection.ts')
-rw-r--r-- | packages/taler-wallet-core/src/coinSelection.ts | 370 |
1 files changed, 306 insertions, 64 deletions
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts index 51316a21f..bc9d51ec7 100644 --- a/packages/taler-wallet-core/src/coinSelection.ts +++ b/packages/taler-wallet-core/src/coinSelection.ts @@ -26,25 +26,30 @@ import { GlobalIDB } from "@gnu-taler/idb-bridge"; import { AbsoluteTime, - AccountRestriction, AgeRestriction, AllowedAuditorInfo, AllowedExchangeInfo, AmountJson, Amounts, + checkAccountRestriction, checkDbInvariant, checkLogicInvariant, CoinStatus, DenominationInfo, ExchangeGlobalFees, ForcedCoinSel, - InternationalizedString, + GetMaxDepositAmountRequest, + GetMaxDepositAmountResponse, + GetMaxPeerPushDebitAmountRequest, + GetMaxPeerPushDebitAmountResponse, j2s, Logger, parsePaytoUri, PayCoinSelection, PaymentInsufficientBalanceDetails, ProspectivePayCoinSelection, + ScopeInfo, + ScopeType, SelectedCoin, SelectedProspectiveCoin, strcmp, @@ -54,6 +59,7 @@ import { getPaymentBalanceDetailsInTx } from "./balance.js"; import { getAutoRefreshExecuteThreshold } from "./common.js"; import { DenominationRecord, WalletDbReadOnlyTransaction } from "./db.js"; import { + checkExchangeInScope, ExchangeWireDetails, getExchangeWireDetailsInTx, } from "./exchanges.js"; @@ -86,6 +92,8 @@ export interface CoinSelectionTally { customerDepositFees: AmountJson; + totalDepositFees: AmountJson; + customerWireFees: AmountJson; wireFeeCoveredForExchange: Set<string>; @@ -152,6 +160,10 @@ function tallyFees( dfRemaining, ).amount; tally.lastDepositFee = feeDeposit; + tally.totalDepositFees = Amounts.add( + tally.totalDepositFees, + feeDeposit, + ).amount; } export type SelectPayCoinsResult = @@ -180,19 +192,32 @@ async function internalSelectPayCoins( | { sel: SelResult; coinRes: SelectedCoin[]; tally: CoinSelectionTally } | undefined > { + let restrictWireMethod; + if (req.depositPaytoUri) { + const parsedPayto = parsePaytoUri(req.depositPaytoUri); + if (!parsedPayto) { + throw Error("invalid payto URI"); + } + restrictWireMethod = parsedPayto.targetType; + if (restrictWireMethod !== req.restrictWireMethod) { + logger.warn(`conflicting payto URI and wire method restriction`); + } + } else { + restrictWireMethod = req.restrictWireMethod; + } + const { contractTermsAmount, depositFeeLimit } = req; - const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates( - wex, - tx, - { - restrictExchanges: req.restrictExchanges, - instructedAmount: req.contractTermsAmount, - restrictWireMethod: req.restrictWireMethod, - depositPaytoUri: req.depositPaytoUri, - requiredMinimumAge: req.requiredMinimumAge, - includePendingCoins, - }, - ); + const candidateRes = await selectPayCandidates(wex, tx, { + currency: Amounts.currencyOf(req.contractTermsAmount), + restrictExchanges: req.restrictExchanges, + restrictWireMethod: req.restrictWireMethod, + depositPaytoUri: req.depositPaytoUri, + requiredMinimumAge: req.requiredMinimumAge, + includePendingCoins, + }); + + const wireFeesPerExchange = candidateRes.currentWireFeePerExchange; + const candidateDenoms = candidateRes.coinAvailability; if (logger.shouldLogTrace()) { logger.trace( @@ -210,6 +235,7 @@ async function internalSelectPayCoins( amountDepositFeeLimitRemaining: depositFeeLimit, customerDepositFees: Amounts.zeroOfCurrency(currency), customerWireFees: Amounts.zeroOfCurrency(currency), + totalDepositFees: Amounts.zeroOfCurrency(currency), wireFeeCoveredForExchange: new Set(), lastDepositFee: Amounts.zeroOfCurrency(currency), }; @@ -452,6 +478,7 @@ async function assembleSelectPayCoinsSuccessResult( coins: coinRes, customerDepositFees: Amounts.stringify(tally.customerDepositFees), customerWireFees: Amounts.stringify(tally.customerWireFees), + totalDepositFees: Amounts.stringify(tally.totalDepositFees), }; } @@ -493,12 +520,16 @@ export async function reportInsufficientBalanceDetails( let missingGlobalFees = false; const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl); if (!exchWire) { + // No wire details about the exchange known, skip! + continue; + } + const globalFees = getGlobalFees(exchWire); + if (!globalFees) { missingGlobalFees = true; - } else { - const globalFees = getGlobalFees(exchWire); - if (!globalFees) { - missingGlobalFees = true; - } + } + if (exchWire.currency !== Amounts.currencyOf(req.instructedAmount)) { + // Do not report anything for an exchange with a different currency. + continue; } const exchDet = await getPaymentBalanceDetailsInTx(wex, tx, { restrictExchanges: { @@ -510,7 +541,7 @@ export async function reportInsufficientBalanceDetails( ], auditors: [], }, - restrictWireMethods: req.wireMethod ? [req.wireMethod] : [], + restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined, currency: Amounts.currencyOf(req.instructedAmount), minAge: req.requiredMinimumAge ?? 0, depositPaytoUri: req.depositPaytoUri, @@ -529,7 +560,7 @@ export async function reportInsufficientBalanceDetails( exchDet.balanceReceiverDepositable, ), maxEffectiveSpendAmount: Amounts.stringify( - exchDet.maxEffectiveSpendAmount, + exchDet.maxMerchantEffectiveDepositAmount, ), missingGlobalFees, }; @@ -549,7 +580,9 @@ export async function reportInsufficientBalanceDetails( balanceReceiverDepositable: Amounts.stringify( details.balanceReceiverDepositable, ), - maxEffectiveSpendAmount: Amounts.stringify(details.maxEffectiveSpendAmount), + maxEffectiveSpendAmount: Amounts.stringify( + details.maxMerchantEffectiveDepositAmount, + ), perExchange, }; } @@ -590,7 +623,7 @@ export interface SelectGreedyRequest { function selectGreedy( req: SelectGreedyRequest, - candidateDenoms: AvailableDenom[], + candidateDenoms: AvailableCoinsOfDenom[], tally: CoinSelectionTally, ): SelResult | undefined { const selectedDenom: SelResult = {}; @@ -653,7 +686,7 @@ function selectGreedy( function selectForced( req: SelectPayCoinRequestNg, - candidateDenoms: AvailableDenom[], + candidateDenoms: AvailableCoinsOfDenom[], ): SelResult | undefined { const selectedDenom: SelResult = {}; @@ -695,31 +728,6 @@ function selectForced( return selectedDenom; } -export function checkAccountRestriction( - paytoUri: string, - restrictions: AccountRestriction[], -): { ok: boolean; hint?: string; hintI18n?: InternationalizedString } { - for (const myRestriction of restrictions) { - switch (myRestriction.type) { - case "deny": - return { ok: false }; - case "regex": { - const regex = new RegExp(myRestriction.payto_regex); - if (!regex.test(paytoUri)) { - return { - ok: false, - hint: myRestriction.human_hint, - hintI18n: myRestriction.human_hint_i18n, - }; - } - } - } - } - return { - ok: true, - }; -} - export interface SelectPayCoinRequestNg { restrictExchanges: ExchangeRestrictionSpec | undefined; restrictWireMethod: string; @@ -739,7 +747,7 @@ export interface SelectPayCoinRequestNg { depositPaytoUri?: string; } -export type AvailableDenom = DenominationInfo & { +export type AvailableCoinsOfDenom = DenominationInfo & { maxAge: number; numAvailable: number; }; @@ -820,7 +828,7 @@ function checkExchangeAccepted( } interface SelectPayCandidatesRequest { - instructedAmount: AmountJson; + currency: string; restrictWireMethod: string | undefined; depositPaytoUri?: string; restrictExchanges: ExchangeRestrictionSpec | undefined; @@ -834,18 +842,23 @@ interface SelectPayCandidatesRequest { includePendingCoins: boolean; } +export interface PayCoinCandidates { + coinAvailability: AvailableCoinsOfDenom[]; + currentWireFeePerExchange: Record<string, AmountJson>; +} + async function selectPayCandidates( wex: WalletExecutionContext, tx: WalletDbReadOnlyTransaction< ["exchanges", "coinAvailability", "exchangeDetails", "denominations"] >, req: SelectPayCandidatesRequest, -): Promise<[AvailableDenom[], Record<string, AmountJson>]> { +): Promise<PayCoinCandidates> { // FIXME: Use the existing helper (from balance.ts) to // get acceptable exchanges. logger.shouldLogTrace() && logger.trace(`selecting available coin candidates for ${j2s(req)}`); - const denoms: AvailableDenom[] = []; + const denoms: AvailableCoinsOfDenom[] = []; const exchanges = await tx.exchanges.iter().toArray(); const wfPerExchange: Record<string, AmountJson> = {}; for (const exchange of exchanges) { @@ -854,7 +867,7 @@ async function selectPayCandidates( exchange.baseUrl, ); // 1. exchange has same currency - if (exchangeDetails?.currency !== req.instructedAmount.currency) { + if (exchangeDetails?.currency !== req.currency) { logger.shouldLogTrace() && logger.trace(`skipping ${exchange.baseUrl} due to currency mismatch`); continue; @@ -961,7 +974,10 @@ async function selectPayCandidates( Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || strcmp(o1.denomPubHash, o2.denomPubHash), ); - return [denoms, wfPerExchange]; + return { + coinAvailability: denoms, + currentWireFeePerExchange: wfPerExchange, + }; } export interface PeerCoinSelectionDetails { @@ -975,7 +991,9 @@ export interface PeerCoinSelectionDetails { /** * How much of the deposit fees is the customer paying? */ - depositFees: AmountJson; + customerDepositFees: AmountJson; + + totalDepositFees: AmountJson; maxExpirationDate: TalerProtocolTimestamp; } @@ -988,7 +1006,9 @@ export interface ProspectivePeerCoinSelectionDetails { /** * How much of the deposit fees is the customer paying? */ - depositFees: AmountJson; + customerDepositFees: AmountJson; + + totalDepositFees: AmountJson; maxExpirationDate: TalerProtocolTimestamp; } @@ -1006,6 +1026,19 @@ export interface PeerCoinSelectionRequest { instructedAmount: AmountJson; /** + * Are deposit fees covered by the counterparty? + * + * Defaults to false. + */ + feesCoveredByCounterparty?: boolean; + + /** + * Restrict the scope of funds that can be spent via the given + * scope info. + */ + restrictScope?: ScopeInfo; + + /** * Instruct the coin selection to repair this coin * selection instead of selecting completely new coins. */ @@ -1045,17 +1078,21 @@ export async function computeCoinSelMaxExpirationDate( } export function emptyTallyForPeerPayment( - instructedAmount: AmountJson, + req: PeerCoinSelectionRequest, ): CoinSelectionTally { + const instructedAmount = req.instructedAmount; const currency = instructedAmount.currency; const zero = Amounts.zeroOfCurrency(currency); return { amountPayRemaining: instructedAmount, customerDepositFees: zero, lastDepositFee: zero, - amountDepositFeeLimitRemaining: zero, + amountDepositFeeLimitRemaining: req.feesCoveredByCounterparty + ? instructedAmount + : zero, customerWireFees: zero, wireFeeCoveredForExchange: new Set(), + totalDepositFees: zero, }; } @@ -1098,7 +1135,7 @@ async function internalSelectPeerCoins( | undefined > { const candidatesRes = await selectPayCandidates(wex, tx, { - instructedAmount: req.instructedAmount, + currency: Amounts.currencyOf(req.instructedAmount), restrictExchanges: { auditors: [], exchanges: [ @@ -1111,11 +1148,11 @@ async function internalSelectPeerCoins( restrictWireMethod: undefined, includePendingCoins, }); - const candidates = candidatesRes[0]; + const candidates = candidatesRes.coinAvailability; if (logger.shouldLogTrace()) { logger.trace(`peer payment candidate coins: ${j2s(candidates)}`); } - const tally = emptyTallyForPeerPayment(req.instructedAmount); + const tally = emptyTallyForPeerPayment(req); const resCoins: SelectedCoin[] = []; await maybeRepairCoinSelection(wex, tx, req.repair ?? [], resCoins, tally, { @@ -1178,6 +1215,19 @@ export async function selectPeerCoinsInTx( if (!exchWire) { continue; } + const isInScope = req.restrictScope + ? await checkExchangeInScope(wex, exch.baseUrl, req.restrictScope) + : true; + if (!isInScope) { + continue; + } + if ( + req.restrictScope && + req.restrictScope.type === ScopeType.Exchange && + req.restrictScope.url !== exch.baseUrl + ) { + continue; + } const globalFees = getGlobalFees(exchWire); if (!globalFees) { continue; @@ -1215,7 +1265,8 @@ export async function selectPeerCoinsInTx( type: "prospective", result: { prospectiveCoins, - depositFees: prospectiveAvRes.tally.customerDepositFees, + customerDepositFees: prospectiveAvRes.tally.customerDepositFees, + totalDepositFees: prospectiveAvRes.tally.totalDepositFees, exchangeBaseUrl: exch.baseUrl, maxExpirationDate, }, @@ -1239,7 +1290,8 @@ export async function selectPeerCoinsInTx( type: "success", result: { coins: r.coins, - depositFees: Amounts.parseOrThrow(r.customerDepositFees), + customerDepositFees: Amounts.parseOrThrow(r.customerDepositFees), + totalDepositFees: Amounts.parseOrThrow(r.totalDepositFees), exchangeBaseUrl: exch.baseUrl, maxExpirationDate, }, @@ -1284,3 +1336,193 @@ export async function selectPeerCoins( }, ); } + +function getMaxDepositAmountForAvailableCoins( + req: GetMaxDepositAmountRequest, + candidateRes: PayCoinCandidates, +): GetMaxDepositAmountResponse { + const wireFeeCoveredForExchange = new Set<string>(); + + let amountEffective = Amounts.zeroOfCurrency(req.currency); + let fees = Amounts.zeroOfCurrency(req.currency); + + for (const cc of candidateRes.coinAvailability) { + if (!wireFeeCoveredForExchange.has(cc.exchangeBaseUrl)) { + const wireFee = + candidateRes.currentWireFeePerExchange[cc.exchangeBaseUrl]; + // Wire fee can be null if max deposit amount is computed + // without restricting the wire method. + if (wireFee != null) { + fees = Amounts.add(fees, wireFee).amount; + } + wireFeeCoveredForExchange.add(cc.exchangeBaseUrl); + } + + amountEffective = Amounts.add( + amountEffective, + Amounts.mult(cc.value, cc.numAvailable).amount, + ).amount; + + fees = Amounts.add( + fees, + Amounts.mult(cc.feeDeposit, cc.numAvailable).amount, + ).amount; + } + + return { + effectiveAmount: Amounts.stringify(amountEffective), + rawAmount: Amounts.stringify(Amounts.sub(amountEffective, fees).amount), + }; +} + +/** + * Only used for unit testing getMaxDepositAmountForAvailableCoins. + */ +export const testing_getMaxDepositAmountForAvailableCoins = + getMaxDepositAmountForAvailableCoins; + +export async function getMaxDepositAmount( + wex: WalletExecutionContext, + req: GetMaxDepositAmountRequest, +): Promise<GetMaxDepositAmountResponse> { + logger.trace(`getting max deposit amount for: ${j2s(req)}`); + return await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "coinAvailability", + "denominations", + "exchangeDetails", + ], + }, + async (tx): Promise<GetMaxDepositAmountResponse> => { + let restrictWireMethod: string | undefined = undefined; + if (req.depositPaytoUri) { + const p = parsePaytoUri(req.depositPaytoUri); + if (!p) { + throw Error("invalid payto URI"); + } + restrictWireMethod = p.targetType; + } + const candidateRes = await selectPayCandidates(wex, tx, { + currency: req.currency, + restrictExchanges: undefined, + restrictWireMethod, + depositPaytoUri: req.depositPaytoUri, + requiredMinimumAge: undefined, + includePendingCoins: true, + }); + return getMaxDepositAmountForAvailableCoins(req, candidateRes); + }, + ); +} + +function getMaxPeerPushDebitAmountForAvailableCoins( + req: GetMaxDepositAmountRequest, + exchangeBaseUrl: string, + candidateRes: PayCoinCandidates, +): GetMaxPeerPushDebitAmountResponse { + let amountEffective = Amounts.zeroOfCurrency(req.currency); + let fees = Amounts.zeroOfCurrency(req.currency); + + for (const cc of candidateRes.coinAvailability) { + amountEffective = Amounts.add( + amountEffective, + Amounts.mult(cc.value, cc.numAvailable).amount, + ).amount; + + fees = Amounts.add( + fees, + Amounts.mult(cc.feeDeposit, cc.numAvailable).amount, + ).amount; + } + + return { + exchangeBaseUrl, + effectiveAmount: Amounts.stringify(amountEffective), + rawAmount: Amounts.stringify(Amounts.sub(amountEffective, fees).amount), + }; +} + +export async function getMaxPeerPushDebitAmount( + wex: WalletExecutionContext, + req: GetMaxPeerPushDebitAmountRequest, +): Promise<GetMaxPeerPushDebitAmountResponse> { + logger.trace(`getting max deposit amount for: ${j2s(req)}`); + + return await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "coinAvailability", + "denominations", + "exchangeDetails", + ], + }, + async (tx): Promise<GetMaxPeerPushDebitAmountResponse> => { + let result: GetMaxDepositAmountResponse | undefined = undefined; + const currency = req.currency; + const exchanges = await tx.exchanges.iter().toArray(); + for (const exch of exchanges) { + if (exch.detailsPointer?.currency !== currency) { + continue; + } + const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl); + if (!exchWire) { + continue; + } + const isInScope = req.restrictScope + ? await checkExchangeInScope(wex, exch.baseUrl, req.restrictScope) + : true; + if (!isInScope) { + continue; + } + if ( + req.restrictScope && + req.restrictScope.type === ScopeType.Exchange && + req.restrictScope.url !== exch.baseUrl + ) { + continue; + } + const globalFees = getGlobalFees(exchWire); + if (!globalFees) { + continue; + } + + const candidatesRes = await selectPayCandidates(wex, tx, { + currency, + restrictExchanges: { + auditors: [], + exchanges: [ + { + exchangeBaseUrl: exchWire.exchangeBaseUrl, + exchangePub: exchWire.masterPublicKey, + }, + ], + }, + restrictWireMethod: undefined, + includePendingCoins: true, + }); + + const myExchangeRes = getMaxPeerPushDebitAmountForAvailableCoins( + req, + exchWire.exchangeBaseUrl, + candidatesRes, + ); + + if (!result) { + result = myExchangeRes; + } else if (Amounts.cmp(result.rawAmount, myExchangeRes.rawAmount) < 0) { + result = myExchangeRes; + } + } + if (!result) { + return { + effectiveAmount: Amounts.stringify(Amounts.zeroOfCurrency(currency)), + rawAmount: Amounts.stringify(Amounts.zeroOfCurrency(currency)), + }; + } + return result; + }, + ); +} |