improved pay coin selection
support for multiple exchanges and healing a previous selection
This commit is contained in:
parent
fb3da3a28d
commit
44b1896b9e
@ -27,7 +27,6 @@ import {
|
|||||||
ExchangeUpdateStatus,
|
ExchangeUpdateStatus,
|
||||||
ExchangeWireInfo,
|
ExchangeWireInfo,
|
||||||
getTimestampNow,
|
getTimestampNow,
|
||||||
PayCoinSelection,
|
|
||||||
ProposalDownload,
|
ProposalDownload,
|
||||||
ProposalStatus,
|
ProposalStatus,
|
||||||
RefreshReason,
|
RefreshReason,
|
||||||
@ -49,6 +48,7 @@ import {
|
|||||||
BackupRefundState,
|
BackupRefundState,
|
||||||
WalletBackupContentV1,
|
WalletBackupContentV1,
|
||||||
} from "../../types/backupTypes";
|
} from "../../types/backupTypes";
|
||||||
|
import { PayCoinSelection } from "../../util/coinSelection";
|
||||||
import { j2s } from "../../util/helpers";
|
import { j2s } from "../../util/helpers";
|
||||||
import { checkDbInvariant, checkLogicInvariant } from "../../util/invariants";
|
import { checkDbInvariant, checkLogicInvariant } from "../../util/invariants";
|
||||||
import { Logger } from "../../util/logging";
|
import { Logger } from "../../util/logging";
|
||||||
|
@ -37,6 +37,7 @@ import {
|
|||||||
codecForString,
|
codecForString,
|
||||||
codecOptional,
|
codecOptional,
|
||||||
} from "../util/codec";
|
} from "../util/codec";
|
||||||
|
import { selectPayCoins } from "../util/coinSelection";
|
||||||
import { canonicalJson } from "../util/helpers";
|
import { canonicalJson } from "../util/helpers";
|
||||||
import { readSuccessResponseJsonOrThrow } from "../util/http";
|
import { readSuccessResponseJsonOrThrow } from "../util/http";
|
||||||
import { parsePaytoUri } from "../util/payto";
|
import { parsePaytoUri } from "../util/payto";
|
||||||
@ -54,7 +55,7 @@ import {
|
|||||||
applyCoinSpend,
|
applyCoinSpend,
|
||||||
extractContractData,
|
extractContractData,
|
||||||
generateDepositPermissions,
|
generateDepositPermissions,
|
||||||
getCoinsForPayment,
|
getCandidatePayCoins,
|
||||||
getEffectiveDepositAmount,
|
getEffectiveDepositAmount,
|
||||||
getTotalPaymentCost,
|
getTotalPaymentCost,
|
||||||
} from "./pay";
|
} from "./pay";
|
||||||
@ -363,7 +364,26 @@ export async function createDepositGroup(
|
|||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
|
|
||||||
const payCoinSel = await getCoinsForPayment(ws, contractData);
|
const candidates = await getCandidatePayCoins(ws, {
|
||||||
|
allowedAuditors: contractData.allowedAuditors,
|
||||||
|
allowedExchanges: contractData.allowedExchanges,
|
||||||
|
amount: contractData.amount,
|
||||||
|
maxDepositFee: contractData.maxDepositFee,
|
||||||
|
maxWireFee: contractData.maxWireFee,
|
||||||
|
timestamp: contractData.timestamp,
|
||||||
|
wireFeeAmortization: contractData.wireFeeAmortization,
|
||||||
|
wireMethod: contractData.wireMethod,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payCoinSel = selectPayCoins({
|
||||||
|
candidates,
|
||||||
|
contractTermsAmount: contractData.amount,
|
||||||
|
depositFeeLimit: contractData.maxDepositFee,
|
||||||
|
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
|
||||||
|
wireFeeLimit: contractData.maxWireFee,
|
||||||
|
prevPayCoins: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
if (!payCoinSel) {
|
if (!payCoinSel) {
|
||||||
throw Error("insufficient funds");
|
throw Error("insufficient funds");
|
||||||
|
@ -34,7 +34,6 @@ import {
|
|||||||
WalletContractData,
|
WalletContractData,
|
||||||
CoinRecord,
|
CoinRecord,
|
||||||
DenominationRecord,
|
DenominationRecord,
|
||||||
PayCoinSelection,
|
|
||||||
AbortStatus,
|
AbortStatus,
|
||||||
AllowedExchangeInfo,
|
AllowedExchangeInfo,
|
||||||
AllowedAuditorInfo,
|
AllowedAuditorInfo,
|
||||||
@ -55,7 +54,7 @@ import {
|
|||||||
PreparePayResultType,
|
PreparePayResultType,
|
||||||
ConfirmPayResultType,
|
ConfirmPayResultType,
|
||||||
} from "../types/walletTypes";
|
} from "../types/walletTypes";
|
||||||
import * as Amounts from "../util/amounts";
|
import { Amounts } from "../util/amounts";
|
||||||
import { AmountJson } from "../util/amounts";
|
import { AmountJson } from "../util/amounts";
|
||||||
import { Logger } from "../util/logging";
|
import { Logger } from "../util/logging";
|
||||||
import { parsePayUri } from "../util/taleruri";
|
import { parsePayUri } from "../util/taleruri";
|
||||||
@ -95,38 +94,13 @@ import {
|
|||||||
getRetryDuration,
|
getRetryDuration,
|
||||||
} from "../util/retries";
|
} from "../util/retries";
|
||||||
import { TransactionHandle } from "../util/query";
|
import { TransactionHandle } from "../util/query";
|
||||||
|
import { PayCoinSelection, CoinCandidateSelection, AvailableCoinInfo, selectPayCoins } from "../util/coinSelection";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logger.
|
* Logger.
|
||||||
*/
|
*/
|
||||||
const logger = new Logger("pay.ts");
|
const logger = new Logger("pay.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.
|
|
||||||
*/
|
|
||||||
denomPub: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Amount still remaining (typically the full amount,
|
|
||||||
* as coins are always refreshed after use.)
|
|
||||||
*/
|
|
||||||
availableAmount: AmountJson;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deposit fee for the coin.
|
|
||||||
*/
|
|
||||||
feeDeposit: AmountJson;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the total cost of a payment to the customer.
|
* Compute the total cost of a payment to the customer.
|
||||||
@ -212,104 +186,6 @@ export async function getEffectiveDepositAmount(
|
|||||||
return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
|
return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a list of available coins, select coins to spend under the merchant's
|
|
||||||
* constraints.
|
|
||||||
*
|
|
||||||
* This function is only exported for the sake of unit tests.
|
|
||||||
*/
|
|
||||||
export function selectPayCoins(
|
|
||||||
acis: AvailableCoinInfo[],
|
|
||||||
contractTermsAmount: AmountJson,
|
|
||||||
customerWireFees: AmountJson,
|
|
||||||
depositFeeLimit: AmountJson,
|
|
||||||
): PayCoinSelection | undefined {
|
|
||||||
if (acis.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const coinPubs: string[] = [];
|
|
||||||
const coinContributions: AmountJson[] = [];
|
|
||||||
// Sort by available amount (descending), deposit fee (ascending) and
|
|
||||||
// denomPub (ascending) if deposit fee is the same
|
|
||||||
// (to guarantee deterministic results)
|
|
||||||
acis.sort(
|
|
||||||
(o1, o2) =>
|
|
||||||
-Amounts.cmp(o1.availableAmount, o2.availableAmount) ||
|
|
||||||
Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
|
|
||||||
strcmp(o1.denomPub, o2.denomPub),
|
|
||||||
);
|
|
||||||
const paymentAmount = Amounts.add(contractTermsAmount, customerWireFees)
|
|
||||||
.amount;
|
|
||||||
const currency = paymentAmount.currency;
|
|
||||||
let amountPayRemaining = paymentAmount;
|
|
||||||
let amountDepositFeeLimitRemaining = depositFeeLimit;
|
|
||||||
const customerDepositFees = Amounts.getZero(currency);
|
|
||||||
for (const aci of acis) {
|
|
||||||
// Don't use this coin if depositing it is more expensive than
|
|
||||||
// the amount it would give the merchant.
|
|
||||||
if (Amounts.cmp(aci.feeDeposit, aci.availableAmount) >= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (amountPayRemaining.value === 0 && amountPayRemaining.fraction === 0) {
|
|
||||||
// We have spent enough!
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// How much does the user spend on deposit fees for this coin?
|
|
||||||
const depositFeeSpend = Amounts.sub(
|
|
||||||
aci.feeDeposit,
|
|
||||||
amountDepositFeeLimitRemaining,
|
|
||||||
).amount;
|
|
||||||
|
|
||||||
if (Amounts.isZero(depositFeeSpend)) {
|
|
||||||
// Fees are still covered by the merchant.
|
|
||||||
amountDepositFeeLimitRemaining = Amounts.sub(
|
|
||||||
amountDepositFeeLimitRemaining,
|
|
||||||
aci.feeDeposit,
|
|
||||||
).amount;
|
|
||||||
} else {
|
|
||||||
amountDepositFeeLimitRemaining = Amounts.getZero(currency);
|
|
||||||
}
|
|
||||||
|
|
||||||
let coinSpend: AmountJson;
|
|
||||||
const amountActualAvailable = Amounts.sub(
|
|
||||||
aci.availableAmount,
|
|
||||||
depositFeeSpend,
|
|
||||||
).amount;
|
|
||||||
|
|
||||||
if (Amounts.cmp(amountActualAvailable, amountPayRemaining) > 0) {
|
|
||||||
// Partial spending, as the coin is worth more than the remaining
|
|
||||||
// amount to pay.
|
|
||||||
coinSpend = Amounts.add(amountPayRemaining, depositFeeSpend).amount;
|
|
||||||
// Make sure we contribute at least the deposit fee, otherwise
|
|
||||||
// contributing this coin would cause a loss for the merchant.
|
|
||||||
if (Amounts.cmp(coinSpend, aci.feeDeposit) < 0) {
|
|
||||||
coinSpend = aci.feeDeposit;
|
|
||||||
}
|
|
||||||
amountPayRemaining = Amounts.getZero(currency);
|
|
||||||
} else {
|
|
||||||
// Spend the full remaining amount on the coin
|
|
||||||
coinSpend = aci.availableAmount;
|
|
||||||
amountPayRemaining = Amounts.add(amountPayRemaining, depositFeeSpend)
|
|
||||||
.amount;
|
|
||||||
amountPayRemaining = Amounts.sub(amountPayRemaining, aci.availableAmount)
|
|
||||||
.amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
coinPubs.push(aci.coinPub);
|
|
||||||
coinContributions.push(coinSpend);
|
|
||||||
}
|
|
||||||
if (Amounts.isZero(amountPayRemaining)) {
|
|
||||||
return {
|
|
||||||
paymentAmount: contractTermsAmount,
|
|
||||||
coinContributions,
|
|
||||||
coinPubs,
|
|
||||||
customerDepositFees,
|
|
||||||
customerWireFees,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSpendableCoin(
|
export function isSpendableCoin(
|
||||||
coin: CoinRecord,
|
coin: CoinRecord,
|
||||||
@ -329,6 +205,7 @@ export function isSpendableCoin(
|
|||||||
|
|
||||||
export interface CoinSelectionRequest {
|
export interface CoinSelectionRequest {
|
||||||
amount: AmountJson;
|
amount: AmountJson;
|
||||||
|
|
||||||
allowedAuditors: AllowedAuditorInfo[];
|
allowedAuditors: AllowedAuditorInfo[];
|
||||||
allowedExchanges: AllowedExchangeInfo[];
|
allowedExchanges: AllowedExchangeInfo[];
|
||||||
|
|
||||||
@ -347,19 +224,23 @@ export interface CoinSelectionRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select coins from the wallet's database that can be used
|
* Get candidate coins. From these candidate coins,
|
||||||
* to pay for the given contract.
|
* the actual contributions will be computed later.
|
||||||
*
|
*
|
||||||
* If payment is impossible, undefined is returned.
|
* The resulting candidate coin list is sorted deterministically.
|
||||||
|
*
|
||||||
|
* TODO: Exclude more coins:
|
||||||
|
* - when we already have a coin with more remaining amount than
|
||||||
|
* the payment amount, coins with even higher amounts can be skipped.
|
||||||
*/
|
*/
|
||||||
export async function getCoinsForPayment(
|
export async function getCandidatePayCoins(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
req: CoinSelectionRequest,
|
req: CoinSelectionRequest,
|
||||||
): Promise<PayCoinSelection | undefined> {
|
): Promise<CoinCandidateSelection> {
|
||||||
const remainingAmount = req.amount;
|
const candidateCoins: AvailableCoinInfo[] = [];
|
||||||
|
const wireFeesPerExchange: Record<string, AmountJson> = {};
|
||||||
|
|
||||||
const exchanges = await ws.db.iter(Stores.exchanges).toArray();
|
const exchanges = await ws.db.iter(Stores.exchanges).toArray();
|
||||||
|
|
||||||
for (const exchange of exchanges) {
|
for (const exchange of exchanges) {
|
||||||
let isOkay = false;
|
let isOkay = false;
|
||||||
const exchangeDetails = exchange.details;
|
const exchangeDetails = exchange.details;
|
||||||
@ -416,7 +297,6 @@ export async function getCoinsForPayment(
|
|||||||
throw Error("db inconsistent");
|
throw Error("db inconsistent");
|
||||||
}
|
}
|
||||||
const currency = firstDenom.value.currency;
|
const currency = firstDenom.value.currency;
|
||||||
const acis: AvailableCoinInfo[] = [];
|
|
||||||
for (const coin of coins) {
|
for (const coin of coins) {
|
||||||
const denom = await ws.db.get(Stores.denominations, [
|
const denom = await ws.db.get(Stores.denominations, [
|
||||||
exchange.baseUrl,
|
exchange.baseUrl,
|
||||||
@ -434,11 +314,12 @@ export async function getCoinsForPayment(
|
|||||||
if (!isSpendableCoin(coin, denom)) {
|
if (!isSpendableCoin(coin, denom)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
acis.push({
|
candidateCoins.push({
|
||||||
availableAmount: coin.currentAmount,
|
availableAmount: coin.currentAmount,
|
||||||
coinPub: coin.coinPub,
|
coinPub: coin.coinPub,
|
||||||
denomPub: coin.denomPub,
|
denomPub: coin.denomPub,
|
||||||
feeDeposit: denom.feeDeposit,
|
feeDeposit: denom.feeDeposit,
|
||||||
|
exchangeBaseUrl: denom.exchangeBaseUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -449,32 +330,25 @@ export async function getCoinsForPayment(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (wireFee) {
|
||||||
let customerWireFee: AmountJson;
|
wireFeesPerExchange[exchange.baseUrl] = wireFee;
|
||||||
|
|
||||||
if (wireFee && req.wireFeeAmortization) {
|
|
||||||
const amortizedWireFee = Amounts.divide(wireFee, req.wireFeeAmortization);
|
|
||||||
if (Amounts.cmp(req.maxWireFee, amortizedWireFee) < 0) {
|
|
||||||
customerWireFee = amortizedWireFee;
|
|
||||||
} else {
|
|
||||||
customerWireFee = Amounts.getZero(currency);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
customerWireFee = Amounts.getZero(currency);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try if paying using this exchange works
|
// Sort by available amount (descending), deposit fee (ascending) and
|
||||||
const res = selectPayCoins(
|
// denomPub (ascending) if deposit fee is the same
|
||||||
acis,
|
// (to guarantee deterministic results)
|
||||||
remainingAmount,
|
candidateCoins.sort(
|
||||||
customerWireFee,
|
(o1, o2) =>
|
||||||
req.maxDepositFee,
|
-Amounts.cmp(o1.availableAmount, o2.availableAmount) ||
|
||||||
|
Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
|
||||||
|
strcmp(o1.denomPub, o2.denomPub),
|
||||||
);
|
);
|
||||||
if (res) {
|
|
||||||
return res;
|
return {
|
||||||
}
|
candidateCoins,
|
||||||
}
|
wireFeesPerExchange,
|
||||||
return undefined;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyCoinSpend(
|
export async function applyCoinSpend(
|
||||||
@ -1009,13 +883,18 @@ async function storePayReplaySuccess(
|
|||||||
* We do this by going through the coin history provided by the exchange and
|
* We do this by going through the coin history provided by the exchange and
|
||||||
* (1) verifying the signatures from the exchange
|
* (1) verifying the signatures from the exchange
|
||||||
* (2) adjusting the remaining coin value
|
* (2) adjusting the remaining coin value
|
||||||
* (3) re-do coin selection.
|
* (3) re-do coin selection with the bad coin removed
|
||||||
*/
|
*/
|
||||||
async function handleInsufficientFunds(
|
async function handleInsufficientFunds(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
proposalId: string,
|
proposalId: string,
|
||||||
err: TalerErrorDetails,
|
err: TalerErrorDetails,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const proposal = await ws.db.get(Stores.purchases, proposalId);
|
||||||
|
if (!proposal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
throw Error("payment re-denomination not implemented yet");
|
throw Error("payment re-denomination not implemented yet");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1232,7 +1111,24 @@ export async function checkPaymentByProposalId(
|
|||||||
|
|
||||||
if (!purchase) {
|
if (!purchase) {
|
||||||
// If not already paid, check if we could pay for it.
|
// If not already paid, check if we could pay for it.
|
||||||
const res = await getCoinsForPayment(ws, contractData);
|
const candidates = await getCandidatePayCoins(ws, {
|
||||||
|
allowedAuditors: contractData.allowedAuditors,
|
||||||
|
allowedExchanges: contractData.allowedExchanges,
|
||||||
|
amount: contractData.amount,
|
||||||
|
maxDepositFee: contractData.maxDepositFee,
|
||||||
|
maxWireFee: contractData.maxWireFee,
|
||||||
|
timestamp: contractData.timestamp,
|
||||||
|
wireFeeAmortization: contractData.wireFeeAmortization,
|
||||||
|
wireMethod: contractData.wireMethod,
|
||||||
|
});
|
||||||
|
const res = selectPayCoins({
|
||||||
|
candidates,
|
||||||
|
contractTermsAmount: contractData.amount,
|
||||||
|
depositFeeLimit: contractData.maxDepositFee,
|
||||||
|
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
|
||||||
|
wireFeeLimit: contractData.maxWireFee,
|
||||||
|
prevPayCoins: [],
|
||||||
|
});
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
logger.info("not confirming payment, insufficient coins");
|
logger.info("not confirming payment, insufficient coins");
|
||||||
@ -1434,12 +1330,34 @@ export async function confirmPay(
|
|||||||
|
|
||||||
logger.trace("confirmPay: purchase record does not exist yet");
|
logger.trace("confirmPay: purchase record does not exist yet");
|
||||||
|
|
||||||
const res = await getCoinsForPayment(ws, d.contractData);
|
const contractData = d.contractData;
|
||||||
|
|
||||||
|
const candidates = await getCandidatePayCoins(ws, {
|
||||||
|
allowedAuditors: contractData.allowedAuditors,
|
||||||
|
allowedExchanges: contractData.allowedExchanges,
|
||||||
|
amount: contractData.amount,
|
||||||
|
maxDepositFee: contractData.maxDepositFee,
|
||||||
|
maxWireFee: contractData.maxWireFee,
|
||||||
|
timestamp: contractData.timestamp,
|
||||||
|
wireFeeAmortization: contractData.wireFeeAmortization,
|
||||||
|
wireMethod: contractData.wireMethod,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = selectPayCoins({
|
||||||
|
candidates,
|
||||||
|
contractTermsAmount: contractData.amount,
|
||||||
|
depositFeeLimit: contractData.maxDepositFee,
|
||||||
|
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
|
||||||
|
wireFeeLimit: contractData.maxWireFee,
|
||||||
|
prevPayCoins: [],
|
||||||
|
});
|
||||||
|
|
||||||
logger.trace("coin selection result", res);
|
logger.trace("coin selection result", res);
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
// 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,
|
||||||
|
// and the status should be stored in the DB.
|
||||||
logger.warn("not confirming payment, insufficient coins");
|
logger.warn("not confirming payment, insufficient coins");
|
||||||
throw Error("insufficient balance");
|
throw Error("insufficient balance");
|
||||||
}
|
}
|
||||||
|
@ -41,6 +41,7 @@ import { ReserveTransaction } from "./ReserveTransaction";
|
|||||||
import { Timestamp, Duration } from "../util/time";
|
import { Timestamp, Duration } from "../util/time";
|
||||||
import { IDBKeyPath } from "@gnu-taler/idb-bridge";
|
import { IDBKeyPath } from "@gnu-taler/idb-bridge";
|
||||||
import { RetryInfo } from "../util/retries";
|
import { RetryInfo } from "../util/retries";
|
||||||
|
import { PayCoinSelection } from "../util/coinSelection";
|
||||||
|
|
||||||
export enum ReserveRecordStatus {
|
export enum ReserveRecordStatus {
|
||||||
/**
|
/**
|
||||||
@ -1138,36 +1139,6 @@ export interface WalletContractData {
|
|||||||
maxDepositFee: AmountJson;
|
maxDepositFee: AmountJson;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of selecting coins, contains the exchange, and selected
|
|
||||||
* coins with their denomination.
|
|
||||||
*/
|
|
||||||
export interface PayCoinSelection {
|
|
||||||
/**
|
|
||||||
* Amount requested by the merchant.
|
|
||||||
*/
|
|
||||||
paymentAmount: AmountJson;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public keys of the coins that were selected.
|
|
||||||
*/
|
|
||||||
coinPubs: string[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Amount that each coin contributes.
|
|
||||||
*/
|
|
||||||
coinContributions: AmountJson[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much of the wire fees is the customer paying?
|
|
||||||
*/
|
|
||||||
customerWireFees: AmountJson;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much of the deposit fees is the customer paying?
|
|
||||||
*/
|
|
||||||
customerDepositFees: AmountJson;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum AbortStatus {
|
export enum AbortStatus {
|
||||||
None = "none",
|
None = "none",
|
||||||
@ -1210,6 +1181,16 @@ export interface PurchaseRecord {
|
|||||||
|
|
||||||
payCoinSelection: PayCoinSelection;
|
payCoinSelection: PayCoinSelection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending removals from pay coin selection.
|
||||||
|
*
|
||||||
|
* Used when a the pay coin selection needs to be changed
|
||||||
|
* because a coin became known as double-spent or invalid,
|
||||||
|
* but a new coin selection can't immediately be done, as
|
||||||
|
* there is not enough balance (e.g. when waiting for a refresh).
|
||||||
|
*/
|
||||||
|
pendingRemovedCoinPubs?: string[];
|
||||||
|
|
||||||
totalPayCost: AmountJson;
|
totalPayCost: AmountJson;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -381,6 +381,25 @@ function mult(a: AmountJson, n: number): Result {
|
|||||||
return add(acc, x);
|
return add(acc, x);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function max(a: AmountLike, b: AmountLike): AmountJson {
|
||||||
|
const cr = Amounts.cmp(a, b);
|
||||||
|
if (cr >= 0) {
|
||||||
|
return jsonifyAmount(a);
|
||||||
|
} else {
|
||||||
|
return jsonifyAmount(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function min(a: AmountLike, b: AmountLike): AmountJson {
|
||||||
|
const cr = Amounts.cmp(a, b);
|
||||||
|
if (cr >= 0) {
|
||||||
|
return jsonifyAmount(b);
|
||||||
|
} else {
|
||||||
|
return jsonifyAmount(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Export all amount-related functions here for better IDE experience.
|
// Export all amount-related functions here for better IDE experience.
|
||||||
export const Amounts = {
|
export const Amounts = {
|
||||||
stringify: stringify,
|
stringify: stringify,
|
||||||
@ -391,6 +410,8 @@ export const Amounts = {
|
|||||||
sum: sum,
|
sum: sum,
|
||||||
sub: sub,
|
sub: sub,
|
||||||
mult: mult,
|
mult: mult,
|
||||||
|
max: max,
|
||||||
|
min: min,
|
||||||
check: check,
|
check: check,
|
||||||
getZero: getZero,
|
getZero: getZero,
|
||||||
isZero: isZero,
|
isZero: isZero,
|
||||||
|
@ -1,24 +1,25 @@
|
|||||||
/*
|
/*
|
||||||
This file is part of TALER
|
This file is part of GNU Taler
|
||||||
(C) 2017 Inria and GNUnet e.V.
|
(C) 2021 Taler Systems S.A.
|
||||||
|
|
||||||
TALER is free software; you can redistribute it and/or modify it under the
|
GNU Taler is free software; you can redistribute it and/or modify it under the
|
||||||
terms of the GNU General Public License as published by the Free Software
|
terms of the GNU General Public License as published by the Free Software
|
||||||
Foundation; either version 3, or (at your option) any later version.
|
Foundation; either version 3, or (at your option) any later version.
|
||||||
|
|
||||||
TALER is distributed in the hope that it will be useful, but WITHOUT ANY
|
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License along with
|
You should have received a copy of the GNU General Public License along with
|
||||||
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports.
|
||||||
|
*/
|
||||||
import test from "ava";
|
import test from "ava";
|
||||||
|
import { AmountJson, Amounts } from "..";
|
||||||
import { AmountJson } from "./util/amounts";
|
import { AvailableCoinInfo, selectPayCoins } from "./coinSelection";
|
||||||
import * as Amounts from "./util/amounts";
|
|
||||||
import { selectPayCoins, AvailableCoinInfo } from "./operations/pay";
|
|
||||||
|
|
||||||
function a(x: string): AmountJson {
|
function a(x: string): AmountJson {
|
||||||
const amt = Amounts.parse(x);
|
const amt = Amounts.parse(x);
|
||||||
@ -34,6 +35,7 @@ function fakeAci(current: string, feeDeposit: string): AvailableCoinInfo {
|
|||||||
coinPub: "foobar",
|
coinPub: "foobar",
|
||||||
denomPub: "foobar",
|
denomPub: "foobar",
|
||||||
feeDeposit: a(feeDeposit),
|
feeDeposit: a(feeDeposit),
|
||||||
|
exchangeBaseUrl: "https://example.com/",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,7 +45,17 @@ test("coin selection 1", (t) => {
|
|||||||
fakeAci("EUR:1.0", "EUR:0.0"),
|
fakeAci("EUR:1.0", "EUR:0.0"),
|
||||||
];
|
];
|
||||||
|
|
||||||
const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0"), a("EUR:0.1"));
|
const res = selectPayCoins({
|
||||||
|
candidates: {
|
||||||
|
candidateCoins: acis,
|
||||||
|
wireFeesPerExchange: {},
|
||||||
|
},
|
||||||
|
contractTermsAmount: a("EUR:2.0"),
|
||||||
|
depositFeeLimit: a("EUR:0.1"),
|
||||||
|
wireFeeLimit: a("EUR:0"),
|
||||||
|
wireFeeAmortization: 1,
|
||||||
|
});
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
@ -59,7 +71,18 @@ test("coin selection 2", (t) => {
|
|||||||
// Merchant covers the fee, this one shouldn't be used
|
// Merchant covers the fee, this one shouldn't be used
|
||||||
fakeAci("EUR:1.0", "EUR:0.0"),
|
fakeAci("EUR:1.0", "EUR:0.0"),
|
||||||
];
|
];
|
||||||
const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0"), a("EUR:0.5"));
|
|
||||||
|
const res = selectPayCoins({
|
||||||
|
candidates: {
|
||||||
|
candidateCoins: acis,
|
||||||
|
wireFeesPerExchange: {},
|
||||||
|
},
|
||||||
|
contractTermsAmount: a("EUR:2.0"),
|
||||||
|
depositFeeLimit: a("EUR:0.5"),
|
||||||
|
wireFeeLimit: a("EUR:0"),
|
||||||
|
wireFeeAmortization: 1,
|
||||||
|
});
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
@ -75,7 +98,18 @@ test("coin selection 3", (t) => {
|
|||||||
// this coin should be selected instead of previous one with fee
|
// this coin should be selected instead of previous one with fee
|
||||||
fakeAci("EUR:1.0", "EUR:0.0"),
|
fakeAci("EUR:1.0", "EUR:0.0"),
|
||||||
];
|
];
|
||||||
const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0"), a("EUR:0.5"));
|
|
||||||
|
const res = selectPayCoins({
|
||||||
|
candidates: {
|
||||||
|
candidateCoins: acis,
|
||||||
|
wireFeesPerExchange: {},
|
||||||
|
},
|
||||||
|
contractTermsAmount: a("EUR:2.0"),
|
||||||
|
depositFeeLimit: a("EUR:0.5"),
|
||||||
|
wireFeeLimit: a("EUR:0"),
|
||||||
|
wireFeeAmortization: 1,
|
||||||
|
});
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
@ -90,7 +124,18 @@ test("coin selection 4", (t) => {
|
|||||||
fakeAci("EUR:1.0", "EUR:0.5"),
|
fakeAci("EUR:1.0", "EUR:0.5"),
|
||||||
fakeAci("EUR:1.0", "EUR:0.5"),
|
fakeAci("EUR:1.0", "EUR:0.5"),
|
||||||
];
|
];
|
||||||
const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0"), a("EUR:0.5"));
|
|
||||||
|
const res = selectPayCoins({
|
||||||
|
candidates: {
|
||||||
|
candidateCoins: acis,
|
||||||
|
wireFeesPerExchange: {},
|
||||||
|
},
|
||||||
|
contractTermsAmount: a("EUR:2.0"),
|
||||||
|
depositFeeLimit: a("EUR:0.5"),
|
||||||
|
wireFeeLimit: a("EUR:0"),
|
||||||
|
wireFeeAmortization: 1,
|
||||||
|
});
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
@ -105,7 +150,18 @@ test("coin selection 5", (t) => {
|
|||||||
fakeAci("EUR:1.0", "EUR:0.5"),
|
fakeAci("EUR:1.0", "EUR:0.5"),
|
||||||
fakeAci("EUR:1.0", "EUR:0.5"),
|
fakeAci("EUR:1.0", "EUR:0.5"),
|
||||||
];
|
];
|
||||||
const res = selectPayCoins(acis, a("EUR:4.0"), a("EUR:0"), a("EUR:0.2"));
|
|
||||||
|
const res = selectPayCoins({
|
||||||
|
candidates: {
|
||||||
|
candidateCoins: acis,
|
||||||
|
wireFeesPerExchange: {},
|
||||||
|
},
|
||||||
|
contractTermsAmount: a("EUR:4.0"),
|
||||||
|
depositFeeLimit: a("EUR:0.2"),
|
||||||
|
wireFeeLimit: a("EUR:0"),
|
||||||
|
wireFeeAmortization: 1,
|
||||||
|
});
|
||||||
|
|
||||||
t.true(!res);
|
t.true(!res);
|
||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
@ -115,7 +171,16 @@ test("coin selection 6", (t) => {
|
|||||||
fakeAci("EUR:1.0", "EUR:0.5"),
|
fakeAci("EUR:1.0", "EUR:0.5"),
|
||||||
fakeAci("EUR:1.0", "EUR:0.5"),
|
fakeAci("EUR:1.0", "EUR:0.5"),
|
||||||
];
|
];
|
||||||
const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0"), a("EUR:0.2"));
|
const res = selectPayCoins({
|
||||||
|
candidates: {
|
||||||
|
candidateCoins: acis,
|
||||||
|
wireFeesPerExchange: {},
|
||||||
|
},
|
||||||
|
contractTermsAmount: a("EUR:2.0"),
|
||||||
|
depositFeeLimit: a("EUR:0.2"),
|
||||||
|
wireFeeLimit: a("EUR:0"),
|
||||||
|
wireFeeAmortization: 1,
|
||||||
|
});
|
||||||
t.true(!res);
|
t.true(!res);
|
||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
263
packages/taler-wallet-core/src/util/coinSelection.ts
Normal file
263
packages/taler-wallet-core/src/util/coinSelection.ts
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2021 Taler Systems S.A.
|
||||||
|
|
||||||
|
GNU Taler is free software; you can redistribute it and/or modify it under the
|
||||||
|
terms of the GNU General Public License as published by the Free Software
|
||||||
|
Foundation; either version 3, or (at your option) any later version.
|
||||||
|
|
||||||
|
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with
|
||||||
|
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection of coins for payments.
|
||||||
|
*
|
||||||
|
* @author Florian Dold
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports.
|
||||||
|
*/
|
||||||
|
import { AmountJson, Amounts } from "./amounts";
|
||||||
|
import { strcmp } from "./helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of selecting coins, contains the exchange, and selected
|
||||||
|
* coins with their denomination.
|
||||||
|
*/
|
||||||
|
export interface PayCoinSelection {
|
||||||
|
/**
|
||||||
|
* Amount requested by the merchant.
|
||||||
|
*/
|
||||||
|
paymentAmount: AmountJson;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public keys of the coins that were selected.
|
||||||
|
*/
|
||||||
|
coinPubs: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amount that each coin contributes.
|
||||||
|
*/
|
||||||
|
coinContributions: AmountJson[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of the wire fees is the customer paying?
|
||||||
|
*/
|
||||||
|
customerWireFees: AmountJson;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of the deposit fees is the customer paying?
|
||||||
|
*/
|
||||||
|
customerDepositFees: AmountJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
denomPub: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreviousPayCoins = {
|
||||||
|
coinPub: string;
|
||||||
|
contribution: AmountJson;
|
||||||
|
feeDeposit: AmountJson;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of candidate coins, 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 function selectPayCoins(
|
||||||
|
req: SelectPayCoinRequest,
|
||||||
|
): PayCoinSelection | undefined {
|
||||||
|
const {
|
||||||
|
candidates,
|
||||||
|
contractTermsAmount,
|
||||||
|
depositFeeLimit,
|
||||||
|
wireFeeLimit,
|
||||||
|
wireFeeAmortization,
|
||||||
|
} = req;
|
||||||
|
|
||||||
|
if (candidates.candidateCoins.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const coinPubs: string[] = [];
|
||||||
|
const coinContributions: AmountJson[] = [];
|
||||||
|
const currency = contractTermsAmount.currency;
|
||||||
|
// Amount that still needs to be paid.
|
||||||
|
// May increase during the computation when fees need to be covered.
|
||||||
|
let amountPayRemaining = contractTermsAmount;
|
||||||
|
// Allowance given by the merchant towards wire fees
|
||||||
|
let amountWireFeeLimitRemaining = wireFeeLimit;
|
||||||
|
// Allowance given by the merchant towards deposit fees
|
||||||
|
// (and wire fees after wire fee limit is exhausted)
|
||||||
|
let amountDepositFeeLimitRemaining = depositFeeLimit;
|
||||||
|
let customerDepositFees = Amounts.getZero(currency);
|
||||||
|
let customerWireFees = Amounts.getZero(currency);
|
||||||
|
|
||||||
|
const wireFeeCoveredForExchange: Set<string> = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account for the fees of spending a coin.
|
||||||
|
*/
|
||||||
|
function tallyFees(exchangeBaseUrl: string, feeDeposit: AmountJson): void {
|
||||||
|
if (!wireFeeCoveredForExchange.has(exchangeBaseUrl)) {
|
||||||
|
const wf =
|
||||||
|
candidates.wireFeesPerExchange[exchangeBaseUrl] ??
|
||||||
|
Amounts.getZero(currency);
|
||||||
|
const wfForgiven = Amounts.min(amountWireFeeLimitRemaining, wf);
|
||||||
|
amountWireFeeLimitRemaining = Amounts.sub(
|
||||||
|
amountWireFeeLimitRemaining,
|
||||||
|
wfForgiven,
|
||||||
|
).amount;
|
||||||
|
// The remaining, amortized amount needs to be paid by the
|
||||||
|
// wallet or covered by the deposit fee allowance.
|
||||||
|
let wfRemaining = Amounts.divide(
|
||||||
|
Amounts.sub(wf, wfForgiven).amount,
|
||||||
|
wireFeeAmortization,
|
||||||
|
);
|
||||||
|
|
||||||
|
// This is the amount forgiven via the deposit fee allowance.
|
||||||
|
const wfDepositForgiven = Amounts.min(
|
||||||
|
amountDepositFeeLimitRemaining,
|
||||||
|
wfRemaining,
|
||||||
|
);
|
||||||
|
amountDepositFeeLimitRemaining = Amounts.sub(
|
||||||
|
amountDepositFeeLimitRemaining,
|
||||||
|
wfDepositForgiven,
|
||||||
|
).amount;
|
||||||
|
|
||||||
|
wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount;
|
||||||
|
customerWireFees = Amounts.add(customerWireFees, wfRemaining).amount;
|
||||||
|
amountPayRemaining = Amounts.add(amountPayRemaining, wfRemaining).amount;
|
||||||
|
wireFeeCoveredForExchange.add(exchangeBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dfForgiven = Amounts.min(feeDeposit, amountDepositFeeLimitRemaining);
|
||||||
|
|
||||||
|
amountDepositFeeLimitRemaining = Amounts.sub(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevPayCoins = req.prevPayCoins ?? [];
|
||||||
|
|
||||||
|
// Look at existing pay coin selection and tally up
|
||||||
|
for (const prev of prevPayCoins) {
|
||||||
|
tallyFees(prev.exchangeBaseUrl, prev.feeDeposit);
|
||||||
|
amountPayRemaining = Amounts.sub(amountPayRemaining, prev.contribution)
|
||||||
|
.amount;
|
||||||
|
|
||||||
|
coinPubs.push(prev.coinPub);
|
||||||
|
coinContributions.push(prev.contribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevCoinPubs = new Set(prevPayCoins.map((x) => x.coinPub));
|
||||||
|
|
||||||
|
// Sort by available amount (descending), deposit fee (ascending) and
|
||||||
|
// denomPub (ascending) if deposit fee is the same
|
||||||
|
// (to guarantee deterministic results)
|
||||||
|
const candidateCoins = [...candidates.candidateCoins].sort(
|
||||||
|
(o1, o2) =>
|
||||||
|
-Amounts.cmp(o1.availableAmount, o2.availableAmount) ||
|
||||||
|
Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
|
||||||
|
strcmp(o1.denomPub, o2.denomPub),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
for (const aci of candidateCoins) {
|
||||||
|
// Don't use this coin if depositing it is more expensive than
|
||||||
|
// the amount it would give the merchant.
|
||||||
|
if (Amounts.cmp(aci.feeDeposit, aci.availableAmount) >= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Amounts.isZero(amountPayRemaining)) {
|
||||||
|
// We have spent enough!
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same coin can't contribute twice to the same payment,
|
||||||
|
// by a fundamental, intentional limitation of the protocol.
|
||||||
|
if (prevCoinPubs.has(aci.coinPub)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tallyFees(aci.exchangeBaseUrl, aci.feeDeposit);
|
||||||
|
|
||||||
|
const coinSpend = Amounts.min(amountPayRemaining, aci.availableAmount);
|
||||||
|
amountPayRemaining = Amounts.sub(amountPayRemaining, coinSpend).amount;
|
||||||
|
coinPubs.push(aci.coinPub);
|
||||||
|
coinContributions.push(coinSpend);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Amounts.isZero(amountPayRemaining)) {
|
||||||
|
return {
|
||||||
|
paymentAmount: contractTermsAmount,
|
||||||
|
coinContributions,
|
||||||
|
coinPubs,
|
||||||
|
customerDepositFees,
|
||||||
|
customerWireFees,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user