aboutsummaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-common.ts12
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.ts512
-rw-r--r--packages/taler-wallet-core/src/wallet-api-types.ts10
-rw-r--r--packages/taler-wallet-core/src/wallet.ts14
4 files changed, 534 insertions, 14 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 717b25f49..72e48cb03 100644
--- a/packages/taler-wallet-core/src/operations/pay-peer-common.ts
+++ b/packages/taler-wallet-core/src/operations/pay-peer-common.ts
@@ -52,7 +52,11 @@ import { InternalWalletState } from "../internal-wallet-state.js";
import { checkDbInvariant } from "../util/invariants.js";
import { getPeerPaymentBalanceDetailsInTx } from "./balance.js";
import { getTotalRefreshCost } from "./refresh.js";
-import { OperationAttemptLongpollResult, OperationAttemptResult, OperationAttemptResultType } from "../util/retries.js";
+import {
+ OperationAttemptLongpollResult,
+ OperationAttemptResult,
+ OperationAttemptResultType,
+} from "../util/retries.js";
const logger = new Logger("operations/peer-to-peer.ts");
@@ -113,6 +117,12 @@ export type SelectPeerCoinsResult =
insufficientBalanceDetails: PayPeerInsufficientBalanceDetails;
};
+/**
+ * Get information about the coin selected for signatures
+ * @param ws
+ * @param csel
+ * @returns
+ */
export async function queryCoinInfosForSelection(
ws: InternalWalletState,
csel: PeerPushPaymentCoinSelection,
diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts
index f4066bf51..8fd09ea2b 100644
--- a/packages/taler-wallet-core/src/util/coinSelection.ts
+++ b/packages/taler-wallet-core/src/util/coinSelection.ts
@@ -30,29 +30,29 @@ import {
AgeRestriction,
AmountJson,
Amounts,
+ AmountString,
CoinStatus,
DenominationInfo,
DenominationPubKey,
DenomSelectionState,
ForcedCoinSel,
ForcedDenomSel,
+ GetPlanForOperationRequest,
+ GetPlanForOperationResponse,
j2s,
Logger,
parsePaytoUri,
PayCoinSelection,
PayMerchantInsufficientBalanceDetails,
strcmp,
+ TransactionType,
} from "@gnu-taler/taler-util";
import {
AllowedAuditorInfo,
AllowedExchangeInfo,
DenominationRecord,
} from "../db.js";
-import {
- getExchangeDetails,
- isWithdrawableDenom,
- WalletConfig,
-} from "../index.js";
+import { getExchangeDetails, isWithdrawableDenom } from "../index.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { getMerchantPaymentBalanceDetails } from "../operations/balance.js";
import { checkDbInvariant, checkLogicInvariant } from "./invariants.js";
@@ -150,7 +150,7 @@ export interface CoinSelectionTally {
/**
* Account for the fees of spending a coin.
*/
-export function tallyFees(
+function tallyFees(
tally: Readonly<CoinSelectionTally>,
wireFeesPerExchange: Record<string, AmountJson>,
wireFeeAmortization: number,
@@ -542,7 +542,7 @@ export type AvailableDenom = DenominationInfo & {
numAvailable: number;
};
-export async function selectCandidates(
+async function selectCandidates(
ws: InternalWalletState,
req: SelectPayCoinRequestNg,
): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
@@ -789,3 +789,501 @@ export function selectForcedWithdrawalDenominations(
totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
};
}
+
+/**
+ * simulate a coin selection and return the amount
+ * that will effectively change the wallet balance and
+ * the raw amount of the operation
+ *
+ * @param ws
+ * @param br
+ * @returns
+ */
+export async function getPlanForOperation(
+ ws: InternalWalletState,
+ req: GetPlanForOperationRequest,
+): Promise<GetPlanForOperationResponse> {
+ const amount = Amounts.parseOrThrow(req.instructedAmount);
+
+ switch (req.type) {
+ case TransactionType.Withdrawal: {
+ const availableCoins = await getAvailableCoins(
+ ws,
+ "credit",
+ amount.currency,
+ false,
+ false,
+ undefined,
+ undefined,
+ undefined,
+ );
+ const usableCoins = selectCoinForOperation(
+ "credit",
+ amount,
+ req.mode === "effective" ? "net" : "gross",
+ availableCoins.denoms,
+ );
+
+ return getAmountsWithFee(
+ "credit",
+ usableCoins.totalValue,
+ usableCoins.totalContribution,
+ usableCoins,
+ );
+ }
+ case TransactionType.Deposit: {
+ const payto = parsePaytoUri(req.account)!;
+ const availableCoins = await getAvailableCoins(
+ ws,
+ "debit",
+ amount.currency,
+ true,
+ false,
+ undefined,
+ [payto.targetType],
+ undefined,
+ );
+ //FIXME: just doing for 1 exchange now
+ //assuming that the wallet has one exchange and all the coins available
+ //are from that exchange
+
+ const wireFee = Object.entries(availableCoins.wfPerExchange)[0][1][
+ payto.targetType
+ ];
+
+ let usableCoins;
+
+ if (req.mode === "effective") {
+ usableCoins = selectCoinForOperation(
+ "debit",
+ amount,
+ "gross",
+ availableCoins.denoms,
+ );
+
+ usableCoins.totalContribution = Amounts.stringify(
+ Amounts.sub(usableCoins.totalContribution, wireFee).amount,
+ );
+ } else {
+ const adjustedAmount = Amounts.add(amount, wireFee).amount;
+
+ usableCoins = selectCoinForOperation(
+ "debit",
+ adjustedAmount,
+ // amount,
+ "net",
+ availableCoins.denoms,
+ );
+
+ usableCoins.totalContribution = Amounts.stringify(
+ Amounts.sub(usableCoins.totalContribution, wireFee).amount,
+ );
+ }
+
+ return getAmountsWithFee(
+ "debit",
+ usableCoins.totalValue,
+ usableCoins.totalContribution,
+ usableCoins,
+ );
+ }
+ default: {
+ throw Error("operation not supported");
+ }
+ }
+}
+
+function getAmountsWithFee(
+ op: "debit" | "credit",
+ value: AmountString,
+ contribution: AmountString,
+ details: any,
+): GetPlanForOperationResponse {
+ return {
+ rawAmount: op === "credit" ? value : contribution,
+ effectiveAmount: op === "credit" ? contribution : value,
+ details,
+ };
+}
+
+/**
+ *
+ * @param op defined which fee are we taking into consideration: deposits or withdraw
+ * @param limit the total amount limit of the operation
+ * @param mode if the total amount is includes the fees or just the contribution
+ * @param denoms list of available denomination for the operation
+ * @returns
+ */
+function selectCoinForOperation(
+ op: "debit" | "credit",
+ limit: AmountJson,
+ mode: "net" | "gross",
+ denoms: AvailableDenom[],
+): SelectedCoins {
+ const result: SelectedCoins = {
+ totalValue: Amounts.stringify(Amounts.zeroOfCurrency(limit.currency)),
+ totalWithdrawalFee: Amounts.stringify(
+ Amounts.zeroOfCurrency(limit.currency),
+ ),
+ totalDepositFee: Amounts.stringify(Amounts.zeroOfCurrency(limit.currency)),
+ totalContribution: Amounts.stringify(
+ Amounts.zeroOfCurrency(limit.currency),
+ ),
+ coins: [],
+ };
+ if (!denoms.length) return result;
+ /**
+ * We can make this faster. We should prevent sorting and
+ * keep the information ready for multiple calls since this
+ * function is expected to work on embedded devices and
+ * create a response on key press
+ */
+
+ //rank coins
+ denoms.sort(
+ op === "credit"
+ ? denomsByDescendingWithdrawContribution
+ : denomsByDescendingDepositContribution,
+ );
+
+ //take coins in order until amount
+ let selectedCoinsAreEnough = false;
+ let denomIdx = 0;
+ iterateDenoms: while (denomIdx < denoms.length) {
+ const cur = denoms[denomIdx];
+ // for (const cur of denoms) {
+ let total = op === "credit" ? Number.MAX_SAFE_INTEGER : cur.numAvailable;
+ const opFee = op === "credit" ? cur.feeWithdraw : cur.feeDeposit;
+ const contribution = Amounts.sub(cur.value, opFee).amount;
+
+ if (Amounts.isZero(contribution)) {
+ // 0 contribution denoms should be the last
+ break iterateDenoms;
+ }
+ iterateCoins: while (total > 0) {
+ const nextValue = Amounts.add(result.totalValue, cur.value).amount;
+
+ const nextContribution = Amounts.add(
+ result.totalContribution,
+ contribution,
+ ).amount;
+
+ const progress = mode === "gross" ? nextValue : nextContribution;
+
+ if (Amounts.cmp(progress, limit) === 1) {
+ //the current coin is more than we need, try next denom
+ break iterateCoins;
+ }
+
+ result.totalValue = Amounts.stringify(nextValue);
+ result.totalContribution = Amounts.stringify(nextContribution);
+
+ result.totalDepositFee = Amounts.stringify(
+ Amounts.add(result.totalDepositFee, cur.feeDeposit).amount,
+ );
+
+ result.totalWithdrawalFee = Amounts.stringify(
+ Amounts.add(result.totalWithdrawalFee, cur.feeWithdraw).amount,
+ );
+
+ result.coins.push(cur.denomPubHash);
+
+ if (Amounts.cmp(progress, limit) === 0) {
+ selectedCoinsAreEnough = true;
+ // we have just enough coins, complete
+ break iterateDenoms;
+ }
+
+ //go next coin
+ total--;
+ }
+ //go next denom
+ denomIdx++;
+ }
+
+ if (selectedCoinsAreEnough) {
+ // we made it
+ return result;
+ }
+ if (op === "credit") {
+ //doing withdraw there is no way to cover the gap
+ return result;
+ }
+ //tried all the coins but there is a gap
+ //doing deposit we can try refreshing coins
+
+ const total = mode === "gross" ? result.totalValue : result.totalContribution;
+ const gap = Amounts.sub(limit, total).amount;
+
+ //about recursive calls
+ //the only way to get here is by doing a deposit (that will do a refresh)
+ //and now we are calculating fee for credit (which does not need to calculate refresh)
+
+ let refreshIdx = 0;
+ let choice: RefreshChoice | undefined = undefined;
+ refreshIteration: while (refreshIdx < denoms.length) {
+ const d = denoms[refreshIdx];
+ const denomContribution =
+ mode === "gross"
+ ? Amounts.sub(d.value, d.feeRefresh).amount
+ : Amounts.sub(d.value, d.feeDeposit, d.feeRefresh).amount;
+
+ const changeAfterDeposit = Amounts.sub(denomContribution, gap).amount;
+ if (Amounts.isZero(changeAfterDeposit)) {
+ //the rest of the coins are very small
+ break refreshIteration;
+ }
+
+ const changeCost = selectCoinForOperation(
+ "credit",
+ changeAfterDeposit,
+ mode,
+ denoms,
+ );
+ const totalFee = Amounts.add(
+ d.feeDeposit,
+ d.feeRefresh,
+ changeCost.totalWithdrawalFee,
+ ).amount;
+
+ if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) {
+ //found cheaper change
+ choice = {
+ gap: Amounts.stringify(gap),
+ totalFee: Amounts.stringify(totalFee),
+ selected: d.denomPubHash,
+ totalValue: d.value,
+ totalRefreshFee: Amounts.stringify(d.feeRefresh),
+ totalDepositFee: d.feeDeposit,
+ totalChangeValue: Amounts.stringify(changeCost.totalValue),
+ totalChangeContribution: Amounts.stringify(
+ changeCost.totalContribution,
+ ),
+ totalChangeWithdrawalFee: Amounts.stringify(
+ changeCost.totalWithdrawalFee,
+ ),
+ change: changeCost.coins,
+ };
+ }
+ refreshIdx++;
+ }
+ if (choice) {
+ if (mode === "gross") {
+ result.totalValue = Amounts.stringify(
+ Amounts.add(result.totalValue, gap).amount,
+ );
+ result.totalContribution = Amounts.stringify(
+ Amounts.add(result.totalContribution, gap).amount,
+ );
+ result.totalContribution = Amounts.stringify(
+ Amounts.sub(result.totalContribution, choice.totalFee).amount,
+ );
+ } else {
+ result.totalContribution = Amounts.stringify(
+ Amounts.add(result.totalContribution, gap).amount,
+ );
+ result.totalValue = Amounts.stringify(
+ Amounts.add(result.totalValue, gap, choice.totalFee).amount,
+ );
+ }
+ }
+
+ // console.log("gap", Amounts.stringify(limit), Amounts.stringify(gap), choice);
+ result.refresh = choice;
+ return result;
+}
+
+function denomsByDescendingDepositContribution(
+ d1: AvailableDenom,
+ d2: AvailableDenom,
+) {
+ const contrib1 = Amounts.sub(d1.value, d1.feeDeposit).amount;
+ const contrib2 = Amounts.sub(d2.value, d2.feeDeposit).amount;
+ return (
+ Amounts.cmp(contrib2, contrib1) || strcmp(d1.denomPubHash, d2.denomPubHash)
+ );
+}
+function denomsByDescendingWithdrawContribution(
+ d1: AvailableDenom,
+ d2: AvailableDenom,
+) {
+ const contrib1 = Amounts.sub(d1.value, d1.feeWithdraw).amount;
+ const contrib2 = Amounts.sub(d2.value, d2.feeWithdraw).amount;
+ return (
+ Amounts.cmp(contrib2, contrib1) || strcmp(d1.denomPubHash, d2.denomPubHash)
+ );
+}
+
+interface RefreshChoice {
+ gap: AmountString;
+ totalFee: AmountString;
+ selected: string;
+
+ totalValue: AmountString;
+ totalDepositFee: AmountString;
+ totalRefreshFee: AmountString;
+ totalChangeValue: AmountString;
+ totalChangeContribution: AmountString;
+ totalChangeWithdrawalFee: AmountString;
+ change: string[];
+}
+
+interface SelectedCoins {
+ totalValue: AmountString;
+ totalContribution: AmountString;
+ totalWithdrawalFee: AmountString;
+ totalDepositFee: AmountString;
+ coins: string[];
+ refresh?: RefreshChoice;
+}
+
+/**
+ * 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 getAvailableCoins(
+ ws: InternalWalletState,
+ op: "credit" | "debit",
+ currency: string,
+ shouldCalculateWireFee: boolean,
+ shouldCalculatePurseFee: boolean,
+ exchangeFilter: string[] | undefined,
+ wireMethodFilter: string[] | undefined,
+ ageRestrictedFilter: number | undefined,
+) {
+ return await ws.db
+ .mktx((x) => [
+ x.exchanges,
+ x.exchangeDetails,
+ x.denominations,
+ x.coinAvailability,
+ ])
+ .runReadOnly(async (tx) => {
+ const denoms: AvailableDenom[] = [];
+ const wfPerExchange: Record<string, Record<string, AmountJson>> = {};
+ const pfPerExchange: Record<string, AmountJson> = {};
+
+ const databaseExchanges = await tx.exchanges.iter().toArray();
+ const exchanges =
+ exchangeFilter === undefined
+ ? databaseExchanges.map((e) => e.baseUrl)
+ : exchangeFilter;
+
+ for (const exchangeBaseUrl of exchanges) {
+ const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl);
+ // 1.- exchange has same currency
+ if (exchangeDetails?.currency !== currency) {
+ continue;
+ }
+
+ const wireMethodFee: Record<string, AmountJson> = {};
+ // 2.- exchange supports wire method
+ if (shouldCalculateWireFee) {
+ for (const acc of exchangeDetails.wireInfo.accounts) {
+ const pp = parsePaytoUri(acc.payto_uri);
+ checkLogicInvariant(!!pp);
+ // also check that wire method is supported now
+ if (wireMethodFilter !== undefined) {
+ if (wireMethodFilter.indexOf(pp.targetType) === -1) {
+ continue;
+ }
+ }
+ const wireFeeStr = exchangeDetails.wireInfo.feesForType[
+ pp.targetType
+ ]?.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ })?.wireFee;
+
+ if (wireFeeStr) {
+ wireMethodFee[pp.targetType] = Amounts.parseOrThrow(wireFeeStr);
+ }
+ break;
+ }
+ if (Object.keys(wireMethodFee).length === 0) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`,
+ );
+ }
+ }
+ wfPerExchange[exchangeBaseUrl] = wireMethodFee;
+
+ // 3.- exchange supports wire method
+ if (shouldCalculatePurseFee) {
+ const purseFeeFound = exchangeDetails.globalFees.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startDate),
+ AbsoluteTime.fromProtocolTimestamp(x.endDate),
+ );
+ })?.purseFee;
+ if (!purseFeeFound) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`,
+ );
+ }
+ const purseFee = Amounts.parseOrThrow(purseFeeFound);
+ pfPerExchange[exchangeBaseUrl] = purseFee;
+ }
+
+ //4.- filter coins restricted by age
+ if (op === "credit") {
+ const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const denom of ds) {
+ denoms.push({
+ ...DenominationRecord.toDenomInfo(denom),
+ numAvailable: Number.MAX_SAFE_INTEGER,
+ maxAge: AgeRestriction.AGE_UNRESTRICTED,
+ });
+ }
+ } else {
+ const ageLower = !ageRestrictedFilter ? 0 : ageRestrictedFilter;
+ 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;
+ }
+ denoms.push({
+ ...DenominationRecord.toDenomInfo(denom),
+ numAvailable: coinAvail.freshCoinCount ?? 0,
+ maxAge: coinAvail.maxAge,
+ });
+ }
+ }
+ }
+
+ return {
+ denoms,
+ wfPerExchange,
+ pfPerExchange,
+ };
+ });
+}
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
index 21a228b64..3b0d11039 100644
--- a/packages/taler-wallet-core/src/wallet-api-types.ts
+++ b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -58,6 +58,8 @@ import {
GetContractTermsDetailsRequest,
GetExchangeTosRequest,
GetExchangeTosResult,
+ GetPlanForOperationRequest,
+ GetPlanForOperationResponse,
GetWithdrawalDetailsForAmountRequest,
GetWithdrawalDetailsForUriRequest,
InitRequest,
@@ -143,6 +145,7 @@ export enum WalletApiOperation {
AcceptManualWithdrawal = "acceptManualWithdrawal",
GetBalances = "getBalances",
GetBalanceDetail = "getBalanceDetail",
+ GetPlanForOperation = "getPlanForOperation",
GetUserAttentionRequests = "getUserAttentionRequests",
GetUserAttentionUnreadCount = "getUserAttentionUnreadCount",
MarkAttentionRequestAsRead = "markAttentionRequestAsRead",
@@ -275,6 +278,12 @@ export type GetBalancesDetailOp = {
response: MerchantPaymentBalanceDetails;
};
+export type GetPlanForOperationOp = {
+ op: WalletApiOperation.GetPlanForOperation;
+ request: GetPlanForOperationRequest;
+ response: GetPlanForOperationResponse;
+};
+
// group: Managing Transactions
/**
@@ -940,6 +949,7 @@ export type WalletOperations = {
[WalletApiOperation.SuspendTransaction]: SuspendTransactionOp;
[WalletApiOperation.ResumeTransaction]: ResumeTransactionOp;
[WalletApiOperation.GetBalances]: GetBalancesOp;
+ [WalletApiOperation.GetPlanForOperation]: GetPlanForOperationOp;
[WalletApiOperation.GetBalanceDetail]: GetBalancesDetailOp;
[WalletApiOperation.GetTransactions]: GetTransactionsOp;
[WalletApiOperation.TestingGetSampleTransactions]: TestingGetSampleTransactionsOp;
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
index 5277916de..a04464630 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -75,6 +75,7 @@ import {
codecForGetBalanceDetailRequest,
codecForGetContractTermsDetails,
codecForGetExchangeTosRequest,
+ codecForGetPlanForOperationRequest,
codecForGetWithdrawalDetailsForAmountRequest,
codecForGetWithdrawalDetailsForUri,
codecForImportDbRequest,
@@ -218,9 +219,7 @@ import {
processPeerPushDebit,
} from "./operations/pay-peer-push-debit.js";
import { getPendingOperations } from "./operations/pending.js";
-import {
- createRecoupGroup, processRecoupGroup,
-} from "./operations/recoup.js";
+import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js";
import {
autoRefresh,
createRefreshGroup,
@@ -283,6 +282,7 @@ import {
WalletCoreApiClient,
WalletCoreResponseType,
} from "./wallet-api-types.js";
+import { getPlanForOperation } from "./util/coinSelection.js";
const logger = new Logger("wallet.ts");
@@ -331,9 +331,7 @@ async function callOperationHandler(
/**
* Process pending operations.
*/
-export async function runPending(
- ws: InternalWalletState,
-): Promise<void> {
+export async function runPending(ws: InternalWalletState): Promise<void> {
const pendingOpsResponse = await getPendingOperations(ws);
for (const p of pendingOpsResponse.pendingOperations) {
if (!AbsoluteTime.isExpired(p.timestampDue)) {
@@ -1336,6 +1334,10 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
await loadBackupRecovery(ws, req);
return {};
}
+ case WalletApiOperation.GetPlanForOperation: {
+ const req = codecForGetPlanForOperationRequest().decode(payload);
+ return await getPlanForOperation(ws, req);
+ }
case WalletApiOperation.GetBackupInfo: {
const resp = await getBackupInfo(ws);
return resp;