wallet-core: insufficient balance details for p2p payments

This commit is contained in:
Florian Dold 2023-01-06 13:55:08 +01:00
parent c2c35925bb
commit 417c07f3f4
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
6 changed files with 170 additions and 26 deletions

View File

@ -67,7 +67,6 @@ export async function runPeerToPeerPushTest(t: GlobalTestState) {
); );
console.log(resp); console.log(resp);
} }
const resp = await wallet1.client.call( const resp = await wallet1.client.call(
WalletApiOperation.InitiatePeerPushPayment, WalletApiOperation.InitiatePeerPushPayment,
@ -114,6 +113,21 @@ export async function runPeerToPeerPushTest(t: GlobalTestState) {
console.log(`txn1: ${j2s(txn1)}`); console.log(`txn1: ${j2s(txn1)}`);
console.log(`txn2: ${j2s(txn2)}`); console.log(`txn2: ${j2s(txn2)}`);
const ex1 = await t.assertThrowsTalerErrorAsync(async () => {
await wallet1.client.call(
WalletApiOperation.InitiatePeerPushPayment,
{
partialContractTerms: {
summary: "(this will fail)",
amount: "TESTKUDOS:15",
purse_expiration
},
},
);
});
console.log("got expected exception detail", j2s(ex1.errorDetail));
} }
runPeerToPeerPushTest.suites = ["wallet"]; runPeerToPeerPushTest.suites = ["wallet"];

View File

@ -3248,6 +3248,14 @@ export enum TalerErrorCode {
WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE = 7027, WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE = 7027,
/**
* The wallet does not have sufficient balance to pay for an invoice.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
WALLET_PEER_PULL_PAYMENT_INSUFFICIENT_BALANCE = 7028,
/** /**
* We encountered a timeout with our payment backend. * We encountered a timeout with our payment backend.
* Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504). * Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504).

View File

@ -2164,20 +2164,11 @@ export interface PayPeerInsufficientBalanceDetails {
*/ */
balanceMaterial: AmountString; balanceMaterial: AmountString;
/** perExchange: {
* Acceptable balance based on restrictions on which [url: string]: {
* exchange can be used. balanceAvailable: AmountString;
*/ balanceMaterial: AmountString;
balanceExchangeAcceptable: AmountString
/**
* If the payment would succeed without fees
* (i.e. balanceExchangeAcceptable >= amountRequested),
* this field contains an estimate of the amount that would additionally
* be required to cover the fees.
*
* It is not possible to give an exact value here, since it depends
* on the coin selection for the amount that would be additionally withdrawn.
*/
feeGapEstimate: AmountString; feeGapEstimate: AmountString;
};
};
} }

View File

@ -25,6 +25,7 @@
*/ */
import { import {
PayMerchantInsufficientBalanceDetails, PayMerchantInsufficientBalanceDetails,
PayPeerInsufficientBalanceDetails,
TalerErrorCode, TalerErrorCode,
TalerErrorDetail, TalerErrorDetail,
TransactionType, TransactionType,
@ -87,6 +88,9 @@ export interface DetailsMap {
[TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE]: { [TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE]: {
insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails; insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails;
}; };
[TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE]: {
insufficientBalanceDetails: PayPeerInsufficientBalanceDetails;
};
} }
type ErrBody<Y> = Y extends keyof DetailsMap ? DetailsMap[Y] : never; type ErrBody<Y> = Y extends keyof DetailsMap ? DetailsMap[Y] : never;

View File

@ -56,7 +56,12 @@ import {
canonicalizeBaseUrl, canonicalizeBaseUrl,
parsePaytoUri, parsePaytoUri,
} from "@gnu-taler/taler-util"; } 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 { GetReadOnlyAccess } from "../util/query.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { getExchangeDetails } from "./exchanges.js"; import { getExchangeDetails } from "./exchanges.js";
@ -362,7 +367,7 @@ export async function getMerchantPaymentBalanceDetails(
balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency), balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency),
}; };
const wbal = await ws.db await ws.db
.mktx((x) => [ .mktx((x) => [
x.coins, x.coins,
x.coinAvailability, x.coinAvailability,
@ -415,3 +420,67 @@ export async function getMerchantPaymentBalanceDetails(
return d; 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<PeerPaymentBalanceDetails> {
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,
};
}

View File

@ -62,6 +62,7 @@ import {
PreparePeerPushPaymentResponse, PreparePeerPushPaymentResponse,
RefreshReason, RefreshReason,
strcmp, strcmp,
TalerErrorCode,
TalerProtocolTimestamp, TalerProtocolTimestamp,
TransactionType, TransactionType,
UnblindedSignature, UnblindedSignature,
@ -77,11 +78,13 @@ import {
WithdrawalGroupStatus, WithdrawalGroupStatus,
WithdrawalRecordType, WithdrawalRecordType,
} from "../db.js"; } from "../db.js";
import { TalerError } from "../errors.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { makeTransactionId, spendCoins } from "../operations/common.js"; import { makeTransactionId, spendCoins } from "../operations/common.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { checkDbInvariant } from "../util/invariants.js"; import { checkDbInvariant } from "../util/invariants.js";
import { GetReadOnlyAccess } from "../util/query.js"; import { GetReadOnlyAccess } from "../util/query.js";
import { getPeerPaymentBalanceDetailsInTx } from "./balance.js";
import { updateExchangeFromUrl } from "./exchanges.js"; import { updateExchangeFromUrl } from "./exchanges.js";
import { internalCreateWithdrawalGroup } from "./withdraw.js"; import { internalCreateWithdrawalGroup } from "./withdraw.js";
@ -135,6 +138,7 @@ export type SelectPeerCoinsResult =
| { type: "success"; result: PeerCoinSelection } | { type: "success"; result: PeerCoinSelection }
| { | {
type: "failure"; type: "failure";
insufficientBalanceDetails: PayPeerInsufficientBalanceDetails;
}; };
export async function selectPeerCoins( export async function selectPeerCoins(
@ -143,12 +147,16 @@ export async function selectPeerCoins(
exchanges: typeof WalletStoresV1.exchanges; exchanges: typeof WalletStoresV1.exchanges;
denominations: typeof WalletStoresV1.denominations; denominations: typeof WalletStoresV1.denominations;
coins: typeof WalletStoresV1.coins; coins: typeof WalletStoresV1.coins;
coinAvailability: typeof WalletStoresV1.coinAvailability;
refreshGroups: typeof WalletStoresV1.refreshGroups;
}>, }>,
instructedAmount: AmountJson, instructedAmount: AmountJson,
): Promise<SelectPeerCoinsResult> { ): Promise<SelectPeerCoinsResult> {
const exchanges = await tx.exchanges.iter().toArray(); const exchanges = await tx.exchanges.iter().toArray();
const exchangeFeeGap: { [url: string]: AmountJson } = {};
const currency = Amounts.currencyOf(instructedAmount);
for (const exch of exchanges) { for (const exch of exchanges) {
if (exch.detailsPointer?.currency !== instructedAmount.currency) { if (exch.detailsPointer?.currency !== currency) {
continue; continue;
} }
const coins = ( const coins = (
@ -184,8 +192,8 @@ export async function selectPeerCoins(
-Amounts.cmp(o1.value, o2.value) || -Amounts.cmp(o1.value, o2.value) ||
strcmp(o1.denomPubHash, o2.denomPubHash), strcmp(o1.denomPubHash, o2.denomPubHash),
); );
let amountAcc = Amounts.zeroOfCurrency(instructedAmount.currency); let amountAcc = Amounts.zeroOfCurrency(currency);
let depositFeesAcc = Amounts.zeroOfCurrency(instructedAmount.currency); let depositFeesAcc = Amounts.zeroOfCurrency(currency);
const resCoins: { const resCoins: {
coinPub: string; coinPub: string;
coinPriv: string; coinPriv: string;
@ -194,6 +202,7 @@ export async function selectPeerCoins(
denomSig: UnblindedSignature; denomSig: UnblindedSignature;
ageCommitmentProof: AgeCommitmentProof | undefined; ageCommitmentProof: AgeCommitmentProof | undefined;
}[] = []; }[] = [];
let lastDepositFee = Amounts.zeroOfCurrency(currency);
for (const coin of coinInfos) { for (const coin of coinInfos) {
if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
break; break;
@ -216,6 +225,7 @@ export async function selectPeerCoins(
denomSig: coin.denomSig, denomSig: coin.denomSig,
ageCommitmentProof: coin.ageCommitmentProof, ageCommitmentProof: coin.ageCommitmentProof,
}); });
lastDepositFee = coin.feeDeposit;
} }
if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
const res: PeerCoinSelection = { const res: PeerCoinSelection = {
@ -225,9 +235,48 @@ export async function selectPeerCoins(
}; };
return { type: "success", result: res }; return { type: "success", result: res };
} }
const diff = Amounts.sub(instructedAmount, amountAcc).amount;
exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, diff).amount;
continue; 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( export async function preparePeerPushPayment(
@ -316,8 +365,12 @@ export async function initiatePeerToPeerPush(
logger.info(`selected p2p coins (push): ${j2s(coinSelRes)}`); logger.info(`selected p2p coins (push): ${j2s(coinSelRes)}`);
if (coinSelRes.type !== "success") { if (coinSelRes.type !== "success") {
// FIXME: use error code with details here throw TalerError.fromDetail(
throw Error("insufficient balance"); TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
},
);
} }
const purseSigResp = await ws.cryptoApi.signPurseCreation({ const purseSigResp = await ws.cryptoApi.signPurseCreation({
@ -675,7 +728,12 @@ export async function acceptPeerPullPayment(
logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`); logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
if (coinSelRes.type !== "success") { 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; const pursePub = peerPullInc.pursePub;