From b0cc65e17f2348f46ae1c9b88b69abae11266899 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 31 Mar 2023 12:27:05 -0300 Subject: move coin selection function to coinSelection.ts and added a test placeholder, and some fixes: * selectCandidates was not save wire fee * selectCandidates show check wire fee time range --- .../src/operations/pay-merchant.ts | 488 +-------------------- 1 file changed, 20 insertions(+), 468 deletions(-) (limited to 'packages/taler-wallet-core/src/operations/pay-merchant.ts') diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts index 25153f9fb..f8fa1d34d 100644 --- a/packages/taler-wallet-core/src/operations/pay-merchant.ts +++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts @@ -24,12 +24,10 @@ /** * Imports. */ -import { GlobalIDB } from "@gnu-taler/idb-bridge"; import { AbortingCoin, AbortRequest, AbsoluteTime, - AgeRestriction, AmountJson, Amounts, ApplyRefundResponse, @@ -44,9 +42,8 @@ import { CoinStatus, ConfirmPayResult, ConfirmPayResultType, - MerchantContractTerms, + constructPayUri, ContractTermsUtil, - DenominationInfo, Duration, encodeCrock, ForcedCoinSel, @@ -54,11 +51,13 @@ import { HttpStatusCode, j2s, Logger, + makeErrorDetail, + makePendingOperationFailedError, MerchantCoinRefundFailureStatus, MerchantCoinRefundStatus, MerchantCoinRefundSuccessStatus, + MerchantContractTerms, NotificationType, - parsePaytoUri, parsePayUri, parseRefundUri, PayCoinSelection, @@ -66,19 +65,24 @@ import { PreparePayResultType, PrepareRefundResult, RefreshReason, - strcmp, + TalerError, TalerErrorCode, TalerErrorDetail, TalerProtocolTimestamp, + TalerProtocolViolationError, TransactionType, URL, - constructPayUri, - PayMerchantInsufficientBalanceDetails, } from "@gnu-taler/taler-util"; +import { + getHttpResponseErrorDetails, + readSuccessResponseJsonOrErrorCode, + readSuccessResponseJsonOrThrow, + readTalerErrorResponse, + readUnexpectedResponseDetails, + throwUnexpectedRequestError, +} from "@gnu-taler/taler-util/http"; import { EddsaKeypair } from "../crypto/cryptoImplementation.js"; import { - AllowedAuditorInfo, - AllowedExchangeInfo, BackupProviderStateTag, CoinRecord, DenominationRecord, @@ -89,51 +93,29 @@ import { WalletContractData, WalletStoresV1, } from "../db.js"; -import { - makeErrorDetail, - makePendingOperationFailedError, - TalerError, - TalerProtocolViolationError, -} from "@gnu-taler/taler-util"; import { GetReadWriteAccess, PendingTaskType } from "../index.js"; import { EXCHANGE_COINS_LOCK, InternalWalletState, } from "../internal-wallet-state.js"; import { assertUnreachable } from "../util/assertUnreachable.js"; +import { PreviousPayCoins, selectPayCoinsNew } from "../util/coinSelection.js"; +import { checkDbInvariant } from "../util/invariants.js"; +import { GetReadOnlyAccess } from "../util/query.js"; import { - CoinSelectionTally, - PreviousPayCoins, - tallyFees, -} from "../util/coinSelection.js"; -import { - getHttpResponseErrorDetails, - readSuccessResponseJsonOrErrorCode, - readSuccessResponseJsonOrThrow, - readTalerErrorResponse, - readUnexpectedResponseDetails, - throwUnexpectedRequestError, -} from "@gnu-taler/taler-util/http"; -import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; -import { + constructTaskIdentifier, OperationAttemptResult, OperationAttemptResultType, RetryInfo, - TaskIdentifiers, scheduleRetry, - constructTaskIdentifier, + TaskIdentifiers, } from "../util/retries.js"; import { makeTransactionId, runOperationWithErrorReporting, spendCoins, - storeOperationError, - storeOperationPending, } from "./common.js"; -import { getExchangeDetails } from "./exchanges.js"; import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js"; -import { GetReadOnlyAccess } from "../util/query.js"; -import { getMerchantPaymentBalanceDetails } from "./balance.js"; /** * Logger. @@ -877,434 +859,6 @@ async function unblockBackup( }); } -export interface SelectPayCoinRequestNg { - exchanges: AllowedExchangeInfo[]; - auditors: AllowedAuditorInfo[]; - wireMethod: string; - contractTermsAmount: AmountJson; - depositFeeLimit: AmountJson; - wireFeeLimit: AmountJson; - wireFeeAmortization: number; - prevPayCoins?: PreviousPayCoins; - requiredMinimumAge?: number; - forcedSelection?: ForcedCoinSel; -} - -export type AvailableDenom = DenominationInfo & { - maxAge: number; - numAvailable: number; -}; - -export async function selectCandidates( - ws: InternalWalletState, - req: SelectPayCoinRequestNg, -): Promise<[AvailableDenom[], Record]> { - return await ws.db - .mktx((x) => [ - x.exchanges, - x.exchangeDetails, - x.denominations, - x.coinAvailability, - ]) - .runReadOnly(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 = {}; - for (const exchange of exchanges) { - const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl); - if (exchangeDetails?.currency !== req.contractTermsAmount.currency) { - continue; - } - let wireMethodSupported = false; - for (const acc of exchangeDetails.wireInfo.accounts) { - const pp = parsePaytoUri(acc.payto_uri); - checkLogicInvariant(!!pp); - if (pp.targetType === req.wireMethod) { - wireMethodSupported = true; - break; - } - } - if (!wireMethodSupported) { - break; - } - exchangeDetails.wireInfo.accounts; - 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; - } - let ageLower = 0; - let ageUpper = AgeRestriction.AGE_UNRESTRICTED; - if (req.requiredMinimumAge) { - ageLower = req.requiredMinimumAge; - } - const myExchangeDenoms = - await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( - GlobalIDB.KeyRange.bound( - [exchangeDetails.exchangeBaseUrl, ageLower, 1], - [ - exchangeDetails.exchangeBaseUrl, - ageUpper, - Number.MAX_SAFE_INTEGER, - ], - ), - ); - // FIXME: Check that the individual denomination is audited! - // FIXME: Should we exclude denominations that are - // not spendable anymore? - for (const denomAvail of myExchangeDenoms) { - const denom = await tx.denominations.get([ - denomAvail.exchangeBaseUrl, - denomAvail.denomPubHash, - ]); - checkDbInvariant(!!denom); - if (denom.isRevoked || !denom.isOffered) { - continue; - } - denoms.push({ - ...DenominationRecord.toDenomInfo(denom), - numAvailable: denomAvail.freshCoinCount ?? 0, - maxAge: denomAvail.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, wfPerExchange]; - }); -} - -function makeAvailabilityKey( - exchangeBaseUrl: string, - denomPubHash: string, - maxAge: number, -): string { - return `${denomPubHash};${maxAge};${exchangeBaseUrl}`; -} - -/** - * Selection result. - */ -interface SelResult { - /** - * Map from an availability key - * to an array of contributions. - */ - [avKey: string]: { - exchangeBaseUrl: string; - denomPubHash: string; - maxAge: number; - contributions: AmountJson[]; - }; -} - -export function selectGreedy( - req: SelectPayCoinRequestNg, - candidateDenoms: AvailableDenom[], - wireFeesPerExchange: Record, - tally: CoinSelectionTally, -): SelResult | undefined { - const { wireFeeAmortization } = req; - const selectedDenom: SelResult = {}; - for (const aci of candidateDenoms) { - const contributions: AmountJson[] = []; - for (let i = 0; i < aci.numAvailable; i++) { - // Don't use this coin if depositing it is more expensive than - // the amount it would give the merchant. - if (Amounts.cmp(aci.feeDeposit, aci.value) > 0) { - tally.lastDepositFee = Amounts.parseOrThrow(aci.feeDeposit); - continue; - } - - if (Amounts.isZero(tally.amountPayRemaining)) { - // We have spent enough! - break; - } - - tally = tallyFees( - tally, - wireFeesPerExchange, - wireFeeAmortization, - aci.exchangeBaseUrl, - Amounts.parseOrThrow(aci.feeDeposit), - ); - - let coinSpend = Amounts.max( - Amounts.min(tally.amountPayRemaining, aci.value), - aci.feeDeposit, - ); - - tally.amountPayRemaining = Amounts.sub( - tally.amountPayRemaining, - coinSpend, - ).amount; - contributions.push(coinSpend); - } - - if (contributions.length) { - 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)) { - return selectedDenom; - } - } - return undefined; -} - -export function selectForced( - req: SelectPayCoinRequestNg, - candidateDenoms: AvailableDenom[], -): SelResult | undefined { - const selectedDenom: SelResult = {}; - - const forcedSelection = req.forcedSelection; - checkLogicInvariant(!!forcedSelection); - - for (const forcedCoin of forcedSelection.coins) { - let found = false; - for (const aci of candidateDenoms) { - if (aci.numAvailable <= 0) { - continue; - } - if (Amounts.cmp(aci.value, forcedCoin.value) === 0) { - aci.numAvailable--; - const avKey = makeAvailabilityKey( - aci.exchangeBaseUrl, - aci.denomPubHash, - aci.maxAge, - ); - let sd = selectedDenom[avKey]; - if (!sd) { - sd = { - contributions: [], - denomPubHash: aci.denomPubHash, - exchangeBaseUrl: aci.exchangeBaseUrl, - maxAge: aci.maxAge, - }; - } - sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value)); - selectedDenom[avKey] = sd; - found = true; - break; - } - } - if (!found) { - throw Error("can't find coin for forced coin selection"); - } - } - - return selectedDenom; -} - -export type SelectPayCoinsResult = - | { - type: "failure"; - insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails; - } - | { type: "success"; coinSel: PayCoinSelection }; - -/** - * Given a list of candidate coins, 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( - ws: InternalWalletState, - req: SelectPayCoinRequestNg, -): Promise { - const { - contractTermsAmount, - depositFeeLimit, - wireFeeLimit, - wireFeeAmortization, - } = req; - - const [candidateDenoms, wireFeesPerExchange] = await selectCandidates( - ws, - req, - ); - - // logger.trace(`candidate denoms: ${j2s(candidateDenoms)}`); - - 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), - }; - - const prevPayCoins = req.prevPayCoins ?? []; - - // 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); - } - - 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) { - const details = await getMerchantPaymentBalanceDetails(ws, { - 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); - } - 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 finalSel = selectedDenom; - - logger.trace(`coin selection request ${j2s(req)}`); - logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`); - - await ws.db - .mktx((x) => [x.coins, x.denominations]) - .runReadOnly(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.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})`, - ); - } - 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), - }, - }; -} - export async function checkPaymentByProposalId( ws: InternalWalletState, proposalId: string, @@ -1704,9 +1258,7 @@ export async function confirmPay( const contractData = d.contractData; - let selectCoinsResult: SelectPayCoinsResult | undefined = undefined; - - selectCoinsResult = await selectPayCoinsNew(ws, { + const selectCoinsResult = await selectPayCoinsNew(ws, { auditors: contractData.allowedAuditors, exchanges: contractData.allowedExchanges, wireMethod: contractData.wireMethod, -- cgit v1.2.3