summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/coinSelection.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/coinSelection.ts')
-rw-r--r--packages/taler-wallet-core/src/coinSelection.ts1202
1 files changed, 579 insertions, 623 deletions
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts
index 3ece5546c..c44ca3d17 100644
--- a/packages/taler-wallet-core/src/coinSelection.ts
+++ b/packages/taler-wallet-core/src/coinSelection.ts
@@ -39,7 +39,6 @@ import {
CoinPublicKeyString,
CoinStatus,
DenominationInfo,
- DenominationPubKey,
DenomSelectionState,
Duration,
ForcedCoinSel,
@@ -50,62 +49,25 @@ import {
parsePaytoUri,
PayCoinSelection,
PayMerchantInsufficientBalanceDetails,
- PayPeerInsufficientBalanceDetails,
strcmp,
TalerProtocolTimestamp,
UnblindedSignature,
} from "@gnu-taler/taler-util";
import {
- getMerchantPaymentBalanceDetails,
- getPeerPaymentBalanceDetailsInTx,
+ getExchangePaymentBalanceDetailsInTx,
+ getMerchantPaymentBalanceDetailsInTx,
} from "./balance.js";
import { getAutoRefreshExecuteThreshold } from "./common.js";
import { DenominationRecord, WalletDbReadOnlyTransaction } from "./db.js";
import { isWithdrawableDenom } from "./denominations.js";
-import { getExchangeWireDetailsInTx } from "./exchanges.js";
+import {
+ ExchangeWireDetails,
+ getExchangeWireDetailsInTx,
+} from "./exchanges.js";
import { getDenomInfo, WalletExecutionContext } from "./wallet.js";
const logger = new Logger("coinSelection.ts");
-/**
- * Structure to describe a coin that is available to be
- * used in a payment.
- */
-export interface AvailableCoinInfo {
- /**
- * Public key of the coin.
- */
- coinPub: string;
-
- /**
- * Coin's denomination public key.
- *
- * FIXME: We should only need the denomPubHash here, if at all.
- */
- denomPub: DenominationPubKey;
-
- /**
- * Full value of the coin.
- */
- value: AmountJson;
-
- /**
- * Amount still remaining (typically the full amount,
- * as coins are always refreshed after use.)
- */
- availableAmount: AmountJson;
-
- /**
- * Deposit fee for the coin.
- */
- feeDeposit: AmountJson;
-
- exchangeBaseUrl: string;
-
- maxAge: number;
- ageCommitmentProof?: AgeCommitmentProof;
-}
-
export type PreviousPayCoins = {
coinPub: string;
contribution: AmountJson;
@@ -113,19 +75,9 @@ export type PreviousPayCoins = {
exchangeBaseUrl: string;
}[];
-export interface CoinCandidateSelection {
- candidateCoins: AvailableCoinInfo[];
- wireFeesPerExchange: Record<string, AmountJson>;
-}
-
-export interface SelectPayCoinRequest {
- candidates: CoinCandidateSelection;
- contractTermsAmount: AmountJson;
- depositFeeLimit: AmountJson;
- wireFeeLimit: AmountJson;
- wireFeeAmortization: number;
- prevPayCoins?: PreviousPayCoins;
- requiredMinimumAge?: number;
+export interface ExchangeRestrictionSpec {
+ exchanges: AllowedExchangeInfo[];
+ auditors: AllowedAuditorInfo[];
}
export interface CoinSelectionTally {
@@ -159,26 +111,20 @@ export interface CoinSelectionTally {
* Account for the fees of spending a coin.
*/
function tallyFees(
- tally: Readonly<CoinSelectionTally>,
+ tally: CoinSelectionTally,
wireFeesPerExchange: Record<string, AmountJson>,
wireFeeAmortization: number,
exchangeBaseUrl: string,
feeDeposit: AmountJson,
-): CoinSelectionTally {
+): void {
const currency = tally.amountPayRemaining.currency;
- let amountWireFeeLimitRemaining = tally.amountWireFeeLimitRemaining;
- let amountDepositFeeLimitRemaining = tally.amountDepositFeeLimitRemaining;
- let customerDepositFees = tally.customerDepositFees;
- let customerWireFees = tally.customerWireFees;
- let amountPayRemaining = tally.amountPayRemaining;
- const wireFeeCoveredForExchange = new Set(tally.wireFeeCoveredForExchange);
if (!tally.wireFeeCoveredForExchange.has(exchangeBaseUrl)) {
const wf =
wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.zeroOfCurrency(currency);
- const wfForgiven = Amounts.min(amountWireFeeLimitRemaining, wf);
- amountWireFeeLimitRemaining = Amounts.sub(
- amountWireFeeLimitRemaining,
+ const wfForgiven = Amounts.min(tally.amountWireFeeLimitRemaining, wf);
+ tally.amountWireFeeLimitRemaining = Amounts.sub(
+ tally.amountWireFeeLimitRemaining,
wfForgiven,
).amount;
// The remaining, amortized amount needs to be paid by the
@@ -187,45 +133,48 @@ function tallyFees(
Amounts.sub(wf, wfForgiven).amount,
wireFeeAmortization,
);
-
// This is the amount forgiven via the deposit fee allowance.
const wfDepositForgiven = Amounts.min(
- amountDepositFeeLimitRemaining,
+ tally.amountDepositFeeLimitRemaining,
wfRemaining,
);
- amountDepositFeeLimitRemaining = Amounts.sub(
- amountDepositFeeLimitRemaining,
+ tally.amountDepositFeeLimitRemaining = Amounts.sub(
+ tally.amountDepositFeeLimitRemaining,
wfDepositForgiven,
).amount;
-
wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount;
- customerWireFees = Amounts.add(customerWireFees, wfRemaining).amount;
- amountPayRemaining = Amounts.add(amountPayRemaining, wfRemaining).amount;
-
- wireFeeCoveredForExchange.add(exchangeBaseUrl);
+ tally.customerWireFees = Amounts.add(
+ tally.customerWireFees,
+ wfRemaining,
+ ).amount;
+ tally.amountPayRemaining = Amounts.add(
+ tally.amountPayRemaining,
+ wfRemaining,
+ ).amount;
+ tally.wireFeeCoveredForExchange.add(exchangeBaseUrl);
}
- const dfForgiven = Amounts.min(feeDeposit, amountDepositFeeLimitRemaining);
+ const dfForgiven = Amounts.min(
+ feeDeposit,
+ tally.amountDepositFeeLimitRemaining,
+ );
- amountDepositFeeLimitRemaining = Amounts.sub(
- amountDepositFeeLimitRemaining,
+ tally.amountDepositFeeLimitRemaining = Amounts.sub(
+ tally.amountDepositFeeLimitRemaining,
dfForgiven,
).amount;
// How much does the user spend on deposit fees for this coin?
const dfRemaining = Amounts.sub(feeDeposit, dfForgiven).amount;
- customerDepositFees = Amounts.add(customerDepositFees, dfRemaining).amount;
- amountPayRemaining = Amounts.add(amountPayRemaining, dfRemaining).amount;
-
- return {
- amountDepositFeeLimitRemaining,
- amountPayRemaining,
- amountWireFeeLimitRemaining,
- customerDepositFees,
- customerWireFees,
- wireFeeCoveredForExchange,
- lastDepositFee: feeDeposit,
- };
+ tally.customerDepositFees = Amounts.add(
+ tally.customerDepositFees,
+ dfRemaining,
+ ).amount;
+ tally.amountPayRemaining = Amounts.add(
+ tally.amountPayRemaining,
+ dfRemaining,
+ ).amount;
+ tally.lastDepositFee = feeDeposit;
}
export type SelectPayCoinsResult =
@@ -236,16 +185,13 @@ export type SelectPayCoinsResult =
| { type: "success"; coinSel: PayCoinSelection };
/**
- * Given a list of candidate coins, select coins to spend under the merchant's
- * constraints.
+ * Select coins to spend under the merchant's constraints.
*
* The prevPayCoins can be specified to "repair" a coin selection
* by adding additional coins, after a broken (e.g. double-spent) coin
* has been removed from the selection.
- *
- * This function is only exported for the sake of unit tests.
*/
-export async function selectPayCoinsNew(
+export async function selectPayCoins(
wex: WalletExecutionContext,
req: SelectPayCoinRequestNg,
): Promise<SelectPayCoinsResult> {
@@ -256,141 +202,203 @@ export async function selectPayCoinsNew(
wireFeeAmortization,
} = req;
- // FIXME: Why don't we do this in a transaction?
- const [candidateDenoms, wireFeesPerExchange] =
- await selectPayMerchantCandidates(wex, req);
+ return await wex.db.runReadOnlyTx(
+ [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "coins",
+ ],
+ async (tx) => {
+ const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates(
+ wex,
+ tx,
+ {
+ restrictExchanges: req.restrictExchanges,
+ instructedAmount: req.contractTermsAmount,
+ restrictWireMethod: req.restrictWireMethod,
+ depositPaytoUri: req.depositPaytoUri,
+ requiredMinimumAge: req.requiredMinimumAge,
+ },
+ );
- const coinPubs: string[] = [];
- const coinContributions: AmountJson[] = [];
- const currency = contractTermsAmount.currency;
+ const coinPubs: string[] = [];
+ const coinContributions: AmountJson[] = [];
+ const currency = contractTermsAmount.currency;
+
+ let tally: CoinSelectionTally = {
+ amountPayRemaining: contractTermsAmount,
+ amountWireFeeLimitRemaining: wireFeeLimit,
+ amountDepositFeeLimitRemaining: depositFeeLimit,
+ customerDepositFees: Amounts.zeroOfCurrency(currency),
+ customerWireFees: Amounts.zeroOfCurrency(currency),
+ wireFeeCoveredForExchange: new Set(),
+ lastDepositFee: Amounts.zeroOfCurrency(currency),
+ };
- let tally: CoinSelectionTally = {
- amountPayRemaining: contractTermsAmount,
- amountWireFeeLimitRemaining: wireFeeLimit,
- amountDepositFeeLimitRemaining: depositFeeLimit,
- customerDepositFees: Amounts.zeroOfCurrency(currency),
- customerWireFees: Amounts.zeroOfCurrency(currency),
- wireFeeCoveredForExchange: new Set(),
- lastDepositFee: Amounts.zeroOfCurrency(currency),
- };
+ const prevPayCoins = req.prevPayCoins ?? [];
- const prevPayCoins = req.prevPayCoins ?? [];
+ // Look at existing pay coin selection and tally up
+ for (const prev of prevPayCoins) {
+ tallyFees(
+ tally,
+ wireFeesPerExchange,
+ wireFeeAmortization,
+ prev.exchangeBaseUrl,
+ prev.feeDeposit,
+ );
+ tally.amountPayRemaining = Amounts.sub(
+ tally.amountPayRemaining,
+ prev.contribution,
+ ).amount;
- // Look at existing pay coin selection and tally up
- for (const prev of prevPayCoins) {
- tally = tallyFees(
- tally,
- wireFeesPerExchange,
- wireFeeAmortization,
- prev.exchangeBaseUrl,
- prev.feeDeposit,
- );
- tally.amountPayRemaining = Amounts.sub(
- tally.amountPayRemaining,
- prev.contribution,
- ).amount;
+ coinPubs.push(prev.coinPub);
+ coinContributions.push(prev.contribution);
+ }
- coinPubs.push(prev.coinPub);
- coinContributions.push(prev.contribution);
- }
+ let selectedDenom: SelResult | undefined;
+ if (req.forcedSelection) {
+ selectedDenom = selectForced(req, candidateDenoms);
+ } else {
+ // FIXME: Here, we should select coins in a smarter way.
+ // Instead of always spending the next-largest coin,
+ // we should try to find the smallest coin that covers the
+ // amount.
+ selectedDenom = selectGreedy(
+ {
+ wireFeeAmortization: req.wireFeeAmortization,
+ wireFeesPerExchange: wireFeesPerExchange,
+ },
+ candidateDenoms,
+ tally,
+ );
+ }
- let selectedDenom: SelResult | undefined;
- if (req.forcedSelection) {
- selectedDenom = selectForced(req, candidateDenoms);
- } else {
- // FIXME: Here, we should select coins in a smarter way.
- // Instead of always spending the next-largest coin,
- // we should try to find the smallest coin that covers the
- // amount.
- selectedDenom = selectGreedy(
- req,
- candidateDenoms,
- wireFeesPerExchange,
- tally,
- );
- }
+ if (!selectedDenom) {
+ return {
+ type: "failure",
+ insufficientBalanceDetails: await reportInsufficientBalanceDetails(
+ wex,
+ tx,
+ {
+ restrictExchanges: req.restrictExchanges,
+ instructedAmount: req.contractTermsAmount,
+ requiredMinimumAge: req.requiredMinimumAge,
+ wireMethod: req.restrictWireMethod,
+ },
+ ),
+ } satisfies SelectPayCoinsResult;
+ }
- if (!selectedDenom) {
- const details = await getMerchantPaymentBalanceDetails(wex, {
- 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);
+ const finalSel = selectedDenom;
+
+ logger.trace(`coin selection request ${j2s(req)}`);
+ logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`);
+
+ for (const dph of Object.keys(finalSel)) {
+ const selInfo = finalSel[dph];
+ const numRequested = selInfo.contributions.length;
+ const query = [
+ selInfo.exchangeBaseUrl,
+ selInfo.denomPubHash,
+ selInfo.maxAge,
+ CoinStatus.Fresh,
+ ];
+ logger.trace(`query: ${j2s(query)}`);
+ const coins =
+ await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll(
+ query,
+ numRequested,
+ );
+ if (coins.length != numRequested) {
+ throw Error(
+ `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`,
+ );
+ }
+ coinPubs.push(...coins.map((x) => x.coinPub));
+ coinContributions.push(...selInfo.contributions);
+ }
+
+ return {
+ type: "success",
+ coinSel: {
+ paymentAmount: Amounts.stringify(contractTermsAmount),
+ coinContributions: coinContributions.map((x) => Amounts.stringify(x)),
+ coinPubs,
+ customerDepositFees: Amounts.stringify(tally.customerDepositFees),
+ customerWireFees: Amounts.stringify(tally.customerWireFees),
+ },
+ };
+ },
+ );
+}
+
+interface ReportInsufficientBalanceRequest {
+ instructedAmount: AmountJson;
+ requiredMinimumAge: number | undefined;
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ wireMethod: string | undefined;
+}
+
+export async function reportInsufficientBalanceDetails(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ ["coinAvailability", "exchanges", "exchangeDetails", "refreshGroups"]
+ >,
+ req: ReportInsufficientBalanceRequest,
+): Promise<PayMerchantInsufficientBalanceDetails> {
+ const currency = Amounts.currencyOf(req.instructedAmount);
+ const details = await getMerchantPaymentBalanceDetailsInTx(wex, tx, {
+ restrictExchanges: req.restrictExchanges,
+ restrictWireMethods: req.wireMethod ? [req.wireMethod] : [],
+ currency: Amounts.currencyOf(req.instructedAmount),
+ minAge: req.requiredMinimumAge ?? 0,
+ });
+ let feeGapEstimate: AmountJson;
+
+ // FIXME: need fee gap estimate
+ // FIXME: We can probably give a better estimate.
+ // feeGapEstimate = Amounts.add(
+ // tally.amountPayRemaining,
+ // tally.lastDepositFee,
+ // ).amount;
+
+ feeGapEstimate = Amounts.zeroOfAmount(req.instructedAmount);
+
+ const perExchange: PayMerchantInsufficientBalanceDetails["perExchange"] = {};
+
+ const exchanges = await tx.exchanges.iter().toArray();
+
+ for (const exch of exchanges) {
+ if (exch.detailsPointer?.currency !== currency) {
+ continue;
}
- 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 infoExchange = await getExchangePaymentBalanceDetailsInTx(wex, tx, {
+ currency,
+ restrictExchangeTo: exch.baseUrl,
+ });
+ perExchange[exch.baseUrl] = {
+ balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable),
+ balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial),
+ feeGapEstimate: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
};
}
- const finalSel = selectedDenom;
-
- logger.trace(`coin selection request ${j2s(req)}`);
- logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`);
-
- await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- for (const dph of Object.keys(finalSel)) {
- const selInfo = finalSel[dph];
- const numRequested = selInfo.contributions.length;
- const query = [
- selInfo.exchangeBaseUrl,
- selInfo.denomPubHash,
- selInfo.maxAge,
- CoinStatus.Fresh,
- ];
- logger.trace(`query: ${j2s(query)}`);
- const coins =
- await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll(
- query,
- numRequested,
- );
- if (coins.length != numRequested) {
- throw Error(
- `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`,
- );
- }
- coinPubs.push(...coins.map((x) => x.coinPub));
- coinContributions.push(...selInfo.contributions);
- }
- });
-
return {
- type: "success",
- coinSel: {
- paymentAmount: Amounts.stringify(contractTermsAmount),
- coinContributions: coinContributions.map((x) => Amounts.stringify(x)),
- coinPubs,
- customerDepositFees: Amounts.stringify(tally.customerDepositFees),
- customerWireFees: Amounts.stringify(tally.customerWireFees),
- },
+ amountRequested: Amounts.stringify(req.instructedAmount),
+ 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),
+ perExchange,
};
}
@@ -426,10 +434,14 @@ export function testing_selectGreedy(
return selectGreedy(...args);
}
+export interface SelectGreedyRequest {
+ wireFeeAmortization: number;
+ wireFeesPerExchange: Record<string, AmountJson>;
+}
+
function selectGreedy(
- req: SelectPayCoinRequestNg,
+ req: SelectGreedyRequest,
candidateDenoms: AvailableDenom[],
- wireFeesPerExchange: Record<string, AmountJson>,
tally: CoinSelectionTally,
): SelResult | undefined {
const { wireFeeAmortization } = req;
@@ -449,9 +461,9 @@ function selectGreedy(
i < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining);
i++
) {
- tally = tallyFees(
+ tallyFees(
tally,
- wireFeesPerExchange,
+ req.wireFeesPerExchange,
wireFeeAmortization,
denom.exchangeBaseUrl,
Amounts.parseOrThrow(denom.feeDeposit),
@@ -491,6 +503,7 @@ function selectGreedy(
selectedDenom[avKey] = sd;
}
}
+ logger.info(`greedy tally: ${j2s(tally)}`);
return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined;
}
@@ -566,9 +579,8 @@ export function checkAccountRestriction(
}
export interface SelectPayCoinRequestNg {
- exchanges: AllowedExchangeInfo[];
- auditors: AllowedAuditorInfo[];
- wireMethod: string;
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ restrictWireMethod: string;
contractTermsAmount: AmountJson;
depositFeeLimit: AmountJson;
wireFeeLimit: AmountJson;
@@ -592,137 +604,183 @@ export type AvailableDenom = DenominationInfo & {
numAvailable: number;
};
-async function selectPayMerchantCandidates(
+function findMatchingWire(
+ wireMethod: string,
+ depositPaytoUri: string | undefined,
+ exchangeWireDetails: ExchangeWireDetails,
+): { wireFee: AmountJson } | undefined {
+ for (const acc of exchangeWireDetails.wireInfo.accounts) {
+ const pp = parsePaytoUri(acc.payto_uri);
+ checkLogicInvariant(!!pp);
+ if (pp.targetType !== wireMethod) {
+ continue;
+ }
+ const wireFeeStr = exchangeWireDetails.wireInfo.feesForType[
+ wireMethod
+ ]?.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ })?.wireFee;
+
+ if (!wireFeeStr) {
+ continue;
+ }
+
+ let debitAccountCheckOk = false;
+ if (depositPaytoUri) {
+ // FIXME: We should somehow propagate the hint here!
+ const checkResult = checkAccountRestriction(
+ depositPaytoUri,
+ acc.debit_restrictions,
+ );
+ if (checkResult.ok) {
+ debitAccountCheckOk = true;
+ }
+ } else {
+ debitAccountCheckOk = true;
+ }
+
+ if (!debitAccountCheckOk) {
+ continue;
+ }
+
+ return {
+ wireFee: Amounts.parseOrThrow(wireFeeStr),
+ };
+ }
+ return undefined;
+}
+
+function checkExchangeAccepted(
+ exchangeDetails: ExchangeWireDetails,
+ exchangeRestrictions: ExchangeRestrictionSpec | undefined,
+): boolean {
+ if (!exchangeRestrictions) {
+ return true;
+ }
+ let accepted = false;
+ for (const allowedExchange of exchangeRestrictions.exchanges) {
+ if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
+ accepted = true;
+ break;
+ }
+ }
+ for (const allowedAuditor of exchangeRestrictions.auditors) {
+ for (const providedAuditor of exchangeDetails.auditors) {
+ if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) {
+ accepted = true;
+ break;
+ }
+ }
+ }
+ return accepted;
+}
+
+interface SelectPayCandidatesRequest {
+ instructedAmount: AmountJson;
+ restrictWireMethod: string | undefined;
+ depositPaytoUri?: string;
+ restrictExchanges:
+ | {
+ exchanges: AllowedExchangeInfo[];
+ auditors: AllowedAuditorInfo[];
+ }
+ | undefined;
+ requiredMinimumAge?: number;
+}
+
+async function selectPayCandidates(
wex: WalletExecutionContext,
- req: SelectPayCoinRequestNg,
+ tx: WalletDbReadOnlyTransaction<
+ ["exchanges", "coinAvailability", "exchangeDetails", "denominations"]
+ >,
+ req: SelectPayCandidatesRequest,
): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
- return await wex.db.runReadOnlyTx(
- ["exchanges", "exchangeDetails", "denominations", "coinAvailability"],
- async (tx) => {
- // FIXME: Use the existing helper (from balance.ts) to
- // get acceptable exchanges.
- const denoms: AvailableDenom[] = [];
- const exchanges = await tx.exchanges.iter().toArray();
- const wfPerExchange: Record<string, AmountJson> = {};
- loopExchange: for (const exchange of exchanges) {
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- exchange.baseUrl,
- );
- // 1.- exchange has same currency
- if (exchangeDetails?.currency !== req.contractTermsAmount.currency) {
- continue;
- }
- let wireMethodFee: string | undefined;
- // 2.- exchange supports wire method
- loopWireAccount: for (const acc of exchangeDetails.wireInfo.accounts) {
- const pp = parsePaytoUri(acc.payto_uri);
- checkLogicInvariant(!!pp);
- if (pp.targetType !== req.wireMethod) {
- continue;
- }
- const wireFeeStr = exchangeDetails.wireInfo.feesForType[
- req.wireMethod
- ]?.find((x) => {
- return AbsoluteTime.isBetween(
- AbsoluteTime.now(),
- AbsoluteTime.fromProtocolTimestamp(x.startStamp),
- AbsoluteTime.fromProtocolTimestamp(x.endStamp),
- );
- })?.wireFee;
- let debitAccountCheckOk = false;
- if (req.depositPaytoUri) {
- // FIXME: We should somehow propagate the hint here!
- const checkResult = checkAccountRestriction(
- req.depositPaytoUri,
- acc.debit_restrictions,
- );
- if (checkResult.ok) {
- debitAccountCheckOk = true;
- }
- } else {
- debitAccountCheckOk = true;
- }
-
- if (wireFeeStr) {
- wireMethodFee = wireFeeStr;
- break loopWireAccount;
- }
- }
- if (!wireMethodFee) {
- continue;
- }
- wfPerExchange[exchange.baseUrl] = Amounts.parseOrThrow(wireMethodFee);
-
- // 3.- exchange is trusted in the exchange list or auditor list
- let accepted = false;
- for (const allowedExchange of req.exchanges) {
- if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
- accepted = true;
- break;
- }
- }
- for (const allowedAuditor of req.auditors) {
- for (const providedAuditor of exchangeDetails.auditors) {
- if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) {
- accepted = true;
- break;
- }
- }
- }
- if (!accepted) {
- continue;
- }
- // 4.- filter coins restricted by age
- let ageLower = 0;
- let ageUpper = AgeRestriction.AGE_UNRESTRICTED;
- if (req.requiredMinimumAge) {
- ageLower = req.requiredMinimumAge;
- }
- 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,
- });
- }
+ // FIXME: Use the existing helper (from balance.ts) to
+ // get acceptable exchanges.
+ const denoms: AvailableDenom[] = [];
+ const exchanges = await tx.exchanges.iter().toArray();
+ const wfPerExchange: Record<string, AmountJson> = {};
+ for (const exchange of exchanges) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchange.baseUrl,
+ );
+ // 1. exchange has same currency
+ if (exchangeDetails?.currency !== req.instructedAmount.currency) {
+ continue;
+ }
+
+ // 2. Exchange supports wire method (only for pay/deposit)
+ if (req.restrictWireMethod) {
+ const wire = findMatchingWire(
+ req.restrictWireMethod,
+ req.depositPaytoUri,
+ exchangeDetails,
+ );
+ if (!wire) {
+ continue;
}
- logger.info(`available denoms ${j2s(denoms)}`);
- // Sort by available amount (descending), deposit fee (ascending) and
- // denomPub (ascending) if deposit fee is the same
- // (to guarantee deterministic results)
- denoms.sort(
- (o1, o2) =>
- -Amounts.cmp(o1.value, o2.value) ||
- Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
- strcmp(o1.denomPubHash, o2.denomPubHash),
+ }
+
+ // 3. exchange is trusted in the exchange list or auditor list
+ let accepted = checkExchangeAccepted(
+ exchangeDetails,
+ req.restrictExchanges,
+ );
+ if (!accepted) {
+ continue;
+ }
+
+ // 4. filter coins restricted by age
+ let ageLower = 0;
+ let ageUpper = AgeRestriction.AGE_UNRESTRICTED;
+ if (req.requiredMinimumAge) {
+ ageLower = req.requiredMinimumAge;
+ }
+
+ const myExchangeCoins =
+ await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
+ GlobalIDB.KeyRange.bound(
+ [exchangeDetails.exchangeBaseUrl, ageLower, 1],
+ [exchangeDetails.exchangeBaseUrl, ageUpper, Number.MAX_SAFE_INTEGER],
+ ),
);
- return [denoms, wfPerExchange];
- },
+
+ // 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,
+ });
+ }
+ }
+ logger.info(`available denoms ${j2s(denoms)}`);
+ // Sort by available amount (descending), deposit fee (ascending) and
+ // denomPub (ascending) if deposit fee is the same
+ // (to guarantee deterministic results)
+ denoms.sort(
+ (o1, o2) =>
+ -Amounts.cmp(o1.value, o2.value) ||
+ Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
+ strcmp(o1.denomPubHash, o2.denomPubHash),
);
+ return [denoms, wfPerExchange];
}
/**
@@ -882,7 +940,7 @@ export type SelectPeerCoinsResult =
| { type: "success"; result: PeerCoinSelectionDetails }
| {
type: "failure";
- insufficientBalanceDetails: PayPeerInsufficientBalanceDetails;
+ insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails;
};
export interface PeerCoinRepair {
@@ -901,134 +959,134 @@ export interface PeerCoinSelectionRequest {
repair?: PeerCoinRepair;
}
-/**
- * Get coin availability information for a certain exchange.
- */
-async function selectPayPeerCandidatesForExchange(
- wex: WalletExecutionContext,
- tx: WalletDbReadOnlyTransaction<["coinAvailability", "denominations"]>,
+async function assemblePeerCoinSelectionDetails(
+ tx: WalletDbReadOnlyTransaction<["coins"]>,
exchangeBaseUrl: string,
-): Promise<AvailableDenom[]> {
- const denoms: AvailableDenom[] = [];
-
- let ageLower = 0;
- let ageUpper = AgeRestriction.AGE_UNRESTRICTED;
- const myExchangeCoins =
- await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
- GlobalIDB.KeyRange.bound(
- [exchangeBaseUrl, ageLower, 1],
- [exchangeBaseUrl, ageUpper, Number.MAX_SAFE_INTEGER],
+ selectedDenom: SelResult,
+ resCoins: ResCoin[],
+ tally: CoinSelectionTally,
+): Promise<PeerCoinSelectionDetails> {
+ let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never();
+ for (const dph of Object.keys(selectedDenom)) {
+ const selInfo = selectedDenom[dph];
+ // Compute earliest time that a selected denom
+ // would have its coins auto-refreshed.
+ minAutorefreshExecuteThreshold = TalerProtocolTimestamp.min(
+ minAutorefreshExecuteThreshold,
+ AbsoluteTime.toProtocolTimestamp(
+ getAutoRefreshExecuteThreshold({
+ stampExpireDeposit: selInfo.expireDeposit,
+ stampExpireWithdraw: selInfo.expireWithdraw,
+ }),
),
);
-
- for (const coinAvail of myExchangeCoins) {
- if (coinAvail.freshCoinCount <= 0) {
- continue;
+ const numRequested = selInfo.contributions.length;
+ const query = [
+ selInfo.exchangeBaseUrl,
+ selInfo.denomPubHash,
+ selInfo.maxAge,
+ CoinStatus.Fresh,
+ ];
+ logger.info(`query: ${j2s(query)}`);
+ const coins =
+ await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll(
+ query,
+ numRequested,
+ );
+ if (coins.length != numRequested) {
+ throw Error(
+ `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`,
+ );
}
- const denom = await tx.denominations.get([
- coinAvail.exchangeBaseUrl,
- coinAvail.denomPubHash,
- ]);
- checkDbInvariant(!!denom);
- if (denom.isRevoked || !denom.isOffered) {
- continue;
+ for (let i = 0; i < selInfo.contributions.length; i++) {
+ resCoins.push({
+ coinPriv: coins[i].coinPriv,
+ coinPub: coins[i].coinPub,
+ contribution: Amounts.stringify(selInfo.contributions[i]),
+ ageCommitmentProof: coins[i].ageCommitmentProof,
+ denomPubHash: selInfo.denomPubHash,
+ denomSig: coins[i].denomSig,
+ });
}
- denoms.push({
- ...DenominationRecord.toDenomInfo(denom),
- numAvailable: coinAvail.freshCoinCount ?? 0,
- maxAge: coinAvail.maxAge,
- });
}
- // Sort by available amount (descending), deposit fee (ascending) and
- // denomPub (ascending) if deposit fee is the same
- // (to guarantee deterministic results)
- denoms.sort(
- (o1, o2) =>
- -Amounts.cmp(o1.value, o2.value) ||
- Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
- strcmp(o1.denomPubHash, o2.denomPubHash),
- );
-
- return denoms;
-}
-export interface PeerCoinSelectionTally {
- amountRemaining: AmountJson;
- depositFeesAcc: AmountJson;
- lastDepositFee: AmountJson;
-}
-
-/**
- * exporting for testing
- */
-export function testing_greedySelectPeer(
- ...args: Parameters<typeof greedySelectPeer>
-): ReturnType<typeof greedySelectPeer> {
- return greedySelectPeer(...args);
+ return {
+ exchangeBaseUrl,
+ coins: resCoins,
+ depositFees: tally.customerDepositFees,
+ maxExpirationDate: minAutorefreshExecuteThreshold,
+ };
}
-function greedySelectPeer(
- candidates: AvailableDenom[],
- tally: PeerCoinSelectionTally,
-): SelResult | undefined {
- const selectedDenom: SelResult = {};
- for (const denom of candidates) {
- const contributions: AmountJson[] = [];
- const feeDeposit = Amounts.parseOrThrow(denom.feeDeposit);
- for (
- let i = 0;
- i < denom.numAvailable && Amounts.isNonZero(tally.amountRemaining);
- i++
- ) {
- tally.depositFeesAcc = Amounts.add(
- tally.depositFeesAcc,
- feeDeposit,
- ).amount;
- tally.amountRemaining = Amounts.add(
- tally.amountRemaining,
- feeDeposit,
- ).amount;
- tally.lastDepositFee = feeDeposit;
-
- const coinSpend = Amounts.max(
- Amounts.min(tally.amountRemaining, denom.value),
- denom.feeDeposit,
+async function maybeRepairPeerCoinSelection(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>,
+ exchangeBaseUrl: string,
+ tally: CoinSelectionTally,
+ repair: PeerCoinRepair | undefined,
+): Promise<ResCoin[]> {
+ const resCoins: ResCoin[] = [];
+
+ if (repair && repair.exchangeBaseUrl === exchangeBaseUrl) {
+ for (let i = 0; i < repair.coinPubs.length; i++) {
+ const contrib = repair.contribs[i];
+ const coin = await tx.coins.get(repair.coinPubs[i]);
+ if (!coin) {
+ throw Error("repair not possible, coin not found");
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
);
-
- tally.amountRemaining = Amounts.sub(
- tally.amountRemaining,
- coinSpend,
+ checkDbInvariant(!!denom);
+ resCoins.push({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contribution: Amounts.stringify(contrib),
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ ageCommitmentProof: coin.ageCommitmentProof,
+ });
+ const depositFee = Amounts.parseOrThrow(denom.feeDeposit);
+ tally.lastDepositFee = depositFee;
+ tally.amountPayRemaining = Amounts.sub(
+ tally.amountPayRemaining,
+ Amounts.sub(contrib, depositFee).amount,
+ ).amount;
+ tally.customerDepositFees = Amounts.add(
+ tally.customerDepositFees,
+ depositFee,
).amount;
-
- contributions.push(coinSpend);
- }
- if (contributions.length > 0) {
- const avKey = makeAvailabilityKey(
- denom.exchangeBaseUrl,
- denom.denomPubHash,
- denom.maxAge,
- );
- let sd = selectedDenom[avKey];
- if (!sd) {
- sd = {
- contributions: [],
- denomPubHash: denom.denomPubHash,
- exchangeBaseUrl: denom.exchangeBaseUrl,
- maxAge: denom.maxAge,
- expireDeposit: denom.stampExpireDeposit,
- expireWithdraw: denom.stampExpireWithdraw,
- };
- }
- sd.contributions.push(...contributions);
- selectedDenom[avKey] = sd;
}
}
+ return resCoins;
+}
- if (Amounts.isZero(tally.amountRemaining)) {
- return selectedDenom;
- }
+interface ResCoin {
+ coinPub: string;
+ coinPriv: string;
+ contribution: AmountString;
+ denomPubHash: string;
+ denomSig: UnblindedSignature;
+ ageCommitmentProof: AgeCommitmentProof | undefined;
+}
- return undefined;
+export function emptyTallyForPeerPayment(
+ instructedAmount: AmountJson,
+): CoinSelectionTally {
+ const currency = instructedAmount.currency;
+ const zero = Amounts.zeroOfCurrency(currency);
+ return {
+ amountPayRemaining: instructedAmount,
+ customerDepositFees: zero,
+ lastDepositFee: zero,
+ amountDepositFeeLimitRemaining: zero,
+ amountWireFeeLimitRemaining: zero,
+ customerWireFees: zero,
+ wireFeeCoveredForExchange: new Set(),
+ };
}
export async function selectPeerCoins(
@@ -1041,6 +1099,7 @@ export async function selectPeerCoins(
// one coin to spend.
throw new Error("amount of zero not allowed");
}
+
return await wex.db.runReadWriteTx(
[
"exchanges",
@@ -1049,72 +1108,40 @@ export async function selectPeerCoins(
"coinAvailability",
"denominations",
"refreshGroups",
- "peerPushDebit",
+ "exchangeDetails",
],
- async (tx) => {
+ async (tx): 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 !== currency) {
continue;
}
- const candidates = await selectPayPeerCandidatesForExchange(
+ const candidatesRes = await selectPayCandidates(wex, tx, {
+ instructedAmount,
+ restrictExchanges: {
+ auditors: [],
+ exchanges: [
+ {
+ exchangeBaseUrl: exch.baseUrl,
+ exchangePub: exch.detailsPointer.masterPublicKey,
+ },
+ ],
+ },
+ restrictWireMethod: undefined,
+ });
+ const candidates = candidatesRes[0];
+ if (logger.shouldLogTrace()) {
+ logger.trace(`peer payment candidate coins: ${j2s(candidates)}`);
+ }
+ const tally = emptyTallyForPeerPayment(req.instructedAmount);
+ const resCoins: ResCoin[] = await maybeRepairPeerCoinSelection(
wex,
tx,
exch.baseUrl,
+ tally,
+ req.repair,
);
- if (logger.shouldLogTrace()) {
- logger.trace(`peer payment candidate coins: ${j2s(candidates)}`);
- }
- const tally: PeerCoinSelectionTally = {
- amountRemaining: Amounts.parseOrThrow(instructedAmount),
- depositFeesAcc: Amounts.zeroOfCurrency(currency),
- lastDepositFee: Amounts.zeroOfCurrency(currency),
- };
- const resCoins: {
- coinPub: string;
- coinPriv: string;
- contribution: AmountString;
- denomPubHash: string;
- denomSig: UnblindedSignature;
- ageCommitmentProof: AgeCommitmentProof | undefined;
- }[] = [];
-
- if (req.repair && req.repair.exchangeBaseUrl === exch.baseUrl) {
- for (let i = 0; i < req.repair.coinPubs.length; i++) {
- const contrib = req.repair.contribs[i];
- const coin = await tx.coins.get(req.repair.coinPubs[i]);
- if (!coin) {
- throw Error("repair not possible, coin not found");
- }
- const denom = await getDenomInfo(
- wex,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(!!denom);
- resCoins.push({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- contribution: Amounts.stringify(contrib),
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- ageCommitmentProof: coin.ageCommitmentProof,
- });
- const depositFee = Amounts.parseOrThrow(denom.feeDeposit);
- tally.lastDepositFee = depositFee;
- tally.amountRemaining = Amounts.sub(
- tally.amountRemaining,
- Amounts.sub(contrib, depositFee).amount,
- ).amount;
- tally.depositFeesAcc = Amounts.add(
- tally.depositFeesAcc,
- depositFee,
- ).amount;
- }
- }
if (logger.shouldLogTrace()) {
logger.trace(`candidates: ${j2s(candidates)}`);
@@ -1122,113 +1149,42 @@ export async function selectPeerCoins(
logger.trace(`tally: ${j2s(tally)}`);
}
- const selectedDenom = greedySelectPeer(candidates, tally);
+ const selectedDenom = selectGreedy(
+ {
+ wireFeeAmortization: 1,
+ wireFeesPerExchange: {},
+ },
+ candidates,
+ tally,
+ );
if (selectedDenom) {
- let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never();
- for (const dph of Object.keys(selectedDenom)) {
- const selInfo = selectedDenom[dph];
- // Compute earliest time that a selected denom
- // would have its coins auto-refreshed.
- minAutorefreshExecuteThreshold = TalerProtocolTimestamp.min(
- minAutorefreshExecuteThreshold,
- AbsoluteTime.toProtocolTimestamp(
- getAutoRefreshExecuteThreshold({
- stampExpireDeposit: selInfo.expireDeposit,
- stampExpireWithdraw: selInfo.expireWithdraw,
- }),
- ),
- );
- const numRequested = selInfo.contributions.length;
- const query = [
- selInfo.exchangeBaseUrl,
- selInfo.denomPubHash,
- selInfo.maxAge,
- CoinStatus.Fresh,
- ];
- logger.info(`query: ${j2s(query)}`);
- const coins =
- await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll(
- query,
- numRequested,
- );
- if (coins.length != numRequested) {
- throw Error(
- `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`,
- );
- }
- for (let i = 0; i < selInfo.contributions.length; i++) {
- resCoins.push({
- coinPriv: coins[i].coinPriv,
- coinPub: coins[i].coinPub,
- contribution: Amounts.stringify(selInfo.contributions[i]),
- ageCommitmentProof: coins[i].ageCommitmentProof,
- denomPubHash: selInfo.denomPubHash,
- denomSig: coins[i].denomSig,
- });
- }
- }
-
- const res: PeerCoinSelectionDetails = {
- exchangeBaseUrl: exch.baseUrl,
- coins: resCoins,
- depositFees: tally.depositFeesAcc,
- maxExpirationDate: minAutorefreshExecuteThreshold,
+ return {
+ type: "success",
+ result: await assemblePeerCoinSelectionDetails(
+ tx,
+ exch.baseUrl,
+ selectedDenom,
+ resCoins,
+ tally,
+ ),
};
- return { type: "success", result: res };
- }
-
- exchangeFeeGap[exch.baseUrl] = Amounts.add(
- tally.lastDepositFee,
- tally.amountRemaining,
- ).amount;
-
- continue;
- }
-
- // We were unable to select coins.
- // Now we need to produce error details.
-
- const infoGeneral = await getPeerPaymentBalanceDetailsInTx(wex, tx, {
- currency,
- });
-
- const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {};
-
- let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency);
-
- for (const exch of exchanges) {
- if (exch.detailsPointer?.currency !== currency) {
- continue;
- }
- const infoExchange = await getPeerPaymentBalanceDetailsInTx(wex, 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.zeroOfCurrency(currency);
}
- perExchange[exch.baseUrl] = {
- balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable),
- balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial),
- feeGapEstimate: Amounts.stringify(gap),
- };
-
- maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap);
}
-
- const errDetails: PayPeerInsufficientBalanceDetails = {
- amountRequested: Amounts.stringify(instructedAmount),
- balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable),
- balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial),
- feeGapEstimate: Amounts.stringify(maxFeeGapEstimate),
- perExchange,
+ const insufficientBalanceDetails = await reportInsufficientBalanceDetails(
+ wex,
+ tx,
+ {
+ restrictExchanges: undefined,
+ instructedAmount: req.instructedAmount,
+ requiredMinimumAge: undefined,
+ wireMethod: undefined,
+ },
+ );
+ return {
+ type: "failure",
+ insufficientBalanceDetails,
};
-
- return { type: "failure", insufficientBalanceDetails: errDetails };
},
);
}