wallet-core: support forced coins in new coin selection algo

This commit is contained in:
Florian Dold 2022-09-15 21:00:36 +02:00
parent b7f7b95602
commit 2747bc260b
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B

View File

@ -38,7 +38,6 @@ import {
ContractTerms, ContractTerms,
ContractTermsUtil, ContractTermsUtil,
DenominationInfo, DenominationInfo,
DenominationPubKey,
Duration, Duration,
encodeCrock, encodeCrock,
ForcedCoinSel, ForcedCoinSel,
@ -93,7 +92,6 @@ import {
CoinSelectionTally, CoinSelectionTally,
PreviousPayCoins, PreviousPayCoins,
selectForcedPayCoins, selectForcedPayCoins,
selectPayCoinsLegacy,
tallyFees, tallyFees,
} from "../util/coinSelection.js"; } from "../util/coinSelection.js";
import { import {
@ -104,6 +102,7 @@ import {
readUnexpectedResponseDetails, readUnexpectedResponseDetails,
throwUnexpectedRequestError, throwUnexpectedRequestError,
} from "../util/http.js"; } from "../util/http.js";
import { checkLogicInvariant } from "../util/invariants.js";
import { GetReadWriteAccess } from "../util/query.js"; import { GetReadWriteAccess } from "../util/query.js";
import { RetryInfo, RetryTags, scheduleRetry } from "../util/retries.js"; import { RetryInfo, RetryTags, scheduleRetry } from "../util/retries.js";
import { spendCoins } from "../wallet.js"; import { spendCoins } from "../wallet.js";
@ -926,17 +925,6 @@ async function handleInsufficientFunds(
const { contractData } = proposal.download; const { contractData } = proposal.download;
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 prevPayCoins: PreviousPayCoins = []; const prevPayCoins: PreviousPayCoins = [];
await ws.db await ws.db
@ -968,8 +956,10 @@ async function handleInsufficientFunds(
} }
}); });
const res = selectPayCoinsLegacy({ const res = await selectPayCoinsNew(ws, {
candidates, auditors: contractData.allowedAuditors,
exchanges: contractData.allowedExchanges,
wireMethod: contractData.wireMethod,
contractTermsAmount: contractData.amount, contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee, depositFeeLimit: contractData.maxDepositFee,
wireFeeAmortization: contractData.wireFeeAmortization ?? 1, wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
@ -1026,8 +1016,8 @@ async function unblockBackup(
} }
export interface SelectPayCoinRequestNg { export interface SelectPayCoinRequestNg {
exchanges: string[]; exchanges: AllowedExchangeInfo[];
auditors: string[]; auditors: AllowedAuditorInfo[];
wireMethod: string; wireMethod: string;
contractTermsAmount: AmountJson; contractTermsAmount: AmountJson;
depositFeeLimit: AmountJson; depositFeeLimit: AmountJson;
@ -1035,34 +1025,18 @@ export interface SelectPayCoinRequestNg {
wireFeeAmortization: number; wireFeeAmortization: number;
prevPayCoins?: PreviousPayCoins; prevPayCoins?: PreviousPayCoins;
requiredMinimumAge?: number; requiredMinimumAge?: number;
forcedSelection?: ForcedCoinSel;
} }
export type AvailableDenom = DenominationInfo & { export type AvailableDenom = DenominationInfo & {
numAvailable: number; numAvailable: number;
}; };
/** async function selectCandidates(
* 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, ws: InternalWalletState,
req: SelectPayCoinRequestNg, req: SelectPayCoinRequestNg,
): Promise<PayCoinSelection | undefined> { ): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
const { return await ws.db
contractTermsAmount,
depositFeeLimit,
wireFeeLimit,
wireFeeAmortization,
} = req;
const [candidateDenoms, wireFeesPerExchange] = await ws.db
.mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations]) .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations])
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
const denoms: AvailableDenom[] = []; const denoms: AvailableDenom[] = [];
@ -1070,16 +1044,21 @@ export async function selectPayCoinsNew(
const wfPerExchange: Record<string, AmountJson> = {}; const wfPerExchange: Record<string, AmountJson> = {};
for (const exchange of exchanges) { for (const exchange of exchanges) {
const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl); const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl);
if (exchangeDetails?.currency !== contractTermsAmount.currency) { if (exchangeDetails?.currency !== req.contractTermsAmount.currency) {
continue; continue;
} }
let accepted = false; let accepted = false;
if (req.exchanges.includes(exchange.baseUrl)) { for (const allowedExchange of req.exchanges) {
accepted = true; if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
} else { accepted = true;
for (const auditor of exchangeDetails.auditors) { break;
if (req.auditors.includes(auditor.auditor_url)) { }
}
for (const allowedAuditor of req.auditors) {
for (const providedAuditor of exchangeDetails.auditors) {
if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) {
accepted = true; accepted = true;
break;
} }
} }
} }
@ -1090,6 +1069,7 @@ export async function selectPayCoinsNew(
const exchangeDenoms = await tx.denominations.indexes.byExchangeBaseUrl const exchangeDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(exchangeDetails.exchangeBaseUrl) .iter(exchangeDetails.exchangeBaseUrl)
.filter((x) => x.freshCoinCount != null && x.freshCoinCount > 0); .filter((x) => x.freshCoinCount != null && x.freshCoinCount > 0);
// FIXME: Check that the individual denomination is audited!
// FIXME: Should we exclude denominations that are // FIXME: Should we exclude denominations that are
// not spendable anymore? // not spendable anymore?
for (const denom of exchangeDenoms) { for (const denom of exchangeDenoms) {
@ -1099,61 +1079,38 @@ export async function selectPayCoinsNew(
}); });
} }
} }
// 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]; return [denoms, wfPerExchange];
}); });
}
const coinPubs: string[] = []; /**
const coinContributions: AmountJson[] = []; * Selection result.
const currency = contractTermsAmount.currency; */
interface SelResult {
let tally: CoinSelectionTally = { /**
amountPayRemaining: contractTermsAmount, * Map from denomination public key hashes
amountWireFeeLimitRemaining: wireFeeLimit, * to an array of contributions.
amountDepositFeeLimitRemaining: depositFeeLimit, */
customerDepositFees: Amounts.getZero(currency), [dph: string]: AmountJson[];
customerWireFees: Amounts.getZero(currency), }
wireFeeCoveredForExchange: new Set(),
};
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);
}
// Sort by available amount (descending), deposit fee (ascending) and
// denomPub (ascending) if deposit fee is the same
// (to guarantee deterministic results)
candidateDenoms.sort(
(o1, o2) =>
-Amounts.cmp(o1.value, o2.value) ||
Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
strcmp(o1.denomPubHash, o2.denomPubHash),
);
// 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.
const selectedDenom: {
[dph: string]: AmountJson[];
} = {};
export function selectGreedy(
req: SelectPayCoinRequestNg,
candidateDenoms: AvailableDenom[],
wireFeesPerExchange: Record<string, AmountJson>,
tally: CoinSelectionTally,
): SelResult | undefined {
const { wireFeeAmortization } = req;
const selectedDenom: SelResult = {};
for (const aci of candidateDenoms) { for (const aci of candidateDenoms) {
const contributions: AmountJson[] = []; const contributions: AmountJson[] = [];
for (let i = 0; i < aci.numAvailable; i++) { for (let i = 0; i < aci.numAvailable; i++) {
@ -1193,37 +1150,153 @@ export async function selectPayCoinsNew(
} }
if (Amounts.isZero(tally.amountPayRemaining)) { if (Amounts.isZero(tally.amountPayRemaining)) {
await ws.db return selectedDenom;
.mktx((x) => [x.coins, x.denominations])
.runReadOnly(async (tx) => {
for (const dph of Object.keys(selectedDenom)) {
const contributions = selectedDenom[dph];
const coins = await tx.coins.indexes.byDenomPubHashAndStatus.getAll(
[dph, CoinStatus.Fresh],
contributions.length,
);
if (coins.length != contributions.length) {
throw Error(
`coin selection failed (not available anymore, got only ${coins.length}/${contributions.length})`,
);
}
coinPubs.push(...coins.map((x) => x.coinPub));
coinContributions.push(...contributions);
}
});
return {
paymentAmount: contractTermsAmount,
coinContributions,
coinPubs,
customerDepositFees: tally.customerDepositFees,
customerWireFees: tally.customerWireFees,
};
} }
} }
return undefined; return undefined;
} }
export 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 contributions = selectedDenom[aci.denomPubHash] ?? [];
contributions.push(Amounts.parseOrThrow(forcedCoin.value));
selectedDenom[aci.denomPubHash] = contributions;
found = true;
break;
}
}
if (!found) {
throw Error("can't find coin for forced coin selection");
}
}
return selectedDenom;
}
/**
* 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<PayCoinSelection | undefined> {
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.getZero(currency),
customerWireFees: Amounts.getZero(currency),
wireFeeCoveredForExchange: new Set(),
};
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) {
return undefined;
}
const finalSel = selectedDenom;
await ws.db
.mktx((x) => [x.coins, x.denominations])
.runReadOnly(async (tx) => {
for (const dph of Object.keys(finalSel)) {
const contributions = finalSel[dph];
const coins = await tx.coins.indexes.byDenomPubHashAndStatus.getAll(
[dph, CoinStatus.Fresh],
contributions.length,
);
if (coins.length != contributions.length) {
throw Error(
`coin selection failed (not available anymore, got only ${coins.length}/${contributions.length})`,
);
}
coinPubs.push(...coins.map((x) => x.coinPub));
coinContributions.push(...contributions);
}
});
return {
paymentAmount: contractTermsAmount,
coinContributions,
coinPubs,
customerDepositFees: tally.customerDepositFees,
customerWireFees: tally.customerWireFees,
};
}
export async function checkPaymentByProposalId( export async function checkPaymentByProposalId(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
@ -1274,24 +1347,16 @@ 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 candidates = await getCandidatePayCoins(ws, { const res = await selectPayCoinsNew(ws, {
allowedAuditors: contractData.allowedAuditors, auditors: contractData.allowedAuditors,
allowedExchanges: contractData.allowedExchanges, exchanges: contractData.allowedExchanges,
amount: contractData.amount,
maxDepositFee: contractData.maxDepositFee,
maxWireFee: contractData.maxWireFee,
timestamp: contractData.timestamp,
wireFeeAmortization: contractData.wireFeeAmortization,
wireMethod: contractData.wireMethod,
});
const res = selectPayCoinsLegacy({
candidates,
contractTermsAmount: contractData.amount, contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee, depositFeeLimit: contractData.maxDepositFee,
wireFeeAmortization: contractData.wireFeeAmortization ?? 1, wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
wireFeeLimit: contractData.maxWireFee, wireFeeLimit: contractData.maxWireFee,
prevPayCoins: [], prevPayCoins: [],
requiredMinimumAge: contractData.minimumAge, requiredMinimumAge: contractData.minimumAge,
wireMethod: contractData.wireMethod,
}); });
if (!res) { if (!res) {
@ -1590,39 +1655,20 @@ export async function confirmPay(
const contractData = 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,
});
let res: PayCoinSelection | undefined = undefined; let res: PayCoinSelection | undefined = undefined;
if (forcedCoinSel) { res = await selectPayCoinsNew(ws, {
res = selectForcedPayCoins(forcedCoinSel, { auditors: contractData.allowedAuditors,
candidates, exchanges: contractData.allowedExchanges,
contractTermsAmount: contractData.amount, wireMethod: contractData.wireMethod,
depositFeeLimit: contractData.maxDepositFee, contractTermsAmount: contractData.amount,
wireFeeAmortization: contractData.wireFeeAmortization ?? 1, depositFeeLimit: contractData.maxDepositFee,
wireFeeLimit: contractData.maxWireFee, wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
requiredMinimumAge: contractData.minimumAge, wireFeeLimit: contractData.maxWireFee,
}); prevPayCoins: [],
} else { requiredMinimumAge: contractData.minimumAge,
res = selectPayCoinsLegacy({ forcedSelection: forcedCoinSel,
candidates, });
contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee,
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
wireFeeLimit: contractData.maxWireFee,
prevPayCoins: [],
requiredMinimumAge: contractData.minimumAge,
});
}
logger.trace("coin selection result", res); logger.trace("coin selection result", res);