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);
}
const resp = await wallet1.client.call(
WalletApiOperation.InitiatePeerPushPayment,
@ -114,6 +113,21 @@ export async function runPeerToPeerPushTest(t: GlobalTestState) {
console.log(`txn1: ${j2s(txn1)}`);
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"];

View File

@ -3248,6 +3248,14 @@ export enum TalerErrorCode {
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.
* Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504).

View File

@ -2164,20 +2164,11 @@ export interface PayPeerInsufficientBalanceDetails {
*/
balanceMaterial: AmountString;
/**
* Acceptable balance based on restrictions on which
* exchange can be used.
*/
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;
perExchange: {
[url: string]: {
balanceAvailable: AmountString;
balanceMaterial: AmountString;
feeGapEstimate: AmountString;
};
};
}

View File

@ -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> = Y extends keyof DetailsMap ? DetailsMap[Y] : never;

View File

@ -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<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,
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<SelectPeerCoinsResult> {
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;