diff --git a/packages/taler-wallet-core/src/operations/pay-peer-common.ts b/packages/taler-wallet-core/src/operations/pay-peer-common.ts index 717b25f49..72e48cb03 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-common.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-common.ts @@ -52,7 +52,11 @@ import { InternalWalletState } from "../internal-wallet-state.js"; import { checkDbInvariant } from "../util/invariants.js"; import { getPeerPaymentBalanceDetailsInTx } from "./balance.js"; import { getTotalRefreshCost } from "./refresh.js"; -import { OperationAttemptLongpollResult, OperationAttemptResult, OperationAttemptResultType } from "../util/retries.js"; +import { + OperationAttemptLongpollResult, + OperationAttemptResult, + OperationAttemptResultType, +} from "../util/retries.js"; const logger = new Logger("operations/peer-to-peer.ts"); @@ -113,6 +117,12 @@ export type SelectPeerCoinsResult = insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; }; +/** + * Get information about the coin selected for signatures + * @param ws + * @param csel + * @returns + */ export async function queryCoinInfosForSelection( ws: InternalWalletState, csel: PeerPushPaymentCoinSelection, diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts index f4066bf51..8fd09ea2b 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -30,29 +30,29 @@ import { AgeRestriction, AmountJson, Amounts, + AmountString, CoinStatus, DenominationInfo, DenominationPubKey, DenomSelectionState, ForcedCoinSel, ForcedDenomSel, + GetPlanForOperationRequest, + GetPlanForOperationResponse, j2s, Logger, parsePaytoUri, PayCoinSelection, PayMerchantInsufficientBalanceDetails, strcmp, + TransactionType, } from "@gnu-taler/taler-util"; import { AllowedAuditorInfo, AllowedExchangeInfo, DenominationRecord, } from "../db.js"; -import { - getExchangeDetails, - isWithdrawableDenom, - WalletConfig, -} from "../index.js"; +import { getExchangeDetails, isWithdrawableDenom } from "../index.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { getMerchantPaymentBalanceDetails } from "../operations/balance.js"; import { checkDbInvariant, checkLogicInvariant } from "./invariants.js"; @@ -150,7 +150,7 @@ export interface CoinSelectionTally { /** * Account for the fees of spending a coin. */ -export function tallyFees( +function tallyFees( tally: Readonly, wireFeesPerExchange: Record, wireFeeAmortization: number, @@ -542,7 +542,7 @@ export type AvailableDenom = DenominationInfo & { numAvailable: number; }; -export async function selectCandidates( +async function selectCandidates( ws: InternalWalletState, req: SelectPayCoinRequestNg, ): Promise<[AvailableDenom[], Record]> { @@ -789,3 +789,501 @@ export function selectForcedWithdrawalDenominations( totalWithdrawCost: Amounts.stringify(totalWithdrawCost), }; } + +/** + * simulate a coin selection and return the amount + * that will effectively change the wallet balance and + * the raw amount of the operation + * + * @param ws + * @param br + * @returns + */ +export async function getPlanForOperation( + ws: InternalWalletState, + req: GetPlanForOperationRequest, +): Promise { + const amount = Amounts.parseOrThrow(req.instructedAmount); + + switch (req.type) { + case TransactionType.Withdrawal: { + const availableCoins = await getAvailableCoins( + ws, + "credit", + amount.currency, + false, + false, + undefined, + undefined, + undefined, + ); + const usableCoins = selectCoinForOperation( + "credit", + amount, + req.mode === "effective" ? "net" : "gross", + availableCoins.denoms, + ); + + return getAmountsWithFee( + "credit", + usableCoins.totalValue, + usableCoins.totalContribution, + usableCoins, + ); + } + case TransactionType.Deposit: { + const payto = parsePaytoUri(req.account)!; + const availableCoins = await getAvailableCoins( + ws, + "debit", + amount.currency, + true, + false, + undefined, + [payto.targetType], + undefined, + ); + //FIXME: just doing for 1 exchange now + //assuming that the wallet has one exchange and all the coins available + //are from that exchange + + const wireFee = Object.entries(availableCoins.wfPerExchange)[0][1][ + payto.targetType + ]; + + let usableCoins; + + if (req.mode === "effective") { + usableCoins = selectCoinForOperation( + "debit", + amount, + "gross", + availableCoins.denoms, + ); + + usableCoins.totalContribution = Amounts.stringify( + Amounts.sub(usableCoins.totalContribution, wireFee).amount, + ); + } else { + const adjustedAmount = Amounts.add(amount, wireFee).amount; + + usableCoins = selectCoinForOperation( + "debit", + adjustedAmount, + // amount, + "net", + availableCoins.denoms, + ); + + usableCoins.totalContribution = Amounts.stringify( + Amounts.sub(usableCoins.totalContribution, wireFee).amount, + ); + } + + return getAmountsWithFee( + "debit", + usableCoins.totalValue, + usableCoins.totalContribution, + usableCoins, + ); + } + default: { + throw Error("operation not supported"); + } + } +} + +function getAmountsWithFee( + op: "debit" | "credit", + value: AmountString, + contribution: AmountString, + details: any, +): GetPlanForOperationResponse { + return { + rawAmount: op === "credit" ? value : contribution, + effectiveAmount: op === "credit" ? contribution : value, + details, + }; +} + +/** + * + * @param op defined which fee are we taking into consideration: deposits or withdraw + * @param limit the total amount limit of the operation + * @param mode if the total amount is includes the fees or just the contribution + * @param denoms list of available denomination for the operation + * @returns + */ +function selectCoinForOperation( + op: "debit" | "credit", + limit: AmountJson, + mode: "net" | "gross", + denoms: AvailableDenom[], +): SelectedCoins { + const result: SelectedCoins = { + totalValue: Amounts.stringify(Amounts.zeroOfCurrency(limit.currency)), + totalWithdrawalFee: Amounts.stringify( + Amounts.zeroOfCurrency(limit.currency), + ), + totalDepositFee: Amounts.stringify(Amounts.zeroOfCurrency(limit.currency)), + totalContribution: Amounts.stringify( + Amounts.zeroOfCurrency(limit.currency), + ), + coins: [], + }; + if (!denoms.length) return result; + /** + * We can make this faster. We should prevent sorting and + * keep the information ready for multiple calls since this + * function is expected to work on embedded devices and + * create a response on key press + */ + + //rank coins + denoms.sort( + op === "credit" + ? denomsByDescendingWithdrawContribution + : denomsByDescendingDepositContribution, + ); + + //take coins in order until amount + let selectedCoinsAreEnough = false; + let denomIdx = 0; + iterateDenoms: while (denomIdx < denoms.length) { + const cur = denoms[denomIdx]; + // for (const cur of denoms) { + let total = op === "credit" ? Number.MAX_SAFE_INTEGER : cur.numAvailable; + const opFee = op === "credit" ? cur.feeWithdraw : cur.feeDeposit; + const contribution = Amounts.sub(cur.value, opFee).amount; + + if (Amounts.isZero(contribution)) { + // 0 contribution denoms should be the last + break iterateDenoms; + } + iterateCoins: while (total > 0) { + const nextValue = Amounts.add(result.totalValue, cur.value).amount; + + const nextContribution = Amounts.add( + result.totalContribution, + contribution, + ).amount; + + const progress = mode === "gross" ? nextValue : nextContribution; + + if (Amounts.cmp(progress, limit) === 1) { + //the current coin is more than we need, try next denom + break iterateCoins; + } + + result.totalValue = Amounts.stringify(nextValue); + result.totalContribution = Amounts.stringify(nextContribution); + + result.totalDepositFee = Amounts.stringify( + Amounts.add(result.totalDepositFee, cur.feeDeposit).amount, + ); + + result.totalWithdrawalFee = Amounts.stringify( + Amounts.add(result.totalWithdrawalFee, cur.feeWithdraw).amount, + ); + + result.coins.push(cur.denomPubHash); + + if (Amounts.cmp(progress, limit) === 0) { + selectedCoinsAreEnough = true; + // we have just enough coins, complete + break iterateDenoms; + } + + //go next coin + total--; + } + //go next denom + denomIdx++; + } + + if (selectedCoinsAreEnough) { + // we made it + return result; + } + if (op === "credit") { + //doing withdraw there is no way to cover the gap + return result; + } + //tried all the coins but there is a gap + //doing deposit we can try refreshing coins + + const total = mode === "gross" ? result.totalValue : result.totalContribution; + const gap = Amounts.sub(limit, total).amount; + + //about recursive calls + //the only way to get here is by doing a deposit (that will do a refresh) + //and now we are calculating fee for credit (which does not need to calculate refresh) + + let refreshIdx = 0; + let choice: RefreshChoice | undefined = undefined; + refreshIteration: while (refreshIdx < denoms.length) { + const d = denoms[refreshIdx]; + const denomContribution = + mode === "gross" + ? Amounts.sub(d.value, d.feeRefresh).amount + : Amounts.sub(d.value, d.feeDeposit, d.feeRefresh).amount; + + const changeAfterDeposit = Amounts.sub(denomContribution, gap).amount; + if (Amounts.isZero(changeAfterDeposit)) { + //the rest of the coins are very small + break refreshIteration; + } + + const changeCost = selectCoinForOperation( + "credit", + changeAfterDeposit, + mode, + denoms, + ); + const totalFee = Amounts.add( + d.feeDeposit, + d.feeRefresh, + changeCost.totalWithdrawalFee, + ).amount; + + if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) { + //found cheaper change + choice = { + gap: Amounts.stringify(gap), + totalFee: Amounts.stringify(totalFee), + selected: d.denomPubHash, + totalValue: d.value, + totalRefreshFee: Amounts.stringify(d.feeRefresh), + totalDepositFee: d.feeDeposit, + totalChangeValue: Amounts.stringify(changeCost.totalValue), + totalChangeContribution: Amounts.stringify( + changeCost.totalContribution, + ), + totalChangeWithdrawalFee: Amounts.stringify( + changeCost.totalWithdrawalFee, + ), + change: changeCost.coins, + }; + } + refreshIdx++; + } + if (choice) { + if (mode === "gross") { + result.totalValue = Amounts.stringify( + Amounts.add(result.totalValue, gap).amount, + ); + result.totalContribution = Amounts.stringify( + Amounts.add(result.totalContribution, gap).amount, + ); + result.totalContribution = Amounts.stringify( + Amounts.sub(result.totalContribution, choice.totalFee).amount, + ); + } else { + result.totalContribution = Amounts.stringify( + Amounts.add(result.totalContribution, gap).amount, + ); + result.totalValue = Amounts.stringify( + Amounts.add(result.totalValue, gap, choice.totalFee).amount, + ); + } + } + + // console.log("gap", Amounts.stringify(limit), Amounts.stringify(gap), choice); + result.refresh = choice; + return result; +} + +function denomsByDescendingDepositContribution( + d1: AvailableDenom, + d2: AvailableDenom, +) { + const contrib1 = Amounts.sub(d1.value, d1.feeDeposit).amount; + const contrib2 = Amounts.sub(d2.value, d2.feeDeposit).amount; + return ( + Amounts.cmp(contrib2, contrib1) || strcmp(d1.denomPubHash, d2.denomPubHash) + ); +} +function denomsByDescendingWithdrawContribution( + d1: AvailableDenom, + d2: AvailableDenom, +) { + const contrib1 = Amounts.sub(d1.value, d1.feeWithdraw).amount; + const contrib2 = Amounts.sub(d2.value, d2.feeWithdraw).amount; + return ( + Amounts.cmp(contrib2, contrib1) || strcmp(d1.denomPubHash, d2.denomPubHash) + ); +} + +interface RefreshChoice { + gap: AmountString; + totalFee: AmountString; + selected: string; + + totalValue: AmountString; + totalDepositFee: AmountString; + totalRefreshFee: AmountString; + totalChangeValue: AmountString; + totalChangeContribution: AmountString; + totalChangeWithdrawalFee: AmountString; + change: string[]; +} + +interface SelectedCoins { + totalValue: AmountString; + totalContribution: AmountString; + totalWithdrawalFee: AmountString; + totalDepositFee: AmountString; + coins: string[]; + refresh?: RefreshChoice; +} + +/** + * Get all the denoms that can be used for a operation that is limited + * by the following restrictions. + * This function is costly (by the database access) but with high chances + * of being cached + */ +async function getAvailableCoins( + ws: InternalWalletState, + op: "credit" | "debit", + currency: string, + shouldCalculateWireFee: boolean, + shouldCalculatePurseFee: boolean, + exchangeFilter: string[] | undefined, + wireMethodFilter: string[] | undefined, + ageRestrictedFilter: number | undefined, +) { + return await ws.db + .mktx((x) => [ + x.exchanges, + x.exchangeDetails, + x.denominations, + x.coinAvailability, + ]) + .runReadOnly(async (tx) => { + const denoms: AvailableDenom[] = []; + const wfPerExchange: Record> = {}; + const pfPerExchange: Record = {}; + + const databaseExchanges = await tx.exchanges.iter().toArray(); + const exchanges = + exchangeFilter === undefined + ? databaseExchanges.map((e) => e.baseUrl) + : exchangeFilter; + + for (const exchangeBaseUrl of exchanges) { + const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl); + // 1.- exchange has same currency + if (exchangeDetails?.currency !== currency) { + continue; + } + + const wireMethodFee: Record = {}; + // 2.- exchange supports wire method + if (shouldCalculateWireFee) { + for (const acc of exchangeDetails.wireInfo.accounts) { + const pp = parsePaytoUri(acc.payto_uri); + checkLogicInvariant(!!pp); + // also check that wire method is supported now + if (wireMethodFilter !== undefined) { + if (wireMethodFilter.indexOf(pp.targetType) === -1) { + continue; + } + } + const wireFeeStr = exchangeDetails.wireInfo.feesForType[ + pp.targetType + ]?.find((x) => { + return AbsoluteTime.isBetween( + AbsoluteTime.now(), + AbsoluteTime.fromProtocolTimestamp(x.startStamp), + AbsoluteTime.fromProtocolTimestamp(x.endStamp), + ); + })?.wireFee; + + if (wireFeeStr) { + wireMethodFee[pp.targetType] = Amounts.parseOrThrow(wireFeeStr); + } + break; + } + if (Object.keys(wireMethodFee).length === 0) { + throw Error( + `exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`, + ); + } + } + wfPerExchange[exchangeBaseUrl] = wireMethodFee; + + // 3.- exchange supports wire method + if (shouldCalculatePurseFee) { + const purseFeeFound = exchangeDetails.globalFees.find((x) => { + return AbsoluteTime.isBetween( + AbsoluteTime.now(), + AbsoluteTime.fromProtocolTimestamp(x.startDate), + AbsoluteTime.fromProtocolTimestamp(x.endDate), + ); + })?.purseFee; + if (!purseFeeFound) { + throw Error( + `exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`, + ); + } + const purseFee = Amounts.parseOrThrow(purseFeeFound); + pfPerExchange[exchangeBaseUrl] = purseFee; + } + + //4.- filter coins restricted by age + if (op === "credit") { + const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + for (const denom of ds) { + denoms.push({ + ...DenominationRecord.toDenomInfo(denom), + numAvailable: Number.MAX_SAFE_INTEGER, + maxAge: AgeRestriction.AGE_UNRESTRICTED, + }); + } + } else { + const ageLower = !ageRestrictedFilter ? 0 : ageRestrictedFilter; + const ageUpper = AgeRestriction.AGE_UNRESTRICTED; + + 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, + }); + } + } + } + + return { + denoms, + wfPerExchange, + pfPerExchange, + }; + }); +} diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index 21a228b64..3b0d11039 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -58,6 +58,8 @@ import { GetContractTermsDetailsRequest, GetExchangeTosRequest, GetExchangeTosResult, + GetPlanForOperationRequest, + GetPlanForOperationResponse, GetWithdrawalDetailsForAmountRequest, GetWithdrawalDetailsForUriRequest, InitRequest, @@ -143,6 +145,7 @@ export enum WalletApiOperation { AcceptManualWithdrawal = "acceptManualWithdrawal", GetBalances = "getBalances", GetBalanceDetail = "getBalanceDetail", + GetPlanForOperation = "getPlanForOperation", GetUserAttentionRequests = "getUserAttentionRequests", GetUserAttentionUnreadCount = "getUserAttentionUnreadCount", MarkAttentionRequestAsRead = "markAttentionRequestAsRead", @@ -275,6 +278,12 @@ export type GetBalancesDetailOp = { response: MerchantPaymentBalanceDetails; }; +export type GetPlanForOperationOp = { + op: WalletApiOperation.GetPlanForOperation; + request: GetPlanForOperationRequest; + response: GetPlanForOperationResponse; +}; + // group: Managing Transactions /** @@ -940,6 +949,7 @@ export type WalletOperations = { [WalletApiOperation.SuspendTransaction]: SuspendTransactionOp; [WalletApiOperation.ResumeTransaction]: ResumeTransactionOp; [WalletApiOperation.GetBalances]: GetBalancesOp; + [WalletApiOperation.GetPlanForOperation]: GetPlanForOperationOp; [WalletApiOperation.GetBalanceDetail]: GetBalancesDetailOp; [WalletApiOperation.GetTransactions]: GetTransactionsOp; [WalletApiOperation.TestingGetSampleTransactions]: TestingGetSampleTransactionsOp; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 5277916de..a04464630 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -75,6 +75,7 @@ import { codecForGetBalanceDetailRequest, codecForGetContractTermsDetails, codecForGetExchangeTosRequest, + codecForGetPlanForOperationRequest, codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, codecForImportDbRequest, @@ -218,9 +219,7 @@ import { processPeerPushDebit, } from "./operations/pay-peer-push-debit.js"; import { getPendingOperations } from "./operations/pending.js"; -import { - createRecoupGroup, processRecoupGroup, -} from "./operations/recoup.js"; +import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js"; import { autoRefresh, createRefreshGroup, @@ -283,6 +282,7 @@ import { WalletCoreApiClient, WalletCoreResponseType, } from "./wallet-api-types.js"; +import { getPlanForOperation } from "./util/coinSelection.js"; const logger = new Logger("wallet.ts"); @@ -331,9 +331,7 @@ async function callOperationHandler( /** * Process pending operations. */ -export async function runPending( - ws: InternalWalletState, -): Promise { +export async function runPending(ws: InternalWalletState): Promise { const pendingOpsResponse = await getPendingOperations(ws); for (const p of pendingOpsResponse.pendingOperations) { if (!AbsoluteTime.isExpired(p.timestampDue)) { @@ -1336,6 +1334,10 @@ async function dispatchRequestInternal( await loadBackupRecovery(ws, req); return {}; } + case WalletApiOperation.GetPlanForOperation: { + const req = codecForGetPlanForOperationRequest().decode(payload); + return await getPlanForOperation(ws, req); + } case WalletApiOperation.GetBackupInfo: { const resp = await getBackupInfo(ws); return resp;