summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2023-08-29 18:33:51 +0200
committerFlorian Dold <florian@dold.me>2023-08-29 18:33:51 +0200
commita386de8a9c1aa3fff76b4cb37fb3287213981387 (patch)
treea1fc443fe78da619eeb8e5f8cde26006add5e498
parent5852b5cf2e91d23a97e757c557226051741f1f3a (diff)
downloadwallet-core-a386de8a9c1aa3fff76b4cb37fb3287213981387.tar.gz
wallet-core-a386de8a9c1aa3fff76b4cb37fb3287213981387.tar.bz2
wallet-core-a386de8a9c1aa3fff76b4cb37fb3287213981387.zip
wallet-core: split coin selection and instructed amount conversion
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-common.ts265
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts3
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts3
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.test.ts742
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.ts1012
-rw-r--r--packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts763
-rw-r--r--packages/taler-wallet-core/src/util/instructedAmountConversion.ts849
-rw-r--r--packages/taler-wallet-core/src/wallet.ts14
8 files changed, 1859 insertions, 1792 deletions
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-common.ts b/packages/taler-wallet-core/src/operations/pay-peer-common.ts
index 4fdfecb4d..49f255eb9 100644
--- a/packages/taler-wallet-core/src/operations/pay-peer-common.ts
+++ b/packages/taler-wallet-core/src/operations/pay-peer-common.ts
@@ -43,8 +43,6 @@ import {
import { SpendCoinDetails } from "../crypto/cryptoImplementation.js";
import {
DenominationRecord,
- KycPendingInfo,
- KycUserType,
PeerPushPaymentCoinSelection,
ReserveRecord,
} from "../db.js";
@@ -52,68 +50,13 @@ import { InternalWalletState } from "../internal-wallet-state.js";
import { checkDbInvariant } from "../util/invariants.js";
import { getPeerPaymentBalanceDetailsInTx } from "./balance.js";
import { getTotalRefreshCost } from "./refresh.js";
+import type { PeerCoinInfo, PeerCoinSelectionRequest, SelectPeerCoinsResult, SelectedPeerCoin } from "../util/coinSelection.js";
const logger = new Logger("operations/peer-to-peer.ts");
-interface SelectedPeerCoin {
- coinPub: string;
- coinPriv: string;
- contribution: AmountString;
- denomPubHash: string;
- denomSig: UnblindedSignature;
- ageCommitmentProof: AgeCommitmentProof | undefined;
-}
-
-interface PeerCoinSelectionDetails {
- exchangeBaseUrl: string;
-
- /**
- * Info of Coins that were selected.
- */
- coins: SelectedPeerCoin[];
-
- /**
- * How much of the deposit fees is the customer paying?
- */
- depositFees: AmountJson;
-}
-
-/**
- * Information about a selected coin for peer to peer payments.
- */
-interface CoinInfo {
- /**
- * Public key of the coin.
- */
- coinPub: string;
-
- coinPriv: string;
-
- /**
- * Deposit fee for the coin.
- */
- feeDeposit: AmountJson;
-
- value: AmountJson;
-
- denomPubHash: string;
-
- denomSig: UnblindedSignature;
-
- maxAge: number;
-
- ageCommitmentProof?: AgeCommitmentProof;
-}
-
-export type SelectPeerCoinsResult =
- | { type: "success"; result: PeerCoinSelectionDetails }
- | {
- type: "failure";
- insufficientBalanceDetails: PayPeerInsufficientBalanceDetails;
- };
-
/**
* Get information about the coin selected for signatures
+ *
* @param ws
* @param csel
* @returns
@@ -153,211 +96,7 @@ export async function queryCoinInfosForSelection(
return infos;
}
-export interface PeerCoinRepair {
- exchangeBaseUrl: string;
- coinPubs: CoinPublicKeyString[];
- contribs: AmountJson[];
-}
-
-export interface PeerCoinSelectionRequest {
- instructedAmount: AmountJson;
- /**
- * Instruct the coin selection to repair this coin
- * selection instead of selecting completely new coins.
- */
- repair?: PeerCoinRepair;
-}
-
-export async function selectPeerCoins(
- ws: InternalWalletState,
- req: PeerCoinSelectionRequest,
-): Promise<SelectPeerCoinsResult> {
- const instructedAmount = req.instructedAmount;
- if (Amounts.isZero(instructedAmount)) {
- // Other parts of the code assume that we have at least
- // one coin to spend.
- throw new Error("amount of zero not allowed");
- }
- return await ws.db
- .mktx((x) => [
- x.exchanges,
- x.contractTerms,
- x.coins,
- x.coinAvailability,
- x.denominations,
- x.refreshGroups,
- x.peerPushPaymentInitiations,
- ])
- .runReadWrite(async (tx) => {
- const exchanges = await tx.exchanges.iter().toArray();
- const exchangeFeeGap: { [url: string]: AmountJson } = {};
- const currency = Amounts.currencyOf(instructedAmount);
- for (const exch of exchanges) {
- if (exch.detailsPointer?.currency !== currency) {
- continue;
- }
- // FIXME: Can't we do this faster by using coinAvailability?
- const coins = (
- await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl)
- ).filter((x) => x.status === CoinStatus.Fresh);
- const coinInfos: CoinInfo[] = [];
- for (const coin of coins) {
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denom) {
- throw Error("denom not found");
- }
- coinInfos.push({
- coinPub: coin.coinPub,
- feeDeposit: Amounts.parseOrThrow(denom.feeDeposit),
- value: Amounts.parseOrThrow(denom.value),
- denomPubHash: denom.denomPubHash,
- coinPriv: coin.coinPriv,
- denomSig: coin.denomSig,
- maxAge: coin.maxAge,
- ageCommitmentProof: coin.ageCommitmentProof,
- });
- }
- if (coinInfos.length === 0) {
- continue;
- }
- coinInfos.sort(
- (o1, o2) =>
- -Amounts.cmp(o1.value, o2.value) ||
- strcmp(o1.denomPubHash, o2.denomPubHash),
- );
- let amountAcc = Amounts.zeroOfCurrency(currency);
- let depositFeesAcc = Amounts.zeroOfCurrency(currency);
- const resCoins: {
- coinPub: string;
- coinPriv: string;
- contribution: AmountString;
- denomPubHash: string;
- denomSig: UnblindedSignature;
- ageCommitmentProof: AgeCommitmentProof | undefined;
- }[] = [];
- let lastDepositFee = Amounts.zeroOfCurrency(currency);
-
- if (req.repair) {
- for (let i = 0; i < req.repair.coinPubs.length; i++) {
- const contrib = req.repair.contribs[i];
- const coin = await tx.coins.get(req.repair.coinPubs[i]);
- if (!coin) {
- throw Error("repair not possible, coin not found");
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(!!denom);
- resCoins.push({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- contribution: Amounts.stringify(contrib),
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- ageCommitmentProof: coin.ageCommitmentProof,
- });
- const depositFee = Amounts.parseOrThrow(denom.feeDeposit);
- lastDepositFee = depositFee;
- amountAcc = Amounts.add(
- amountAcc,
- Amounts.sub(contrib, depositFee).amount,
- ).amount;
- depositFeesAcc = Amounts.add(depositFeesAcc, depositFee).amount;
- }
- }
-
- for (const coin of coinInfos) {
- if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
- break;
- }
- const gap = Amounts.add(
- coin.feeDeposit,
- Amounts.sub(instructedAmount, amountAcc).amount,
- ).amount;
- const contrib = Amounts.min(gap, coin.value);
- amountAcc = Amounts.add(
- amountAcc,
- Amounts.sub(contrib, coin.feeDeposit).amount,
- ).amount;
- depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount;
- resCoins.push({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- contribution: Amounts.stringify(contrib),
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- ageCommitmentProof: coin.ageCommitmentProof,
- });
- lastDepositFee = coin.feeDeposit;
- }
- if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
- const res: PeerCoinSelectionDetails = {
- exchangeBaseUrl: exch.baseUrl,
- coins: resCoins,
- depositFees: depositFeesAcc,
- };
- return { type: "success", result: res };
- }
- const diff = Amounts.sub(instructedAmount, amountAcc).amount;
- exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, diff).amount;
-
- continue;
- }
-
- // We were unable to select coins.
- // Now we need to produce error details.
-
- const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
- currency,
- });
-
- const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {};
-
- let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency);
-
- for (const exch of exchanges) {
- if (exch.detailsPointer?.currency !== currency) {
- continue;
- }
- const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
- currency,
- restrictExchangeTo: exch.baseUrl,
- });
- let gap =
- exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency);
- if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) {
- // Show fee gap only if we should've been able to pay with the material amount
- gap = Amounts.zeroOfCurrency(currency);
- }
- perExchange[exch.baseUrl] = {
- balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable),
- balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial),
- feeGapEstimate: Amounts.stringify(gap),
- };
-
- maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap);
- }
-
- const errDetails: PayPeerInsufficientBalanceDetails = {
- amountRequested: Amounts.stringify(instructedAmount),
- balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable),
- balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial),
- feeGapEstimate: Amounts.stringify(maxFeeGapEstimate),
- perExchange,
- };
-
- return { type: "failure", insufficientBalanceDetails: errDetails };
- });
-}
export async function getTotalPeerPaymentCost(
ws: InternalWalletState,
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts
index 8ba84585c..0de91bf97 100644
--- a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts
+++ b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts
@@ -68,11 +68,9 @@ import {
spendCoins,
} from "./common.js";
import {
- PeerCoinRepair,
codecForExchangePurseStatus,
getTotalPeerPaymentCost,
queryCoinInfosForSelection,
- selectPeerCoins,
} from "./pay-peer-common.js";
import {
constructTransactionIdentifier,
@@ -80,6 +78,7 @@ import {
parseTransactionIdentifier,
stopLongpolling,
} from "./transactions.js";
+import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js";
const logger = new Logger("pay-peer-pull-debit.ts");
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts
index c853bc0ef..2349e5c4a 100644
--- a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts
+++ b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts
@@ -68,17 +68,16 @@ import {
spendCoins,
} from "./common.js";
import {
- PeerCoinRepair,
codecForExchangePurseStatus,
getTotalPeerPaymentCost,
queryCoinInfosForSelection,
- selectPeerCoins,
} from "./pay-peer-common.js";
import {
constructTransactionIdentifier,
notifyTransition,
stopLongpolling,
} from "./transactions.js";
+import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js";
const logger = new Logger("pay-peer-push-debit.ts");
diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/util/coinSelection.test.ts
index fddd217ea..b907eb160 100644
--- a/packages/taler-wallet-core/src/util/coinSelection.test.ts
+++ b/packages/taler-wallet-core/src/util/coinSelection.test.ts
@@ -22,746 +22,4 @@ import {
TransactionAmountMode,
} from "@gnu-taler/taler-util";
import test, { ExecutionContext } from "ava";
-import {
- CoinInfo,
- convertDepositAmountForAvailableCoins,
- convertWithdrawalAmountFromAvailableCoins,
- getMaxDepositAmountForAvailableCoins,
-} from "./coinSelection.js";
-
-function makeCurrencyHelper(currency: string) {
- return (sx: TemplateStringsArray, ...vx: any[]) => {
- const s = String.raw({ raw: sx }, ...vx);
- return Amounts.parseOrThrow(`${currency}:${s}`);
- };
-}
-
-const kudos = makeCurrencyHelper("kudos");
-
-function defaultFeeConfig(value: AmountJson, totalAvailable: number): CoinInfo {
- return {
- id: Amounts.stringify(value),
- denomDeposit: kudos`0.01`,
- denomRefresh: kudos`0.01`,
- denomWithdraw: kudos`0.01`,
- exchangeBaseUrl: "1",
- duration: Duration.getForever(),
- exchangePurse: undefined,
- exchangeWire: undefined,
- maxAge: AgeRestriction.AGE_UNRESTRICTED,
- totalAvailable,
- value,
- };
-}
-type Coin = [AmountJson, number];
-
-/**
- * Making a deposit with effective amount
- *
- */
-
-test("deposit effective 2", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`2`,
- TransactionAmountMode.Effective,
- );
- t.is(Amounts.stringifyValue(result.effective), "2");
- t.is(Amounts.stringifyValue(result.raw), "1.99");
-});
-
-test("deposit effective 10", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`10`,
- TransactionAmountMode.Effective,
- );
- t.is(Amounts.stringifyValue(result.effective), "10");
- t.is(Amounts.stringifyValue(result.raw), "9.98");
-});
-
-test("deposit effective 24", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`24`,
- TransactionAmountMode.Effective,
- );
- t.is(Amounts.stringifyValue(result.effective), "24");
- t.is(Amounts.stringifyValue(result.raw), "23.94");
-});
-
-test("deposit effective 40", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`40`,
- TransactionAmountMode.Effective,
- );
- t.is(Amounts.stringifyValue(result.effective), "35");
- t.is(Amounts.stringifyValue(result.raw), "34.9");
-});
-
-test("deposit with wire fee effective 2", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {
- one: {
- wireFee: kudos`0.1`,
- purseFee: kudos`0.00`,
- creditDeadline: AbsoluteTime.never(),
- debitDeadline: AbsoluteTime.never(),
- },
- },
- },
- kudos`2`,
- TransactionAmountMode.Effective,
- );
- t.is(Amounts.stringifyValue(result.effective), "2");
- t.is(Amounts.stringifyValue(result.raw), "1.89");
-});
-
-/**
- * Making a deposit with raw amount, using the result from effective
- *
- */
-
-test("deposit raw 1.99 (effective 2)", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`1.99`,
- TransactionAmountMode.Raw,
- );
- t.is(Amounts.stringifyValue(result.effective), "2");
- t.is(Amounts.stringifyValue(result.raw), "1.99");
-});
-
-test("deposit raw 9.98 (effective 10)", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`9.98`,
- TransactionAmountMode.Raw,
- );
- t.is(Amounts.stringifyValue(result.effective), "10");
- t.is(Amounts.stringifyValue(result.raw), "9.98");
-});
-
-test("deposit raw 23.94 (effective 24)", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`23.94`,
- TransactionAmountMode.Raw,
- );
- t.is(Amounts.stringifyValue(result.effective), "24");
- t.is(Amounts.stringifyValue(result.raw), "23.94");
-});
-
-test("deposit raw 34.9 (effective 40)", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`34.9`,
- TransactionAmountMode.Raw,
- );
- t.is(Amounts.stringifyValue(result.effective), "35");
- t.is(Amounts.stringifyValue(result.raw), "34.9");
-});
-
-test("deposit with wire fee raw 2", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {
- one: {
- wireFee: kudos`0.1`,
- purseFee: kudos`0.00`,
- creditDeadline: AbsoluteTime.never(),
- debitDeadline: AbsoluteTime.never(),
- },
- },
- },
- kudos`2`,
- TransactionAmountMode.Effective,
- );
- t.is(Amounts.stringifyValue(result.effective), "2");
- t.is(Amounts.stringifyValue(result.raw), "1.89");
-});
-
-/**
- * Calculating the max amount possible to deposit
- *
- */
-
-test("deposit max 35", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = getMaxDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {
- "2": {
- wireFee: kudos`0.00`,
- purseFee: kudos`0.00`,
- creditDeadline: AbsoluteTime.never(),
- debitDeadline: AbsoluteTime.never(),
- },
- },
- },
- "KUDOS",
- );
- t.is(Amounts.stringifyValue(result.raw), "34.9");
- t.is(Amounts.stringifyValue(result.effective), "35");
-});
-
-test("deposit max 35 with wirefee", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = getMaxDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {
- "2": {
- wireFee: kudos`1`,
- purseFee: kudos`0.00`,
- creditDeadline: AbsoluteTime.never(),
- debitDeadline: AbsoluteTime.never(),
- },
- },
- },
- "KUDOS",
- );
- t.is(Amounts.stringifyValue(result.raw), "33.9");
- t.is(Amounts.stringifyValue(result.effective), "35");
-});
-
-test("deposit max repeated denom", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 1],
- [kudos`2`, 1],
- [kudos`5`, 1],
- ];
- const result = getMaxDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {
- "2": {
- wireFee: kudos`0.00`,
- purseFee: kudos`0.00`,
- creditDeadline: AbsoluteTime.never(),
- debitDeadline: AbsoluteTime.never(),
- },
- },
- },
- "KUDOS",
- );
- t.is(Amounts.stringifyValue(result.raw), "8.97");
- t.is(Amounts.stringifyValue(result.effective), "9");
-});
-
-/**
- * Making a withdrawal with effective amount
- *
- */
-
-test("withdraw effective 2", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertWithdrawalAmountFromAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`2`,
- TransactionAmountMode.Effective,
- );
- t.is(Amounts.stringifyValue(result.effective), "2");
- t.is(Amounts.stringifyValue(result.raw), "2.01");
-});
-
-test("withdraw effective 10", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertWithdrawalAmountFromAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`10`,
- TransactionAmountMode.Effective,
- );
- t.is(Amounts.stringifyValue(result.effective), "10");
- t.is(Amounts.stringifyValue(result.raw), "10.02");
-});
-
-test("withdraw effective 24", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertWithdrawalAmountFromAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`24`,
- TransactionAmountMode.Effective,
- );
- t.is(Amounts.stringifyValue(result.effective), "24");
- t.is(Amounts.stringifyValue(result.raw), "24.06");
-});
-
-test("withdraw effective 40", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertWithdrawalAmountFromAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`40`,
- TransactionAmountMode.Effective,
- );
- t.is(Amounts.stringifyValue(result.effective), "40");
- t.is(Amounts.stringifyValue(result.raw), "40.08");
-});
-
-/**
- * Making a deposit with raw amount, using the result from effective
- *
- */
-
-test("withdraw raw 2.01 (effective 2)", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertWithdrawalAmountFromAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`2.01`,
- TransactionAmountMode.Raw,
- );
- t.is(Amounts.stringifyValue(result.effective), "2");
- t.is(Amounts.stringifyValue(result.raw), "2.01");
-});
-
-test("withdraw raw 10.02 (effective 10)", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertWithdrawalAmountFromAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`10.02`,
- TransactionAmountMode.Raw,
- );
- t.is(Amounts.stringifyValue(result.effective), "10");
- t.is(Amounts.stringifyValue(result.raw), "10.02");
-});
-
-test("withdraw raw 24.06 (effective 24)", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertWithdrawalAmountFromAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`24.06`,
- TransactionAmountMode.Raw,
- );
- t.is(Amounts.stringifyValue(result.effective), "24");
- t.is(Amounts.stringifyValue(result.raw), "24.06");
-});
-
-test("withdraw raw 40.08 (effective 40)", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertWithdrawalAmountFromAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`40.08`,
- TransactionAmountMode.Raw,
- );
- t.is(Amounts.stringifyValue(result.effective), "40");
- t.is(Amounts.stringifyValue(result.raw), "40.08");
-});
-
-test("withdraw raw 25", (t) => {
- const coinList: Coin[] = [
- [kudos`0.1`, 0],
- [kudos`1`, 0],
- [kudos`2`, 0],
- [kudos`5`, 0],
- ];
- const result = convertWithdrawalAmountFromAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`25`,
- TransactionAmountMode.Raw,
- );
- t.is(Amounts.stringifyValue(result.effective), "24.8");
- t.is(Amounts.stringifyValue(result.raw), "24.94");
-});
-
-test("withdraw effective 24.8 (raw 25)", (t) => {
- const coinList: Coin[] = [
- [kudos`0.1`, 0],
- [kudos`1`, 0],
- [kudos`2`, 0],
- [kudos`5`, 0],
- ];
- const result = convertWithdrawalAmountFromAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`24.8`,
- TransactionAmountMode.Effective,
- );
- t.is(Amounts.stringifyValue(result.effective), "24.8");
- t.is(Amounts.stringifyValue(result.raw), "24.94");
-});
-
-/**
- * Making a deposit with refresh
- *
- */
-
-test("deposit with refresh: effective 3", (t) => {
- const coinList: Coin[] = [
- [kudos`0.1`, 0],
- [kudos`1`, 0],
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`3`,
- TransactionAmountMode.Effective,
- );
- t.is(Amounts.stringifyValue(result.effective), "3.1");
- t.is(Amounts.stringifyValue(result.raw), "2.98");
- expectDefined(t, result.refresh);
- //FEES
- //deposit 2 x 0.01
- //refresh 1 x 0.01
- //withdraw 9 x 0.01
- //-----------------
- //op 0.12
-
- //coins sent 2 x 2.0
- //coins recv 9 x 0.1
- //-------------------
- //effective 3.10
- //raw 2.98
- t.is(Amounts.stringifyValue(result.refresh.selected.id), "2");
- t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 9]]);
-});
-
-test("deposit with refresh: raw 2.98 (effective 3)", (t) => {
- const coinList: Coin[] = [
- [kudos`0.1`, 0],
- [kudos`1`, 0],
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`2.98`,
- TransactionAmountMode.Raw,
- );
- t.is(Amounts.stringifyValue(result.effective), "3.2");
- t.is(Amounts.stringifyValue(result.raw), "3.09");
- expectDefined(t, result.refresh);
- //FEES
- //deposit 1 x 0.01
- //refresh 1 x 0.01
- //withdraw 8 x 0.01
- //-----------------
- //op 0.10
-
- //coins sent 1 x 2.0
- //coins recv 8 x 0.1
- //-------------------
- //effective 3.20
- //raw 3.09
- t.is(Amounts.stringifyValue(result.refresh.selected.id), "2");
- t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 8]]);
-});
-
-test("deposit with refresh: effective 3.2 (raw 2.98)", (t) => {
- const coinList: Coin[] = [
- [kudos`0.1`, 0],
- [kudos`1`, 0],
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = convertDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`3.2`,
- TransactionAmountMode.Effective,
- );
- t.is(Amounts.stringifyValue(result.effective), "3.3");
- t.is(Amounts.stringifyValue(result.raw), "3.2");
- expectDefined(t, result.refresh);
- //FEES
- //deposit 2 x 0.01
- //refresh 1 x 0.01
- //withdraw 7 x 0.01
- //-----------------
- //op 0.10
-
- //coins sent 2 x 2.0
- //coins recv 7 x 0.1
- //-------------------
- //effective 3.30
- //raw 3.20
- t.is(Amounts.stringifyValue(result.refresh.selected.id), "2");
- t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 7]]);
-});
-
-function expectDefined<T>(
- t: ExecutionContext,
- v: T | undefined,
-): asserts v is T {
- t.assert(v !== undefined);
-}
-
-function asCoinList(v: { info: CoinInfo; size: number }[]): any {
- return v.map((c) => {
- return [c.info.value, c.size];
- });
-}
-
-/**
- * regression tests
- */
-
-test("demo: withdraw raw 25", (t) => {
- const coinList: Coin[] = [
- [kudos`0.1`, 0],
- [kudos`1`, 0],
- [kudos`2`, 0],
- [kudos`5`, 0],
- [kudos`10`, 0],
- ];
- const result = convertWithdrawalAmountFromAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`25`,
- TransactionAmountMode.Raw,
- );
- t.is(Amounts.stringifyValue(result.effective), "24.8");
- t.is(Amounts.stringifyValue(result.raw), "24.92");
- // coins received
- // 8 x 0.1
- // 2 x 0.2
- // 2 x 10.0
- // total effective 24.8
- // fee 12 x 0.01 = 0.12
- // total raw 24.92
- // left in reserve 25 - 24.92 == 0.08
-
- //current wallet impl: hides the left in reserve fee
- //shows fee = 0.2
-});
-
-test("demo: deposit max after withdraw raw 25", (t) => {
- const coinList: Coin[] = [
- [kudos`0.1`, 8],
- [kudos`1`, 0],
- [kudos`2`, 2],
- [kudos`5`, 0],
- [kudos`10`, 2],
- ];
- const result = getMaxDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {
- one: {
- wireFee: kudos`0.01`,
- purseFee: kudos`0.00`,
- creditDeadline: AbsoluteTime.never(),
- debitDeadline: AbsoluteTime.never(),
- },
- },
- },
- "KUDOS",
- );
- t.is(Amounts.stringifyValue(result.effective), "24.8");
- t.is(Amounts.stringifyValue(result.raw), "24.67");
-
- // 8 x 0.1
- // 2 x 0.2
- // 2 x 10.0
- // total effective 24.8
- // deposit fee 12 x 0.01 = 0.12
- // wire fee 0.01
- // total raw: 24.8 - 0.13 = 24.67
-
- // current wallet impl fee 0.14
-});
-
-test("demo: withdraw raw 13", (t) => {
- const coinList: Coin[] = [
- [kudos`0.1`, 0],
- [kudos`1`, 0],
- [kudos`2`, 0],
- [kudos`5`, 0],
- [kudos`10`, 0],
- ];
- const result = convertWithdrawalAmountFromAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {},
- },
- kudos`13`,
- TransactionAmountMode.Raw,
- );
- t.is(Amounts.stringifyValue(result.effective), "12.8");
- t.is(Amounts.stringifyValue(result.raw), "12.9");
- // coins received
- // 8 x 0.1
- // 1 x 0.2
- // 1 x 10.0
- // total effective 12.8
- // fee 10 x 0.01 = 0.10
- // total raw 12.9
- // left in reserve 13 - 12.9 == 0.1
-
- //current wallet impl: hides the left in reserve fee
- //shows fee = 0.2
-});
-
-test("demo: deposit max after withdraw raw 13", (t) => {
- const coinList: Coin[] = [
- [kudos`0.1`, 8],
- [kudos`1`, 0],
- [kudos`2`, 1],
- [kudos`5`, 0],
- [kudos`10`, 1],
- ];
- const result = getMaxDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {
- one: {
- wireFee: kudos`0.01`,
- purseFee: kudos`0.00`,
- creditDeadline: AbsoluteTime.never(),
- debitDeadline: AbsoluteTime.never(),
- },
- },
- },
- "KUDOS",
- );
- t.is(Amounts.stringifyValue(result.effective), "12.8");
- t.is(Amounts.stringifyValue(result.raw), "12.69");
-
- // 8 x 0.1
- // 1 x 0.2
- // 1 x 10.0
- // total effective 12.8
- // deposit fee 10 x 0.01 = 0.10
- // wire fee 0.01
- // total raw: 12.8 - 0.11 = 12.69
- // current wallet impl fee 0.14
-});
diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts
index d3c6ffc67..bb901fd75 100644
--- a/packages/taler-wallet-core/src/util/coinSelection.ts
+++ b/packages/taler-wallet-core/src/util/coinSelection.ts
@@ -31,6 +31,8 @@ import {
AmountJson,
AmountResponse,
Amounts,
+ AmountString,
+ CoinPublicKeyString,
CoinStatus,
ConvertAmountRequest,
DenominationInfo,
@@ -40,28 +42,28 @@ import {
ForcedCoinSel,
ForcedDenomSel,
GetAmountRequest,
- GetPlanForOperationRequest,
j2s,
Logger,
parsePaytoUri,
PayCoinSelection,
PayMerchantInsufficientBalanceDetails,
+ PayPeerInsufficientBalanceDetails,
strcmp,
TransactionAmountMode,
TransactionType,
+ UnblindedSignature,
} from "@gnu-taler/taler-util";
import {
AllowedAuditorInfo,
AllowedExchangeInfo,
DenominationRecord,
} from "../db.js";
-import {
- CoinAvailabilityRecord,
- getExchangeDetails,
- isWithdrawableDenom,
-} from "../index.js";
+import { getExchangeDetails, isWithdrawableDenom } from "../index.js";
import { InternalWalletState } from "../internal-wallet-state.js";
-import { getMerchantPaymentBalanceDetails } from "../operations/balance.js";
+import {
+ getMerchantPaymentBalanceDetails,
+ getPeerPaymentBalanceDetailsInTx,
+} from "../operations/balance.js";
import { checkDbInvariant, checkLogicInvariant } from "./invariants.js";
const logger = new Logger("coinSelection.ts");
@@ -255,7 +257,7 @@ export async function selectPayCoinsNew(
wireFeeAmortization,
} = req;
- const [candidateDenoms, wireFeesPerExchange] = await selectCandidates(
+ const [candidateDenoms, wireFeesPerExchange] = await selectPayMerchantCandidates(
ws,
req,
);
@@ -549,7 +551,7 @@ export type AvailableDenom = DenominationInfo & {
numAvailable: number;
};
-async function selectCandidates(
+async function selectPayMerchantCandidates(
ws: InternalWalletState,
req: SelectPayCoinRequestNg,
): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
@@ -797,76 +799,6 @@ export function selectForcedWithdrawalDenominations(
};
}
-function getCoinsFilter(req: GetPlanForOperationRequest): CoinsFilter {
- switch (req.type) {
- case TransactionType.Withdrawal: {
- return {
- exchanges:
- req.exchangeUrl === undefined ? undefined : [req.exchangeUrl],
- };
- }
- case TransactionType.Deposit: {
- const payto = parsePaytoUri(req.account);
- if (!payto) {
- throw Error(`wrong payto ${req.account}`);
- }
- return {
- wireMethod: payto.targetType,
- };
- }
- }
-}
-
-/**
- * If the operation going to be plan subtracts
- * or adds amount in the wallet db
- */
-export enum OperationType {
- Credit = "credit",
- Debit = "debit",
-}
-
-function getOperationType(txType: TransactionType): OperationType {
- const operationType =
- txType === TransactionType.Withdrawal
- ? OperationType.Credit
- : txType === TransactionType.Deposit
- ? OperationType.Debit
- : undefined;
- if (!operationType) {
- throw Error(`operation type ${txType} not yet supported`);
- }
- return operationType;
-}
-
-interface RefreshChoice {
- /**
- * Amount that need to be covered
- */
- gap: AmountJson;
- totalFee: AmountJson;
- selected: CoinInfo;
- totalChangeValue: AmountJson;
- refreshEffective: AmountJson;
- coins: { info: CoinInfo; size: number }[];
-
- // totalValue: AmountJson;
- // totalDepositFee: AmountJson;
- // totalRefreshFee: AmountJson;
- // totalChangeContribution: AmountJson;
- // totalChangeWithdrawalFee: AmountJson;
-}
-
-interface AvailableCoins {
- list: CoinInfo[];
- exchanges: Record<string, ExchangeInfo>;
-}
-interface SelectedCoins {
- totalValue: AmountJson;
- coins: { info: CoinInfo; size: number }[];
- refresh?: RefreshChoice;
-}
-
export interface CoinInfo {
id: string;
value: AmountJson;
@@ -880,739 +812,267 @@ export interface CoinInfo {
exchangeBaseUrl: string;
maxAge: number;
}
-interface ExchangeInfo {
- wireFee: AmountJson | undefined;
- purseFee: AmountJson | undefined;
- creditDeadline: AbsoluteTime;
- debitDeadline: AbsoluteTime;
-}
-
-interface CoinsFilter {
- shouldCalculatePurseFee?: boolean;
- exchanges?: string[];
- wireMethod?: string;
- ageRestricted?: number;
-}
-/**
- * Get all the denoms that can be used for a operation that is limited
- * by the following restrictions.
- * This function is costly (by the database access) but with high chances
- * of being cached
- */
-async function getAvailableDenoms(
- ws: InternalWalletState,
- op: TransactionType,
- currency: string,
- filters: CoinsFilter = {},
-): Promise<AvailableCoins> {
- const operationType = getOperationType(TransactionType.Deposit);
-
- return await ws.db
- .mktx((x) => [
- x.exchanges,
- x.exchangeDetails,
- x.denominations,
- x.coinAvailability,
- ])
- .runReadOnly(async (tx) => {
- const list: CoinInfo[] = [];
- const exchanges: Record<string, ExchangeInfo> = {};
-
- const databaseExchanges = await tx.exchanges.iter().toArray();
- const filteredExchanges =
- filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl);
-
- for (const exchangeBaseUrl of filteredExchanges) {
- const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl);
- // 1.- exchange has same currency
- if (exchangeDetails?.currency !== currency) {
- continue;
- }
-
- let deadline = AbsoluteTime.never();
- // 2.- exchange supports wire method
- let wireFee: AmountJson | undefined;
- if (filters.wireMethod) {
- const wireMethodWithDates =
- exchangeDetails.wireInfo.feesForType[filters.wireMethod];
-
- if (!wireMethodWithDates) {
- throw Error(
- `exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`,
- );
- }
- const wireMethodFee = wireMethodWithDates.find((x) => {
- return AbsoluteTime.isBetween(
- AbsoluteTime.now(),
- AbsoluteTime.fromProtocolTimestamp(x.startStamp),
- AbsoluteTime.fromProtocolTimestamp(x.endStamp),
- );
- });
-
- if (!wireMethodFee) {
- throw Error(
- `exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`,
- );
- }
- wireFee = Amounts.parseOrThrow(wireMethodFee.wireFee);
- deadline = AbsoluteTime.min(
- deadline,
- AbsoluteTime.fromProtocolTimestamp(wireMethodFee.endStamp),
- );
- }
- // exchanges[exchangeBaseUrl].wireFee = wireMethodFee;
-
- // 3.- exchange supports wire method
- let purseFee: AmountJson | undefined;
- if (filters.shouldCalculatePurseFee) {
- const purseFeeFound = exchangeDetails.globalFees.find((x) => {
- return AbsoluteTime.isBetween(
- AbsoluteTime.now(),
- AbsoluteTime.fromProtocolTimestamp(x.startDate),
- AbsoluteTime.fromProtocolTimestamp(x.endDate),
- );
- });
- if (!purseFeeFound) {
- throw Error(
- `exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`,
- );
- }
- purseFee = Amounts.parseOrThrow(purseFeeFound.purseFee);
- deadline = AbsoluteTime.min(
- deadline,
- AbsoluteTime.fromProtocolTimestamp(purseFeeFound.endDate),
- );
- }
-
- let creditDeadline = AbsoluteTime.never();
- let debitDeadline = AbsoluteTime.never();
- //4.- filter coins restricted by age
- if (operationType === OperationType.Credit) {
- const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
- exchangeBaseUrl,
- );
- for (const denom of ds) {
- const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
- denom.stampExpireWithdraw,
- );
- const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
- denom.stampExpireDeposit,
- );
- creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
- debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
- list.push(
- buildCoinInfoFromDenom(
- denom,
- purseFee,
- wireFee,
- AgeRestriction.AGE_UNRESTRICTED,
- Number.MAX_SAFE_INTEGER, // Max withdrawable from single denom
- ),
- );
- }
- } else {
- const ageLower = filters.ageRestricted ?? 0;
- const ageUpper = AgeRestriction.AGE_UNRESTRICTED;
-
- const myExchangeCoins =
- await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
- GlobalIDB.KeyRange.bound(
- [exchangeDetails.exchangeBaseUrl, ageLower, 1],
- [
- exchangeDetails.exchangeBaseUrl,
- ageUpper,
- Number.MAX_SAFE_INTEGER,
- ],
- ),
- );
- //5.- save denoms with how many coins are available
- // FIXME: Check that the individual denomination is audited!
- // FIXME: Should we exclude denominations that are
- // not spendable anymore?
- for (const coinAvail of myExchangeCoins) {
- const denom = await tx.denominations.get([
- coinAvail.exchangeBaseUrl,
- coinAvail.denomPubHash,
- ]);
- checkDbInvariant(!!denom);
- if (denom.isRevoked || !denom.isOffered) {
- continue;
- }
- const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
- denom.stampExpireWithdraw,
- );
- const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
- denom.stampExpireDeposit,
- );
- creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
- debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
- list.push(
- buildCoinInfoFromDenom(
- denom,
- purseFee,
- wireFee,
- coinAvail.maxAge,
- coinAvail.freshCoinCount,
- ),
- );
- }
- }
-
- exchanges[exchangeBaseUrl] = {
- purseFee,
- wireFee,
- debitDeadline,
- creditDeadline,
- };
- }
-
- return { list, exchanges };
- });
-}
-function buildCoinInfoFromDenom(
- denom: DenominationRecord,
- purseFee: AmountJson | undefined,
- wireFee: AmountJson | undefined,
- maxAge: number,
- total: number,
-): CoinInfo {
- return {
- id: denom.denomPubHash,
- denomWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw),
- denomDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
- denomRefresh: Amounts.parseOrThrow(denom.fees.feeRefresh),
- exchangePurse: purseFee,
- exchangeWire: wireFee,
- exchangeBaseUrl: denom.exchangeBaseUrl,
- duration: AbsoluteTime.difference(
- AbsoluteTime.now(),
- AbsoluteTime.fromProtocolTimestamp(denom.stampExpireDeposit),
- ),
- totalAvailable: total,
- value: DenominationRecord.getValue(denom),
- maxAge,
- };
-}
-
-export async function convertDepositAmount(
- ws: InternalWalletState,
- req: ConvertAmountRequest,
-): Promise<AmountResponse> {
- const amount = Amounts.parseOrThrow(req.amount);
- // const filter = getCoinsFilter(req);
-
- const denoms = await getAvailableDenoms(
- ws,
- TransactionType.Deposit,
- amount.currency,
- {},
- );
- const result = convertDepositAmountForAvailableCoins(
- denoms,
- amount,
- req.type,
- );
- return {
- effectiveAmount: Amounts.stringify(result.effective),
- rawAmount: Amounts.stringify(result.raw),
- };
+export interface SelectedPeerCoin {
+ coinPub: string;
+ coinPriv: string;
+ contribution: AmountString;
+ denomPubHash: string;
+ denomSig: UnblindedSignature;
+ ageCommitmentProof: AgeCommitmentProof | undefined;
}
-const LOG_REFRESH = false;
-const LOG_DEPOSIT = false;
-export function convertDepositAmountForAvailableCoins(
- denoms: AvailableCoins,
- amount: AmountJson,
- mode: TransactionAmountMode,
-): AmountAndRefresh {
- const zero = Amounts.zeroOfCurrency(amount.currency);
- if (!denoms.list.length) {
- // no coins in the database
- return { effective: zero, raw: zero };
- }
- const depositDenoms = rankDenominationForDeposit(denoms.list, mode);
-
- //FIXME: we are not taking into account
- // * exchanges with multiple accounts
- // * wallet with multiple exchanges
- const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero;
- const adjustedAmount = Amounts.add(amount, wireFee).amount;
-
- const selected = selectGreedyCoins(depositDenoms, adjustedAmount);
-
- const gap = Amounts.sub(amount, selected.totalValue).amount;
-
- const result = getTotalEffectiveAndRawForDeposit(
- selected.coins,
- amount.currency,
- );
- result.raw = Amounts.sub(result.raw, wireFee).amount;
-
- if (Amounts.isZero(gap)) {
- // exact amount founds
- return result;
- }
-
- if (LOG_DEPOSIT) {
- const logInfo = selected.coins.map((c) => {
- return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`;
- });
- console.log(
- "deposit used:",
- logInfo.join(", "),
- "gap:",
- Amounts.stringifyValue(gap),
- );
- }
+export interface PeerCoinSelectionDetails {
+ exchangeBaseUrl: string;
- const refreshDenoms = rankDenominationForRefresh(denoms.list);
/**
- * FIXME: looking for refresh AFTER selecting greedy is not optimal
+ * Info of Coins that were selected.
*/
- const refreshCoin = searchBestRefreshCoin(
- depositDenoms,
- refreshDenoms,
- gap,
- mode,
- );
-
- if (refreshCoin) {
- const fee = Amounts.sub(result.effective, result.raw).amount;
- const effective = Amounts.add(
- result.effective,
- refreshCoin.refreshEffective,
- ).amount;
- const raw = Amounts.sub(effective, fee, refreshCoin.totalFee).amount;
- //found with change
- return {
- effective,
- raw,
- refresh: refreshCoin,
- };
- }
+ coins: SelectedPeerCoin[];
- // there is a gap, but no refresh coin was found
- return result;
-}
-
-export async function getMaxDepositAmount(
- ws: InternalWalletState,
- req: GetAmountRequest,
-): Promise<AmountResponse> {
- // const filter = getCoinsFilter(req);
-
- const denoms = await getAvailableDenoms(
- ws,
- TransactionType.Deposit,
- req.currency,
- {},
- );
-
- const result = getMaxDepositAmountForAvailableCoins(denoms, req.currency);
- return {
- effectiveAmount: Amounts.stringify(result.effective),
- rawAmount: Amounts.stringify(result.raw),
- };
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ depositFees: AmountJson;
}
-export function getMaxDepositAmountForAvailableCoins(
- denoms: AvailableCoins,
- currency: string,
-) {
- const zero = Amounts.zeroOfCurrency(currency);
- if (!denoms.list.length) {
- // no coins in the database
- return { effective: zero, raw: zero };
- }
-
- const result = getTotalEffectiveAndRawForDeposit(
- denoms.list.map((info) => {
- return { info, size: info.totalAvailable ?? 0 };
- }),
- currency,
- );
-
- const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero;
- result.raw = Amounts.sub(result.raw, wireFee).amount;
-
- return result;
-}
+/**
+ * Information about a selected coin for peer to peer payments.
+ */
+export interface PeerCoinInfo {
+ /**
+ * Public key of the coin.
+ */
+ coinPub: string;
-export async function convertPeerPushAmount(
- ws: InternalWalletState,
- req: ConvertAmountRequest,
-): Promise<AmountResponse> {
- throw Error("to be implemented after 1.0");
-}
-export async function getMaxPeerPushAmount(
- ws: InternalWalletState,
- req: GetAmountRequest,
-): Promise<AmountResponse> {
- throw Error("to be implemented after 1.0");
-}
-export async function convertWithdrawalAmount(
- ws: InternalWalletState,
- req: ConvertAmountRequest,
-): Promise<AmountResponse> {
- const amount = Amounts.parseOrThrow(req.amount);
+ coinPriv: string;
- const denoms = await getAvailableDenoms(
- ws,
- TransactionType.Withdrawal,
- amount.currency,
- {},
- );
+ /**
+ * Deposit fee for the coin.
+ */
+ feeDeposit: AmountJson;
- const result = convertWithdrawalAmountFromAvailableCoins(
- denoms,
- amount,
- req.type,
- );
+ value: AmountJson;
- return {
- effectiveAmount: Amounts.stringify(result.effective),
- rawAmount: Amounts.stringify(result.raw),
- };
-}
+ denomPubHash: string;
-export function convertWithdrawalAmountFromAvailableCoins(
- denoms: AvailableCoins,
- amount: AmountJson,
- mode: TransactionAmountMode,
-) {
- const zero = Amounts.zeroOfCurrency(amount.currency);
- if (!denoms.list.length) {
- // no coins in the database
- return { effective: zero, raw: zero };
- }
- const withdrawDenoms = rankDenominationForWithdrawals(denoms.list, mode);
+ denomSig: UnblindedSignature;
- const selected = selectGreedyCoins(withdrawDenoms, amount);
+ maxAge: number;
- return getTotalEffectiveAndRawForWithdrawal(selected.coins, amount.currency);
+ ageCommitmentProof?: AgeCommitmentProof;
}
-/** *****************************************************
- * HELPERS
- * *****************************************************
- */
-
-/**
- *
- * @param depositDenoms
- * @param refreshDenoms
- * @param amount
- * @param mode
- * @returns
- */
-function searchBestRefreshCoin(
- depositDenoms: SelectableElement[],
- refreshDenoms: Record<string, SelectableElement[]>,
- amount: AmountJson,
- mode: TransactionAmountMode,
-): RefreshChoice | undefined {
- let choice: RefreshChoice | undefined = undefined;
- let refreshIdx = 0;
- refreshIteration: while (refreshIdx < depositDenoms.length) {
- const d = depositDenoms[refreshIdx];
-
- const denomContribution =
- mode === TransactionAmountMode.Effective
- ? d.value
- : Amounts.sub(d.value, d.info.denomRefresh, d.info.denomDeposit).amount;
-
- const changeAfterDeposit = Amounts.sub(denomContribution, amount).amount;
- if (Amounts.isZero(changeAfterDeposit)) {
- //this coin is not big enough to use for refresh
- //since the list is sorted, we can break here
- break refreshIteration;
- }
-
- const withdrawDenoms = refreshDenoms[d.info.exchangeBaseUrl];
- const change = selectGreedyCoins(withdrawDenoms, changeAfterDeposit);
-
- const zero = Amounts.zeroOfCurrency(amount.currency);
- const withdrawChangeFee = change.coins.reduce((cur, prev) => {
- return Amounts.add(
- cur,
- Amounts.mult(prev.info.denomWithdraw, prev.size).amount,
- ).amount;
- }, zero);
-
- const withdrawChangeValue = change.coins.reduce((cur, prev) => {
- return Amounts.add(cur, Amounts.mult(prev.info.value, prev.size).amount)
- .amount;
- }, zero);
-
- const totalFee = Amounts.add(
- d.info.denomDeposit,
- d.info.denomRefresh,
- withdrawChangeFee,
- ).amount;
+export type SelectPeerCoinsResult =
+ | { type: "success"; result: PeerCoinSelectionDetails }
+ | {
+ type: "failure";
+ insufficientBalanceDetails: PayPeerInsufficientBalanceDetails;
+ };
- if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) {
- //found cheaper change
- choice = {
- gap: amount,
- totalFee: totalFee,
- totalChangeValue: change.totalValue, //change after refresh
- refreshEffective: Amounts.sub(d.info.value, withdrawChangeValue).amount, // what of the denom used is not recovered
- selected: d.info,
- coins: change.coins,
- };
- }
- refreshIdx++;
- }
- if (choice) {
- if (LOG_REFRESH) {
- const logInfo = choice.coins.map((c) => {
- return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`;
- });
- console.log(
- "refresh used:",
- Amounts.stringifyValue(choice.selected.value),
- "change:",
- logInfo.join(", "),
- "fee:",
- Amounts.stringifyValue(choice.totalFee),
- "refreshEffective:",
- Amounts.stringifyValue(choice.refreshEffective),
- "totalChangeValue:",
- Amounts.stringifyValue(choice.totalChangeValue),
- );
- }
- }
- return choice;
+export interface PeerCoinRepair {
+ exchangeBaseUrl: string;
+ coinPubs: CoinPublicKeyString[];
+ contribs: AmountJson[];
}
-/**
- * Returns a copy of the list sorted for the best denom to withdraw first
- *
- * @param denoms
- * @returns
- */
-function rankDenominationForWithdrawals(
- denoms: CoinInfo[],
- mode: TransactionAmountMode,
-): SelectableElement[] {
- const copyList = [...denoms];
- /**
- * Rank coins
- */
- copyList.sort((d1, d2) => {
- // the best coin to use is
- // 1.- the one that contrib more and pay less fee
- // 2.- it takes more time before expires
-
- //different exchanges may have different wireFee
- //ranking should take the relative contribution in the exchange
- //which is (value - denomFee / fixedFee)
- const rate1 = Amounts.divmod(d1.value, d1.denomWithdraw).quotient;
- const rate2 = Amounts.divmod(d2.value, d2.denomWithdraw).quotient;
- const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1;
- return (
- contribCmp ||
- Duration.cmp(d1.duration, d2.duration) ||
- strcmp(d1.id, d2.id)
- );
- });
-
- return copyList.map((info) => {
- switch (mode) {
- case TransactionAmountMode.Effective: {
- //if the user instructed "effective" then we need to selected
- //greedy total coin value
- return {
- info,
- value: info.value,
- total: Number.MAX_SAFE_INTEGER,
- };
- }
- case TransactionAmountMode.Raw: {
- //if the user instructed "raw" then we need to selected
- //greedy total coin raw amount (without fee)
- return {
- info,
- value: Amounts.add(info.value, info.denomWithdraw).amount,
- total: Number.MAX_SAFE_INTEGER,
- };
- }
- }
- });
-}
+export interface PeerCoinSelectionRequest {
+ instructedAmount: AmountJson;
-/**
- * Returns a copy of the list sorted for the best denom to deposit first
- *
- * @param denoms
- * @returns
- */
-function rankDenominationForDeposit(
- denoms: CoinInfo[],
- mode: TransactionAmountMode,
-): SelectableElement[] {
- const copyList = [...denoms];
/**
- * Rank coins
+ * Instruct the coin selection to repair this coin
+ * selection instead of selecting completely new coins.
*/
- copyList.sort((d1, d2) => {
- // the best coin to use is
- // 1.- the one that contrib more and pay less fee
- // 2.- it takes more time before expires
-
- //different exchanges may have different wireFee
- //ranking should take the relative contribution in the exchange
- //which is (value - denomFee / fixedFee)
- const rate1 = Amounts.divmod(d1.value, d1.denomDeposit).quotient;
- const rate2 = Amounts.divmod(d2.value, d2.denomDeposit).quotient;
- const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1;
- return (
- contribCmp ||
- Duration.cmp(d1.duration, d2.duration) ||
- strcmp(d1.id, d2.id)
- );
- });
-
- return copyList.map((info) => {
- switch (mode) {
- case TransactionAmountMode.Effective: {
- //if the user instructed "effective" then we need to selected
- //greedy total coin value
- return {
- info,
- value: info.value,
- total: info.totalAvailable ?? 0,
- };
- }
- case TransactionAmountMode.Raw: {
- //if the user instructed "raw" then we need to selected
- //greedy total coin raw amount (without fee)
- return {
- info,
- value: Amounts.sub(info.value, info.denomDeposit).amount,
- total: info.totalAvailable ?? 0,
- };
- }
- }
- });
+ repair?: PeerCoinRepair;
}
-/**
- * Returns a copy of the list sorted for the best denom to withdraw first
- *
- * @param denoms
- * @returns
- */
-function rankDenominationForRefresh(
- denoms: CoinInfo[],
-): Record<string, SelectableElement[]> {
- const groupByExchange: Record<string, CoinInfo[]> = {};
- for (const d of denoms) {
- if (!groupByExchange[d.exchangeBaseUrl]) {
- groupByExchange[d.exchangeBaseUrl] = [];
- }
- groupByExchange[d.exchangeBaseUrl].push(d);
- }
-
- const result: Record<string, SelectableElement[]> = {};
- for (const d of denoms) {
- result[d.exchangeBaseUrl] = rankDenominationForWithdrawals(
- groupByExchange[d.exchangeBaseUrl],
- TransactionAmountMode.Raw,
- );
+export async function selectPeerCoins(
+ ws: InternalWalletState,
+ req: PeerCoinSelectionRequest,
+): Promise<SelectPeerCoinsResult> {
+ const instructedAmount = req.instructedAmount;
+ if (Amounts.isZero(instructedAmount)) {
+ // Other parts of the code assume that we have at least
+ // one coin to spend.
+ throw new Error("amount of zero not allowed");
}
- return result;
-}
-
-interface SelectableElement {
- total: number;
- value: AmountJson;
- info: CoinInfo;
-}
-
-function selectGreedyCoins(
- coins: SelectableElement[],
- limit: AmountJson,
-): SelectedCoins {
- const result: SelectedCoins = {
- totalValue: Amounts.zeroOfCurrency(limit.currency),
- coins: [],
- };
- if (!coins.length) return result;
-
- let denomIdx = 0;
- iterateDenoms: while (denomIdx < coins.length) {
- const denom = coins[denomIdx];
- // let total = denom.total;
- const left = Amounts.sub(limit, result.totalValue).amount;
+ return await ws.db
+ .mktx((x) => [
+ x.exchanges,
+ x.contractTerms,
+ x.coins,
+ x.coinAvailability,
+ x.denominations,
+ x.refreshGroups,
+ x.peerPushPaymentInitiations,
+ ])
+ .runReadWrite(async (tx) => {
+ const exchanges = await tx.exchanges.iter().toArray();
+ const exchangeFeeGap: { [url: string]: AmountJson } = {};
+ const currency = Amounts.currencyOf(instructedAmount);
+ for (const exch of exchanges) {
+ if (exch.detailsPointer?.currency !== currency) {
+ continue;
+ }
+ // FIXME: Can't we do this faster by using coinAvailability?
+ const coins = (
+ await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl)
+ ).filter((x) => x.status === CoinStatus.Fresh);
+ const coinInfos: PeerCoinInfo[] = [];
+ for (const coin of coins) {
+ const denom = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denom) {
+ throw Error("denom not found");
+ }
+ coinInfos.push({
+ coinPub: coin.coinPub,
+ feeDeposit: Amounts.parseOrThrow(denom.feeDeposit),
+ value: Amounts.parseOrThrow(denom.value),
+ denomPubHash: denom.denomPubHash,
+ coinPriv: coin.coinPriv,
+ denomSig: coin.denomSig,
+ maxAge: coin.maxAge,
+ ageCommitmentProof: coin.ageCommitmentProof,
+ });
+ }
+ if (coinInfos.length === 0) {
+ continue;
+ }
+ coinInfos.sort(
+ (o1, o2) =>
+ -Amounts.cmp(o1.value, o2.value) ||
+ strcmp(o1.denomPubHash, o2.denomPubHash),
+ );
+ let amountAcc = Amounts.zeroOfCurrency(currency);
+ let depositFeesAcc = Amounts.zeroOfCurrency(currency);
+ const resCoins: {
+ coinPub: string;
+ coinPriv: string;
+ contribution: AmountString;
+ denomPubHash: string;
+ denomSig: UnblindedSignature;
+ ageCommitmentProof: AgeCommitmentProof | undefined;
+ }[] = [];
+ let lastDepositFee = Amounts.zeroOfCurrency(currency);
+
+ if (req.repair) {
+ for (let i = 0; i < req.repair.coinPubs.length; i++) {
+ const contrib = req.repair.contribs[i];
+ const coin = await tx.coins.get(req.repair.coinPubs[i]);
+ if (!coin) {
+ throw Error("repair not possible, coin not found");
+ }
+ const denom = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(!!denom);
+ resCoins.push({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contribution: Amounts.stringify(contrib),
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ ageCommitmentProof: coin.ageCommitmentProof,
+ });
+ const depositFee = Amounts.parseOrThrow(denom.feeDeposit);
+ lastDepositFee = depositFee;
+ amountAcc = Amounts.add(
+ amountAcc,
+ Amounts.sub(contrib, depositFee).amount,
+ ).amount;
+ depositFeesAcc = Amounts.add(depositFeesAcc, depositFee).amount;
+ }
+ }
- if (Amounts.isZero(denom.value)) {
- // 0 contribution denoms should be the last
- break iterateDenoms;
- }
+ for (const coin of coinInfos) {
+ if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
+ break;
+ }
+ const gap = Amounts.add(
+ coin.feeDeposit,
+ Amounts.sub(instructedAmount, amountAcc).amount,
+ ).amount;
+ const contrib = Amounts.min(gap, coin.value);
+ amountAcc = Amounts.add(
+ amountAcc,
+ Amounts.sub(contrib, coin.feeDeposit).amount,
+ ).amount;
+ depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount;
+ resCoins.push({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contribution: Amounts.stringify(contrib),
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ ageCommitmentProof: coin.ageCommitmentProof,
+ });
+ lastDepositFee = coin.feeDeposit;
+ }
+ if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
+ const res: PeerCoinSelectionDetails = {
+ exchangeBaseUrl: exch.baseUrl,
+ coins: resCoins,
+ depositFees: depositFeesAcc,
+ };
+ return { type: "success", result: res };
+ }
+ const diff = Amounts.sub(instructedAmount, amountAcc).amount;
+ exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, diff).amount;
- //use Amounts.divmod instead of iterate
- const div = Amounts.divmod(left, denom.value);
- const size = Math.min(div.quotient, denom.total);
- if (size > 0) {
- const mul = Amounts.mult(denom.value, size).amount;
- const progress = Amounts.add(result.totalValue, mul).amount;
+ continue;
+ }
- result.totalValue = progress;
- result.coins.push({ info: denom.info, size });
- denom.total = denom.total - size;
- }
+ // We were unable to select coins.
+ // Now we need to produce error details.
- //go next denom
- denomIdx++;
- }
+ const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
+ currency,
+ });
- return result;
-}
+ const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {};
-type AmountWithFee = { raw: AmountJson; effective: AmountJson };
-type AmountAndRefresh = AmountWithFee & { refresh?: RefreshChoice };
+ let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency);
-export function getTotalEffectiveAndRawForDeposit(
- list: { info: CoinInfo; size: number }[],
- currency: string,
-): AmountWithFee {
- const init = {
- raw: Amounts.zeroOfCurrency(currency),
- effective: Amounts.zeroOfCurrency(currency),
- };
- return list.reduce((prev, cur) => {
- const ef = Amounts.mult(cur.info.value, cur.size).amount;
- const rw = Amounts.mult(
- Amounts.sub(cur.info.value, cur.info.denomDeposit).amount,
- cur.size,
- ).amount;
+ for (const exch of exchanges) {
+ if (exch.detailsPointer?.currency !== currency) {
+ continue;
+ }
+ const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
+ currency,
+ restrictExchangeTo: exch.baseUrl,
+ });
+ let gap =
+ exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency);
+ if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) {
+ // Show fee gap only if we should've been able to pay with the material amount
+ gap = Amounts.zeroOfCurrency(currency);
+ }
+ perExchange[exch.baseUrl] = {
+ balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable),
+ balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial),
+ feeGapEstimate: Amounts.stringify(gap),
+ };
- prev.effective = Amounts.add(prev.effective, ef).amount;
- prev.raw = Amounts.add(prev.raw, rw).amount;
- return prev;
- }, init);
-}
+ maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap);
+ }
-function getTotalEffectiveAndRawForWithdrawal(
- list: { info: CoinInfo; size: number }[],
- currency: string,
-): AmountWithFee {
- const init = {
- raw: Amounts.zeroOfCurrency(currency),
- effective: Amounts.zeroOfCurrency(currency),
- };
- return list.reduce((prev, cur) => {
- const ef = Amounts.mult(cur.info.value, cur.size).amount;
- const rw = Amounts.mult(
- Amounts.add(cur.info.value, cur.info.denomWithdraw).amount,
- cur.size,
- ).amount;
+ const errDetails: PayPeerInsufficientBalanceDetails = {
+ amountRequested: Amounts.stringify(instructedAmount),
+ balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable),
+ balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial),
+ feeGapEstimate: Amounts.stringify(maxFeeGapEstimate),
+ perExchange,
+ };
- prev.effective = Amounts.add(prev.effective, ef).amount;
- prev.raw = Amounts.add(prev.raw, rw).amount;
- return prev;
- }, init);
+ return { type: "failure", insufficientBalanceDetails: errDetails };
+ });
}
diff --git a/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts b/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts
new file mode 100644
index 000000000..de8515d09
--- /dev/null
+++ b/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts
@@ -0,0 +1,763 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ AmountJson,
+ Amounts,
+ Duration,
+ TransactionAmountMode,
+} from "@gnu-taler/taler-util";
+import test, { ExecutionContext } from "ava";
+import { CoinInfo } from "./coinSelection.js";
+import { convertDepositAmountForAvailableCoins, getMaxDepositAmountForAvailableCoins, convertWithdrawalAmountFromAvailableCoins } from "./instructedAmountConversion.js";
+
+function makeCurrencyHelper(currency: string) {
+ return (sx: TemplateStringsArray, ...vx: any[]) => {
+ const s = String.raw({ raw: sx }, ...vx);
+ return Amounts.parseOrThrow(`${currency}:${s}`);
+ };
+}
+
+const kudos = makeCurrencyHelper("kudos");
+
+function defaultFeeConfig(value: AmountJson, totalAvailable: number): CoinInfo {
+ return {
+ id: Amounts.stringify(value),
+ denomDeposit: kudos`0.01`,
+ denomRefresh: kudos`0.01`,
+ denomWithdraw: kudos`0.01`,
+ exchangeBaseUrl: "1",
+ duration: Duration.getForever(),
+ exchangePurse: undefined,
+ exchangeWire: undefined,
+ maxAge: AgeRestriction.AGE_UNRESTRICTED,
+ totalAvailable,
+ value,
+ };
+}
+type Coin = [AmountJson, number];
+
+/**
+ * Making a deposit with effective amount
+ *
+ */
+
+test("deposit effective 2", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "1.99");
+});
+
+test("deposit effective 10", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`10`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "10");
+ t.is(Amounts.stringifyValue(result.raw), "9.98");
+});
+
+test("deposit effective 24", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`24`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24");
+ t.is(Amounts.stringifyValue(result.raw), "23.94");
+});
+
+test("deposit effective 40", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`40`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "35");
+ t.is(Amounts.stringifyValue(result.raw), "34.9");
+});
+
+test("deposit with wire fee effective 2", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ one: {
+ wireFee: kudos`0.1`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ kudos`2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "1.89");
+});
+
+/**
+ * Making a deposit with raw amount, using the result from effective
+ *
+ */
+
+test("deposit raw 1.99 (effective 2)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`1.99`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "1.99");
+});
+
+test("deposit raw 9.98 (effective 10)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`9.98`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "10");
+ t.is(Amounts.stringifyValue(result.raw), "9.98");
+});
+
+test("deposit raw 23.94 (effective 24)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`23.94`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24");
+ t.is(Amounts.stringifyValue(result.raw), "23.94");
+});
+
+test("deposit raw 34.9 (effective 40)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`34.9`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "35");
+ t.is(Amounts.stringifyValue(result.raw), "34.9");
+});
+
+test("deposit with wire fee raw 2", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ one: {
+ wireFee: kudos`0.1`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ kudos`2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "1.89");
+});
+
+/**
+ * Calculating the max amount possible to deposit
+ *
+ */
+
+test("deposit max 35", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ "2": {
+ wireFee: kudos`0.00`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.raw), "34.9");
+ t.is(Amounts.stringifyValue(result.effective), "35");
+});
+
+test("deposit max 35 with wirefee", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ "2": {
+ wireFee: kudos`1`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.raw), "33.9");
+ t.is(Amounts.stringifyValue(result.effective), "35");
+});
+
+test("deposit max repeated denom", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 1],
+ [kudos`2`, 1],
+ [kudos`5`, 1],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ "2": {
+ wireFee: kudos`0.00`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.raw), "8.97");
+ t.is(Amounts.stringifyValue(result.effective), "9");
+});
+
+/**
+ * Making a withdrawal with effective amount
+ *
+ */
+
+test("withdraw effective 2", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "2.01");
+});
+
+test("withdraw effective 10", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`10`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "10");
+ t.is(Amounts.stringifyValue(result.raw), "10.02");
+});
+
+test("withdraw effective 24", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`24`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24");
+ t.is(Amounts.stringifyValue(result.raw), "24.06");
+});
+
+test("withdraw effective 40", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`40`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "40");
+ t.is(Amounts.stringifyValue(result.raw), "40.08");
+});
+
+/**
+ * Making a deposit with raw amount, using the result from effective
+ *
+ */
+
+test("withdraw raw 2.01 (effective 2)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`2.01`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "2.01");
+});
+
+test("withdraw raw 10.02 (effective 10)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`10.02`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "10");
+ t.is(Amounts.stringifyValue(result.raw), "10.02");
+});
+
+test("withdraw raw 24.06 (effective 24)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`24.06`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24");
+ t.is(Amounts.stringifyValue(result.raw), "24.06");
+});
+
+test("withdraw raw 40.08 (effective 40)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`40.08`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "40");
+ t.is(Amounts.stringifyValue(result.raw), "40.08");
+});
+
+test("withdraw raw 25", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 0],
+ [kudos`5`, 0],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`25`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24.8");
+ t.is(Amounts.stringifyValue(result.raw), "24.94");
+});
+
+test("withdraw effective 24.8 (raw 25)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 0],
+ [kudos`5`, 0],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`24.8`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24.8");
+ t.is(Amounts.stringifyValue(result.raw), "24.94");
+});
+
+/**
+ * Making a deposit with refresh
+ *
+ */
+
+test("deposit with refresh: effective 3", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`3`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "3.1");
+ t.is(Amounts.stringifyValue(result.raw), "2.98");
+ expectDefined(t, result.refresh);
+ //FEES
+ //deposit 2 x 0.01
+ //refresh 1 x 0.01
+ //withdraw 9 x 0.01
+ //-----------------
+ //op 0.12
+
+ //coins sent 2 x 2.0
+ //coins recv 9 x 0.1
+ //-------------------
+ //effective 3.10
+ //raw 2.98
+ t.is(Amounts.stringifyValue(result.refresh.selected.id), "2");
+ t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 9]]);
+});
+
+test("deposit with refresh: raw 2.98 (effective 3)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`2.98`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "3.2");
+ t.is(Amounts.stringifyValue(result.raw), "3.09");
+ expectDefined(t, result.refresh);
+ //FEES
+ //deposit 1 x 0.01
+ //refresh 1 x 0.01
+ //withdraw 8 x 0.01
+ //-----------------
+ //op 0.10
+
+ //coins sent 1 x 2.0
+ //coins recv 8 x 0.1
+ //-------------------
+ //effective 3.20
+ //raw 3.09
+ t.is(Amounts.stringifyValue(result.refresh.selected.id), "2");
+ t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 8]]);
+});
+
+test("deposit with refresh: effective 3.2 (raw 2.98)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`3.2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "3.3");
+ t.is(Amounts.stringifyValue(result.raw), "3.2");
+ expectDefined(t, result.refresh);
+ //FEES
+ //deposit 2 x 0.01
+ //refresh 1 x 0.01
+ //withdraw 7 x 0.01
+ //-----------------
+ //op 0.10
+
+ //coins sent 2 x 2.0
+ //coins recv 7 x 0.1
+ //-------------------
+ //effective 3.30
+ //raw 3.20
+ t.is(Amounts.stringifyValue(result.refresh.selected.id), "2");
+ t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 7]]);
+});
+
+function expectDefined<T>(
+ t: ExecutionContext,
+ v: T | undefined,
+): asserts v is T {
+ t.assert(v !== undefined);
+}
+
+function asCoinList(v: { info: CoinInfo; size: number }[]): any {
+ return v.map((c) => {
+ return [c.info.value, c.size];
+ });
+}
+
+/**
+ * regression tests
+ */
+
+test("demo: withdraw raw 25", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 0],
+ [kudos`5`, 0],
+ [kudos`10`, 0],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`25`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24.8");
+ t.is(Amounts.stringifyValue(result.raw), "24.92");
+ // coins received
+ // 8 x 0.1
+ // 2 x 0.2
+ // 2 x 10.0
+ // total effective 24.8
+ // fee 12 x 0.01 = 0.12
+ // total raw 24.92
+ // left in reserve 25 - 24.92 == 0.08
+
+ //current wallet impl: hides the left in reserve fee
+ //shows fee = 0.2
+});
+
+test("demo: deposit max after withdraw raw 25", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 8],
+ [kudos`1`, 0],
+ [kudos`2`, 2],
+ [kudos`5`, 0],
+ [kudos`10`, 2],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ one: {
+ wireFee: kudos`0.01`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24.8");
+ t.is(Amounts.stringifyValue(result.raw), "24.67");
+
+ // 8 x 0.1
+ // 2 x 0.2
+ // 2 x 10.0
+ // total effective 24.8
+ // deposit fee 12 x 0.01 = 0.12
+ // wire fee 0.01
+ // total raw: 24.8 - 0.13 = 24.67
+
+ // current wallet impl fee 0.14
+});
+
+test("demo: withdraw raw 13", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 0],
+ [kudos`5`, 0],
+ [kudos`10`, 0],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`13`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "12.8");
+ t.is(Amounts.stringifyValue(result.raw), "12.9");
+ // coins received
+ // 8 x 0.1
+ // 1 x 0.2
+ // 1 x 10.0
+ // total effective 12.8
+ // fee 10 x 0.01 = 0.10
+ // total raw 12.9
+ // left in reserve 13 - 12.9 == 0.1
+
+ //current wallet impl: hides the left in reserve fee
+ //shows fee = 0.2
+});
+
+test("demo: deposit max after withdraw raw 13", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 8],
+ [kudos`1`, 0],
+ [kudos`2`, 1],
+ [kudos`5`, 0],
+ [kudos`10`, 1],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ one: {
+ wireFee: kudos`0.01`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.effective), "12.8");
+ t.is(Amounts.stringifyValue(result.raw), "12.69");
+
+ // 8 x 0.1
+ // 1 x 0.2
+ // 1 x 10.0
+ // total effective 12.8
+ // deposit fee 10 x 0.01 = 0.10
+ // wire fee 0.01
+ // total raw: 12.8 - 0.11 = 12.69
+
+ // current wallet impl fee 0.14
+});
diff --git a/packages/taler-wallet-core/src/util/instructedAmountConversion.ts b/packages/taler-wallet-core/src/util/instructedAmountConversion.ts
new file mode 100644
index 000000000..bd02e7b22
--- /dev/null
+++ b/packages/taler-wallet-core/src/util/instructedAmountConversion.ts
@@ -0,0 +1,849 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ AmountJson,
+ AmountResponse,
+ Amounts,
+ ConvertAmountRequest,
+ Duration,
+ GetAmountRequest,
+ GetPlanForOperationRequest,
+ TransactionAmountMode,
+ TransactionType,
+ parsePaytoUri,
+ strcmp,
+} from "@gnu-taler/taler-util";
+import { checkDbInvariant } from "./invariants.js";
+import {
+ DenominationRecord,
+ InternalWalletState,
+ getExchangeDetails,
+} from "../index.js";
+import { CoinInfo } from "./coinSelection.js";
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+
+/**
+ * If the operation going to be plan subtracts
+ * or adds amount in the wallet db
+ */
+export enum OperationType {
+ Credit = "credit",
+ Debit = "debit",
+}
+
+// FIXME: Name conflict ...
+interface ExchangeInfo {
+ wireFee: AmountJson | undefined;
+ purseFee: AmountJson | undefined;
+ creditDeadline: AbsoluteTime;
+ debitDeadline: AbsoluteTime;
+}
+
+function getOperationType(txType: TransactionType): OperationType {
+ const operationType =
+ txType === TransactionType.Withdrawal
+ ? OperationType.Credit
+ : txType === TransactionType.Deposit
+ ? OperationType.Debit
+ : undefined;
+ if (!operationType) {
+ throw Error(`operation type ${txType} not yet supported`);
+ }
+ return operationType;
+}
+
+interface SelectedCoins {
+ totalValue: AmountJson;
+ coins: { info: CoinInfo; size: number }[];
+ refresh?: RefreshChoice;
+}
+
+function getCoinsFilter(req: GetPlanForOperationRequest): CoinsFilter {
+ switch (req.type) {
+ case TransactionType.Withdrawal: {
+ return {
+ exchanges:
+ req.exchangeUrl === undefined ? undefined : [req.exchangeUrl],
+ };
+ }
+ case TransactionType.Deposit: {
+ const payto = parsePaytoUri(req.account);
+ if (!payto) {
+ throw Error(`wrong payto ${req.account}`);
+ }
+ return {
+ wireMethod: payto.targetType,
+ };
+ }
+ }
+}
+
+interface RefreshChoice {
+ /**
+ * Amount that need to be covered
+ */
+ gap: AmountJson;
+ totalFee: AmountJson;
+ selected: CoinInfo;
+ totalChangeValue: AmountJson;
+ refreshEffective: AmountJson;
+ coins: { info: CoinInfo; size: number }[];
+
+ // totalValue: AmountJson;
+ // totalDepositFee: AmountJson;
+ // totalRefreshFee: AmountJson;
+ // totalChangeContribution: AmountJson;
+ // totalChangeWithdrawalFee: AmountJson;
+}
+
+interface CoinsFilter {
+ shouldCalculatePurseFee?: boolean;
+ exchanges?: string[];
+ wireMethod?: string;
+ ageRestricted?: number;
+}
+
+interface AvailableCoins {
+ list: CoinInfo[];
+ exchanges: Record<string, ExchangeInfo>;
+}
+
+/**
+ * Get all the denoms that can be used for a operation that is limited
+ * by the following restrictions.
+ * This function is costly (by the database access) but with high chances
+ * of being cached
+ */
+async function getAvailableDenoms(
+ ws: InternalWalletState,
+ op: TransactionType,
+ currency: string,
+ filters: CoinsFilter = {},
+): Promise<AvailableCoins> {
+ const operationType = getOperationType(TransactionType.Deposit);
+
+ return await ws.db
+ .mktx((x) => [
+ x.exchanges,
+ x.exchangeDetails,
+ x.denominations,
+ x.coinAvailability,
+ ])
+ .runReadOnly(async (tx) => {
+ const list: CoinInfo[] = [];
+ const exchanges: Record<string, ExchangeInfo> = {};
+
+ const databaseExchanges = await tx.exchanges.iter().toArray();
+ const filteredExchanges =
+ filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl);
+
+ for (const exchangeBaseUrl of filteredExchanges) {
+ const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl);
+ // 1.- exchange has same currency
+ if (exchangeDetails?.currency !== currency) {
+ continue;
+ }
+
+ let deadline = AbsoluteTime.never();
+ // 2.- exchange supports wire method
+ let wireFee: AmountJson | undefined;
+ if (filters.wireMethod) {
+ const wireMethodWithDates =
+ exchangeDetails.wireInfo.feesForType[filters.wireMethod];
+
+ if (!wireMethodWithDates) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`,
+ );
+ }
+ const wireMethodFee = wireMethodWithDates.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ });
+
+ if (!wireMethodFee) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`,
+ );
+ }
+ wireFee = Amounts.parseOrThrow(wireMethodFee.wireFee);
+ deadline = AbsoluteTime.min(
+ deadline,
+ AbsoluteTime.fromProtocolTimestamp(wireMethodFee.endStamp),
+ );
+ }
+ // exchanges[exchangeBaseUrl].wireFee = wireMethodFee;
+
+ // 3.- exchange supports wire method
+ let purseFee: AmountJson | undefined;
+ if (filters.shouldCalculatePurseFee) {
+ const purseFeeFound = exchangeDetails.globalFees.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startDate),
+ AbsoluteTime.fromProtocolTimestamp(x.endDate),
+ );
+ });
+ if (!purseFeeFound) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`,
+ );
+ }
+ purseFee = Amounts.parseOrThrow(purseFeeFound.purseFee);
+ deadline = AbsoluteTime.min(
+ deadline,
+ AbsoluteTime.fromProtocolTimestamp(purseFeeFound.endDate),
+ );
+ }
+
+ let creditDeadline = AbsoluteTime.never();
+ let debitDeadline = AbsoluteTime.never();
+ //4.- filter coins restricted by age
+ if (operationType === OperationType.Credit) {
+ const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const denom of ds) {
+ const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ denom.stampExpireWithdraw,
+ );
+ const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
+ denom.stampExpireDeposit,
+ );
+ creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
+ debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
+ list.push(
+ buildCoinInfoFromDenom(
+ denom,
+ purseFee,
+ wireFee,
+ AgeRestriction.AGE_UNRESTRICTED,
+ Number.MAX_SAFE_INTEGER, // Max withdrawable from single denom
+ ),
+ );
+ }
+ } else {
+ const ageLower = filters.ageRestricted ?? 0;
+ const ageUpper = AgeRestriction.AGE_UNRESTRICTED;
+
+ const myExchangeCoins =
+ await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
+ GlobalIDB.KeyRange.bound(
+ [exchangeDetails.exchangeBaseUrl, ageLower, 1],
+ [
+ exchangeDetails.exchangeBaseUrl,
+ ageUpper,
+ Number.MAX_SAFE_INTEGER,
+ ],
+ ),
+ );
+ //5.- save denoms with how many coins are available
+ // FIXME: Check that the individual denomination is audited!
+ // FIXME: Should we exclude denominations that are
+ // not spendable anymore?
+ for (const coinAvail of myExchangeCoins) {
+ const denom = await tx.denominations.get([
+ coinAvail.exchangeBaseUrl,
+ coinAvail.denomPubHash,
+ ]);
+ checkDbInvariant(!!denom);
+ if (denom.isRevoked || !denom.isOffered) {
+ continue;
+ }
+ const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ denom.stampExpireWithdraw,
+ );
+ const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
+ denom.stampExpireDeposit,
+ );
+ creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
+ debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
+ list.push(
+ buildCoinInfoFromDenom(
+ denom,
+ purseFee,
+ wireFee,
+ coinAvail.maxAge,
+ coinAvail.freshCoinCount,
+ ),
+ );
+ }
+ }
+
+ exchanges[exchangeBaseUrl] = {
+ purseFee,
+ wireFee,
+ debitDeadline,
+ creditDeadline,
+ };
+ }
+
+ return { list, exchanges };
+ });
+}
+
+function buildCoinInfoFromDenom(
+ denom: DenominationRecord,
+ purseFee: AmountJson | undefined,
+ wireFee: AmountJson | undefined,
+ maxAge: number,
+ total: number,
+): CoinInfo {
+ return {
+ id: denom.denomPubHash,
+ denomWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw),
+ denomDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
+ denomRefresh: Amounts.parseOrThrow(denom.fees.feeRefresh),
+ exchangePurse: purseFee,
+ exchangeWire: wireFee,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ duration: AbsoluteTime.difference(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(denom.stampExpireDeposit),
+ ),
+ totalAvailable: total,
+ value: DenominationRecord.getValue(denom),
+ maxAge,
+ };
+}
+
+export async function convertDepositAmount(
+ ws: InternalWalletState,
+ req: ConvertAmountRequest,
+): Promise<AmountResponse> {
+ const amount = Amounts.parseOrThrow(req.amount);
+ // const filter = getCoinsFilter(req);
+
+ const denoms = await getAvailableDenoms(
+ ws,
+ TransactionType.Deposit,
+ amount.currency,
+ {},
+ );
+ const result = convertDepositAmountForAvailableCoins(
+ denoms,
+ amount,
+ req.type,
+ );
+
+ return {
+ effectiveAmount: Amounts.stringify(result.effective),
+ rawAmount: Amounts.stringify(result.raw),
+ };
+}
+
+const LOG_REFRESH = false;
+const LOG_DEPOSIT = false;
+export function convertDepositAmountForAvailableCoins(
+ denoms: AvailableCoins,
+ amount: AmountJson,
+ mode: TransactionAmountMode,
+): AmountAndRefresh {
+ const zero = Amounts.zeroOfCurrency(amount.currency);
+ if (!denoms.list.length) {
+ // no coins in the database
+ return { effective: zero, raw: zero };
+ }
+ const depositDenoms = rankDenominationForDeposit(denoms.list, mode);
+
+ //FIXME: we are not taking into account
+ // * exchanges with multiple accounts
+ // * wallet with multiple exchanges
+ const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero;
+ const adjustedAmount = Amounts.add(amount, wireFee).amount;
+
+ const selected = selectGreedyCoins(depositDenoms, adjustedAmount);
+
+ const gap = Amounts.sub(amount, selected.totalValue).amount;
+
+ const result = getTotalEffectiveAndRawForDeposit(
+ selected.coins,
+ amount.currency,
+ );
+ result.raw = Amounts.sub(result.raw, wireFee).amount;
+
+ if (Amounts.isZero(gap)) {
+ // exact amount founds
+ return result;
+ }
+
+ if (LOG_DEPOSIT) {
+ const logInfo = selected.coins.map((c) => {
+ return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`;
+ });
+ console.log(
+ "deposit used:",
+ logInfo.join(", "),
+ "gap:",
+ Amounts.stringifyValue(gap),
+ );
+ }
+
+ const refreshDenoms = rankDenominationForRefresh(denoms.list);
+ /**
+ * FIXME: looking for refresh AFTER selecting greedy is not optimal
+ */
+ const refreshCoin = searchBestRefreshCoin(
+ depositDenoms,
+ refreshDenoms,
+ gap,
+ mode,
+ );
+
+ if (refreshCoin) {
+ const fee = Amounts.sub(result.effective, result.raw).amount;
+ const effective = Amounts.add(
+ result.effective,
+ refreshCoin.refreshEffective,
+ ).amount;
+ const raw = Amounts.sub(effective, fee, refreshCoin.totalFee).amount;
+ //found with change
+ return {
+ effective,
+ raw,
+ refresh: refreshCoin,
+ };
+ }
+
+ // there is a gap, but no refresh coin was found
+ return result;
+}
+
+export async function getMaxDepositAmount(
+ ws: InternalWalletState,
+ req: GetAmountRequest,
+): Promise<AmountResponse> {
+ // const filter = getCoinsFilter(req);
+
+ const denoms = await getAvailableDenoms(
+ ws,
+ TransactionType.Deposit,
+ req.currency,
+ {},
+ );
+
+ const result = getMaxDepositAmountForAvailableCoins(denoms, req.currency);
+ return {
+ effectiveAmount: Amounts.stringify(result.effective),
+ rawAmount: Amounts.stringify(result.raw),
+ };
+}
+
+export function getMaxDepositAmountForAvailableCoins(
+ denoms: AvailableCoins,
+ currency: string,
+) {
+ const zero = Amounts.zeroOfCurrency(currency);
+ if (!denoms.list.length) {
+ // no coins in the database
+ return { effective: zero, raw: zero };
+ }
+
+ const result = getTotalEffectiveAndRawForDeposit(
+ denoms.list.map((info) => {
+ return { info, size: info.totalAvailable ?? 0 };
+ }),
+ currency,
+ );
+
+ const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero;
+ result.raw = Amounts.sub(result.raw, wireFee).amount;
+
+ return result;
+}
+
+export async function convertPeerPushAmount(
+ ws: InternalWalletState,
+ req: ConvertAmountRequest,
+): Promise<AmountResponse> {
+ throw Error("to be implemented after 1.0");
+}
+export async function getMaxPeerPushAmount(
+ ws: InternalWalletState,
+ req: GetAmountRequest,
+): Promise<AmountResponse> {
+ throw Error("to be implemented after 1.0");
+}
+export async function convertWithdrawalAmount(
+ ws: InternalWalletState,
+ req: ConvertAmountRequest,
+): Promise<AmountResponse> {
+ const amount = Amounts.parseOrThrow(req.amount);
+
+ const denoms = await getAvailableDenoms(
+ ws,
+ TransactionType.Withdrawal,
+ amount.currency,
+ {},
+ );
+
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ denoms,
+ amount,
+ req.type,
+ );
+
+ return {
+ effectiveAmount: Amounts.stringify(result.effective),
+ rawAmount: Amounts.stringify(result.raw),
+ };
+}
+
+export function convertWithdrawalAmountFromAvailableCoins(
+ denoms: AvailableCoins,
+ amount: AmountJson,
+ mode: TransactionAmountMode,
+) {
+ const zero = Amounts.zeroOfCurrency(amount.currency);
+ if (!denoms.list.length) {
+ // no coins in the database
+ return { effective: zero, raw: zero };
+ }
+ const withdrawDenoms = rankDenominationForWithdrawals(denoms.list, mode);
+
+ const selected = selectGreedyCoins(withdrawDenoms, amount);
+
+ return getTotalEffectiveAndRawForWithdrawal(selected.coins, amount.currency);
+}
+
+/** *****************************************************
+ * HELPERS
+ * *****************************************************
+ */
+
+/**
+ *
+ * @param depositDenoms
+ * @param refreshDenoms
+ * @param amount
+ * @param mode
+ * @returns
+ */
+function searchBestRefreshCoin(
+ depositDenoms: SelectableElement[],
+ refreshDenoms: Record<string, SelectableElement[]>,
+ amount: AmountJson,
+ mode: TransactionAmountMode,
+): RefreshChoice | undefined {
+ let choice: RefreshChoice | undefined = undefined;
+ let refreshIdx = 0;
+ refreshIteration: while (refreshIdx < depositDenoms.length) {
+ const d = depositDenoms[refreshIdx];
+
+ const denomContribution =
+ mode === TransactionAmountMode.Effective
+ ? d.value
+ : Amounts.sub(d.value, d.info.denomRefresh, d.info.denomDeposit).amount;
+
+ const changeAfterDeposit = Amounts.sub(denomContribution, amount).amount;
+ if (Amounts.isZero(changeAfterDeposit)) {
+ //this coin is not big enough to use for refresh
+ //since the list is sorted, we can break here
+ break refreshIteration;
+ }
+
+ const withdrawDenoms = refreshDenoms[d.info.exchangeBaseUrl];
+ const change = selectGreedyCoins(withdrawDenoms, changeAfterDeposit);
+
+ const zero = Amounts.zeroOfCurrency(amount.currency);
+ const withdrawChangeFee = change.coins.reduce((cur, prev) => {
+ return Amounts.add(
+ cur,
+ Amounts.mult(prev.info.denomWithdraw, prev.size).amount,
+ ).amount;
+ }, zero);
+
+ const withdrawChangeValue = change.coins.reduce((cur, prev) => {
+ return Amounts.add(cur, Amounts.mult(prev.info.value, prev.size).amount)
+ .amount;
+ }, zero);
+
+ const totalFee = Amounts.add(
+ d.info.denomDeposit,
+ d.info.denomRefresh,
+ withdrawChangeFee,
+ ).amount;
+
+ if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) {
+ //found cheaper change
+ choice = {
+ gap: amount,
+ totalFee: totalFee,
+ totalChangeValue: change.totalValue, //change after refresh
+ refreshEffective: Amounts.sub(d.info.value, withdrawChangeValue).amount, // what of the denom used is not recovered
+ selected: d.info,
+ coins: change.coins,
+ };
+ }
+ refreshIdx++;
+ }
+ if (choice) {
+ if (LOG_REFRESH) {
+ const logInfo = choice.coins.map((c) => {
+ return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`;
+ });
+ console.log(
+ "refresh used:",
+ Amounts.stringifyValue(choice.selected.value),
+ "change:",
+ logInfo.join(", "),
+ "fee:",
+ Amounts.stringifyValue(choice.totalFee),
+ "refreshEffective:",
+ Amounts.stringifyValue(choice.refreshEffective),
+ "totalChangeValue:",
+ Amounts.stringifyValue(choice.totalChangeValue),
+ );
+ }
+ }
+ return choice;
+}
+
+/**
+ * Returns a copy of the list sorted for the best denom to withdraw first
+ *
+ * @param denoms
+ * @returns
+ */
+function rankDenominationForWithdrawals(
+ denoms: CoinInfo[],
+ mode: TransactionAmountMode,
+): SelectableElement[] {
+ const copyList = [...denoms];
+ /**
+ * Rank coins
+ */
+ copyList.sort((d1, d2) => {
+ // the best coin to use is
+ // 1.- the one that contrib more and pay less fee
+ // 2.- it takes more time before expires
+
+ //different exchanges may have different wireFee
+ //ranking should take the relative contribution in the exchange
+ //which is (value - denomFee / fixedFee)
+ const rate1 = Amounts.divmod(d1.value, d1.denomWithdraw).quotient;
+ const rate2 = Amounts.divmod(d2.value, d2.denomWithdraw).quotient;
+ const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1;
+ return (
+ contribCmp ||
+ Duration.cmp(d1.duration, d2.duration) ||
+ strcmp(d1.id, d2.id)
+ );
+ });
+
+ return copyList.map((info) => {
+ switch (mode) {
+ case TransactionAmountMode.Effective: {
+ //if the user instructed "effective" then we need to selected
+ //greedy total coin value
+ return {
+ info,
+ value: info.value,
+ total: Number.MAX_SAFE_INTEGER,
+ };
+ }
+ case TransactionAmountMode.Raw: {
+ //if the user instructed "raw" then we need to selected
+ //greedy total coin raw amount (without fee)
+ return {
+ info,
+ value: Amounts.add(info.value, info.denomWithdraw).amount,
+ total: Number.MAX_SAFE_INTEGER,
+ };
+ }
+ }
+ });
+}
+
+/**
+ * Returns a copy of the list sorted for the best denom to deposit first
+ *
+ * @param denoms
+ * @returns
+ */
+function rankDenominationForDeposit(
+ denoms: CoinInfo[],
+ mode: TransactionAmountMode,
+): SelectableElement[] {
+ const copyList = [...denoms];
+ /**
+ * Rank coins
+ */
+ copyList.sort((d1, d2) => {
+ // the best coin to use is
+ // 1.- the one that contrib more and pay less fee
+ // 2.- it takes more time before expires
+
+ //different exchanges may have different wireFee
+ //ranking should take the relative contribution in the exchange
+ //which is (value - denomFee / fixedFee)
+ const rate1 = Amounts.divmod(d1.value, d1.denomDeposit).quotient;
+ const rate2 = Amounts.divmod(d2.value, d2.denomDeposit).quotient;
+ const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1;
+ return (
+ contribCmp ||
+ Duration.cmp(d1.duration, d2.duration) ||
+ strcmp(d1.id, d2.id)
+ );
+ });
+
+ return copyList.map((info) => {
+ switch (mode) {
+ case TransactionAmountMode.Effective: {
+ //if the user instructed "effective" then we need to selected
+ //greedy total coin value
+ return {
+ info,
+ value: info.value,
+ total: info.totalAvailable ?? 0,
+ };
+ }
+ case TransactionAmountMode.Raw: {
+ //if the user instructed "raw" then we need to selected
+ //greedy total coin raw amount (without fee)
+ return {
+ info,
+ value: Amounts.sub(info.value, info.denomDeposit).amount,
+ total: info.totalAvailable ?? 0,
+ };
+ }
+ }
+ });
+}
+
+/**
+ * Returns a copy of the list sorted for the best denom to withdraw first
+ *
+ * @param denoms
+ * @returns
+ */
+function rankDenominationForRefresh(
+ denoms: CoinInfo[],
+): Record<string, SelectableElement[]> {
+ const groupByExchange: Record<string, CoinInfo[]> = {};
+ for (const d of denoms) {
+ if (!groupByExchange[d.exchangeBaseUrl]) {
+ groupByExchange[d.exchangeBaseUrl] = [];
+ }
+ groupByExchange[d.exchangeBaseUrl].push(d);
+ }
+
+ const result: Record<string, SelectableElement[]> = {};
+ for (const d of denoms) {
+ result[d.exchangeBaseUrl] = rankDenominationForWithdrawals(
+ groupByExchange[d.exchangeBaseUrl],
+ TransactionAmountMode.Raw,
+ );
+ }
+ return result;
+}
+
+interface SelectableElement {
+ total: number;
+ value: AmountJson;
+ info: CoinInfo;
+}
+
+function selectGreedyCoins(
+ coins: SelectableElement[],
+ limit: AmountJson,
+): SelectedCoins {
+ const result: SelectedCoins = {
+ totalValue: Amounts.zeroOfCurrency(limit.currency),
+ coins: [],
+ };
+ if (!coins.length) return result;
+
+ let denomIdx = 0;
+ iterateDenoms: while (denomIdx < coins.length) {
+ const denom = coins[denomIdx];
+ // let total = denom.total;
+ const left = Amounts.sub(limit, result.totalValue).amount;
+
+ if (Amounts.isZero(denom.value)) {
+ // 0 contribution denoms should be the last
+ break iterateDenoms;
+ }
+
+ //use Amounts.divmod instead of iterate
+ const div = Amounts.divmod(left, denom.value);
+ const size = Math.min(div.quotient, denom.total);
+ if (size > 0) {
+ const mul = Amounts.mult(denom.value, size).amount;
+ const progress = Amounts.add(result.totalValue, mul).amount;
+
+ result.totalValue = progress;
+ result.coins.push({ info: denom.info, size });
+ denom.total = denom.total - size;
+ }
+
+ //go next denom
+ denomIdx++;
+ }
+
+ return result;
+}
+
+type AmountWithFee = { raw: AmountJson; effective: AmountJson };
+type AmountAndRefresh = AmountWithFee & { refresh?: RefreshChoice };
+
+export function getTotalEffectiveAndRawForDeposit(
+ list: { info: CoinInfo; size: number }[],
+ currency: string,
+): AmountWithFee {
+ const init = {
+ raw: Amounts.zeroOfCurrency(currency),
+ effective: Amounts.zeroOfCurrency(currency),
+ };
+ return list.reduce((prev, cur) => {
+ const ef = Amounts.mult(cur.info.value, cur.size).amount;
+ const rw = Amounts.mult(
+ Amounts.sub(cur.info.value, cur.info.denomDeposit).amount,
+ cur.size,
+ ).amount;
+
+ prev.effective = Amounts.add(prev.effective, ef).amount;
+ prev.raw = Amounts.add(prev.raw, rw).amount;
+ return prev;
+ }, init);
+}
+
+function getTotalEffectiveAndRawForWithdrawal(
+ list: { info: CoinInfo; size: number }[],
+ currency: string,
+): AmountWithFee {
+ const init = {
+ raw: Amounts.zeroOfCurrency(currency),
+ effective: Amounts.zeroOfCurrency(currency),
+ };
+ return list.reduce((prev, cur) => {
+ const ef = Amounts.mult(cur.info.value, cur.size).amount;
+ const rw = Amounts.mult(
+ Amounts.add(cur.info.value, cur.info.denomWithdraw).amount,
+ cur.size,
+ ).amount;
+
+ prev.effective = Amounts.add(prev.effective, ef).amount;
+ prev.raw = Amounts.add(prev.raw, rw).amount;
+ return prev;
+ }, init);
+}
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
index b967571d0..bff4442b6 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -277,13 +277,6 @@ import {
import { PendingTaskInfo, PendingTaskType } from "./pending-types.js";
import { assertUnreachable } from "./util/assertUnreachable.js";
import {
- convertDepositAmount,
- convertPeerPushAmount,
- convertWithdrawalAmount,
- getMaxDepositAmount,
- getMaxPeerPushAmount,
-} from "./util/coinSelection.js";
-import {
createTimeline,
selectBestForOverlappingDenominations,
selectMinimumFee,
@@ -313,6 +306,13 @@ import {
WalletCoreApiClient,
WalletCoreResponseType,
} from "./wallet-api-types.js";
+import {
+ convertDepositAmount,
+ getMaxDepositAmount,
+ convertPeerPushAmount,
+ getMaxPeerPushAmount,
+ convertWithdrawalAmount,
+} from "./util/instructedAmountConversion.js";
const logger = new Logger("wallet.ts");