diff options
author | Florian Dold <florian@dold.me> | 2023-01-05 18:45:49 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2023-01-05 18:45:54 +0100 |
commit | 92f1b5928c764b3af12a29b97bbc3e434a82b1b0 (patch) | |
tree | 040f88aa54aec8fedb99ba57ad18218715d19e25 | |
parent | 44aaa7a636ba25b37c1c26a306e64e0db75a2747 (diff) |
wallet-core: implement insufficient balance details
For now, only for merchant payments
-rw-r--r-- | packages/taler-util/src/wallet-types.ts | 30 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/db.ts | 15 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/internal-wallet-state.ts | 1 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/backup/import.ts | 1 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/balance.ts | 287 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/common.ts | 2 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/deposits.ts | 24 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/pay-merchant.ts | 99 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/recoup.ts | 1 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/refresh.ts | 5 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/util/coinSelection.ts | 3 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/wallet.ts | 4 |
12 files changed, 392 insertions, 80 deletions
diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index d7685fa6e..6b3e39794 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -419,6 +419,7 @@ export const codecForPreparePayResultInsufficientBalance = "status", codecForConstString(PreparePayResultType.InsufficientBalance), ) + .property("balanceDetails", codecForPayMerchantInsufficientBalanceDetails()) .build("PreparePayResultInsufficientBalance"); export const codecForPreparePayResultAlreadyConfirmed = @@ -483,6 +484,7 @@ export interface PreparePayResultInsufficientBalance { amountRaw: string; noncePriv: string; talerUri: string; + balanceDetails: PayMerchantInsufficientBalanceDetails; } export interface PreparePayResultAlreadyConfirmed { @@ -2090,32 +2092,32 @@ export interface PayMerchantInsufficientBalanceDetails { /** * Amount requested by the merchant. */ - amountRequested: AmountJson; + amountRequested: AmountString; /** * Balance of type "available" (see balance.ts for definition). */ - balanceAvailable: AmountJson; + balanceAvailable: AmountString; /** * Balance of type "material" (see balance.ts for definition). */ - balanceMaterial: AmountJson; + balanceMaterial: AmountString; /** * Balance of type "age-acceptable" (see balance.ts for definition). */ - balanceAgeAcceptable: AmountJson; + balanceAgeAcceptable: AmountString; /** * Balance of type "merchant-acceptable" (see balance.ts for definition). */ - balanceMechantAcceptable: AmountJson; + balanceMerchantAcceptable: AmountString; /** * Balance of type "merchant-depositable" (see balance.ts for definition). */ - balanceMechantDepositable: AmountJson; + balanceMerchantDepositable: AmountString; /** * If the payment would succeed without fees @@ -2126,5 +2128,17 @@ export interface PayMerchantInsufficientBalanceDetails { * It is not possible to give an exact value here, since it depends * on the coin selection for the amount that would be additionally withdrawn. */ - feeGapEstimate: AmountJson; -}
\ No newline at end of file + feeGapEstimate: AmountString; +} + +const codecForPayMerchantInsufficientBalanceDetails = +(): Codec<PayMerchantInsufficientBalanceDetails> => + buildCodecForObject<PayMerchantInsufficientBalanceDetails>() + .property("amountRequested", codecForAmountString()) + .property("balanceAgeAcceptable", codecForAmountString()) + .property("balanceAvailable", codecForAmountString()) + .property("balanceMaterial", codecForAmountString()) + .property("balanceMerchantAcceptable", codecForAmountString()) + .property("balanceMerchantDepositable", codecForAmountString()) + .property("feeGapEstimate", codecForAmountString()) + .build("PayMerchantInsufficientBalanceDetails");
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 2bf417cac..299c7a36c 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -835,6 +835,14 @@ export enum RefreshOperationStatus { FinishedWithError = 51 /* DORMANT_START + 1 */, } +/** + * Group of refresh operations. The refreshed coins do not + * have to belong to the same exchange, but must have the same + * currency. + * + * FIXME: Should include the currency as a top-level field, + * but we need to write a migration for that. + */ export interface RefreshGroupRecord { operationStatus: RefreshOperationStatus; @@ -848,6 +856,13 @@ export interface RefreshGroupRecord { refreshGroupId: string; /** + * Currency of this refresh group. + * + * FIXME: Write a migration to add this to earlier DB versions. + */ + currency: string; + + /** * Reason why this refresh group has been created. */ reason: RefreshReason; diff --git a/packages/taler-wallet-core/src/internal-wallet-state.ts b/packages/taler-wallet-core/src/internal-wallet-state.ts index 93d813cc9..879d18a48 100644 --- a/packages/taler-wallet-core/src/internal-wallet-state.ts +++ b/packages/taler-wallet-core/src/internal-wallet-state.ts @@ -86,6 +86,7 @@ export interface RefreshOperations { refreshGroups: typeof WalletStoresV1.refreshGroups; coinAvailability: typeof WalletStoresV1.coinAvailability; }>, + currency: string, oldCoinPubs: CoinRefreshRequest[], reason: RefreshReason, ): Promise<RefreshGroupId>; diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index 5fd220113..805b0c6d3 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -778,6 +778,7 @@ export async function importBackup( timestampFinished: backupRefreshGroup.timestamp_finish, timestampCreated: backupRefreshGroup.timestamp_created, refreshGroupId: backupRefreshGroup.refresh_group_id, + currency: Amounts.currencyOf(backupRefreshGroup.old_coins[0].input_amount), reason, lastErrorPerCoin: {}, oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub), diff --git a/packages/taler-wallet-core/src/operations/balance.ts b/packages/taler-wallet-core/src/operations/balance.ts index 95ade1cb4..f697679af 100644 --- a/packages/taler-wallet-core/src/operations/balance.ts +++ b/packages/taler-wallet-core/src/operations/balance.ts @@ -16,15 +16,15 @@ /** * Functions to compute the wallet's balance. - * + * * There are multiple definition of the wallet's balance. * We use the following terminology: - * + * * - "available": Balance that the wallet believes will certainly be available * for spending, modulo any failures of the exchange or double spending issues. * This includes available coins *not* allocated to any * spending/refresh/... operation. Pending withdrawals are *not* counted - * towards this balance, because they are not certain to succeed. + * towards this balance, because they are not certain to succeed. * Pending refreshes *are* counted towards this balance. * This balance type is nice to show to the user, because it does not * temporarily decrease after payment when we are waiting for refreshes @@ -38,12 +38,11 @@ * * - "merchant-acceptable": Subset of the material balance that can be spent with a particular * merchant (restricted via min age, exchange, auditor, wire_method). - * + * * - "merchant-depositable": Subset of the merchant-acceptable balance that the merchant * can accept via their supported wire methods. */ - /** * Imports. */ @@ -52,10 +51,16 @@ import { BalancesResponse, Amounts, Logger, + AuditorHandle, + ExchangeHandle, + canonicalizeBaseUrl, + parsePaytoUri, } from "@gnu-taler/taler-util"; -import { WalletStoresV1 } from "../db.js"; +import { AllowedAuditorInfo, AllowedExchangeInfo, RefreshGroupRecord, WalletStoresV1 } from "../db.js"; import { GetReadOnlyAccess } from "../util/query.js"; import { InternalWalletState } from "../internal-wallet-state.js"; +import { getExchangeDetails } from "./exchanges.js"; +import { checkLogicInvariant } from "../util/invariants.js"; /** * Logger. @@ -69,6 +74,30 @@ interface WalletBalance { } /** + * Compute the available amount that the wallet expects to get + * out of a refresh group. + */ +function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): AmountJson { + // Don't count finished refreshes, since the refresh already resulted + // in coins being added to the wallet. + let available = Amounts.zeroOfCurrency(r.currency); + if (r.timestampFinished) { + return available; + } + for (let i = 0; i < r.oldCoinPubs.length; i++) { + const session = r.refreshSessionPerCoin[i]; + if (session) { + // We are always assuming the refresh will succeed, thus we + // report the output as available balance. + available = Amounts.add(available, session.amountRefreshOutput).amount; + } else { + available = Amounts.add(available, r.estimatedOutputPerCoin[i]).amount; + } + } + return available; +} + +/** * Get balance information. */ export async function getBalancesInsideTransaction( @@ -110,33 +139,11 @@ export async function getBalancesInsideTransaction( }); await tx.refreshGroups.iter().forEach((r) => { - // Don't count finished refreshes, since the refresh already resulted - // in coins being added to the wallet. - if (r.timestampFinished) { - return; - } - for (let i = 0; i < r.oldCoinPubs.length; i++) { - const session = r.refreshSessionPerCoin[i]; - if (session) { - const currency = Amounts.parseOrThrow( - session.amountRefreshOutput, - ).currency; - const b = initBalance(currency); - // We are always assuming the refresh will succeed, thus we - // report the output as available balance. - b.available = Amounts.add( - b.available, - session.amountRefreshOutput, - ).amount; - } else { - const currency = Amounts.parseOrThrow(r.inputPerCoin[i]).currency; - const b = initBalance(currency); - b.available = Amounts.add( - b.available, - r.estimatedOutputPerCoin[i], - ).amount; - } - } + const b = initBalance(r.currency); + b.available = Amounts.add( + b.available, + computeRefreshGroupAvailableAmount(r), + ).amount; }); await tx.withdrawalGroups.iter().forEach((wds) => { @@ -194,3 +201,217 @@ export async function getBalances( return wbal; } + +/** + * Information about the balance for a particular payment to a particular + * merchant. + */ +export interface MerchantPaymentBalanceDetails { + balanceAvailable: AmountJson; +} + +export interface MerchantPaymentRestrictionsForBalance { + currency: string; + minAge: number; + acceptedExchanges: AllowedExchangeInfo[]; + acceptedAuditors: AllowedAuditorInfo[]; + acceptedWireMethods: string[]; +} + +export interface AcceptableExchanges { + /** + * Exchanges accepted by the merchant, but wire method might not match. + */ + acceptableExchanges: string[]; + + /** + * Exchanges accepted by the merchant, including a matching + * wire method, i.e. the merchant can deposit coins there. + */ + depositableExchanges: string[]; +} + +/** + * Get all exchanges that are acceptable for a particular payment. + */ +export async function getAcceptableExchangeBaseUrls( + ws: InternalWalletState, + req: MerchantPaymentRestrictionsForBalance, +): Promise<AcceptableExchanges> { + const acceptableExchangeUrls = new Set<string>(); + const depositableExchangeUrls = new Set<string>(); + await ws.db + .mktx((x) => [x.exchanges, x.exchangeDetails, x.auditorTrust]) + .runReadOnly(async (tx) => { + // FIXME: We should have a DB index to look up all exchanges + // for a particular auditor ... + + const canonExchanges = new Set<string>(); + const canonAuditors = new Set<string>(); + + for (const exchangeHandle of req.acceptedExchanges) { + const normUrl = canonicalizeBaseUrl(exchangeHandle.exchangeBaseUrl); + canonExchanges.add(normUrl); + } + + for (const auditorHandle of req.acceptedAuditors) { + const normUrl = canonicalizeBaseUrl(auditorHandle.auditorBaseUrl); + canonAuditors.add(normUrl); + } + + await tx.exchanges.iter().forEachAsync(async (exchange) => { + const dp = exchange.detailsPointer; + if (!dp) { + return; + } + const { currency, masterPublicKey } = dp; + const exchangeDetails = await tx.exchangeDetails.indexes.byPointer.get([ + exchange.baseUrl, + currency, + masterPublicKey, + ]); + if (!exchangeDetails) { + return; + } + + let acceptable = false; + + if (canonExchanges.has(exchange.baseUrl)) { + acceptableExchangeUrls.add(exchange.baseUrl); + acceptable = true; + } + for (const exchangeAuditor of exchangeDetails.auditors) { + if (canonAuditors.has(exchangeAuditor.auditor_url)) { + acceptableExchangeUrls.add(exchange.baseUrl); + acceptable = true; + break; + } + } + + if (!acceptable) { + return; + } + // FIXME: Also consider exchange and auditor public key + // instead of just base URLs? + + let wireMethodSupported = false; + for (const acc of exchangeDetails.wireInfo.accounts) { + const pp = parsePaytoUri(acc.payto_uri); + checkLogicInvariant(!!pp); + for (const wm of req.acceptedWireMethods) { + if (pp.targetType === wm) { + wireMethodSupported = true; + break; + } + if (wireMethodSupported) { + break; + } + } + } + + acceptableExchangeUrls.add(exchange.baseUrl); + if (wireMethodSupported) { + depositableExchangeUrls.add(exchange.baseUrl); + } + }); + }); + return { + acceptableExchanges: [...acceptableExchangeUrls], + depositableExchanges: [...depositableExchangeUrls], + }; +} + +export interface MerchantPaymentBalanceDetails { + /** + * Balance of type "available" (see balance.ts for definition). + */ + balanceAvailable: AmountJson; + + /** + * Balance of type "material" (see balance.ts for definition). + */ + balanceMaterial: AmountJson; + + /** + * Balance of type "age-acceptable" (see balance.ts for definition). + */ + balanceAgeAcceptable: AmountJson; + + /** + * Balance of type "merchant-acceptable" (see balance.ts for definition). + */ + balanceMerchantAcceptable: AmountJson; + + /** + * Balance of type "merchant-depositable" (see balance.ts for definition). + */ + balanceMerchantDepositable: AmountJson; +} + +export async function getMerchantPaymentBalanceDetails( + ws: InternalWalletState, + req: MerchantPaymentRestrictionsForBalance, +): Promise<MerchantPaymentBalanceDetails> { + const acceptability = await getAcceptableExchangeBaseUrls(ws, req); + + const d: MerchantPaymentBalanceDetails = { + balanceAvailable: Amounts.zeroOfCurrency(req.currency), + balanceMaterial: Amounts.zeroOfCurrency(req.currency), + balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency), + balanceMerchantAcceptable: Amounts.zeroOfCurrency(req.currency), + balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency), + }; + + const wbal = await ws.db + .mktx((x) => [ + x.coins, + x.coinAvailability, + x.refreshGroups, + x.purchases, + x.withdrawalGroups, + ]) + .runReadOnly(async (tx) => { + await tx.coinAvailability.iter().forEach((ca) => { + const singleCoinAmount: AmountJson = { + currency: ca.currency, + fraction: ca.amountFrac, + value: ca.amountVal, + }; + const coinAmount: AmountJson = Amounts.mult( + singleCoinAmount, + ca.freshCoinCount, + ).amount; + d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount; + d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount; + if (ca.maxAge === 0 || ca.maxAge > req.minAge) { + d.balanceAgeAcceptable = Amounts.add( + d.balanceAgeAcceptable, + coinAmount, + ).amount; + if (acceptability.acceptableExchanges.includes(ca.exchangeBaseUrl)) { + d.balanceMerchantAcceptable = Amounts.add( + d.balanceMerchantAcceptable, + coinAmount, + ).amount; + if ( + acceptability.depositableExchanges.includes(ca.exchangeBaseUrl) + ) { + d.balanceMerchantDepositable = Amounts.add( + d.balanceMerchantDepositable, + coinAmount, + ).amount; + } + } + } + }); + + await tx.refreshGroups.iter().forEach((r) => { + d.balanceAvailable = Amounts.add( + d.balanceAvailable, + computeRefreshGroupAvailableAmount(r), + ).amount; + }); + }); + + return d; +} diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/operations/common.ts index 2323cb82c..cb22105e1 100644 --- a/packages/taler-wallet-core/src/operations/common.ts +++ b/packages/taler-wallet-core/src/operations/common.ts @@ -175,9 +175,11 @@ export async function spendCoins( await tx.coins.put(coin); await tx.coinAvailability.put(coinAvailability); } + await ws.refreshOps.createRefreshGroup( ws, tx, + Amounts.currencyOf(csi.contributions[0]), refreshCoinPubs, RefreshReason.PayMerchant, ); diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts index 406d658af..1cb051365 100644 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -268,7 +268,7 @@ export async function getFeeForDeposit( prevPayCoins: [], }); - if (!payCoinSel) { + if (payCoinSel.type !== "success") { throw Error("insufficient funds"); } @@ -276,7 +276,7 @@ export async function getFeeForDeposit( ws, p.targetType, amount, - payCoinSel, + payCoinSel.coinSel, ); } @@ -355,16 +355,16 @@ export async function prepareDepositGroup( prevPayCoins: [], }); - if (!payCoinSel) { + if (payCoinSel.type !== "success") { throw Error("insufficient funds"); } - const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel); + const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel); const effectiveDepositAmount = await getEffectiveDepositAmount( ws, p.targetType, - payCoinSel, + payCoinSel.coinSel, ); return { @@ -452,18 +452,18 @@ export async function createDepositGroup( prevPayCoins: [], }); - if (!payCoinSel) { + if (payCoinSel.type !== "success") { throw Error("insufficient funds"); } - const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel); + const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel); const depositGroupId = encodeCrock(getRandomBytes(32)); const effectiveDepositAmount = await getEffectiveDepositAmount( ws, p.targetType, - payCoinSel, + payCoinSel.coinSel, ); const depositGroup: DepositGroupRecord = { @@ -474,9 +474,9 @@ export async function createDepositGroup( noncePub: noncePair.pub, timestampCreated: AbsoluteTime.toTimestamp(now), timestampFinished: undefined, - payCoinSelection: payCoinSel, + payCoinSelection: payCoinSel.coinSel, payCoinSelectionUid: encodeCrock(getRandomBytes(32)), - depositedPerCoin: payCoinSel.coinPubs.map(() => false), + depositedPerCoin: payCoinSel.coinSel.coinPubs.map(() => false), merchantPriv: merchantPair.priv, merchantPub: merchantPair.pub, totalPayCost: Amounts.stringify(totalDepositCost), @@ -500,8 +500,8 @@ export async function createDepositGroup( .runReadWrite(async (tx) => { await spendCoins(ws, tx, { allocationId: `txn:deposit:${depositGroup.depositGroupId}`, - coinPubs: payCoinSel.coinPubs, - contributions: payCoinSel.coinContributions.map((x) => + coinPubs: payCoinSel.coinSel.coinPubs, + contributions: payCoinSel.coinSel.coinContributions.map((x) => Amounts.parseOrThrow(x), ), refreshReason: RefreshReason.PayDeposit, diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts index 05da0a020..6026e0860 100644 --- a/packages/taler-wallet-core/src/operations/pay-merchant.ts +++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts @@ -73,6 +73,7 @@ import { TransactionType, URL, constructPayUri, + PayMerchantInsufficientBalanceDetails, } from "@gnu-taler/taler-util"; import { EddsaKeypair } from "../crypto/cryptoImplementation.js"; import { @@ -131,11 +132,12 @@ import { import { getExchangeDetails } from "./exchanges.js"; import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js"; import { GetReadOnlyAccess } from "../util/query.js"; +import { getMerchantPaymentBalanceDetails } from "./balance.js"; /** * Logger. */ -const logger = new Logger("pay.ts"); +const logger = new Logger("pay-merchant.ts"); /** * Compute the total cost of a payment to the customer. @@ -817,7 +819,7 @@ async function handleInsufficientFunds( requiredMinimumAge: contractData.minimumAge, }); - if (!res) { + if (res.type !== "success") { logger.trace("insufficient funds for coin re-selection"); return; } @@ -841,8 +843,7 @@ async function handleInsufficientFunds( if (!payInfo) { return; } - payInfo.payCoinSelection = res; - payInfo.payCoinSelection = res; + payInfo.payCoinSelection = res.coinSel; payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); await tx.purchases.put(p); await spendCoins(ws, tx, { @@ -905,6 +906,8 @@ export async function selectCandidates( 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> = {}; @@ -1030,6 +1033,7 @@ export function selectGreedy( // 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.value) > 0) { + tally.lastDepositFee = Amounts.parseOrThrow(aci.feeDeposit); continue; } @@ -1129,6 +1133,13 @@ export function selectForced( return selectedDenom; } +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. @@ -1142,7 +1153,7 @@ export function selectForced( export async function selectPayCoinsNew( ws: InternalWalletState, req: SelectPayCoinRequestNg, -): Promise<PayCoinSelection | undefined> { +): Promise<SelectPayCoinsResult> { const { contractTermsAmount, depositFeeLimit, @@ -1168,6 +1179,7 @@ export async function selectPayCoinsNew( customerDepositFees: Amounts.zeroOfCurrency(currency), customerWireFees: Amounts.zeroOfCurrency(currency), wireFeeCoveredForExchange: new Set(), + lastDepositFee: Amounts.zeroOfCurrency(currency), }; const prevPayCoins = req.prevPayCoins ?? []; @@ -1207,7 +1219,44 @@ export async function selectPayCoinsNew( } if (!selectedDenom) { - return undefined; + 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; @@ -1244,11 +1293,14 @@ export async function selectPayCoinsNew( }); return { - paymentAmount: Amounts.stringify(contractTermsAmount), - coinContributions: coinContributions.map((x) => Amounts.stringify(x)), - coinPubs, - customerDepositFees: Amounts.stringify(tally.customerDepositFees), - customerWireFees: Amounts.stringify(tally.customerWireFees), + 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), + }, }; } @@ -1318,7 +1370,7 @@ export async function checkPaymentByProposalId( wireMethod: contractData.wireMethod, }); - if (!res) { + if (res.type !== "success") { logger.info("not allowing payment, insufficient coins"); return { status: PreparePayResultType.InsufficientBalance, @@ -1327,10 +1379,11 @@ export async function checkPaymentByProposalId( noncePriv: proposal.noncePriv, amountRaw: Amounts.stringify(d.contractData.amount), talerUri, + balanceDetails: res.insufficientBalanceDetails, }; } - const totalCost = await getTotalPaymentCost(ws, res); + const totalCost = await getTotalPaymentCost(ws, res.coinSel); logger.trace("costInfo", totalCost); logger.trace("coinsForPayment", res); @@ -1340,7 +1393,7 @@ export async function checkPaymentByProposalId( proposalId: proposal.proposalId, noncePriv: proposal.noncePriv, amountEffective: Amounts.stringify(totalCost), - amountRaw: Amounts.stringify(res.paymentAmount), + amountRaw: Amounts.stringify(res.coinSel.paymentAmount), contractTermsHash: d.contractData.contractTermsHash, talerUri, }; @@ -1666,9 +1719,9 @@ export async function confirmPay( const contractData = d.contractData; - let maybeCoinSelection: PayCoinSelection | undefined = undefined; + let selectCoinsResult: SelectPayCoinsResult | undefined = undefined; - maybeCoinSelection = await selectPayCoinsNew(ws, { + selectCoinsResult = await selectPayCoinsNew(ws, { auditors: contractData.allowedAuditors, exchanges: contractData.allowedExchanges, wireMethod: contractData.wireMethod, @@ -1681,9 +1734,9 @@ export async function confirmPay( forcedSelection: forcedCoinSel, }); - logger.trace("coin selection result", maybeCoinSelection); + logger.trace("coin selection result", selectCoinsResult); - if (!maybeCoinSelection) { + if (selectCoinsResult.type === "failure") { // 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. @@ -1691,14 +1744,7 @@ export async function confirmPay( throw Error("insufficient balance"); } - const coinSelection = maybeCoinSelection; - - const depositPermissions = await generateDepositPermissions( - ws, - coinSelection, - d.contractData, - ); - + const coinSelection = selectCoinsResult.coinSel; const payCostInfo = await getTotalPaymentCost(ws, coinSelection); let sessionId: string | undefined; @@ -2373,6 +2419,7 @@ async function acceptRefunds( await createRefreshGroup( ws, tx, + Amounts.currencyOf(refreshCoinsPubs[0].amount), refreshCoinsPubs, RefreshReason.Refund, ); diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts index 4feb4430d..00dd0e1c6 100644 --- a/packages/taler-wallet-core/src/operations/recoup.ts +++ b/packages/taler-wallet-core/src/operations/recoup.ts @@ -429,6 +429,7 @@ export async function processRecoupGroupHandler( const refreshGroupId = await createRefreshGroup( ws, tx, + Amounts.currencyOf(rg2.scheduleRefreshCoins[0].amount), rg2.scheduleRefreshCoins, RefreshReason.Recoup, ); diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index eeff84be6..638dec8a6 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -850,6 +850,7 @@ export async function createRefreshGroup( refreshGroups: typeof WalletStoresV1.refreshGroups; coinAvailability: typeof WalletStoresV1.coinAvailability; }>, + currency: string, oldCoinPubs: CoinRefreshRequest[], reason: RefreshReason, ): Promise<RefreshGroupId> { @@ -934,6 +935,7 @@ export async function createRefreshGroup( const refreshGroup: RefreshGroupRecord = { operationStatus: RefreshOperationStatus.Pending, + currency, timestampFinished: undefined, statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending), oldCoinPubs: oldCoinPubs.map((x) => x.coinPub), @@ -1018,7 +1020,7 @@ export async function autoRefresh( ]) .runReadWrite(async (tx) => { const exchange = await tx.exchanges.get(exchangeBaseUrl); - if (!exchange) { + if (!exchange || !exchange.detailsPointer) { return; } const coins = await tx.coins.indexes.byBaseUrl @@ -1059,6 +1061,7 @@ export async function autoRefresh( const res = await createRefreshGroup( ws, tx, + exchange.detailsPointer?.currency, refreshCoins, RefreshReason.Scheduled, ); diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts index cadf8d829..0bd624bf7 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -117,6 +117,8 @@ export interface CoinSelectionTally { customerWireFees: AmountJson; wireFeeCoveredForExchange: Set<string>; + + lastDepositFee: AmountJson; } /** @@ -188,5 +190,6 @@ export function tallyFees( customerDepositFees, customerWireFees, wireFeeCoveredForExchange, + lastDepositFee: feeDeposit, }; } diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 1defff0d2..e15c6110c 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -1178,6 +1178,9 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( } case WalletApiOperation.ForceRefresh: { const req = codecForForceRefreshRequest().decode(payload); + if (req.coinPubList.length == 0) { + throw Error("refusing to create empty refresh group"); + } const refreshGroupId = await ws.db .mktx((x) => [ x.refreshGroups, @@ -1207,6 +1210,7 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( return await createRefreshGroup( ws, tx, + Amounts.currencyOf(coinPubs[0].amount), coinPubs, RefreshReason.Manual, ); |