aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2023-01-05 18:45:49 +0100
committerFlorian Dold <florian@dold.me>2023-01-05 18:45:54 +0100
commit92f1b5928c764b3af12a29b97bbc3e434a82b1b0 (patch)
tree040f88aa54aec8fedb99ba57ad18218715d19e25
parent44aaa7a636ba25b37c1c26a306e64e0db75a2747 (diff)
downloadwallet-core-92f1b5928c764b3af12a29b97bbc3e434a82b1b0.tar.xz
wallet-core: implement insufficient balance details
For now, only for merchant payments
-rw-r--r--packages/taler-util/src/wallet-types.ts30
-rw-r--r--packages/taler-wallet-core/src/db.ts15
-rw-r--r--packages/taler-wallet-core/src/internal-wallet-state.ts1
-rw-r--r--packages/taler-wallet-core/src/operations/backup/import.ts1
-rw-r--r--packages/taler-wallet-core/src/operations/balance.ts287
-rw-r--r--packages/taler-wallet-core/src/operations/common.ts2
-rw-r--r--packages/taler-wallet-core/src/operations/deposits.ts24
-rw-r--r--packages/taler-wallet-core/src/operations/pay-merchant.ts99
-rw-r--r--packages/taler-wallet-core/src/operations/recoup.ts1
-rw-r--r--packages/taler-wallet-core/src/operations/refresh.ts5
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.ts3
-rw-r--r--packages/taler-wallet-core/src/wallet.ts4
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,
);