diff options
Diffstat (limited to 'packages/taler-wallet-core/src/coinSelection.ts')
-rw-r--r-- | packages/taler-wallet-core/src/coinSelection.ts | 366 |
1 files changed, 167 insertions, 199 deletions
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts index 1208e7c37..5ac52e1d3 100644 --- a/packages/taler-wallet-core/src/coinSelection.ts +++ b/packages/taler-wallet-core/src/coinSelection.ts @@ -27,19 +27,15 @@ import { GlobalIDB } from "@gnu-taler/idb-bridge"; import { AbsoluteTime, AccountRestriction, - AgeCommitmentProof, AgeRestriction, AllowedAuditorInfo, AllowedExchangeInfo, AmountJson, Amounts, - AmountString, checkDbInvariant, checkLogicInvariant, - CoinPublicKeyString, CoinStatus, DenominationInfo, - Duration, ForcedCoinSel, InternationalizedString, j2s, @@ -47,9 +43,9 @@ import { parsePaytoUri, PayCoinSelection, PayMerchantInsufficientBalanceDetails, + SelectedCoin, strcmp, TalerProtocolTimestamp, - UnblindedSignature, } from "@gnu-taler/taler-util"; import { getExchangePaymentBalanceDetailsInTx, @@ -68,8 +64,6 @@ const logger = new Logger("coinSelection.ts"); export type PreviousPayCoins = { coinPub: string; contribution: AmountJson; - feeDeposit: AmountJson; - exchangeBaseUrl: string; }[]; export interface ExchangeRestrictionSpec { @@ -192,12 +186,7 @@ export async function selectPayCoins( wex: WalletExecutionContext, req: SelectPayCoinRequestNg, ): Promise<SelectPayCoinsResult> { - const { - contractTermsAmount, - depositFeeLimit, - wireFeeLimit, - wireFeeAmortization, - } = req; + const { contractTermsAmount, depositFeeLimit, wireFeeLimit } = req; return await wex.db.runReadOnlyTx( [ @@ -221,8 +210,7 @@ export async function selectPayCoins( }, ); - const coinPubs: string[] = []; - const coinContributions: AmountJson[] = []; + const coinRes: SelectedCoin[] = []; const currency = contractTermsAmount.currency; let tally: CoinSelectionTally = { @@ -235,25 +223,17 @@ export async function selectPayCoins( lastDepositFee: Amounts.zeroOfCurrency(currency), }; - const prevPayCoins = req.prevPayCoins ?? []; - - // Look at existing pay coin selection and tally up - for (const prev of prevPayCoins) { - tallyFees( - tally, - wireFeesPerExchange, - wireFeeAmortization, - prev.exchangeBaseUrl, - prev.feeDeposit, - ); - tally.amountPayRemaining = Amounts.sub( - tally.amountPayRemaining, - prev.contribution, - ).amount; - - coinPubs.push(prev.coinPub); - coinContributions.push(prev.contribution); - } + await maybeRepairCoinSelection( + wex, + tx, + req.prevPayCoins ?? [], + coinRes, + tally, + { + wireFeeAmortization: req.wireFeeAmortization, + wireFeesPerExchange: wireFeesPerExchange, + }, + ); let selectedDenom: SelResult | undefined; if (req.forcedSelection) { @@ -292,8 +272,7 @@ export async function selectPayCoins( const coinSel = await assembleSelectPayCoinsSuccessResult( tx, selectedDenom, - coinPubs, - coinContributions, + coinRes, req.contractTermsAmount, tally, ); @@ -306,11 +285,55 @@ export async function selectPayCoins( ); } +async function maybeRepairCoinSelection( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>, + prevPayCoins: PreviousPayCoins, + coinRes: SelectedCoin[], + tally: CoinSelectionTally, + feeInfo: { + wireFeeAmortization: number; + wireFeesPerExchange: Record<string, AmountJson>; + }, +): Promise<void> { + // Look at existing pay coin selection and tally up + for (const prev of prevPayCoins) { + const coin = await tx.coins.get(prev.coinPub); + if (!coin) { + continue; + } + const denom = await getDenomInfo( + wex, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + if (!denom) { + continue; + } + tallyFees( + tally, + feeInfo.wireFeesPerExchange, + feeInfo.wireFeeAmortization, + coin.exchangeBaseUrl, + Amounts.parseOrThrow(denom.feeDeposit), + ); + tally.amountPayRemaining = Amounts.sub( + tally.amountPayRemaining, + prev.contribution, + ).amount; + + coinRes.push({ + coinPub: prev.coinPub, + contribution: Amounts.stringify(prev.contribution), + }); + } +} + async function assembleSelectPayCoinsSuccessResult( tx: WalletDbReadOnlyTransaction<["coins"]>, finalSel: SelResult, - coinPubs: string[], - coinContributions: AmountJson[], + coinRes: SelectedCoin[], contractTermsAmount: AmountJson, tally: CoinSelectionTally, ): Promise<PayCoinSelection> { @@ -334,15 +357,17 @@ async function assembleSelectPayCoinsSuccessResult( `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`, ); } - coinPubs.push(...coins.map((x) => x.coinPub)); - coinContributions.push(...selInfo.contributions); + + for (let i = 0; i < selInfo.contributions.length; i++) { + coinRes.push({ + coinPub: coins[i].coinPub, + contribution: Amounts.stringify(selInfo.contributions[i]), + }); + } } return { - // FIXME: Why do we return this?! - paymentAmount: Amounts.stringify(contractTermsAmount), - coinContributions: coinContributions.map((x) => Amounts.stringify(x)), - coinPubs, + coins: coinRes, customerDepositFees: Amounts.stringify(tally.customerDepositFees), customerWireFees: Amounts.stringify(tally.customerWireFees), }; @@ -358,7 +383,13 @@ interface ReportInsufficientBalanceRequest { export async function reportInsufficientBalanceDetails( wex: WalletExecutionContext, tx: WalletDbReadOnlyTransaction< - ["coinAvailability", "exchanges", "exchangeDetails", "refreshGroups"] + [ + "coinAvailability", + "exchanges", + "exchangeDetails", + "refreshGroups", + "denominations", + ] >, req: ReportInsufficientBalanceRequest, ): Promise<PayMerchantInsufficientBalanceDetails> { @@ -384,10 +415,52 @@ export async function reportInsufficientBalanceDetails( const exchanges = await tx.exchanges.iter().toArray(); + let maxEffectiveSpendAmount = Amounts.zeroOfAmount(req.instructedAmount); + for (const exch of exchanges) { if (exch.detailsPointer?.currency !== currency) { continue; } + + // We now see how much we could spend if we paid all the fees ourselves + // in a worst-case estimate. + + const exchangeBaseUrl = exch.baseUrl; + 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( + [exchangeBaseUrl, ageLower, 1], + [exchangeBaseUrl, ageUpper, Number.MAX_SAFE_INTEGER], + ), + ); + + for (const ec of myExchangeCoins) { + maxEffectiveSpendAmount = Amounts.add( + maxEffectiveSpendAmount, + Amounts.mult(ec.value, ec.freshCoinCount).amount, + ).amount; + + const denom = await getDenomInfo( + wex, + tx, + exchangeBaseUrl, + ec.denomPubHash, + ); + if (!denom) { + continue; + } + maxEffectiveSpendAmount = Amounts.sub( + maxEffectiveSpendAmount, + Amounts.mult(denom.feeDeposit, ec.freshCoinCount).amount, + ).amount; + } + const infoExchange = await getExchangePaymentBalanceDetailsInTx(wex, tx, { currency, restrictExchangeTo: exch.baseUrl, @@ -395,7 +468,6 @@ export async function reportInsufficientBalanceDetails( perExchange[exch.baseUrl] = { balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable), balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial), - feeGapEstimate: Amounts.stringify(Amounts.zeroOfCurrency(currency)), }; } @@ -410,7 +482,7 @@ export async function reportInsufficientBalanceDetails( balanceMerchantDepositable: Amounts.stringify( details.balanceMerchantDepositable, ), - feeGapEstimate: Amounts.stringify(feeGapEstimate), + maxEffectiveSpendAmount: Amounts.stringify(maxEffectiveSpendAmount), perExchange, }; } @@ -434,8 +506,6 @@ interface SelResult { [avKey: string]: { exchangeBaseUrl: string; denomPubHash: string; - expireWithdraw: TalerProtocolTimestamp; - expireDeposit: TalerProtocolTimestamp; maxAge: number; contributions: AmountJson[]; }; @@ -508,8 +578,6 @@ function selectGreedy( denomPubHash: denom.denomPubHash, exchangeBaseUrl: denom.exchangeBaseUrl, maxAge: denom.maxAge, - expireDeposit: denom.stampExpireDeposit, - expireWithdraw: denom.stampExpireWithdraw, }; } sd.contributions.push(...contributions); @@ -549,8 +617,6 @@ function selectForced( denomPubHash: aci.denomPubHash, exchangeBaseUrl: aci.exchangeBaseUrl, maxAge: aci.maxAge, - expireDeposit: aci.stampExpireDeposit, - expireWithdraw: aci.stampExpireWithdraw, }; } sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value)); @@ -563,7 +629,6 @@ function selectForced( throw Error("can't find coin for forced coin selection"); } } - return selectedDenom; } @@ -696,12 +761,7 @@ interface SelectPayCandidatesRequest { instructedAmount: AmountJson; restrictWireMethod: string | undefined; depositPaytoUri?: string; - restrictExchanges: - | { - exchanges: AllowedExchangeInfo[]; - auditors: AllowedAuditorInfo[]; - } - | undefined; + restrictExchanges: ExchangeRestrictionSpec | undefined; requiredMinimumAge?: number; } @@ -796,36 +856,13 @@ async function selectPayCandidates( return [denoms, wfPerExchange]; } -export interface CoinInfo { - id: string; - value: AmountJson; - denomDeposit: AmountJson; - denomWithdraw: AmountJson; - denomRefresh: AmountJson; - totalAvailable: number | undefined; - exchangeWire: AmountJson | undefined; - exchangePurse: AmountJson | undefined; - duration: Duration; - exchangeBaseUrl: string; - maxAge: number; -} - -export interface SelectedPeerCoin { - coinPub: string; - coinPriv: string; - contribution: AmountString; - denomPubHash: string; - denomSig: UnblindedSignature; - ageCommitmentProof: AgeCommitmentProof | undefined; -} - export interface PeerCoinSelectionDetails { exchangeBaseUrl: string; /** * Info of Coins that were selected. */ - coins: SelectedPeerCoin[]; + coins: SelectedCoin[]; /** * How much of the deposit fees is the customer paying? @@ -842,12 +879,6 @@ export type SelectPeerCoinsResult = insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails; }; -export interface PeerCoinRepair { - exchangeBaseUrl: string; - coinPubs: CoinPublicKeyString[]; - contribs: AmountJson[]; -} - export interface PeerCoinSelectionRequest { instructedAmount: AmountJson; @@ -855,121 +886,39 @@ export interface PeerCoinSelectionRequest { * Instruct the coin selection to repair this coin * selection instead of selecting completely new coins. */ - repair?: PeerCoinRepair; + repair?: PreviousPayCoins; } -async function assemblePeerCoinSelectionDetails( - tx: WalletDbReadOnlyTransaction<["coins"]>, - exchangeBaseUrl: string, +export async function computeCoinSelMaxExpirationDate( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>, selectedDenom: SelResult, - resCoins: ResCoin[], - tally: CoinSelectionTally, -): Promise<PeerCoinSelectionDetails> { +): Promise<TalerProtocolTimestamp> { let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never(); for (const dph of Object.keys(selectedDenom)) { const selInfo = selectedDenom[dph]; + const denom = await getDenomInfo( + wex, + tx, + selInfo.exchangeBaseUrl, + selInfo.denomPubHash, + ); + if (!denom) { + continue; + } // Compute earliest time that a selected denom // would have its coins auto-refreshed. minAutorefreshExecuteThreshold = TalerProtocolTimestamp.min( minAutorefreshExecuteThreshold, AbsoluteTime.toProtocolTimestamp( getAutoRefreshExecuteThreshold({ - stampExpireDeposit: selInfo.expireDeposit, - stampExpireWithdraw: selInfo.expireWithdraw, + stampExpireDeposit: denom.stampExpireDeposit, + stampExpireWithdraw: denom.stampExpireWithdraw, }), ), ); - const numRequested = selInfo.contributions.length; - const query = [ - selInfo.exchangeBaseUrl, - selInfo.denomPubHash, - selInfo.maxAge, - CoinStatus.Fresh, - ]; - logger.info(`query: ${j2s(query)}`); - const coins = - await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll( - query, - numRequested, - ); - if (coins.length != numRequested) { - throw Error( - `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`, - ); - } - for (let i = 0; i < selInfo.contributions.length; i++) { - resCoins.push({ - coinPriv: coins[i].coinPriv, - coinPub: coins[i].coinPub, - contribution: Amounts.stringify(selInfo.contributions[i]), - ageCommitmentProof: coins[i].ageCommitmentProof, - denomPubHash: selInfo.denomPubHash, - denomSig: coins[i].denomSig, - }); - } } - - return { - exchangeBaseUrl, - coins: resCoins, - depositFees: tally.customerDepositFees, - maxExpirationDate: minAutorefreshExecuteThreshold, - }; -} - -async function maybeRepairPeerCoinSelection( - wex: WalletExecutionContext, - tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>, - exchangeBaseUrl: string, - tally: CoinSelectionTally, - repair: PeerCoinRepair | undefined, -): Promise<ResCoin[]> { - const resCoins: ResCoin[] = []; - - if (repair && repair.exchangeBaseUrl === exchangeBaseUrl) { - for (let i = 0; i < repair.coinPubs.length; i++) { - const contrib = repair.contribs[i]; - const coin = await tx.coins.get(repair.coinPubs[i]); - if (!coin) { - throw Error("repair not possible, coin not found"); - } - const denom = await getDenomInfo( - wex, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - checkDbInvariant(!!denom); - resCoins.push({ - coinPriv: coin.coinPriv, - coinPub: coin.coinPub, - contribution: Amounts.stringify(contrib), - denomPubHash: coin.denomPubHash, - denomSig: coin.denomSig, - ageCommitmentProof: coin.ageCommitmentProof, - }); - const depositFee = Amounts.parseOrThrow(denom.feeDeposit); - tally.lastDepositFee = depositFee; - tally.amountPayRemaining = Amounts.sub( - tally.amountPayRemaining, - Amounts.sub(contrib, depositFee).amount, - ).amount; - tally.customerDepositFees = Amounts.add( - tally.customerDepositFees, - depositFee, - ).amount; - } - } - return resCoins; -} - -interface ResCoin { - coinPub: string; - coinPriv: string; - contribution: AmountString; - denomPubHash: string; - denomSig: UnblindedSignature; - ageCommitmentProof: AgeCommitmentProof | undefined; + return minAutorefreshExecuteThreshold; } export function emptyTallyForPeerPayment( @@ -1034,12 +983,18 @@ export async function selectPeerCoins( logger.trace(`peer payment candidate coins: ${j2s(candidates)}`); } const tally = emptyTallyForPeerPayment(req.instructedAmount); - const resCoins: ResCoin[] = await maybeRepairPeerCoinSelection( + const resCoins: SelectedCoin[] = []; + + await maybeRepairCoinSelection( wex, tx, - exch.baseUrl, + req.repair ?? [], + resCoins, tally, - req.repair, + { + wireFeeAmortization: 1, + wireFeesPerExchange: {}, + }, ); if (logger.shouldLogTrace()) { @@ -1058,15 +1013,28 @@ export async function selectPeerCoins( ); if (selectedDenom) { + const r = await assembleSelectPayCoinsSuccessResult( + tx, + selectedDenom, + resCoins, + req.instructedAmount, + tally, + ); + + const maxExpirationDate = await computeCoinSelMaxExpirationDate( + wex, + tx, + selectedDenom, + ); + return { type: "success", - result: await assemblePeerCoinSelectionDetails( - tx, - exch.baseUrl, - selectedDenom, - resCoins, - tally, - ), + result: { + coins: r.coins, + depositFees: Amounts.parseOrThrow(r.customerDepositFees), + exchangeBaseUrl: exch.baseUrl, + maxExpirationDate, + }, }; } } |