wallet-core: implement insufficient balance details

For now, only for merchant payments
This commit is contained in:
Florian Dold 2023-01-05 18:45:49 +01:00
parent 44aaa7a636
commit 92f1b5928c
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
12 changed files with 392 additions and 80 deletions

View File

@ -419,6 +419,7 @@ export const codecForPreparePayResultInsufficientBalance =
"status", "status",
codecForConstString(PreparePayResultType.InsufficientBalance), codecForConstString(PreparePayResultType.InsufficientBalance),
) )
.property("balanceDetails", codecForPayMerchantInsufficientBalanceDetails())
.build("PreparePayResultInsufficientBalance"); .build("PreparePayResultInsufficientBalance");
export const codecForPreparePayResultAlreadyConfirmed = export const codecForPreparePayResultAlreadyConfirmed =
@ -483,6 +484,7 @@ export interface PreparePayResultInsufficientBalance {
amountRaw: string; amountRaw: string;
noncePriv: string; noncePriv: string;
talerUri: string; talerUri: string;
balanceDetails: PayMerchantInsufficientBalanceDetails;
} }
export interface PreparePayResultAlreadyConfirmed { export interface PreparePayResultAlreadyConfirmed {
@ -2090,32 +2092,32 @@ export interface PayMerchantInsufficientBalanceDetails {
/** /**
* Amount requested by the merchant. * Amount requested by the merchant.
*/ */
amountRequested: AmountJson; amountRequested: AmountString;
/** /**
* Balance of type "available" (see balance.ts for definition). * Balance of type "available" (see balance.ts for definition).
*/ */
balanceAvailable: AmountJson; balanceAvailable: AmountString;
/** /**
* Balance of type "material" (see balance.ts for definition). * Balance of type "material" (see balance.ts for definition).
*/ */
balanceMaterial: AmountJson; balanceMaterial: AmountString;
/** /**
* Balance of type "age-acceptable" (see balance.ts for definition). * Balance of type "age-acceptable" (see balance.ts for definition).
*/ */
balanceAgeAcceptable: AmountJson; balanceAgeAcceptable: AmountString;
/** /**
* Balance of type "merchant-acceptable" (see balance.ts for definition). * Balance of type "merchant-acceptable" (see balance.ts for definition).
*/ */
balanceMechantAcceptable: AmountJson; balanceMerchantAcceptable: AmountString;
/** /**
* Balance of type "merchant-depositable" (see balance.ts for definition). * Balance of type "merchant-depositable" (see balance.ts for definition).
*/ */
balanceMechantDepositable: AmountJson; balanceMerchantDepositable: AmountString;
/** /**
* If the payment would succeed without fees * If the payment would succeed without fees
@ -2126,5 +2128,17 @@ export interface PayMerchantInsufficientBalanceDetails {
* It is not possible to give an exact value here, since it depends * 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. * on the coin selection for the amount that would be additionally withdrawn.
*/ */
feeGapEstimate: AmountJson; feeGapEstimate: AmountString;
} }
const codecForPayMerchantInsufficientBalanceDetails =
(): Codec<PayMerchantInsufficientBalanceDetails> =>
buildCodecForObject<PayMerchantInsufficientBalanceDetails>()
.property("amountRequested", codecForAmountString())
.property("balanceAgeAcceptable", codecForAmountString())
.property("balanceAvailable", codecForAmountString())
.property("balanceMaterial", codecForAmountString())
.property("balanceMerchantAcceptable", codecForAmountString())
.property("balanceMerchantDepositable", codecForAmountString())
.property("feeGapEstimate", codecForAmountString())
.build("PayMerchantInsufficientBalanceDetails");

View File

@ -835,6 +835,14 @@ export enum RefreshOperationStatus {
FinishedWithError = 51 /* DORMANT_START + 1 */, FinishedWithError = 51 /* DORMANT_START + 1 */,
} }
/**
* Group of refresh operations. The refreshed coins do not
* have to belong to the same exchange, but must have the same
* currency.
*
* FIXME: Should include the currency as a top-level field,
* but we need to write a migration for that.
*/
export interface RefreshGroupRecord { export interface RefreshGroupRecord {
operationStatus: RefreshOperationStatus; operationStatus: RefreshOperationStatus;
@ -847,6 +855,13 @@ export interface RefreshGroupRecord {
*/ */
refreshGroupId: string; refreshGroupId: string;
/**
* Currency of this refresh group.
*
* FIXME: Write a migration to add this to earlier DB versions.
*/
currency: string;
/** /**
* Reason why this refresh group has been created. * Reason why this refresh group has been created.
*/ */

View File

@ -86,6 +86,7 @@ export interface RefreshOperations {
refreshGroups: typeof WalletStoresV1.refreshGroups; refreshGroups: typeof WalletStoresV1.refreshGroups;
coinAvailability: typeof WalletStoresV1.coinAvailability; coinAvailability: typeof WalletStoresV1.coinAvailability;
}>, }>,
currency: string,
oldCoinPubs: CoinRefreshRequest[], oldCoinPubs: CoinRefreshRequest[],
reason: RefreshReason, reason: RefreshReason,
): Promise<RefreshGroupId>; ): Promise<RefreshGroupId>;

View File

@ -778,6 +778,7 @@ export async function importBackup(
timestampFinished: backupRefreshGroup.timestamp_finish, timestampFinished: backupRefreshGroup.timestamp_finish,
timestampCreated: backupRefreshGroup.timestamp_created, timestampCreated: backupRefreshGroup.timestamp_created,
refreshGroupId: backupRefreshGroup.refresh_group_id, refreshGroupId: backupRefreshGroup.refresh_group_id,
currency: Amounts.currencyOf(backupRefreshGroup.old_coins[0].input_amount),
reason, reason,
lastErrorPerCoin: {}, lastErrorPerCoin: {},
oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub), oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub),

View File

@ -43,7 +43,6 @@
* can accept via their supported wire methods. * can accept via their supported wire methods.
*/ */
/** /**
* Imports. * Imports.
*/ */
@ -52,10 +51,16 @@ import {
BalancesResponse, BalancesResponse,
Amounts, Amounts,
Logger, Logger,
AuditorHandle,
ExchangeHandle,
canonicalizeBaseUrl,
parsePaytoUri,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { 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 { checkLogicInvariant } from "../util/invariants.js";
/** /**
* Logger. * Logger.
@ -68,6 +73,30 @@ interface WalletBalance {
pendingOutgoing: AmountJson; pendingOutgoing: AmountJson;
} }
/**
* Compute the available amount that the wallet expects to get
* out of a refresh group.
*/
function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): AmountJson {
// Don't count finished refreshes, since the refresh already resulted
// in coins being added to the wallet.
let available = Amounts.zeroOfCurrency(r.currency);
if (r.timestampFinished) {
return available;
}
for (let i = 0; i < r.oldCoinPubs.length; i++) {
const session = r.refreshSessionPerCoin[i];
if (session) {
// We are always assuming the refresh will succeed, thus we
// report the output as available balance.
available = Amounts.add(available, session.amountRefreshOutput).amount;
} else {
available = Amounts.add(available, r.estimatedOutputPerCoin[i]).amount;
}
}
return available;
}
/** /**
* Get balance information. * Get balance information.
*/ */
@ -110,33 +139,11 @@ export async function getBalancesInsideTransaction(
}); });
await tx.refreshGroups.iter().forEach((r) => { await tx.refreshGroups.iter().forEach((r) => {
// Don't count finished refreshes, since the refresh already resulted const b = initBalance(r.currency);
// in coins being added to the wallet.
if (r.timestampFinished) {
return;
}
for (let i = 0; i < r.oldCoinPubs.length; i++) {
const session = r.refreshSessionPerCoin[i];
if (session) {
const currency = Amounts.parseOrThrow(
session.amountRefreshOutput,
).currency;
const b = initBalance(currency);
// We are always assuming the refresh will succeed, thus we
// report the output as available balance.
b.available = Amounts.add( b.available = Amounts.add(
b.available, b.available,
session.amountRefreshOutput, computeRefreshGroupAvailableAmount(r),
).amount; ).amount;
} else {
const currency = Amounts.parseOrThrow(r.inputPerCoin[i]).currency;
const b = initBalance(currency);
b.available = Amounts.add(
b.available,
r.estimatedOutputPerCoin[i],
).amount;
}
}
}); });
await tx.withdrawalGroups.iter().forEach((wds) => { await tx.withdrawalGroups.iter().forEach((wds) => {
@ -194,3 +201,217 @@ export async function getBalances(
return wbal; return wbal;
} }
/**
* Information about the balance for a particular payment to a particular
* merchant.
*/
export interface MerchantPaymentBalanceDetails {
balanceAvailable: AmountJson;
}
export interface MerchantPaymentRestrictionsForBalance {
currency: string;
minAge: number;
acceptedExchanges: AllowedExchangeInfo[];
acceptedAuditors: AllowedAuditorInfo[];
acceptedWireMethods: string[];
}
export interface AcceptableExchanges {
/**
* Exchanges accepted by the merchant, but wire method might not match.
*/
acceptableExchanges: string[];
/**
* Exchanges accepted by the merchant, including a matching
* wire method, i.e. the merchant can deposit coins there.
*/
depositableExchanges: string[];
}
/**
* Get all exchanges that are acceptable for a particular payment.
*/
export async function getAcceptableExchangeBaseUrls(
ws: InternalWalletState,
req: MerchantPaymentRestrictionsForBalance,
): Promise<AcceptableExchanges> {
const acceptableExchangeUrls = new Set<string>();
const depositableExchangeUrls = new Set<string>();
await ws.db
.mktx((x) => [x.exchanges, x.exchangeDetails, x.auditorTrust])
.runReadOnly(async (tx) => {
// FIXME: We should have a DB index to look up all exchanges
// for a particular auditor ...
const canonExchanges = new Set<string>();
const canonAuditors = new Set<string>();
for (const exchangeHandle of req.acceptedExchanges) {
const normUrl = canonicalizeBaseUrl(exchangeHandle.exchangeBaseUrl);
canonExchanges.add(normUrl);
}
for (const auditorHandle of req.acceptedAuditors) {
const normUrl = canonicalizeBaseUrl(auditorHandle.auditorBaseUrl);
canonAuditors.add(normUrl);
}
await tx.exchanges.iter().forEachAsync(async (exchange) => {
const dp = exchange.detailsPointer;
if (!dp) {
return;
}
const { currency, masterPublicKey } = dp;
const exchangeDetails = await tx.exchangeDetails.indexes.byPointer.get([
exchange.baseUrl,
currency,
masterPublicKey,
]);
if (!exchangeDetails) {
return;
}
let acceptable = false;
if (canonExchanges.has(exchange.baseUrl)) {
acceptableExchangeUrls.add(exchange.baseUrl);
acceptable = true;
}
for (const exchangeAuditor of exchangeDetails.auditors) {
if (canonAuditors.has(exchangeAuditor.auditor_url)) {
acceptableExchangeUrls.add(exchange.baseUrl);
acceptable = true;
break;
}
}
if (!acceptable) {
return;
}
// FIXME: Also consider exchange and auditor public key
// instead of just base URLs?
let wireMethodSupported = false;
for (const acc of exchangeDetails.wireInfo.accounts) {
const pp = parsePaytoUri(acc.payto_uri);
checkLogicInvariant(!!pp);
for (const wm of req.acceptedWireMethods) {
if (pp.targetType === wm) {
wireMethodSupported = true;
break;
}
if (wireMethodSupported) {
break;
}
}
}
acceptableExchangeUrls.add(exchange.baseUrl);
if (wireMethodSupported) {
depositableExchangeUrls.add(exchange.baseUrl);
}
});
});
return {
acceptableExchanges: [...acceptableExchangeUrls],
depositableExchanges: [...depositableExchangeUrls],
};
}
export interface MerchantPaymentBalanceDetails {
/**
* Balance of type "available" (see balance.ts for definition).
*/
balanceAvailable: AmountJson;
/**
* Balance of type "material" (see balance.ts for definition).
*/
balanceMaterial: AmountJson;
/**
* Balance of type "age-acceptable" (see balance.ts for definition).
*/
balanceAgeAcceptable: AmountJson;
/**
* Balance of type "merchant-acceptable" (see balance.ts for definition).
*/
balanceMerchantAcceptable: AmountJson;
/**
* Balance of type "merchant-depositable" (see balance.ts for definition).
*/
balanceMerchantDepositable: AmountJson;
}
export async function getMerchantPaymentBalanceDetails(
ws: InternalWalletState,
req: MerchantPaymentRestrictionsForBalance,
): Promise<MerchantPaymentBalanceDetails> {
const acceptability = await getAcceptableExchangeBaseUrls(ws, req);
const d: MerchantPaymentBalanceDetails = {
balanceAvailable: Amounts.zeroOfCurrency(req.currency),
balanceMaterial: Amounts.zeroOfCurrency(req.currency),
balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency),
balanceMerchantAcceptable: Amounts.zeroOfCurrency(req.currency),
balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency),
};
const wbal = await ws.db
.mktx((x) => [
x.coins,
x.coinAvailability,
x.refreshGroups,
x.purchases,
x.withdrawalGroups,
])
.runReadOnly(async (tx) => {
await tx.coinAvailability.iter().forEach((ca) => {
const singleCoinAmount: AmountJson = {
currency: ca.currency,
fraction: ca.amountFrac,
value: ca.amountVal,
};
const coinAmount: AmountJson = Amounts.mult(
singleCoinAmount,
ca.freshCoinCount,
).amount;
d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount;
d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount;
if (ca.maxAge === 0 || ca.maxAge > req.minAge) {
d.balanceAgeAcceptable = Amounts.add(
d.balanceAgeAcceptable,
coinAmount,
).amount;
if (acceptability.acceptableExchanges.includes(ca.exchangeBaseUrl)) {
d.balanceMerchantAcceptable = Amounts.add(
d.balanceMerchantAcceptable,
coinAmount,
).amount;
if (
acceptability.depositableExchanges.includes(ca.exchangeBaseUrl)
) {
d.balanceMerchantDepositable = Amounts.add(
d.balanceMerchantDepositable,
coinAmount,
).amount;
}
}
}
});
await tx.refreshGroups.iter().forEach((r) => {
d.balanceAvailable = Amounts.add(
d.balanceAvailable,
computeRefreshGroupAvailableAmount(r),
).amount;
});
});
return d;
}

View File

@ -175,9 +175,11 @@ export async function spendCoins(
await tx.coins.put(coin); await tx.coins.put(coin);
await tx.coinAvailability.put(coinAvailability); await tx.coinAvailability.put(coinAvailability);
} }
await ws.refreshOps.createRefreshGroup( await ws.refreshOps.createRefreshGroup(
ws, ws,
tx, tx,
Amounts.currencyOf(csi.contributions[0]),
refreshCoinPubs, refreshCoinPubs,
RefreshReason.PayMerchant, RefreshReason.PayMerchant,
); );

View File

@ -268,7 +268,7 @@ export async function getFeeForDeposit(
prevPayCoins: [], prevPayCoins: [],
}); });
if (!payCoinSel) { if (payCoinSel.type !== "success") {
throw Error("insufficient funds"); throw Error("insufficient funds");
} }
@ -276,7 +276,7 @@ export async function getFeeForDeposit(
ws, ws,
p.targetType, p.targetType,
amount, amount,
payCoinSel, payCoinSel.coinSel,
); );
} }
@ -355,16 +355,16 @@ export async function prepareDepositGroup(
prevPayCoins: [], prevPayCoins: [],
}); });
if (!payCoinSel) { if (payCoinSel.type !== "success") {
throw Error("insufficient funds"); throw Error("insufficient funds");
} }
const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel); const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
const effectiveDepositAmount = await getEffectiveDepositAmount( const effectiveDepositAmount = await getEffectiveDepositAmount(
ws, ws,
p.targetType, p.targetType,
payCoinSel, payCoinSel.coinSel,
); );
return { return {
@ -452,18 +452,18 @@ export async function createDepositGroup(
prevPayCoins: [], prevPayCoins: [],
}); });
if (!payCoinSel) { if (payCoinSel.type !== "success") {
throw Error("insufficient funds"); throw Error("insufficient funds");
} }
const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel); const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
const depositGroupId = encodeCrock(getRandomBytes(32)); const depositGroupId = encodeCrock(getRandomBytes(32));
const effectiveDepositAmount = await getEffectiveDepositAmount( const effectiveDepositAmount = await getEffectiveDepositAmount(
ws, ws,
p.targetType, p.targetType,
payCoinSel, payCoinSel.coinSel,
); );
const depositGroup: DepositGroupRecord = { const depositGroup: DepositGroupRecord = {
@ -474,9 +474,9 @@ export async function createDepositGroup(
noncePub: noncePair.pub, noncePub: noncePair.pub,
timestampCreated: AbsoluteTime.toTimestamp(now), timestampCreated: AbsoluteTime.toTimestamp(now),
timestampFinished: undefined, timestampFinished: undefined,
payCoinSelection: payCoinSel, payCoinSelection: payCoinSel.coinSel,
payCoinSelectionUid: encodeCrock(getRandomBytes(32)), payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
depositedPerCoin: payCoinSel.coinPubs.map(() => false), depositedPerCoin: payCoinSel.coinSel.coinPubs.map(() => false),
merchantPriv: merchantPair.priv, merchantPriv: merchantPair.priv,
merchantPub: merchantPair.pub, merchantPub: merchantPair.pub,
totalPayCost: Amounts.stringify(totalDepositCost), totalPayCost: Amounts.stringify(totalDepositCost),
@ -500,8 +500,8 @@ export async function createDepositGroup(
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
await spendCoins(ws, tx, { await spendCoins(ws, tx, {
allocationId: `txn:deposit:${depositGroup.depositGroupId}`, allocationId: `txn:deposit:${depositGroup.depositGroupId}`,
coinPubs: payCoinSel.coinPubs, coinPubs: payCoinSel.coinSel.coinPubs,
contributions: payCoinSel.coinContributions.map((x) => contributions: payCoinSel.coinSel.coinContributions.map((x) =>
Amounts.parseOrThrow(x), Amounts.parseOrThrow(x),
), ),
refreshReason: RefreshReason.PayDeposit, refreshReason: RefreshReason.PayDeposit,

View File

@ -73,6 +73,7 @@ import {
TransactionType, TransactionType,
URL, URL,
constructPayUri, constructPayUri,
PayMerchantInsufficientBalanceDetails,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { EddsaKeypair } from "../crypto/cryptoImplementation.js"; import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
import { import {
@ -131,11 +132,12 @@ import {
import { getExchangeDetails } from "./exchanges.js"; import { getExchangeDetails } from "./exchanges.js";
import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js"; import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
import { GetReadOnlyAccess } from "../util/query.js"; import { GetReadOnlyAccess } from "../util/query.js";
import { getMerchantPaymentBalanceDetails } from "./balance.js";
/** /**
* Logger. * Logger.
*/ */
const logger = new Logger("pay.ts"); const logger = new Logger("pay-merchant.ts");
/** /**
* Compute the total cost of a payment to the customer. * Compute the total cost of a payment to the customer.
@ -817,7 +819,7 @@ async function handleInsufficientFunds(
requiredMinimumAge: contractData.minimumAge, requiredMinimumAge: contractData.minimumAge,
}); });
if (!res) { if (res.type !== "success") {
logger.trace("insufficient funds for coin re-selection"); logger.trace("insufficient funds for coin re-selection");
return; return;
} }
@ -841,8 +843,7 @@ async function handleInsufficientFunds(
if (!payInfo) { if (!payInfo) {
return; return;
} }
payInfo.payCoinSelection = res; payInfo.payCoinSelection = res.coinSel;
payInfo.payCoinSelection = res;
payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
await tx.purchases.put(p); await tx.purchases.put(p);
await spendCoins(ws, tx, { await spendCoins(ws, tx, {
@ -905,6 +906,8 @@ export async function selectCandidates(
x.coinAvailability, x.coinAvailability,
]) ])
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
// FIXME: Use the existing helper (from balance.ts) to
// get acceptable exchanges.
const denoms: AvailableDenom[] = []; const denoms: AvailableDenom[] = [];
const exchanges = await tx.exchanges.iter().toArray(); const exchanges = await tx.exchanges.iter().toArray();
const wfPerExchange: Record<string, AmountJson> = {}; const wfPerExchange: Record<string, AmountJson> = {};
@ -1030,6 +1033,7 @@ export function selectGreedy(
// Don't use this coin if depositing it is more expensive than // Don't use this coin if depositing it is more expensive than
// the amount it would give the merchant. // the amount it would give the merchant.
if (Amounts.cmp(aci.feeDeposit, aci.value) > 0) { if (Amounts.cmp(aci.feeDeposit, aci.value) > 0) {
tally.lastDepositFee = Amounts.parseOrThrow(aci.feeDeposit);
continue; continue;
} }
@ -1129,6 +1133,13 @@ export function selectForced(
return selectedDenom; 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 * Given a list of candidate coins, select coins to spend under the merchant's
* constraints. * constraints.
@ -1142,7 +1153,7 @@ export function selectForced(
export async function selectPayCoinsNew( export async function selectPayCoinsNew(
ws: InternalWalletState, ws: InternalWalletState,
req: SelectPayCoinRequestNg, req: SelectPayCoinRequestNg,
): Promise<PayCoinSelection | undefined> { ): Promise<SelectPayCoinsResult> {
const { const {
contractTermsAmount, contractTermsAmount,
depositFeeLimit, depositFeeLimit,
@ -1168,6 +1179,7 @@ export async function selectPayCoinsNew(
customerDepositFees: Amounts.zeroOfCurrency(currency), customerDepositFees: Amounts.zeroOfCurrency(currency),
customerWireFees: Amounts.zeroOfCurrency(currency), customerWireFees: Amounts.zeroOfCurrency(currency),
wireFeeCoveredForExchange: new Set(), wireFeeCoveredForExchange: new Set(),
lastDepositFee: Amounts.zeroOfCurrency(currency),
}; };
const prevPayCoins = req.prevPayCoins ?? []; const prevPayCoins = req.prevPayCoins ?? [];
@ -1207,7 +1219,44 @@ export async function selectPayCoinsNew(
} }
if (!selectedDenom) { if (!selectedDenom) {
return undefined; 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; const finalSel = selectedDenom;
@ -1244,11 +1293,14 @@ export async function selectPayCoinsNew(
}); });
return { return {
type: "success",
coinSel: {
paymentAmount: Amounts.stringify(contractTermsAmount), paymentAmount: Amounts.stringify(contractTermsAmount),
coinContributions: coinContributions.map((x) => Amounts.stringify(x)), coinContributions: coinContributions.map((x) => Amounts.stringify(x)),
coinPubs, coinPubs,
customerDepositFees: Amounts.stringify(tally.customerDepositFees), customerDepositFees: Amounts.stringify(tally.customerDepositFees),
customerWireFees: Amounts.stringify(tally.customerWireFees), customerWireFees: Amounts.stringify(tally.customerWireFees),
},
}; };
} }
@ -1318,7 +1370,7 @@ export async function checkPaymentByProposalId(
wireMethod: contractData.wireMethod, wireMethod: contractData.wireMethod,
}); });
if (!res) { if (res.type !== "success") {
logger.info("not allowing payment, insufficient coins"); logger.info("not allowing payment, insufficient coins");
return { return {
status: PreparePayResultType.InsufficientBalance, status: PreparePayResultType.InsufficientBalance,
@ -1327,10 +1379,11 @@ export async function checkPaymentByProposalId(
noncePriv: proposal.noncePriv, noncePriv: proposal.noncePriv,
amountRaw: Amounts.stringify(d.contractData.amount), amountRaw: Amounts.stringify(d.contractData.amount),
talerUri, talerUri,
balanceDetails: res.insufficientBalanceDetails,
}; };
} }
const totalCost = await getTotalPaymentCost(ws, res); const totalCost = await getTotalPaymentCost(ws, res.coinSel);
logger.trace("costInfo", totalCost); logger.trace("costInfo", totalCost);
logger.trace("coinsForPayment", res); logger.trace("coinsForPayment", res);
@ -1340,7 +1393,7 @@ export async function checkPaymentByProposalId(
proposalId: proposal.proposalId, proposalId: proposal.proposalId,
noncePriv: proposal.noncePriv, noncePriv: proposal.noncePriv,
amountEffective: Amounts.stringify(totalCost), amountEffective: Amounts.stringify(totalCost),
amountRaw: Amounts.stringify(res.paymentAmount), amountRaw: Amounts.stringify(res.coinSel.paymentAmount),
contractTermsHash: d.contractData.contractTermsHash, contractTermsHash: d.contractData.contractTermsHash,
talerUri, talerUri,
}; };
@ -1666,9 +1719,9 @@ export async function confirmPay(
const contractData = d.contractData; const contractData = d.contractData;
let maybeCoinSelection: PayCoinSelection | undefined = undefined; let selectCoinsResult: SelectPayCoinsResult | undefined = undefined;
maybeCoinSelection = await selectPayCoinsNew(ws, { selectCoinsResult = await selectPayCoinsNew(ws, {
auditors: contractData.allowedAuditors, auditors: contractData.allowedAuditors,
exchanges: contractData.allowedExchanges, exchanges: contractData.allowedExchanges,
wireMethod: contractData.wireMethod, wireMethod: contractData.wireMethod,
@ -1681,9 +1734,9 @@ export async function confirmPay(
forcedSelection: forcedCoinSel, forcedSelection: forcedCoinSel,
}); });
logger.trace("coin selection result", maybeCoinSelection); logger.trace("coin selection result", selectCoinsResult);
if (!maybeCoinSelection) { if (selectCoinsResult.type === "failure") {
// Should not happen, since checkPay should be called first // Should not happen, since checkPay should be called first
// FIXME: Actually, this should be handled gracefully, // FIXME: Actually, this should be handled gracefully,
// and the status should be stored in the DB. // and the status should be stored in the DB.
@ -1691,14 +1744,7 @@ export async function confirmPay(
throw Error("insufficient balance"); throw Error("insufficient balance");
} }
const coinSelection = maybeCoinSelection; const coinSelection = selectCoinsResult.coinSel;
const depositPermissions = await generateDepositPermissions(
ws,
coinSelection,
d.contractData,
);
const payCostInfo = await getTotalPaymentCost(ws, coinSelection); const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
let sessionId: string | undefined; let sessionId: string | undefined;
@ -2373,6 +2419,7 @@ async function acceptRefunds(
await createRefreshGroup( await createRefreshGroup(
ws, ws,
tx, tx,
Amounts.currencyOf(refreshCoinsPubs[0].amount),
refreshCoinsPubs, refreshCoinsPubs,
RefreshReason.Refund, RefreshReason.Refund,
); );

View File

@ -429,6 +429,7 @@ export async function processRecoupGroupHandler(
const refreshGroupId = await createRefreshGroup( const refreshGroupId = await createRefreshGroup(
ws, ws,
tx, tx,
Amounts.currencyOf(rg2.scheduleRefreshCoins[0].amount),
rg2.scheduleRefreshCoins, rg2.scheduleRefreshCoins,
RefreshReason.Recoup, RefreshReason.Recoup,
); );

View File

@ -850,6 +850,7 @@ export async function createRefreshGroup(
refreshGroups: typeof WalletStoresV1.refreshGroups; refreshGroups: typeof WalletStoresV1.refreshGroups;
coinAvailability: typeof WalletStoresV1.coinAvailability; coinAvailability: typeof WalletStoresV1.coinAvailability;
}>, }>,
currency: string,
oldCoinPubs: CoinRefreshRequest[], oldCoinPubs: CoinRefreshRequest[],
reason: RefreshReason, reason: RefreshReason,
): Promise<RefreshGroupId> { ): Promise<RefreshGroupId> {
@ -934,6 +935,7 @@ export async function createRefreshGroup(
const refreshGroup: RefreshGroupRecord = { const refreshGroup: RefreshGroupRecord = {
operationStatus: RefreshOperationStatus.Pending, operationStatus: RefreshOperationStatus.Pending,
currency,
timestampFinished: undefined, timestampFinished: undefined,
statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending), statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
oldCoinPubs: oldCoinPubs.map((x) => x.coinPub), oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
@ -1018,7 +1020,7 @@ export async function autoRefresh(
]) ])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const exchange = await tx.exchanges.get(exchangeBaseUrl); const exchange = await tx.exchanges.get(exchangeBaseUrl);
if (!exchange) { if (!exchange || !exchange.detailsPointer) {
return; return;
} }
const coins = await tx.coins.indexes.byBaseUrl const coins = await tx.coins.indexes.byBaseUrl
@ -1059,6 +1061,7 @@ export async function autoRefresh(
const res = await createRefreshGroup( const res = await createRefreshGroup(
ws, ws,
tx, tx,
exchange.detailsPointer?.currency,
refreshCoins, refreshCoins,
RefreshReason.Scheduled, RefreshReason.Scheduled,
); );

View File

@ -117,6 +117,8 @@ export interface CoinSelectionTally {
customerWireFees: AmountJson; customerWireFees: AmountJson;
wireFeeCoveredForExchange: Set<string>; wireFeeCoveredForExchange: Set<string>;
lastDepositFee: AmountJson;
} }
/** /**
@ -188,5 +190,6 @@ export function tallyFees(
customerDepositFees, customerDepositFees,
customerWireFees, customerWireFees,
wireFeeCoveredForExchange, wireFeeCoveredForExchange,
lastDepositFee: feeDeposit,
}; };
} }

View File

@ -1178,6 +1178,9 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
} }
case WalletApiOperation.ForceRefresh: { case WalletApiOperation.ForceRefresh: {
const req = codecForForceRefreshRequest().decode(payload); const req = codecForForceRefreshRequest().decode(payload);
if (req.coinPubList.length == 0) {
throw Error("refusing to create empty refresh group");
}
const refreshGroupId = await ws.db const refreshGroupId = await ws.db
.mktx((x) => [ .mktx((x) => [
x.refreshGroups, x.refreshGroups,
@ -1207,6 +1210,7 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
return await createRefreshGroup( return await createRefreshGroup(
ws, ws,
tx, tx,
Amounts.currencyOf(coinPubs[0].amount),
coinPubs, coinPubs,
RefreshReason.Manual, RefreshReason.Manual,
); );