From 417c07f3f4866918e1aaa6d42b7d5ec0ca59dd51 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Fri, 6 Jan 2023 13:55:08 +0100 Subject: wallet-core: insufficient balance details for p2p payments --- packages/taler-wallet-core/src/errors.ts | 4 ++ .../taler-wallet-core/src/operations/balance.ts | 73 +++++++++++++++++++++- .../taler-wallet-core/src/operations/pay-peer.ts | 72 ++++++++++++++++++--- 3 files changed, 140 insertions(+), 9 deletions(-) (limited to 'packages/taler-wallet-core') diff --git a/packages/taler-wallet-core/src/errors.ts b/packages/taler-wallet-core/src/errors.ts index 68cd39b54..37a31a8aa 100644 --- a/packages/taler-wallet-core/src/errors.ts +++ b/packages/taler-wallet-core/src/errors.ts @@ -25,6 +25,7 @@ */ import { PayMerchantInsufficientBalanceDetails, + PayPeerInsufficientBalanceDetails, TalerErrorCode, TalerErrorDetail, TransactionType, @@ -87,6 +88,9 @@ export interface DetailsMap { [TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE]: { insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails; }; + [TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE]: { + insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; + }; } type ErrBody = Y extends keyof DetailsMap ? DetailsMap[Y] : never; diff --git a/packages/taler-wallet-core/src/operations/balance.ts b/packages/taler-wallet-core/src/operations/balance.ts index f697679af..383aa5dc1 100644 --- a/packages/taler-wallet-core/src/operations/balance.ts +++ b/packages/taler-wallet-core/src/operations/balance.ts @@ -56,7 +56,12 @@ import { canonicalizeBaseUrl, parsePaytoUri, } from "@gnu-taler/taler-util"; -import { AllowedAuditorInfo, AllowedExchangeInfo, RefreshGroupRecord, WalletStoresV1 } from "../db.js"; +import { + AllowedAuditorInfo, + AllowedExchangeInfo, + RefreshGroupRecord, + WalletStoresV1, +} from "../db.js"; import { GetReadOnlyAccess } from "../util/query.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { getExchangeDetails } from "./exchanges.js"; @@ -362,7 +367,7 @@ export async function getMerchantPaymentBalanceDetails( balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency), }; - const wbal = await ws.db + await ws.db .mktx((x) => [ x.coins, x.coinAvailability, @@ -415,3 +420,67 @@ export async function getMerchantPaymentBalanceDetails( return d; } + +export interface PeerPaymentRestrictionsForBalance { + currency: string; + restrictExchangeTo?: string; +} + +export interface PeerPaymentBalanceDetails { + /** + * Balance of type "available" (see balance.ts for definition). + */ + balanceAvailable: AmountJson; + + /** + * Balance of type "material" (see balance.ts for definition). + */ + balanceMaterial: AmountJson; +} + +export async function getPeerPaymentBalanceDetailsInTx( + ws: InternalWalletState, + tx: GetReadOnlyAccess<{ + coinAvailability: typeof WalletStoresV1.coinAvailability; + refreshGroups: typeof WalletStoresV1.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 = { + currency: ca.currency, + fraction: ca.amountFrac, + value: ca.amountVal, + }; + 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) => { + balanceAvailable = Amounts.add( + balanceAvailable, + computeRefreshGroupAvailableAmount(r), + ).amount; + }); + + return { + balanceAvailable, + balanceMaterial, + }; +} diff --git a/packages/taler-wallet-core/src/operations/pay-peer.ts b/packages/taler-wallet-core/src/operations/pay-peer.ts index 3d03c46db..3ee1795b0 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer.ts @@ -62,6 +62,7 @@ import { PreparePeerPushPaymentResponse, RefreshReason, strcmp, + TalerErrorCode, TalerProtocolTimestamp, TransactionType, UnblindedSignature, @@ -77,11 +78,13 @@ import { WithdrawalGroupStatus, WithdrawalRecordType, } from "../db.js"; +import { TalerError } from "../errors.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { makeTransactionId, spendCoins } from "../operations/common.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { checkDbInvariant } from "../util/invariants.js"; import { GetReadOnlyAccess } from "../util/query.js"; +import { getPeerPaymentBalanceDetailsInTx } from "./balance.js"; import { updateExchangeFromUrl } from "./exchanges.js"; import { internalCreateWithdrawalGroup } from "./withdraw.js"; @@ -135,6 +138,7 @@ export type SelectPeerCoinsResult = | { type: "success"; result: PeerCoinSelection } | { type: "failure"; + insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; }; export async function selectPeerCoins( @@ -143,12 +147,16 @@ export async function selectPeerCoins( exchanges: typeof WalletStoresV1.exchanges; denominations: typeof WalletStoresV1.denominations; coins: typeof WalletStoresV1.coins; + coinAvailability: typeof WalletStoresV1.coinAvailability; + refreshGroups: typeof WalletStoresV1.refreshGroups; }>, instructedAmount: AmountJson, ): 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 !== instructedAmount.currency) { + if (exch.detailsPointer?.currency !== currency) { continue; } const coins = ( @@ -184,8 +192,8 @@ export async function selectPeerCoins( -Amounts.cmp(o1.value, o2.value) || strcmp(o1.denomPubHash, o2.denomPubHash), ); - let amountAcc = Amounts.zeroOfCurrency(instructedAmount.currency); - let depositFeesAcc = Amounts.zeroOfCurrency(instructedAmount.currency); + let amountAcc = Amounts.zeroOfCurrency(currency); + let depositFeesAcc = Amounts.zeroOfCurrency(currency); const resCoins: { coinPub: string; coinPriv: string; @@ -194,6 +202,7 @@ export async function selectPeerCoins( denomSig: UnblindedSignature; ageCommitmentProof: AgeCommitmentProof | undefined; }[] = []; + let lastDepositFee = Amounts.zeroOfCurrency(currency); for (const coin of coinInfos) { if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { break; @@ -216,6 +225,7 @@ export async function selectPeerCoins( denomSig: coin.denomSig, ageCommitmentProof: coin.ageCommitmentProof, }); + lastDepositFee = coin.feeDeposit; } if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { const res: PeerCoinSelection = { @@ -225,9 +235,48 @@ export async function selectPeerCoins( }; return { type: "success", result: res }; } + const diff = Amounts.sub(instructedAmount, amountAcc).amount; + exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, diff).amount; + continue; } - return { type: "failure" }; + // We were unable to select coins. + // Now we need to produce error details. + + const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, { + currency, + }); + + const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {}; + + for (const exch of exchanges) { + if (exch.detailsPointer?.currency !== currency) { + continue; + } + const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, 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.zeroOfAmount(currency); + } + perExchange[exch.baseUrl] = { + balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable), + balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial), + feeGapEstimate: Amounts.stringify(gap), + }; + } + + const errDetails: PayPeerInsufficientBalanceDetails = { + amountRequested: Amounts.stringify(instructedAmount), + balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable), + balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial), + perExchange, + }; + + return { type: "failure", insufficientBalanceDetails: errDetails }; } export async function preparePeerPushPayment( @@ -316,8 +365,12 @@ export async function initiatePeerToPeerPush( logger.info(`selected p2p coins (push): ${j2s(coinSelRes)}`); if (coinSelRes.type !== "success") { - // FIXME: use error code with details here - throw Error("insufficient balance"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); } const purseSigResp = await ws.cryptoApi.signPurseCreation({ @@ -675,7 +728,12 @@ export async function acceptPeerPullPayment( logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`); if (coinSelRes.type !== "success") { - throw Error("insufficient balance"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); } const pursePub = peerPullInc.pursePub; -- cgit v1.2.3