2021-03-15 13:43:53 +01:00
|
|
|
/*
|
|
|
|
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.
|
|
|
|
*/
|
2023-03-31 17:27:05 +02:00
|
|
|
import { GlobalIDB } from "@gnu-taler/idb-bridge";
|
2021-11-27 20:56:58 +01:00
|
|
|
import {
|
2023-03-31 17:27:05 +02:00
|
|
|
AbsoluteTime,
|
2022-04-29 21:05:17 +02:00
|
|
|
AgeCommitmentProof,
|
2023-03-31 17:27:05 +02:00
|
|
|
AgeRestriction,
|
2021-11-27 20:56:58 +01:00
|
|
|
AmountJson,
|
|
|
|
Amounts,
|
2023-06-13 21:46:16 +02:00
|
|
|
AmountString,
|
2023-03-31 17:27:05 +02:00
|
|
|
CoinStatus,
|
|
|
|
DenominationInfo,
|
2021-11-27 20:56:58 +01:00
|
|
|
DenominationPubKey,
|
2023-03-31 17:27:05 +02:00
|
|
|
DenomSelectionState,
|
2023-06-15 18:07:31 +02:00
|
|
|
Duration,
|
2023-03-31 17:27:05 +02:00
|
|
|
ForcedCoinSel,
|
|
|
|
ForcedDenomSel,
|
2023-06-13 21:46:16 +02:00
|
|
|
GetPlanForOperationRequest,
|
|
|
|
GetPlanForOperationResponse,
|
2023-03-31 17:27:05 +02:00
|
|
|
j2s,
|
2022-01-24 21:14:21 +01:00
|
|
|
Logger,
|
2023-03-31 17:27:05 +02:00
|
|
|
parsePaytoUri,
|
|
|
|
PayCoinSelection,
|
|
|
|
PayMerchantInsufficientBalanceDetails,
|
|
|
|
strcmp,
|
2023-06-16 14:40:45 +02:00
|
|
|
TransactionAmountMode,
|
2023-06-13 21:46:16 +02:00
|
|
|
TransactionType,
|
2021-11-27 20:56:58 +01:00
|
|
|
} from "@gnu-taler/taler-util";
|
2023-03-31 17:27:05 +02:00
|
|
|
import {
|
|
|
|
AllowedAuditorInfo,
|
|
|
|
AllowedExchangeInfo,
|
|
|
|
DenominationRecord,
|
|
|
|
} from "../db.js";
|
2023-06-15 18:07:31 +02:00
|
|
|
import {
|
|
|
|
CoinAvailabilityRecord,
|
|
|
|
getExchangeDetails,
|
|
|
|
isWithdrawableDenom,
|
|
|
|
} from "../index.js";
|
2023-03-31 17:27:05 +02:00
|
|
|
import { InternalWalletState } from "../internal-wallet-state.js";
|
|
|
|
import { getMerchantPaymentBalanceDetails } from "../operations/balance.js";
|
|
|
|
import { checkDbInvariant, checkLogicInvariant } from "./invariants.js";
|
2021-03-27 19:35:44 +01:00
|
|
|
|
|
|
|
const logger = new Logger("coinSelection.ts");
|
2021-03-15 13:43:53 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
2022-04-29 21:05:17 +02:00
|
|
|
*
|
2022-03-10 16:30:24 +01:00
|
|
|
* FIXME: We should only need the denomPubHash here, if at all.
|
2021-03-15 13:43:53 +01:00
|
|
|
*/
|
2021-11-17 10:23:22 +01:00
|
|
|
denomPub: DenominationPubKey;
|
2021-03-15 13:43:53 +01:00
|
|
|
|
2022-06-10 13:03:47 +02:00
|
|
|
/**
|
|
|
|
* Full value of the coin.
|
|
|
|
*/
|
|
|
|
value: AmountJson;
|
|
|
|
|
2021-03-15 13:43:53 +01:00
|
|
|
/**
|
|
|
|
* 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;
|
2022-04-29 21:05:17 +02:00
|
|
|
|
2022-09-16 16:20:47 +02:00
|
|
|
maxAge: number;
|
2022-04-29 21:05:17 +02:00
|
|
|
ageCommitmentProof?: AgeCommitmentProof;
|
2021-03-15 13:43:53 +01:00
|
|
|
}
|
|
|
|
|
2021-04-07 19:29:51 +02:00
|
|
|
export type PreviousPayCoins = {
|
2021-03-15 13:43:53 +01:00
|
|
|
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;
|
2022-04-29 21:05:17 +02:00
|
|
|
requiredMinimumAge?: number;
|
2021-03-15 13:43:53 +01:00
|
|
|
}
|
|
|
|
|
2022-09-15 20:16:42 +02:00
|
|
|
export interface CoinSelectionTally {
|
2021-03-27 19:35:44 +01:00
|
|
|
/**
|
|
|
|
* Amount that still needs to be paid.
|
|
|
|
* May increase during the computation when fees need to be covered.
|
|
|
|
*/
|
|
|
|
amountPayRemaining: AmountJson;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Allowance given by the merchant towards wire fees
|
|
|
|
*/
|
|
|
|
amountWireFeeLimitRemaining: AmountJson;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Allowance given by the merchant towards deposit fees
|
|
|
|
* (and wire fees after wire fee limit is exhausted)
|
|
|
|
*/
|
|
|
|
amountDepositFeeLimitRemaining: AmountJson;
|
|
|
|
|
|
|
|
customerDepositFees: AmountJson;
|
|
|
|
|
|
|
|
customerWireFees: AmountJson;
|
|
|
|
|
|
|
|
wireFeeCoveredForExchange: Set<string>;
|
2023-01-05 18:45:49 +01:00
|
|
|
|
|
|
|
lastDepositFee: AmountJson;
|
2021-03-27 19:35:44 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Account for the fees of spending a coin.
|
|
|
|
*/
|
2023-06-13 21:46:16 +02:00
|
|
|
function tallyFees(
|
2023-03-31 17:27:05 +02:00
|
|
|
tally: Readonly<CoinSelectionTally>,
|
2021-03-27 19:35:44 +01:00
|
|
|
wireFeesPerExchange: Record<string, AmountJson>,
|
|
|
|
wireFeeAmortization: number,
|
|
|
|
exchangeBaseUrl: string,
|
|
|
|
feeDeposit: AmountJson,
|
|
|
|
): CoinSelectionTally {
|
|
|
|
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 =
|
2022-11-02 17:42:14 +01:00
|
|
|
wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.zeroOfCurrency(currency);
|
2021-03-27 19:35:44 +01:00
|
|
|
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;
|
|
|
|
|
|
|
|
return {
|
|
|
|
amountDepositFeeLimitRemaining,
|
|
|
|
amountPayRemaining,
|
|
|
|
amountWireFeeLimitRemaining,
|
|
|
|
customerDepositFees,
|
|
|
|
customerWireFees,
|
|
|
|
wireFeeCoveredForExchange,
|
2023-01-05 18:45:49 +01:00
|
|
|
lastDepositFee: feeDeposit,
|
2021-03-27 19:35:44 +01:00
|
|
|
};
|
|
|
|
}
|
2023-03-31 17:27:05 +02:00
|
|
|
|
|
|
|
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
|
|
|
|
* 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(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
req: SelectPayCoinRequestNg,
|
|
|
|
): Promise<SelectPayCoinsResult> {
|
|
|
|
const {
|
|
|
|
contractTermsAmount,
|
|
|
|
depositFeeLimit,
|
|
|
|
wireFeeLimit,
|
|
|
|
wireFeeAmortization,
|
|
|
|
} = req;
|
|
|
|
|
|
|
|
const [candidateDenoms, wireFeesPerExchange] = await selectCandidates(
|
|
|
|
ws,
|
|
|
|
req,
|
|
|
|
);
|
|
|
|
|
|
|
|
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),
|
|
|
|
};
|
|
|
|
|
|
|
|
const prevPayCoins = req.prevPayCoins ?? [];
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
|
|
|
|
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) {
|
|
|
|
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;
|
|
|
|
|
|
|
|
logger.trace(`coin selection request ${j2s(req)}`);
|
|
|
|
logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`);
|
|
|
|
|
|
|
|
await ws.db
|
|
|
|
.mktx((x) => [x.coins, x.denominations])
|
|
|
|
.runReadOnly(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.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})`,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
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),
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function makeAvailabilityKey(
|
|
|
|
exchangeBaseUrl: string,
|
|
|
|
denomPubHash: string,
|
|
|
|
maxAge: number,
|
|
|
|
): string {
|
|
|
|
return `${denomPubHash};${maxAge};${exchangeBaseUrl}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Selection result.
|
|
|
|
*/
|
|
|
|
interface SelResult {
|
|
|
|
/**
|
|
|
|
* Map from an availability key
|
|
|
|
* to an array of contributions.
|
|
|
|
*/
|
|
|
|
[avKey: string]: {
|
|
|
|
exchangeBaseUrl: string;
|
|
|
|
denomPubHash: string;
|
|
|
|
maxAge: number;
|
|
|
|
contributions: AmountJson[];
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function selectGreedy(
|
|
|
|
req: SelectPayCoinRequestNg,
|
|
|
|
candidateDenoms: AvailableDenom[],
|
|
|
|
wireFeesPerExchange: Record<string, AmountJson>,
|
|
|
|
tally: CoinSelectionTally,
|
|
|
|
): SelResult | undefined {
|
|
|
|
const { wireFeeAmortization } = req;
|
|
|
|
const selectedDenom: SelResult = {};
|
|
|
|
for (const denom of candidateDenoms) {
|
|
|
|
const contributions: AmountJson[] = [];
|
|
|
|
|
|
|
|
// Don't use this coin if depositing it is more expensive than
|
|
|
|
// the amount it would give the merchant.
|
|
|
|
if (Amounts.cmp(denom.feeDeposit, denom.value) > 0) {
|
|
|
|
tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit);
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
for (
|
|
|
|
let i = 0;
|
|
|
|
i < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining);
|
|
|
|
i++
|
|
|
|
) {
|
|
|
|
tally = tallyFees(
|
|
|
|
tally,
|
|
|
|
wireFeesPerExchange,
|
|
|
|
wireFeeAmortization,
|
|
|
|
denom.exchangeBaseUrl,
|
|
|
|
Amounts.parseOrThrow(denom.feeDeposit),
|
|
|
|
);
|
|
|
|
|
|
|
|
const coinSpend = Amounts.max(
|
|
|
|
Amounts.min(tally.amountPayRemaining, denom.value),
|
|
|
|
denom.feeDeposit,
|
|
|
|
);
|
|
|
|
|
|
|
|
tally.amountPayRemaining = Amounts.sub(
|
|
|
|
tally.amountPayRemaining,
|
|
|
|
coinSpend,
|
|
|
|
).amount;
|
|
|
|
|
|
|
|
contributions.push(coinSpend);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (contributions.length) {
|
|
|
|
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,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
sd.contributions.push(...contributions);
|
|
|
|
selectedDenom[avKey] = sd;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
function selectForced(
|
|
|
|
req: SelectPayCoinRequestNg,
|
|
|
|
candidateDenoms: AvailableDenom[],
|
|
|
|
): SelResult | undefined {
|
|
|
|
const selectedDenom: SelResult = {};
|
|
|
|
|
|
|
|
const forcedSelection = req.forcedSelection;
|
|
|
|
checkLogicInvariant(!!forcedSelection);
|
|
|
|
|
|
|
|
for (const forcedCoin of forcedSelection.coins) {
|
|
|
|
let found = false;
|
|
|
|
for (const aci of candidateDenoms) {
|
|
|
|
if (aci.numAvailable <= 0) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
if (Amounts.cmp(aci.value, forcedCoin.value) === 0) {
|
|
|
|
aci.numAvailable--;
|
|
|
|
const avKey = makeAvailabilityKey(
|
|
|
|
aci.exchangeBaseUrl,
|
|
|
|
aci.denomPubHash,
|
|
|
|
aci.maxAge,
|
|
|
|
);
|
|
|
|
let sd = selectedDenom[avKey];
|
|
|
|
if (!sd) {
|
|
|
|
sd = {
|
|
|
|
contributions: [],
|
|
|
|
denomPubHash: aci.denomPubHash,
|
|
|
|
exchangeBaseUrl: aci.exchangeBaseUrl,
|
|
|
|
maxAge: aci.maxAge,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value));
|
|
|
|
selectedDenom[avKey] = sd;
|
|
|
|
found = true;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (!found) {
|
|
|
|
throw Error("can't find coin for forced coin selection");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return selectedDenom;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface SelectPayCoinRequestNg {
|
|
|
|
exchanges: AllowedExchangeInfo[];
|
|
|
|
auditors: AllowedAuditorInfo[];
|
|
|
|
wireMethod: string;
|
|
|
|
contractTermsAmount: AmountJson;
|
|
|
|
depositFeeLimit: AmountJson;
|
|
|
|
wireFeeLimit: AmountJson;
|
|
|
|
wireFeeAmortization: number;
|
|
|
|
prevPayCoins?: PreviousPayCoins;
|
|
|
|
requiredMinimumAge?: number;
|
|
|
|
forcedSelection?: ForcedCoinSel;
|
|
|
|
}
|
|
|
|
|
|
|
|
export type AvailableDenom = DenominationInfo & {
|
|
|
|
maxAge: number;
|
|
|
|
numAvailable: number;
|
|
|
|
};
|
|
|
|
|
2023-06-13 21:46:16 +02:00
|
|
|
async function selectCandidates(
|
2023-03-31 17:27:05 +02:00
|
|
|
ws: InternalWalletState,
|
|
|
|
req: SelectPayCoinRequestNg,
|
|
|
|
): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
|
|
|
|
return await ws.db
|
|
|
|
.mktx((x) => [
|
|
|
|
x.exchanges,
|
|
|
|
x.exchangeDetails,
|
|
|
|
x.denominations,
|
|
|
|
x.coinAvailability,
|
|
|
|
])
|
|
|
|
.runReadOnly(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> = {};
|
|
|
|
for (const exchange of exchanges) {
|
|
|
|
const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl);
|
|
|
|
// 1.- exchange has same currency
|
|
|
|
if (exchangeDetails?.currency !== req.contractTermsAmount.currency) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
let wireMethodFee: string | undefined;
|
|
|
|
// 2.- exchange supports wire method
|
|
|
|
for (const acc of exchangeDetails.wireInfo.accounts) {
|
|
|
|
const pp = parsePaytoUri(acc.payto_uri);
|
|
|
|
checkLogicInvariant(!!pp);
|
|
|
|
if (pp.targetType === req.wireMethod) {
|
|
|
|
// also check that wire method is supported now
|
|
|
|
const wireFeeStr = exchangeDetails.wireInfo.feesForType[
|
|
|
|
req.wireMethod
|
|
|
|
]?.find((x) => {
|
|
|
|
return AbsoluteTime.isBetween(
|
|
|
|
AbsoluteTime.now(),
|
2023-05-26 12:19:32 +02:00
|
|
|
AbsoluteTime.fromProtocolTimestamp(x.startStamp),
|
|
|
|
AbsoluteTime.fromProtocolTimestamp(x.endStamp),
|
2023-03-31 17:27:05 +02:00
|
|
|
);
|
|
|
|
})?.wireFee;
|
|
|
|
if (wireFeeStr) {
|
|
|
|
wireMethodFee = wireFeeStr;
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (!wireMethodFee) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
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,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// 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];
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get a list of denominations (with repetitions possible)
|
|
|
|
* whose total value is as close as possible to the available
|
|
|
|
* amount, but never larger.
|
|
|
|
*/
|
|
|
|
export function selectWithdrawalDenominations(
|
|
|
|
amountAvailable: AmountJson,
|
|
|
|
denoms: DenominationRecord[],
|
2023-04-19 17:42:47 +02:00
|
|
|
denomselAllowLate: boolean = false,
|
2023-03-31 17:27:05 +02:00
|
|
|
): DenomSelectionState {
|
|
|
|
let remaining = Amounts.copy(amountAvailable);
|
|
|
|
|
|
|
|
const selectedDenoms: {
|
|
|
|
count: number;
|
|
|
|
denomPubHash: string;
|
|
|
|
}[] = [];
|
|
|
|
|
|
|
|
let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
|
|
|
|
let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
|
|
|
|
|
2023-04-19 17:42:47 +02:00
|
|
|
denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate));
|
2023-03-31 17:27:05 +02:00
|
|
|
denoms.sort((d1, d2) =>
|
|
|
|
Amounts.cmp(
|
|
|
|
DenominationRecord.getValue(d2),
|
|
|
|
DenominationRecord.getValue(d1),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
|
|
|
|
for (const d of denoms) {
|
|
|
|
const cost = Amounts.add(
|
|
|
|
DenominationRecord.getValue(d),
|
|
|
|
d.fees.feeWithdraw,
|
|
|
|
).amount;
|
2023-05-24 12:54:07 +02:00
|
|
|
const res = Amounts.divmod(remaining, cost);
|
|
|
|
const count = res.quotient;
|
|
|
|
remaining = Amounts.sub(remaining, Amounts.mult(cost, count).amount).amount;
|
2023-03-31 17:27:05 +02:00
|
|
|
if (count > 0) {
|
|
|
|
totalCoinValue = Amounts.add(
|
|
|
|
totalCoinValue,
|
|
|
|
Amounts.mult(DenominationRecord.getValue(d), count).amount,
|
|
|
|
).amount;
|
|
|
|
totalWithdrawCost = Amounts.add(
|
|
|
|
totalWithdrawCost,
|
|
|
|
Amounts.mult(cost, count).amount,
|
|
|
|
).amount;
|
|
|
|
selectedDenoms.push({
|
|
|
|
count,
|
|
|
|
denomPubHash: d.denomPubHash,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
if (Amounts.isZero(remaining)) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (logger.shouldLogTrace()) {
|
|
|
|
logger.trace(
|
|
|
|
`selected withdrawal denoms for ${Amounts.stringify(totalCoinValue)}`,
|
|
|
|
);
|
|
|
|
for (const sd of selectedDenoms) {
|
|
|
|
logger.trace(`denom_pub_hash=${sd.denomPubHash}, count=${sd.count}`);
|
|
|
|
}
|
|
|
|
logger.trace("(end of withdrawal denom list)");
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
selectedDenoms,
|
|
|
|
totalCoinValue: Amounts.stringify(totalCoinValue),
|
|
|
|
totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
export function selectForcedWithdrawalDenominations(
|
|
|
|
amountAvailable: AmountJson,
|
|
|
|
denoms: DenominationRecord[],
|
|
|
|
forcedDenomSel: ForcedDenomSel,
|
2023-04-19 17:42:47 +02:00
|
|
|
denomselAllowLate: boolean,
|
2023-03-31 17:27:05 +02:00
|
|
|
): DenomSelectionState {
|
|
|
|
const selectedDenoms: {
|
|
|
|
count: number;
|
|
|
|
denomPubHash: string;
|
|
|
|
}[] = [];
|
|
|
|
|
|
|
|
let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
|
|
|
|
let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
|
|
|
|
|
2023-04-19 17:42:47 +02:00
|
|
|
denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate));
|
2023-03-31 17:27:05 +02:00
|
|
|
denoms.sort((d1, d2) =>
|
|
|
|
Amounts.cmp(
|
|
|
|
DenominationRecord.getValue(d2),
|
|
|
|
DenominationRecord.getValue(d1),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
|
|
|
|
for (const fds of forcedDenomSel.denoms) {
|
|
|
|
const count = fds.count;
|
|
|
|
const denom = denoms.find((x) => {
|
|
|
|
return Amounts.cmp(DenominationRecord.getValue(x), fds.value) == 0;
|
|
|
|
});
|
|
|
|
if (!denom) {
|
|
|
|
throw Error(
|
|
|
|
`unable to find denom for forced selection (value ${fds.value})`,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
const cost = Amounts.add(
|
|
|
|
DenominationRecord.getValue(denom),
|
|
|
|
denom.fees.feeWithdraw,
|
|
|
|
).amount;
|
|
|
|
totalCoinValue = Amounts.add(
|
|
|
|
totalCoinValue,
|
|
|
|
Amounts.mult(DenominationRecord.getValue(denom), count).amount,
|
|
|
|
).amount;
|
|
|
|
totalWithdrawCost = Amounts.add(
|
|
|
|
totalWithdrawCost,
|
|
|
|
Amounts.mult(cost, count).amount,
|
|
|
|
).amount;
|
|
|
|
selectedDenoms.push({
|
|
|
|
count,
|
|
|
|
denomPubHash: denom.denomPubHash,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
selectedDenoms,
|
|
|
|
totalCoinValue: Amounts.stringify(totalCoinValue),
|
|
|
|
totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
|
|
|
|
};
|
|
|
|
}
|
2023-06-13 21:46:16 +02:00
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
function getCoinsFilter(req: GetPlanForOperationRequest): CoinsFilter {
|
2023-06-13 21:46:16 +02:00
|
|
|
switch (req.type) {
|
|
|
|
case TransactionType.Withdrawal: {
|
2023-06-15 18:07:31 +02:00
|
|
|
return {
|
|
|
|
exchanges:
|
|
|
|
req.exchangeUrl === undefined ? undefined : [req.exchangeUrl],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
case TransactionType.Deposit: {
|
|
|
|
const payto = parsePaytoUri(req.account);
|
|
|
|
if (!payto) {
|
|
|
|
throw Error(`wrong payto ${req.account}`);
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
wireMethod: payto.targetType,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-06-13 21:46:16 +02:00
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
export function calculatePlanFormAvailableCoins(
|
|
|
|
transactionType: TransactionType,
|
|
|
|
amount: AmountJson,
|
2023-06-16 14:40:45 +02:00
|
|
|
mode: TransactionAmountMode,
|
2023-06-15 18:07:31 +02:00
|
|
|
availableCoins: AvailableCoins,
|
|
|
|
) {
|
|
|
|
const operationType = getOperationType(transactionType);
|
|
|
|
let usableCoins;
|
|
|
|
switch (transactionType) {
|
|
|
|
case TransactionType.Withdrawal: {
|
|
|
|
usableCoins = selectCoinForOperation(
|
|
|
|
operationType,
|
|
|
|
amount,
|
2023-06-16 14:40:45 +02:00
|
|
|
mode === TransactionAmountMode.Effective
|
|
|
|
? AmountMode.Net
|
|
|
|
: AmountMode.Gross,
|
2023-06-15 18:07:31 +02:00
|
|
|
availableCoins,
|
2023-06-13 21:46:16 +02:00
|
|
|
);
|
2023-06-15 18:07:31 +02:00
|
|
|
break;
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
|
|
|
case TransactionType.Deposit: {
|
|
|
|
//FIXME: just doing for 1 exchange now
|
|
|
|
//assuming that the wallet has one exchange and all the coins available
|
|
|
|
//are from that exchange
|
2023-06-15 18:07:31 +02:00
|
|
|
const wireFee = Object.values(availableCoins.exchanges)[0].wireFee!;
|
2023-06-13 21:46:16 +02:00
|
|
|
|
2023-06-16 14:40:45 +02:00
|
|
|
if (mode === TransactionAmountMode.Effective) {
|
2023-06-13 21:46:16 +02:00
|
|
|
usableCoins = selectCoinForOperation(
|
2023-06-15 18:07:31 +02:00
|
|
|
operationType,
|
2023-06-13 21:46:16 +02:00
|
|
|
amount,
|
2023-06-16 14:40:45 +02:00
|
|
|
AmountMode.Gross,
|
2023-06-15 18:07:31 +02:00
|
|
|
availableCoins,
|
2023-06-13 21:46:16 +02:00
|
|
|
);
|
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
usableCoins.totalContribution = Amounts.sub(
|
|
|
|
usableCoins.totalContribution,
|
|
|
|
wireFee,
|
|
|
|
).amount;
|
2023-06-13 21:46:16 +02:00
|
|
|
} else {
|
|
|
|
const adjustedAmount = Amounts.add(amount, wireFee).amount;
|
|
|
|
|
|
|
|
usableCoins = selectCoinForOperation(
|
2023-06-15 18:07:31 +02:00
|
|
|
operationType,
|
2023-06-13 21:46:16 +02:00
|
|
|
adjustedAmount,
|
2023-06-16 14:40:45 +02:00
|
|
|
AmountMode.Net,
|
2023-06-15 18:07:31 +02:00
|
|
|
availableCoins,
|
2023-06-13 21:46:16 +02:00
|
|
|
);
|
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
usableCoins.totalContribution = Amounts.sub(
|
|
|
|
usableCoins.totalContribution,
|
|
|
|
wireFee,
|
|
|
|
).amount;
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
2023-06-15 18:07:31 +02:00
|
|
|
break;
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
|
|
|
default: {
|
|
|
|
throw Error("operation not supported");
|
|
|
|
}
|
|
|
|
}
|
2023-06-15 18:07:31 +02:00
|
|
|
|
|
|
|
return getAmountsWithFee(
|
|
|
|
operationType,
|
|
|
|
usableCoins!.totalValue,
|
|
|
|
usableCoins!.totalContribution,
|
|
|
|
usableCoins,
|
|
|
|
);
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
/**
|
|
|
|
* simulate a coin selection and return the amount
|
|
|
|
* that will effectively change the wallet balance and
|
|
|
|
* the raw amount of the operation
|
|
|
|
*
|
|
|
|
* @param ws
|
|
|
|
* @param br
|
|
|
|
* @returns
|
|
|
|
*/
|
|
|
|
export async function getPlanForOperation(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
req: GetPlanForOperationRequest,
|
|
|
|
): Promise<GetPlanForOperationResponse> {
|
|
|
|
const amount = Amounts.parseOrThrow(req.instructedAmount);
|
|
|
|
const operationType = getOperationType(req.type);
|
|
|
|
const filter = getCoinsFilter(req);
|
|
|
|
|
|
|
|
const availableCoins = await getAvailableCoins(
|
|
|
|
ws,
|
|
|
|
operationType,
|
|
|
|
amount.currency,
|
|
|
|
filter,
|
|
|
|
);
|
|
|
|
|
|
|
|
return calculatePlanFormAvailableCoins(
|
|
|
|
req.type,
|
|
|
|
amount,
|
|
|
|
req.mode,
|
|
|
|
availableCoins,
|
|
|
|
);
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
|
|
|
|
2023-06-16 14:40:45 +02:00
|
|
|
/**
|
|
|
|
* If the operation going to be plan subtracts
|
|
|
|
* or adds amount in the wallet db
|
|
|
|
*/
|
|
|
|
export enum OperationType {
|
|
|
|
Credit = "credit",
|
|
|
|
Debit = "debit",
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* How the amount should be interpreted
|
|
|
|
* net = without fee
|
|
|
|
* gross = with fee
|
|
|
|
*
|
|
|
|
* Net value is always lower than gross
|
|
|
|
*/
|
|
|
|
export enum AmountMode {
|
|
|
|
Net = "net",
|
|
|
|
Gross = "gross",
|
|
|
|
}
|
|
|
|
|
2023-06-13 21:46:16 +02:00
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param op defined which fee are we taking into consideration: deposits or withdraw
|
|
|
|
* @param limit the total amount limit of the operation
|
|
|
|
* @param mode if the total amount is includes the fees or just the contribution
|
|
|
|
* @param denoms list of available denomination for the operation
|
|
|
|
* @returns
|
|
|
|
*/
|
2023-06-15 18:07:31 +02:00
|
|
|
export function selectCoinForOperation(
|
2023-06-16 14:40:45 +02:00
|
|
|
op: OperationType,
|
2023-06-13 21:46:16 +02:00
|
|
|
limit: AmountJson,
|
2023-06-16 14:40:45 +02:00
|
|
|
mode: AmountMode,
|
2023-06-15 18:07:31 +02:00
|
|
|
coins: AvailableCoins,
|
2023-06-13 21:46:16 +02:00
|
|
|
): SelectedCoins {
|
|
|
|
const result: SelectedCoins = {
|
2023-06-15 18:07:31 +02:00
|
|
|
totalValue: Amounts.zeroOfCurrency(limit.currency),
|
|
|
|
totalWithdrawalFee: Amounts.zeroOfCurrency(limit.currency),
|
|
|
|
totalDepositFee: Amounts.zeroOfCurrency(limit.currency),
|
|
|
|
totalContribution: Amounts.zeroOfCurrency(limit.currency),
|
2023-06-13 21:46:16 +02:00
|
|
|
coins: [],
|
|
|
|
};
|
2023-06-15 18:07:31 +02:00
|
|
|
if (!coins.list.length) return result;
|
2023-06-13 21:46:16 +02:00
|
|
|
/**
|
|
|
|
* We can make this faster. We should prevent sorting and
|
|
|
|
* keep the information ready for multiple calls since this
|
|
|
|
* function is expected to work on embedded devices and
|
|
|
|
* create a response on key press
|
|
|
|
*/
|
|
|
|
|
|
|
|
//rank coins
|
2023-06-15 18:07:31 +02:00
|
|
|
coins.list.sort(buildRankingForCoins(op));
|
2023-06-13 21:46:16 +02:00
|
|
|
|
|
|
|
//take coins in order until amount
|
|
|
|
let selectedCoinsAreEnough = false;
|
|
|
|
let denomIdx = 0;
|
2023-06-15 18:07:31 +02:00
|
|
|
iterateDenoms: while (denomIdx < coins.list.length) {
|
|
|
|
const denom = coins.list[denomIdx];
|
|
|
|
let total =
|
2023-06-16 14:40:45 +02:00
|
|
|
op === OperationType.Credit
|
|
|
|
? Number.MAX_SAFE_INTEGER
|
|
|
|
: denom.totalAvailable ?? 0;
|
|
|
|
const opFee =
|
|
|
|
op === OperationType.Credit ? denom.denomWithdraw : denom.denomDeposit;
|
2023-06-15 18:07:31 +02:00
|
|
|
const contribution = Amounts.sub(denom.value, opFee).amount;
|
2023-06-13 21:46:16 +02:00
|
|
|
|
|
|
|
if (Amounts.isZero(contribution)) {
|
|
|
|
// 0 contribution denoms should be the last
|
|
|
|
break iterateDenoms;
|
|
|
|
}
|
2023-06-15 18:07:31 +02:00
|
|
|
|
|
|
|
//use Amounts.divmod instead of iterate
|
2023-06-13 21:46:16 +02:00
|
|
|
iterateCoins: while (total > 0) {
|
2023-06-15 18:07:31 +02:00
|
|
|
const nextValue = Amounts.add(result.totalValue, denom.value).amount;
|
2023-06-13 21:46:16 +02:00
|
|
|
|
|
|
|
const nextContribution = Amounts.add(
|
|
|
|
result.totalContribution,
|
|
|
|
contribution,
|
|
|
|
).amount;
|
|
|
|
|
2023-06-16 14:40:45 +02:00
|
|
|
const progress = mode === AmountMode.Gross ? nextValue : nextContribution;
|
2023-06-13 21:46:16 +02:00
|
|
|
|
|
|
|
if (Amounts.cmp(progress, limit) === 1) {
|
|
|
|
//the current coin is more than we need, try next denom
|
|
|
|
break iterateCoins;
|
|
|
|
}
|
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
result.totalValue = nextValue;
|
|
|
|
result.totalContribution = nextContribution;
|
2023-06-13 21:46:16 +02:00
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
result.totalDepositFee = Amounts.add(
|
|
|
|
result.totalDepositFee,
|
|
|
|
denom.denomDeposit,
|
|
|
|
).amount;
|
2023-06-13 21:46:16 +02:00
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
result.totalWithdrawalFee = Amounts.add(
|
|
|
|
result.totalWithdrawalFee,
|
|
|
|
denom.denomWithdraw,
|
|
|
|
).amount;
|
2023-06-13 21:46:16 +02:00
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
result.coins.push(denom.id);
|
2023-06-13 21:46:16 +02:00
|
|
|
|
|
|
|
if (Amounts.cmp(progress, limit) === 0) {
|
|
|
|
selectedCoinsAreEnough = true;
|
|
|
|
// we have just enough coins, complete
|
|
|
|
break iterateDenoms;
|
|
|
|
}
|
|
|
|
|
|
|
|
//go next coin
|
|
|
|
total--;
|
|
|
|
}
|
|
|
|
//go next denom
|
|
|
|
denomIdx++;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (selectedCoinsAreEnough) {
|
|
|
|
// we made it
|
|
|
|
return result;
|
|
|
|
}
|
2023-06-16 14:40:45 +02:00
|
|
|
if (op === OperationType.Credit) {
|
2023-06-13 21:46:16 +02:00
|
|
|
//doing withdraw there is no way to cover the gap
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
//tried all the coins but there is a gap
|
|
|
|
//doing deposit we can try refreshing coins
|
|
|
|
|
2023-06-16 14:40:45 +02:00
|
|
|
const total =
|
|
|
|
mode === AmountMode.Gross ? result.totalValue : result.totalContribution;
|
2023-06-13 21:46:16 +02:00
|
|
|
const gap = Amounts.sub(limit, total).amount;
|
|
|
|
|
|
|
|
//about recursive calls
|
|
|
|
//the only way to get here is by doing a deposit (that will do a refresh)
|
|
|
|
//and now we are calculating fee for credit (which does not need to calculate refresh)
|
|
|
|
|
|
|
|
let refreshIdx = 0;
|
|
|
|
let choice: RefreshChoice | undefined = undefined;
|
2023-06-15 18:07:31 +02:00
|
|
|
refreshIteration: while (refreshIdx < coins.list.length) {
|
|
|
|
const d = coins.list[refreshIdx];
|
2023-06-13 21:46:16 +02:00
|
|
|
const denomContribution =
|
2023-06-16 14:40:45 +02:00
|
|
|
mode === AmountMode.Gross
|
2023-06-15 18:07:31 +02:00
|
|
|
? Amounts.sub(d.value, d.denomRefresh).amount
|
|
|
|
: Amounts.sub(d.value, d.denomDeposit, d.denomRefresh).amount;
|
2023-06-13 21:46:16 +02:00
|
|
|
|
|
|
|
const changeAfterDeposit = Amounts.sub(denomContribution, gap).amount;
|
|
|
|
if (Amounts.isZero(changeAfterDeposit)) {
|
|
|
|
//the rest of the coins are very small
|
|
|
|
break refreshIteration;
|
|
|
|
}
|
|
|
|
|
|
|
|
const changeCost = selectCoinForOperation(
|
2023-06-16 14:40:45 +02:00
|
|
|
OperationType.Credit,
|
2023-06-13 21:46:16 +02:00
|
|
|
changeAfterDeposit,
|
|
|
|
mode,
|
2023-06-15 18:07:31 +02:00
|
|
|
coins,
|
2023-06-13 21:46:16 +02:00
|
|
|
);
|
|
|
|
const totalFee = Amounts.add(
|
2023-06-15 18:07:31 +02:00
|
|
|
d.denomDeposit,
|
|
|
|
d.denomRefresh,
|
2023-06-13 21:46:16 +02:00
|
|
|
changeCost.totalWithdrawalFee,
|
|
|
|
).amount;
|
|
|
|
|
|
|
|
if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) {
|
|
|
|
//found cheaper change
|
|
|
|
choice = {
|
2023-06-15 18:07:31 +02:00
|
|
|
gap: gap,
|
|
|
|
totalFee: totalFee,
|
|
|
|
selected: d.id,
|
2023-06-13 21:46:16 +02:00
|
|
|
totalValue: d.value,
|
2023-06-15 18:07:31 +02:00
|
|
|
totalRefreshFee: d.denomRefresh,
|
|
|
|
totalDepositFee: d.denomDeposit,
|
|
|
|
totalChangeValue: changeCost.totalValue,
|
|
|
|
totalChangeContribution: changeCost.totalContribution,
|
|
|
|
totalChangeWithdrawalFee: changeCost.totalWithdrawalFee,
|
2023-06-13 21:46:16 +02:00
|
|
|
change: changeCost.coins,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
refreshIdx++;
|
|
|
|
}
|
|
|
|
if (choice) {
|
2023-06-16 14:40:45 +02:00
|
|
|
if (mode === AmountMode.Gross) {
|
2023-06-15 18:07:31 +02:00
|
|
|
result.totalValue = Amounts.add(result.totalValue, gap).amount;
|
|
|
|
result.totalContribution = Amounts.add(
|
|
|
|
result.totalContribution,
|
|
|
|
gap,
|
|
|
|
).amount;
|
|
|
|
result.totalContribution = Amounts.sub(
|
|
|
|
result.totalContribution,
|
|
|
|
choice.totalFee,
|
|
|
|
).amount;
|
2023-06-13 21:46:16 +02:00
|
|
|
} else {
|
2023-06-15 18:07:31 +02:00
|
|
|
result.totalContribution = Amounts.add(
|
|
|
|
result.totalContribution,
|
|
|
|
gap,
|
|
|
|
).amount;
|
|
|
|
result.totalValue = Amounts.add(
|
|
|
|
result.totalValue,
|
|
|
|
gap,
|
|
|
|
choice.totalFee,
|
|
|
|
).amount;
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// console.log("gap", Amounts.stringify(limit), Amounts.stringify(gap), choice);
|
|
|
|
result.refresh = choice;
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
type CompareCoinsFunction = (d1: CoinInfo, d2: CoinInfo) => -1 | 0 | 1;
|
2023-06-16 14:40:45 +02:00
|
|
|
function buildRankingForCoins(op: OperationType): CompareCoinsFunction {
|
2023-06-15 18:07:31 +02:00
|
|
|
function getFee(d: CoinInfo) {
|
2023-06-16 14:40:45 +02:00
|
|
|
return op === OperationType.Credit ? d.denomWithdraw : d.denomDeposit;
|
2023-06-15 18:07:31 +02:00
|
|
|
}
|
|
|
|
//different exchanges may have different wireFee
|
|
|
|
//ranking should take the relative contribution in the exchange
|
|
|
|
//which is (value - denomFee / fixedFee)
|
|
|
|
// where denomFee is withdraw or deposit
|
|
|
|
// and fixedFee can be purse or wire
|
|
|
|
return function rank(d1: CoinInfo, d2: CoinInfo) {
|
|
|
|
const contrib1 = Amounts.sub(d1.value, getFee(d1)).amount;
|
|
|
|
const contrib2 = Amounts.sub(d2.value, getFee(d2)).amount;
|
|
|
|
return (
|
|
|
|
Amounts.cmp(contrib2, contrib1) ||
|
|
|
|
Duration.cmp(d1.duration, d2.duration) ||
|
|
|
|
strcmp(d1.id, d2.id)
|
|
|
|
);
|
|
|
|
};
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
2023-06-15 18:07:31 +02:00
|
|
|
|
2023-06-16 14:40:45 +02:00
|
|
|
function getOperationType(txType: TransactionType): OperationType {
|
2023-06-15 18:07:31 +02:00
|
|
|
const operationType =
|
|
|
|
txType === TransactionType.Withdrawal
|
2023-06-16 14:40:45 +02:00
|
|
|
? OperationType.Credit
|
2023-06-15 18:07:31 +02:00
|
|
|
: txType === TransactionType.Deposit
|
2023-06-16 14:40:45 +02:00
|
|
|
? OperationType.Debit
|
2023-06-15 18:07:31 +02:00
|
|
|
: undefined;
|
|
|
|
if (!operationType) {
|
2023-06-16 14:40:45 +02:00
|
|
|
throw Error(`operation type ${txType} not yet supported`);
|
2023-06-15 18:07:31 +02:00
|
|
|
}
|
|
|
|
return operationType;
|
|
|
|
}
|
|
|
|
|
|
|
|
function getAmountsWithFee(
|
2023-06-16 14:40:45 +02:00
|
|
|
op: OperationType,
|
2023-06-15 18:07:31 +02:00
|
|
|
value: AmountJson,
|
|
|
|
contribution: AmountJson,
|
|
|
|
details: any,
|
|
|
|
): GetPlanForOperationResponse {
|
|
|
|
return {
|
2023-06-16 14:40:45 +02:00
|
|
|
rawAmount: Amounts.stringify(
|
|
|
|
op === OperationType.Credit ? value : contribution,
|
|
|
|
),
|
|
|
|
effectiveAmount: Amounts.stringify(
|
|
|
|
op === OperationType.Credit ? contribution : value,
|
|
|
|
),
|
2023-06-15 18:07:31 +02:00
|
|
|
details,
|
|
|
|
};
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
interface RefreshChoice {
|
2023-06-15 18:07:31 +02:00
|
|
|
gap: AmountJson;
|
|
|
|
totalFee: AmountJson;
|
2023-06-13 21:46:16 +02:00
|
|
|
selected: string;
|
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
totalValue: AmountJson;
|
|
|
|
totalDepositFee: AmountJson;
|
|
|
|
totalRefreshFee: AmountJson;
|
|
|
|
totalChangeValue: AmountJson;
|
|
|
|
totalChangeContribution: AmountJson;
|
|
|
|
totalChangeWithdrawalFee: AmountJson;
|
2023-06-13 21:46:16 +02:00
|
|
|
change: string[];
|
|
|
|
}
|
|
|
|
|
|
|
|
interface SelectedCoins {
|
2023-06-15 18:07:31 +02:00
|
|
|
totalValue: AmountJson;
|
|
|
|
totalContribution: AmountJson;
|
|
|
|
totalWithdrawalFee: AmountJson;
|
|
|
|
totalDepositFee: AmountJson;
|
2023-06-13 21:46:16 +02:00
|
|
|
coins: string[];
|
|
|
|
refresh?: RefreshChoice;
|
|
|
|
}
|
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
interface AvailableCoins {
|
|
|
|
list: CoinInfo[];
|
|
|
|
exchanges: Record<string, ExchangeInfo>;
|
|
|
|
}
|
|
|
|
interface CoinInfo {
|
|
|
|
id: string;
|
|
|
|
value: AmountJson;
|
|
|
|
denomDeposit: AmountJson;
|
|
|
|
denomWithdraw: AmountJson;
|
|
|
|
denomRefresh: AmountJson;
|
|
|
|
totalAvailable: number | undefined;
|
|
|
|
exchangeWire: AmountJson | undefined;
|
|
|
|
exchangePurse: AmountJson | undefined;
|
|
|
|
duration: Duration;
|
|
|
|
maxAge: number;
|
|
|
|
}
|
|
|
|
interface ExchangeInfo {
|
|
|
|
wireFee: AmountJson | undefined;
|
|
|
|
purseFee: AmountJson | undefined;
|
|
|
|
creditDeadline: AbsoluteTime;
|
|
|
|
debitDeadline: AbsoluteTime;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface CoinsFilter {
|
|
|
|
shouldCalculatePurseFee?: boolean;
|
|
|
|
exchanges?: string[];
|
|
|
|
wireMethod?: string;
|
|
|
|
ageRestricted?: number;
|
|
|
|
}
|
2023-06-13 21:46:16 +02:00
|
|
|
/**
|
|
|
|
* Get all the denoms that can be used for a operation that is limited
|
|
|
|
* by the following restrictions.
|
|
|
|
* This function is costly (by the database access) but with high chances
|
|
|
|
* of being cached
|
|
|
|
*/
|
|
|
|
async function getAvailableCoins(
|
|
|
|
ws: InternalWalletState,
|
2023-06-16 14:40:45 +02:00
|
|
|
op: OperationType,
|
2023-06-13 21:46:16 +02:00
|
|
|
currency: string,
|
2023-06-15 18:07:31 +02:00
|
|
|
filters: CoinsFilter = {},
|
|
|
|
): Promise<AvailableCoins> {
|
2023-06-13 21:46:16 +02:00
|
|
|
return await ws.db
|
|
|
|
.mktx((x) => [
|
|
|
|
x.exchanges,
|
|
|
|
x.exchangeDetails,
|
|
|
|
x.denominations,
|
|
|
|
x.coinAvailability,
|
|
|
|
])
|
|
|
|
.runReadOnly(async (tx) => {
|
2023-06-15 18:07:31 +02:00
|
|
|
const list: CoinInfo[] = [];
|
|
|
|
const exchanges: Record<string, ExchangeInfo> = {};
|
2023-06-13 21:46:16 +02:00
|
|
|
|
|
|
|
const databaseExchanges = await tx.exchanges.iter().toArray();
|
2023-06-15 18:07:31 +02:00
|
|
|
const filteredExchanges =
|
|
|
|
filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl);
|
2023-06-13 21:46:16 +02:00
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
for (const exchangeBaseUrl of filteredExchanges) {
|
2023-06-13 21:46:16 +02:00
|
|
|
const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl);
|
|
|
|
// 1.- exchange has same currency
|
|
|
|
if (exchangeDetails?.currency !== currency) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
let deadline = AbsoluteTime.never();
|
2023-06-13 21:46:16 +02:00
|
|
|
// 2.- exchange supports wire method
|
2023-06-15 18:07:31 +02:00
|
|
|
let wireFee: AmountJson | undefined;
|
|
|
|
if (filters.wireMethod) {
|
|
|
|
const wireMethodWithDates =
|
|
|
|
exchangeDetails.wireInfo.feesForType[filters.wireMethod];
|
2023-06-13 21:46:16 +02:00
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
if (!wireMethodWithDates) {
|
|
|
|
throw Error(
|
|
|
|
`exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`,
|
|
|
|
);
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
2023-06-15 18:07:31 +02:00
|
|
|
const wireMethodFee = wireMethodWithDates.find((x) => {
|
|
|
|
return AbsoluteTime.isBetween(
|
|
|
|
AbsoluteTime.now(),
|
|
|
|
AbsoluteTime.fromProtocolTimestamp(x.startStamp),
|
|
|
|
AbsoluteTime.fromProtocolTimestamp(x.endStamp),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!wireMethodFee) {
|
2023-06-13 21:46:16 +02:00
|
|
|
throw Error(
|
|
|
|
`exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`,
|
|
|
|
);
|
|
|
|
}
|
2023-06-15 18:07:31 +02:00
|
|
|
wireFee = Amounts.parseOrThrow(wireMethodFee.wireFee);
|
|
|
|
deadline = AbsoluteTime.min(
|
|
|
|
deadline,
|
|
|
|
AbsoluteTime.fromProtocolTimestamp(wireMethodFee.endStamp),
|
|
|
|
);
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
2023-06-15 18:07:31 +02:00
|
|
|
// exchanges[exchangeBaseUrl].wireFee = wireMethodFee;
|
2023-06-13 21:46:16 +02:00
|
|
|
|
|
|
|
// 3.- exchange supports wire method
|
2023-06-15 18:07:31 +02:00
|
|
|
let purseFee: AmountJson | undefined;
|
|
|
|
if (filters.shouldCalculatePurseFee) {
|
2023-06-13 21:46:16 +02:00
|
|
|
const purseFeeFound = exchangeDetails.globalFees.find((x) => {
|
|
|
|
return AbsoluteTime.isBetween(
|
|
|
|
AbsoluteTime.now(),
|
|
|
|
AbsoluteTime.fromProtocolTimestamp(x.startDate),
|
|
|
|
AbsoluteTime.fromProtocolTimestamp(x.endDate),
|
|
|
|
);
|
2023-06-15 18:07:31 +02:00
|
|
|
});
|
2023-06-13 21:46:16 +02:00
|
|
|
if (!purseFeeFound) {
|
|
|
|
throw Error(
|
|
|
|
`exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`,
|
|
|
|
);
|
|
|
|
}
|
2023-06-15 18:07:31 +02:00
|
|
|
purseFee = Amounts.parseOrThrow(purseFeeFound.purseFee);
|
|
|
|
deadline = AbsoluteTime.min(
|
|
|
|
deadline,
|
|
|
|
AbsoluteTime.fromProtocolTimestamp(purseFeeFound.endDate),
|
|
|
|
);
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
let creditDeadline = AbsoluteTime.never();
|
|
|
|
let debitDeadline = AbsoluteTime.never();
|
2023-06-13 21:46:16 +02:00
|
|
|
//4.- filter coins restricted by age
|
2023-06-16 14:40:45 +02:00
|
|
|
if (op === OperationType.Credit) {
|
2023-06-13 21:46:16 +02:00
|
|
|
const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
|
|
|
|
exchangeBaseUrl,
|
|
|
|
);
|
|
|
|
for (const denom of ds) {
|
2023-06-15 18:07:31 +02:00
|
|
|
const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
|
|
|
|
denom.stampExpireWithdraw,
|
|
|
|
);
|
|
|
|
const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
|
|
|
|
denom.stampExpireDeposit,
|
|
|
|
);
|
|
|
|
creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
|
|
|
|
debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
|
|
|
|
list.push(
|
|
|
|
buildCoinInfoFromDenom(
|
|
|
|
denom,
|
|
|
|
purseFee,
|
|
|
|
wireFee,
|
|
|
|
AgeRestriction.AGE_UNRESTRICTED,
|
|
|
|
Number.MAX_SAFE_INTEGER, // Max withdrawable from single denom
|
|
|
|
),
|
|
|
|
);
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
|
|
|
} else {
|
2023-06-15 18:07:31 +02:00
|
|
|
const ageLower = filters.ageRestricted ?? 0;
|
2023-06-13 21:46:16 +02:00
|
|
|
const ageUpper = AgeRestriction.AGE_UNRESTRICTED;
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
2023-06-15 18:07:31 +02:00
|
|
|
const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
|
|
|
|
denom.stampExpireWithdraw,
|
|
|
|
);
|
|
|
|
const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
|
|
|
|
denom.stampExpireDeposit,
|
|
|
|
);
|
|
|
|
creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
|
|
|
|
debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
|
|
|
|
list.push(
|
|
|
|
buildCoinInfoFromDenom(
|
|
|
|
denom,
|
|
|
|
purseFee,
|
|
|
|
wireFee,
|
|
|
|
coinAvail.maxAge,
|
|
|
|
coinAvail.freshCoinCount,
|
|
|
|
),
|
|
|
|
);
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
|
|
|
}
|
2023-06-15 18:07:31 +02:00
|
|
|
|
|
|
|
exchanges[exchangeBaseUrl] = {
|
|
|
|
purseFee,
|
|
|
|
wireFee,
|
|
|
|
debitDeadline,
|
|
|
|
creditDeadline,
|
|
|
|
};
|
2023-06-13 21:46:16 +02:00
|
|
|
}
|
|
|
|
|
2023-06-15 18:07:31 +02:00
|
|
|
return { list, exchanges };
|
2023-06-13 21:46:16 +02:00
|
|
|
});
|
|
|
|
}
|
2023-06-15 18:07:31 +02:00
|
|
|
|
|
|
|
function buildCoinInfoFromDenom(
|
|
|
|
denom: DenominationRecord,
|
|
|
|
purseFee: AmountJson | undefined,
|
|
|
|
wireFee: AmountJson | undefined,
|
|
|
|
maxAge: number,
|
|
|
|
total: number,
|
|
|
|
): CoinInfo {
|
|
|
|
return {
|
|
|
|
id: denom.denomPubHash,
|
|
|
|
denomWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw),
|
|
|
|
denomDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
|
|
|
|
denomRefresh: Amounts.parseOrThrow(denom.fees.feeRefresh),
|
|
|
|
exchangePurse: purseFee,
|
|
|
|
exchangeWire: wireFee,
|
|
|
|
duration: AbsoluteTime.difference(
|
|
|
|
AbsoluteTime.now(),
|
|
|
|
AbsoluteTime.fromProtocolTimestamp(denom.stampExpireDeposit),
|
|
|
|
),
|
|
|
|
totalAvailable: total,
|
|
|
|
value: DenominationRecord.getValue(denom),
|
|
|
|
maxAge,
|
|
|
|
};
|
|
|
|
}
|