summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2024-04-03 12:58:01 +0200
committerFlorian Dold <florian@dold.me>2024-04-03 12:58:01 +0200
commit5417b8b7b866f1c4f4d99d6ec9ad001af67822b6 (patch)
tree8e14f48ca356621343ca949d1ff700dc41d08776
parentbc3e40310ef37c90ef16d562440fffe9793f1099 (diff)
downloadwallet-core-5417b8b7b866f1c4f4d99d6ec9ad001af67822b6.tar.gz
wallet-core-5417b8b7b866f1c4f4d99d6ec9ad001af67822b6.tar.bz2
wallet-core-5417b8b7b866f1c4f4d99d6ec9ad001af67822b6.zip
wallet-core: preparations for deferred coin selection
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-refresh-blocked.ts12
-rw-r--r--packages/taler-util/src/wallet-types.ts34
-rw-r--r--packages/taler-wallet-core/src/coinSelection.ts400
-rw-r--r--packages/taler-wallet-core/src/deposits.ts107
-rw-r--r--packages/taler-wallet-core/src/pay-merchant.ts106
-rw-r--r--packages/taler-wallet-core/src/pay-peer-pull-debit.ts79
-rw-r--r--packages/taler-wallet-core/src/pay-peer-push-debit.ts75
-rw-r--r--packages/taler-wallet-core/src/wallet.ts4
8 files changed, 562 insertions, 255 deletions
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-refresh-blocked.ts b/packages/taler-harness/src/integrationtests/test-wallet-refresh-blocked.ts
index 8c568d190..4662c5110 100644
--- a/packages/taler-harness/src/integrationtests/test-wallet-refresh-blocked.ts
+++ b/packages/taler-harness/src/integrationtests/test-wallet-refresh-blocked.ts
@@ -17,7 +17,7 @@
/**
* Imports.
*/
-import { j2s } from "@gnu-taler/taler-util";
+import { AmountString, j2s } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { CoinConfig } from "../harness/denomStructures.js";
import { GlobalTestState } from "../harness/harness.js";
@@ -106,7 +106,15 @@ export async function runWalletRefreshBlockedTest(t: GlobalTestState) {
console.log(`balance details: ${j2s(balDet)}`);
// FIXME: Now check deposit/p2p/pay
- t.assertTrue(false);
+
+ const depositCheckResp = await w1.call(WalletApiOperation.PrepareDeposit, {
+ amount: "TESTKUDOS:18" as AmountString,
+ depositPaytoUri: "payto://x-taler-bank/localhost/myuser",
+ });
+
+ console.log(`check resp: ${j2s(depositCheckResp)}`);
+
+ // t.assertTrue(false);
}
runWalletRefreshBlockedTest.suites = ["wallet"];
diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts
index e5eb618f0..441da7a87 100644
--- a/packages/taler-util/src/wallet-types.ts
+++ b/packages/taler-util/src/wallet-types.ts
@@ -633,11 +633,11 @@ export interface CoinDumpJson {
withdrawal_reserve_pub: string | undefined;
coin_status: CoinStatus;
spend_allocation:
- | {
- id: string;
- amount: AmountString;
- }
- | undefined;
+ | {
+ id: string;
+ amount: AmountString;
+ }
+ | undefined;
/**
* Information about the age restriction
*/
@@ -836,7 +836,7 @@ export const codecForPreparePayResultPaymentPossible =
)
.build("PreparePayResultPaymentPossible");
-export interface BalanceDetails { }
+export interface BalanceDetails {}
/**
* Detailed reason for why the wallet's balance is insufficient.
@@ -2662,8 +2662,16 @@ export interface TestPayResult {
}
export interface SelectedCoin {
+ denomPubHash: string;
coinPub: string;
contribution: AmountString;
+ exchangeBaseUrl: string;
+}
+
+export interface SelectedProspectiveCoin {
+ denomPubHash: string;
+ contribution: AmountString;
+ exchangeBaseUrl: string;
}
/**
@@ -2684,6 +2692,20 @@ export interface PayCoinSelection {
customerDepositFees: AmountString;
}
+export interface ProspectivePayCoinSelection {
+ prospectiveCoins: SelectedProspectiveCoin[];
+
+ /**
+ * How much of the wire fees is the customer paying?
+ */
+ customerWireFees: AmountString;
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ customerDepositFees: AmountString;
+}
+
export interface CheckPeerPushDebitRequest {
/**
* Preferred exchange to use for the p2p payment.
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts
index 6e3ef5917..bce51fd91 100644
--- a/packages/taler-wallet-core/src/coinSelection.ts
+++ b/packages/taler-wallet-core/src/coinSelection.ts
@@ -44,7 +44,9 @@ import {
parsePaytoUri,
PayCoinSelection,
PaymentInsufficientBalanceDetails,
+ ProspectivePayCoinSelection,
SelectedCoin,
+ SelectedProspectiveCoin,
strcmp,
TalerProtocolTimestamp,
} from "@gnu-taler/taler-util";
@@ -158,8 +160,101 @@ export type SelectPayCoinsResult =
type: "failure";
insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
}
+ | { type: "prospective"; result: ProspectivePayCoinSelection }
| { type: "success"; coinSel: PayCoinSelection };
+async function internalSelectPayCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "coins",
+ ]
+ >,
+ req: SelectPayCoinRequestNg,
+ includePendingCoins: boolean,
+): Promise<
+ | { sel: SelResult; coinRes: SelectedCoin[]; tally: CoinSelectionTally }
+ | undefined
+> {
+ const { contractTermsAmount, depositFeeLimit } = req;
+ const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates(
+ wex,
+ tx,
+ {
+ restrictExchanges: req.restrictExchanges,
+ instructedAmount: req.contractTermsAmount,
+ restrictWireMethod: req.restrictWireMethod,
+ depositPaytoUri: req.depositPaytoUri,
+ requiredMinimumAge: req.requiredMinimumAge,
+ includePendingCoins,
+ },
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `instructed amount: ${Amounts.stringify(req.contractTermsAmount)}`,
+ );
+ logger.trace(`wire fees per exchange: ${j2s(wireFeesPerExchange)}`);
+ logger.trace(`candidates: ${j2s(candidateDenoms)}`);
+ }
+
+ const coinRes: SelectedCoin[] = [];
+ const currency = contractTermsAmount.currency;
+
+ let tally: CoinSelectionTally = {
+ amountPayRemaining: contractTermsAmount,
+ amountDepositFeeLimitRemaining: depositFeeLimit,
+ customerDepositFees: Amounts.zeroOfCurrency(currency),
+ customerWireFees: Amounts.zeroOfCurrency(currency),
+ wireFeeCoveredForExchange: new Set(),
+ lastDepositFee: Amounts.zeroOfCurrency(currency),
+ };
+
+ await maybeRepairCoinSelection(
+ wex,
+ tx,
+ req.prevPayCoins ?? [],
+ coinRes,
+ tally,
+ {
+ wireFeeAmortization: req.wireFeeAmortization,
+ wireFeesPerExchange: wireFeesPerExchange,
+ },
+ );
+
+ 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(
+ {
+ wireFeeAmortization: req.wireFeeAmortization,
+ wireFeesPerExchange: wireFeesPerExchange,
+ },
+ candidateDenoms,
+ tally,
+ );
+ }
+
+ if (!selectedDenom) {
+ return undefined;
+ }
+ return {
+ sel: selectedDenom,
+ coinRes,
+ tally,
+ };
+}
+
/**
* Select coins to spend under the merchant's constraints.
*
@@ -171,8 +266,6 @@ export async function selectPayCoins(
wex: WalletExecutionContext,
req: SelectPayCoinRequestNg,
): Promise<SelectPayCoinsResult> {
- const { contractTermsAmount, depositFeeLimit } = req;
-
if (logger.shouldLogTrace()) {
logger.trace(`selecting coins for ${j2s(req)}`);
}
@@ -187,69 +280,42 @@ export async function selectPayCoins(
"coins",
],
async (tx) => {
- const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates(
- wex,
- tx,
- {
- restrictExchanges: req.restrictExchanges,
- instructedAmount: req.contractTermsAmount,
- restrictWireMethod: req.restrictWireMethod,
- depositPaytoUri: req.depositPaytoUri,
- requiredMinimumAge: req.requiredMinimumAge,
- },
- );
+ const materialAvSel = await internalSelectPayCoins(wex, tx, req, false);
- if (logger.shouldLogTrace()) {
- logger.trace(
- `instructed amount: ${Amounts.stringify(req.contractTermsAmount)}`,
+ if (!materialAvSel) {
+ const prospectiveAvSel = await internalSelectPayCoins(
+ wex,
+ tx,
+ req,
+ true,
);
- logger.trace(`wire fees per exchange: ${j2s(wireFeesPerExchange)}`);
- logger.trace(`candidates: ${j2s(candidateDenoms)}`);
- }
- const coinRes: SelectedCoin[] = [];
- const currency = contractTermsAmount.currency;
-
- let tally: CoinSelectionTally = {
- amountPayRemaining: contractTermsAmount,
- amountDepositFeeLimitRemaining: depositFeeLimit,
- customerDepositFees: Amounts.zeroOfCurrency(currency),
- customerWireFees: Amounts.zeroOfCurrency(currency),
- wireFeeCoveredForExchange: new Set(),
- lastDepositFee: Amounts.zeroOfCurrency(currency),
- };
-
- await maybeRepairCoinSelection(
- wex,
- tx,
- req.prevPayCoins ?? [],
- coinRes,
- tally,
- {
- wireFeeAmortization: req.wireFeeAmortization,
- wireFeesPerExchange: wireFeesPerExchange,
- },
- );
-
- 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(
- {
- wireFeeAmortization: req.wireFeeAmortization,
- wireFeesPerExchange: wireFeesPerExchange,
- },
- candidateDenoms,
- tally,
- );
- }
+ if (prospectiveAvSel) {
+ const prospectiveCoins: SelectedProspectiveCoin[] = [];
+ for (const avKey of Object.keys(prospectiveAvSel.sel)) {
+ const mySel = prospectiveAvSel.sel[avKey];
+ for (const contrib of mySel.contributions) {
+ prospectiveCoins.push({
+ denomPubHash: mySel.denomPubHash,
+ contribution: Amounts.stringify(contrib),
+ exchangeBaseUrl: mySel.exchangeBaseUrl,
+ });
+ }
+ }
+ return {
+ type: "prospective",
+ result: {
+ prospectiveCoins,
+ customerDepositFees: Amounts.stringify(
+ prospectiveAvSel.tally.customerDepositFees,
+ ),
+ customerWireFees: Amounts.stringify(
+ prospectiveAvSel.tally.customerWireFees,
+ ),
+ },
+ } satisfies SelectPayCoinsResult;
+ }
- if (!selectedDenom) {
return {
type: "failure",
insufficientBalanceDetails: await reportInsufficientBalanceDetails(
@@ -268,9 +334,9 @@ export async function selectPayCoins(
const coinSel = await assembleSelectPayCoinsSuccessResult(
tx,
- selectedDenom,
- coinRes,
- tally,
+ materialAvSel.sel,
+ materialAvSel.coinRes,
+ materialAvSel.tally,
);
if (logger.shouldLogTrace()) {
@@ -324,12 +390,18 @@ async function maybeRepairCoinSelection(
).amount;
coinRes.push({
+ exchangeBaseUrl: coin.exchangeBaseUrl,
+ denomPubHash: coin.denomPubHash,
coinPub: prev.coinPub,
contribution: Amounts.stringify(prev.contribution),
});
}
}
+/**
+ * Returns undefined if the success response could not be assembled,
+ * as not enough coins are actually available.
+ */
async function assembleSelectPayCoinsSuccessResult(
tx: WalletDbReadOnlyTransaction<["coins"]>,
finalSel: SelResult,
@@ -359,8 +431,10 @@ async function assembleSelectPayCoinsSuccessResult(
for (let i = 0; i < selInfo.contributions.length; i++) {
coinRes.push({
+ denomPubHash: coins[i].denomPubHash,
coinPub: coins[i].coinPub,
contribution: Amounts.stringify(selInfo.contributions[i]),
+ exchangeBaseUrl: coins[i].exchangeBaseUrl,
});
}
}
@@ -745,6 +819,13 @@ interface SelectPayCandidatesRequest {
depositPaytoUri?: string;
restrictExchanges: ExchangeRestrictionSpec | undefined;
requiredMinimumAge?: number;
+
+ /**
+ * If set to true, the coin selection will also use coins that are not
+ * materially available yet, but that are expected to become available
+ * as the output of a refresh operation.
+ */
+ includePendingCoins: boolean;
}
async function selectPayCandidates(
@@ -845,9 +926,13 @@ async function selectPayCandidates(
continue;
}
numUsable++;
+ let numAvailable = coinAvail.freshCoinCount ?? 0;
+ if (req.includePendingCoins) {
+ numAvailable += coinAvail.pendingRefreshOutputCount ?? 0;
+ }
denoms.push({
...DenominationRecord.toDenomInfo(denom),
- numAvailable: coinAvail.freshCoinCount ?? 0,
+ numAvailable,
maxAge: coinAvail.maxAge,
});
}
@@ -886,8 +971,23 @@ export interface PeerCoinSelectionDetails {
maxExpirationDate: TalerProtocolTimestamp;
}
+export interface ProspectivePeerCoinSelectionDetails {
+ exchangeBaseUrl: string;
+
+ prospectiveCoins: SelectedProspectiveCoin[];
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ depositFees: AmountJson;
+
+ maxExpirationDate: TalerProtocolTimestamp;
+}
+
export type SelectPeerCoinsResult =
| { type: "success"; result: PeerCoinSelectionDetails }
+ // Successful, but using coins that are not materially available yet.
+ | { type: "prospective"; result: ProspectivePeerCoinSelectionDetails }
| {
type: "failure";
insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
@@ -901,6 +1001,13 @@ export interface PeerCoinSelectionRequest {
* selection instead of selecting completely new coins.
*/
repair?: PreviousPayCoins;
+
+ /**
+ * If set to true, the coin selection will also use coins that are not
+ * materially available yet, but that are expected to become available
+ * as the output of a refresh operation.
+ */
+ includePendingCoins: boolean;
}
export async function computeCoinSelMaxExpirationDate(
@@ -968,6 +1075,77 @@ function getGlobalFees(
return undefined;
}
+async function internalSelectPeerCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchangeDetails",
+ ]
+ >,
+ req: PeerCoinSelectionRequest,
+ exch: ExchangeWireDetails,
+ includePendingCoins: boolean,
+): Promise<
+ | { sel: SelResult; tally: CoinSelectionTally; resCoins: SelectedCoin[] }
+ | undefined
+> {
+ const candidatesRes = await selectPayCandidates(wex, tx, {
+ instructedAmount: req.instructedAmount,
+ restrictExchanges: {
+ auditors: [],
+ exchanges: [
+ {
+ exchangeBaseUrl: exch.exchangeBaseUrl,
+ exchangePub: exch.masterPublicKey,
+ },
+ ],
+ },
+ restrictWireMethod: undefined,
+ includePendingCoins,
+ });
+ const candidates = candidatesRes[0];
+ if (logger.shouldLogTrace()) {
+ logger.trace(`peer payment candidate coins: ${j2s(candidates)}`);
+ }
+ const tally = emptyTallyForPeerPayment(req.instructedAmount);
+ const resCoins: SelectedCoin[] = [];
+
+ await maybeRepairCoinSelection(wex, tx, req.repair ?? [], resCoins, tally, {
+ wireFeeAmortization: 1,
+ wireFeesPerExchange: {},
+ });
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`candidates: ${j2s(candidates)}`);
+ logger.trace(`instructedAmount: ${j2s(req.instructedAmount)}`);
+ logger.trace(`tally: ${j2s(tally)}`);
+ }
+
+ const selRes = selectGreedy(
+ {
+ wireFeeAmortization: 1,
+ wireFeesPerExchange: {},
+ },
+ candidates,
+ tally,
+ );
+ if (!selRes) {
+ return undefined;
+ }
+
+ return {
+ sel: selRes,
+ tally,
+ resCoins,
+ };
+}
+
export async function selectPeerCoins(
wex: WalletExecutionContext,
req: PeerCoinSelectionRequest,
@@ -1004,65 +1182,63 @@ export async function selectPeerCoins(
if (!globalFees) {
continue;
}
- const candidatesRes = await selectPayCandidates(wex, tx, {
- instructedAmount,
- restrictExchanges: {
- auditors: [],
- exchanges: [
- {
- exchangeBaseUrl: exch.baseUrl,
- exchangePub: exch.detailsPointer.masterPublicKey,
- },
- ],
- },
- restrictWireMethod: undefined,
- });
- const candidates = candidatesRes[0];
- if (logger.shouldLogTrace()) {
- logger.trace(`peer payment candidate coins: ${j2s(candidates)}`);
- }
- const tally = emptyTallyForPeerPayment(req.instructedAmount);
- const resCoins: SelectedCoin[] = [];
- await maybeRepairCoinSelection(
+ const avRes = await internalSelectPeerCoins(
wex,
tx,
- req.repair ?? [],
- resCoins,
- tally,
- {
- wireFeeAmortization: 1,
- wireFeesPerExchange: {},
- },
+ req,
+ exchWire,
+ false,
);
- if (logger.shouldLogTrace()) {
- logger.trace(`candidates: ${j2s(candidates)}`);
- logger.trace(`instructedAmount: ${j2s(instructedAmount)}`);
- logger.trace(`tally: ${j2s(tally)}`);
- }
-
- const selectedDenom = selectGreedy(
- {
- wireFeeAmortization: 1,
- wireFeesPerExchange: {},
- },
- candidates,
- tally,
- );
-
- if (selectedDenom) {
+ if (!avRes && req.includePendingCoins) {
+ // Try to see if we can do a prospective selection
+ const prospectiveAvRes = await internalSelectPeerCoins(
+ wex,
+ tx,
+ req,
+ exchWire,
+ true,
+ );
+ if (prospectiveAvRes) {
+ const prospectiveCoins: SelectedProspectiveCoin[] = [];
+ for (const avKey of Object.keys(prospectiveAvRes.sel)) {
+ const mySel = prospectiveAvRes.sel[avKey];
+ for (const contrib of mySel.contributions) {
+ prospectiveCoins.push({
+ denomPubHash: mySel.denomPubHash,
+ contribution: Amounts.stringify(contrib),
+ exchangeBaseUrl: mySel.exchangeBaseUrl,
+ });
+ }
+ }
+ const maxExpirationDate = await computeCoinSelMaxExpirationDate(
+ wex,
+ tx,
+ prospectiveAvRes.sel,
+ );
+ return {
+ type: "prospective",
+ result: {
+ prospectiveCoins,
+ depositFees: prospectiveAvRes.tally.customerDepositFees,
+ exchangeBaseUrl: exch.baseUrl,
+ maxExpirationDate,
+ },
+ };
+ }
+ } else if (avRes) {
const r = await assembleSelectPayCoinsSuccessResult(
tx,
- selectedDenom,
- resCoins,
- tally,
+ avRes.sel,
+ avRes.resCoins,
+ avRes.tally,
);
const maxExpirationDate = await computeCoinSelMaxExpirationDate(
wex,
tx,
- selectedDenom,
+ avRes.sel,
);
return {
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
index a8612744f..05a5d780a 100644
--- a/packages/taler-wallet-core/src/deposits.ts
+++ b/packages/taler-wallet-core/src/deposits.ts
@@ -39,10 +39,10 @@ import {
Logger,
MerchantContractTerms,
NotificationType,
- PayCoinSelection,
PrepareDepositRequest,
PrepareDepositResponse,
RefreshReason,
+ SelectedProspectiveCoin,
TalerError,
TalerErrorCode,
TalerPreciseTimestamp,
@@ -1155,11 +1155,8 @@ async function trackDeposit(
/**
* Check if creating a deposit group is possible and calculate
* the associated fees.
- *
- * FIXME: This should be renamed to checkDepositGroup,
- * as it doesn't prepare anything
*/
-export async function prepareDepositGroup(
+export async function checkDepositGroup(
wex: WalletExecutionContext,
req: PrepareDepositRequest,
): Promise<PrepareDepositResponse> {
@@ -1168,6 +1165,7 @@ export async function prepareDepositGroup(
throw Error("invalid payto URI");
}
const amount = Amounts.parseOrThrow(req.amount);
+ const currency = Amounts.currencyOf(amount);
const exchangeInfos: ExchangeHandle[] = [];
@@ -1231,28 +1229,39 @@ export async function prepareDepositGroup(
prevPayCoins: [],
});
- if (payCoinSel.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
- },
- );
+ let selCoins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (payCoinSel.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ selCoins = payCoinSel.result.prospectiveCoins;
+ break;
+ case "success":
+ selCoins = payCoinSel.coinSel.coins;
+ break;
+ default:
+ assertUnreachable(payCoinSel);
}
- const totalDepositCost = await getTotalPaymentCost(wex, payCoinSel.coinSel);
+ const totalDepositCost = await getTotalPaymentCost(wex, currency, selCoins);
const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount(
wex,
p.targetType,
- payCoinSel.coinSel,
+ selCoins,
);
const fees = await getTotalFeesForDepositAmount(
wex,
p.targetType,
amount,
- payCoinSel.coinSel,
+ selCoins,
);
return {
@@ -1280,6 +1289,7 @@ export async function createDepositGroup(
}
const amount = Amounts.parseOrThrow(req.amount);
+ const currency = amount.currency;
const exchangeInfos: { url: string; master_pub: string }[] = [];
@@ -1350,16 +1360,28 @@ export async function createDepositGroup(
prevPayCoins: [],
});
- if (payCoinSel.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
- },
- );
+ switch (payCoinSel.type) {
+ case "success":
+ break;
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ // FIXME: Here we need to create the deposit group without a full coin selection!
+ throw Error("insufficient balance (pending refresh)");
+ default:
+ assertUnreachable(payCoinSel);
}
- const totalDepositCost = await getTotalPaymentCost(wex, payCoinSel.coinSel);
+ const totalDepositCost = await getTotalPaymentCost(
+ wex,
+ currency,
+ payCoinSel.coinSel.coins,
+ );
let depositGroupId: string;
if (req.transactionId) {
@@ -1400,7 +1422,7 @@ export async function createDepositGroup(
await getCounterpartyEffectiveDepositAmount(
wex,
p.targetType,
- payCoinSel.coinSel,
+ payCoinSel.coinSel.coins,
);
const depositGroup: DepositGroupRecord = {
@@ -1500,7 +1522,7 @@ export async function createDepositGroup(
export async function getCounterpartyEffectiveDepositAmount(
wex: WalletExecutionContext,
wireType: string,
- pcs: PayCoinSelection,
+ pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
const amt: AmountJson[] = [];
const fees: AmountJson[] = [];
@@ -1509,23 +1531,19 @@ export async function getCounterpartyEffectiveDepositAmount(
await wex.db.runReadOnlyTx(
["coins", "denominations", "exchangeDetails", "exchanges"],
async (tx) => {
- for (let i = 0; i < pcs.coins.length; i++) {
- const coin = await tx.coins.get(pcs.coins[i].coinPub);
- if (!coin) {
- throw Error("can't calculate deposit amount, coin not found");
- }
+ for (let i = 0; i < pcs.length; i++) {
const denom = await getDenomInfo(
wex,
tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
);
if (!denom) {
throw Error("can't find denomination to calculate deposit amount");
}
- amt.push(Amounts.parseOrThrow(pcs.coins[i].contribution));
+ amt.push(Amounts.parseOrThrow(pcs[i].contribution));
fees.push(Amounts.parseOrThrow(denom.feeDeposit));
- exchangeSet.add(coin.exchangeBaseUrl);
+ exchangeSet.add(pcs[i].exchangeBaseUrl);
}
for (const exchangeUrl of exchangeSet.values()) {
@@ -1564,7 +1582,7 @@ async function getTotalFeesForDepositAmount(
wex: WalletExecutionContext,
wireType: string,
total: AmountJson,
- pcs: PayCoinSelection,
+ pcs: SelectedProspectiveCoin[],
): Promise<DepositGroupFees> {
const wireFee: AmountJson[] = [];
const coinFee: AmountJson[] = [];
@@ -1575,33 +1593,26 @@ async function getTotalFeesForDepositAmount(
await wex.db.runReadOnlyTx(
["coins", "denominations", "exchanges", "exchangeDetails"],
async (tx) => {
- for (let i = 0; i < pcs.coins.length; i++) {
- const coin = await tx.coins.get(pcs.coins[i].coinPub);
- if (!coin) {
- throw Error("can't calculate deposit amount, coin not found");
- }
+ for (let i = 0; i < pcs.length; i++) {
const denom = await getDenomInfo(
wex,
tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
);
if (!denom) {
throw Error("can't find denomination to calculate deposit amount");
}
coinFee.push(Amounts.parseOrThrow(denom.feeDeposit));
- exchangeSet.add(coin.exchangeBaseUrl);
+ exchangeSet.add(pcs[i].exchangeBaseUrl);
const allDenoms = await getCandidateWithdrawalDenomsTx(
wex,
tx,
- coin.exchangeBaseUrl,
+ pcs[i].exchangeBaseUrl,
currency,
);
- const amountLeft = Amounts.sub(
- denom.value,
- pcs.coins[i].contribution,
- ).amount;
+ const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
const refreshCost = getTotalRefreshCost(
allDenoms,
denom,
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
index 3b58c1e0a..25725052c 100644
--- a/packages/taler-wallet-core/src/pay-merchant.ts
+++ b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -63,12 +63,12 @@ import {
parsePayTemplateUri,
parsePayUri,
parseTalerUri,
- PayCoinSelection,
PreparePayResult,
PreparePayResultType,
PreparePayTemplateRequest,
randomBytes,
RefreshReason,
+ SelectedProspectiveCoin,
SharePaymentResult,
StartRefundQueryForUriResponse,
stringifyPayUri,
@@ -453,19 +453,15 @@ export class RefundTransactionContext implements TransactionContext {
*/
export async function getTotalPaymentCost(
wex: WalletExecutionContext,
- pcs: PayCoinSelection,
+ currency: string,
+ pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
- const currency = Amounts.currencyOf(pcs.customerDepositFees);
return wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
const costs: AmountJson[] = [];
- for (let i = 0; i < pcs.coins.length; i++) {
- const coin = await tx.coins.get(pcs.coins[i].coinPub);
- if (!coin) {
- throw Error("can't calculate payment cost, coin not found");
- }
+ for (let i = 0; i < pcs.length; i++) {
const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
]);
if (!denom) {
throw Error(
@@ -475,23 +471,20 @@ export async function getTotalPaymentCost(
const allDenoms = await getCandidateWithdrawalDenomsTx(
wex,
tx,
- coin.exchangeBaseUrl,
+ pcs[i].exchangeBaseUrl,
currency,
);
- const amountLeft = Amounts.sub(
- denom.value,
- pcs.coins[i].contribution,
- ).amount;
+ const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
const refreshCost = getTotalRefreshCost(
allDenoms,
DenominationRecord.toDenomInfo(denom),
amountLeft,
wex.ws.config.testing.denomselAllowLate,
);
- costs.push(Amounts.parseOrThrow(pcs.coins[i].contribution));
+ costs.push(Amounts.parseOrThrow(pcs[i].contribution));
costs.push(refreshCost);
}
- const zero = Amounts.zeroOfAmount(pcs.customerDepositFees);
+ const zero = Amounts.zeroOfCurrency(currency);
return Amounts.sum([zero, ...costs]).amount;
});
}
@@ -1256,6 +1249,8 @@ async function checkPaymentByProposalId(
proposalId = proposal.proposalId;
+ const currency = Amounts.currencyOf(contractData.amount);
+
const ctx = new PayMerchantTransactionContext(wex, proposalId);
const transactionId = ctx.transactionId;
@@ -1293,23 +1288,37 @@ async function checkPaymentByProposalId(
restrictWireMethod: contractData.wireMethod,
});
- if (res.type !== "success") {
- logger.info("not allowing payment, insufficient coins");
- logger.info(
- `insufficient balance details: ${j2s(res.insufficientBalanceDetails)}`,
- );
- return {
- status: PreparePayResultType.InsufficientBalance,
- contractTerms: d.contractTermsRaw,
- proposalId: proposal.proposalId,
- transactionId,
- amountRaw: Amounts.stringify(d.contractData.amount),
- talerUri,
- balanceDetails: res.insufficientBalanceDetails,
- };
+ switch (res.type) {
+ case "failure": {
+ logger.info("not allowing payment, insufficient coins");
+ logger.info(
+ `insufficient balance details: ${j2s(
+ res.insufficientBalanceDetails,
+ )}`,
+ );
+ return {
+ status: PreparePayResultType.InsufficientBalance,
+ contractTerms: d.contractTermsRaw,
+ proposalId: proposal.proposalId,
+ transactionId,
+ amountRaw: Amounts.stringify(d.contractData.amount),
+ talerUri,
+ balanceDetails: res.insufficientBalanceDetails,
+ };
+ }
+ case "prospective":
+ throw Error("insufficient balance (waiting on refresh)");
+ case "success":
+ break;
+ default:
+ assertUnreachable(res);
}
- const totalCost = await getTotalPaymentCost(wex, res.coinSel);
+ const totalCost = await getTotalPaymentCost(
+ wex,
+ currency,
+ res.coinSel.coins,
+ );
logger.trace("costInfo", totalCost);
logger.trace("coinsForPayment", res);
@@ -1813,6 +1822,8 @@ export async function confirmPay(
const contractData = d.contractData;
+ const currency = Amounts.currencyOf(contractData.amount);
+
const selectCoinsResult = await selectPayCoins(wex, {
restrictExchanges: {
auditors: [],
@@ -1827,18 +1838,31 @@ export async function confirmPay(
forcedSelection: forcedCoinSel,
});
- logger.trace("coin selection result", selectCoinsResult);
-
- 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.
- logger.warn("not confirming payment, insufficient coins");
- throw Error("insufficient balance");
+ switch (selectCoinsResult.type) {
+ case "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.
+ logger.warn("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+ case "prospective": {
+ throw Error("insufficient balance (waiting on refresh)");
+ }
+ case "success":
+ break;
+ default:
+ assertUnreachable(selectCoinsResult);
}
+ logger.trace("coin selection result", selectCoinsResult);
+
const coinSelection = selectCoinsResult.coinSel;
- const payCostInfo = await getTotalPaymentCost(wex, coinSelection);
+ const payCostInfo = await getTotalPaymentCost(
+ wex,
+ currency,
+ coinSelection.coins,
+ );
let sessionId: string | undefined;
if (sessionIdOverride) {
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
index da68d7839..2cc241187 100644
--- a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
+++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -370,13 +370,26 @@ async function handlePurseCreationConflict(
}
}
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair });
+ const coinSelRes = await selectPeerCoins(ws, {
+ instructedAmount,
+ repair,
+ includePendingCoins: false,
+ });
- if (coinSelRes.type == "failure") {
- // FIXME: Details!
- throw Error(
- "insufficient balance to re-select coins to repair double spending",
- );
+ switch (coinSelRes.type) {
+ case "failure":
+ // FIXME: Details!
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending",
+ );
+ case "prospective":
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending (blocked on refresh)",
+ );
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
}
const totalAmount = await getTotalPeerPaymentCost(
@@ -583,18 +596,30 @@ export async function confirmPeerPullDebit(
const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
- const coinSelRes = await selectPeerCoins(wex, { instructedAmount });
+ // FIXME: Select coins once with pending coins, once without.
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ includePendingCoins: false,
+ });
if (logger.shouldLogTrace()) {
logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
}
- if (coinSelRes.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ throw Error("insufficient balance (blocked on refresh)");
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
}
const sel = coinSelRes.result;
@@ -758,18 +783,28 @@ export async function preparePeerPullDebit(
const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
- const coinSelRes = await selectPeerCoins(wex, { instructedAmount });
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ includePendingCoins: true,
+ });
if (logger.shouldLogTrace()) {
logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
}
- if (coinSelRes.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ throw Error("insufficient balance (waiting on refresh)");
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
}
const totalAmount = await getTotalPeerPaymentCost(
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
index 20001e040..51b865b99 100644
--- a/packages/taler-wallet-core/src/pay-peer-push-debit.ts
+++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
@@ -343,14 +343,24 @@ export async function checkPeerPushDebit(
logger.trace(
`checking peer push debit for ${Amounts.stringify(instructedAmount)}`,
);
- const coinSelRes = await selectPeerCoins(wex, { instructedAmount });
- if (coinSelRes.type === "failure") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ includePendingCoins: true,
+ });
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ throw Error("not supported");
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
}
logger.trace(`selected peer coins (len=${coinSelRes.result.coins.length})`);
const totalAmount = await getTotalPeerPaymentCost(
@@ -402,13 +412,23 @@ async function handlePurseCreationConflict(
}
}
- const coinSelRes = await selectPeerCoins(wex, { instructedAmount, repair });
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ repair,
+ includePendingCoins: false,
+ });
- if (coinSelRes.type == "failure") {
- // FIXME: Details!
- throw Error(
- "insufficient balance to re-select coins to repair double spending",
- );
+ switch (coinSelRes.type) {
+ case "failure":
+ case "prospective":
+ // FIXME: Details!
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending",
+ );
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
}
await wex.db.runReadWriteTx(["peerPushDebit"], async (tx) => {
@@ -934,15 +954,26 @@ export async function initiatePeerPushDebit(
const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({});
- const coinSelRes = await selectPeerCoins(wex, { instructedAmount });
+ // FIXME: Check first if possible with pending coins, in that case defer coin selection
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ includePendingCoins: false,
+ });
- if (coinSelRes.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ throw Error("blocked on pending refresh");
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
}
const sel = coinSelRes.result;
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
index f531c32a3..eb981e79c 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -188,7 +188,7 @@ import {
import {
createDepositGroup,
generateDepositGroupTxId,
- prepareDepositGroup,
+ checkDepositGroup,
} from "./deposits.js";
import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js";
import {
@@ -1191,7 +1191,7 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
}
case WalletApiOperation.PrepareDeposit: {
const req = codecForPrepareDepositRequest().decode(payload);
- return await prepareDepositGroup(wex, req);
+ return await checkDepositGroup(wex, req);
}
case WalletApiOperation.GenerateDepositGroupTxId:
return {