From f57dc7bf7a1e3a14c67512ba67d92fa350c95c0e Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Fri, 10 Jun 2022 13:03:47 +0200 Subject: wallet-core: implement and test forced coin/denom selection --- packages/taler-util/src/walletTypes.ts | 80 +++++++++--- .../src/integrationtests/test-forced-selection.ts | 94 ++++++++++++++ .../src/integrationtests/testrunner.ts | 2 + packages/taler-wallet-core/src/db.ts | 2 +- .../src/operations/backup/import.ts | 3 +- .../taler-wallet-core/src/operations/deposits.ts | 3 +- packages/taler-wallet-core/src/operations/pay.ts | 37 ++++-- .../taler-wallet-core/src/operations/reserves.ts | 108 +++++++++------- .../taler-wallet-core/src/operations/testing.ts | 76 ++++++++---- .../src/util/coinSelection.test.ts | 2 + .../taler-wallet-core/src/util/coinSelection.ts | 138 ++++++++++++++++----- packages/taler-wallet-core/src/util/retries.ts | 2 +- packages/taler-wallet-core/src/wallet-api-types.ts | 11 +- packages/taler-wallet-core/src/wallet.ts | 78 ++++++------ 14 files changed, 463 insertions(+), 173 deletions(-) create mode 100644 packages/taler-wallet-cli/src/integrationtests/test-forced-selection.ts diff --git a/packages/taler-util/src/walletTypes.ts b/packages/taler-util/src/walletTypes.ts index 00a489861..2e5dd418d 100644 --- a/packages/taler-util/src/walletTypes.ts +++ b/packages/taler-util/src/walletTypes.ts @@ -33,7 +33,6 @@ import { codecForAmountString, } from "./amounts.js"; import { - AbsoluteTime, codecForTimestamp, TalerProtocolTimestamp, } from "./time.js"; @@ -231,6 +230,7 @@ export const codecForCreateReserveRequest = (): Codec => .property("exchangePaytoUri", codecForString()) .property("senderWire", codecOptional(codecForString())) .property("bankWithdrawStatusUrl", codecOptional(codecForString())) + .property("forcedDenomSel", codecForAny()) .build("CreateReserveRequest"); /** @@ -674,6 +674,7 @@ export interface TestPayArgs { merchantAuthToken?: string; amount: string; summary: string; + forcedCoinSel?: ForcedCoinSel; } export const codecForTestPayArgs = (): Codec => @@ -682,6 +683,7 @@ export const codecForTestPayArgs = (): Codec => .property("merchantAuthToken", codecOptional(codecForString())) .property("amount", codecForString()) .property("summary", codecForString()) + .property("forcedCoinSel", codecForAny()) .build("TestPayArgs"); export interface IntegrationTestArgs { @@ -738,7 +740,7 @@ export const codecForGetExchangeTosRequest = (): Codec => export interface AcceptManualWithdrawalRequest { exchangeBaseUrl: string; amount: string; - restrictAge?: number, + restrictAge?: number; } export const codecForAcceptManualWithdrawalRequet = @@ -803,10 +805,11 @@ export interface ApplyRefundFromPurchaseIdRequest { purchaseId: string; } -export const codecForApplyRefundFromPurchaseIdRequest = (): Codec => - buildCodecForObject() - .property("purchaseId", codecForString()) - .build("ApplyRefundFromPurchaseIdRequest"); +export const codecForApplyRefundFromPurchaseIdRequest = + (): Codec => + buildCodecForObject() + .property("purchaseId", codecForString()) + .build("ApplyRefundFromPurchaseIdRequest"); export interface GetWithdrawalDetailsForUriRequest { talerWithdrawUri: string; @@ -866,12 +869,14 @@ export const codecForPreparePayRequest = (): Codec => export interface ConfirmPayRequest { proposalId: string; sessionId?: string; + forcedCoinSel?: ForcedCoinSel; } export const codecForConfirmPayRequest = (): Codec => buildCodecForObject() .property("proposalId", codecForString()) .property("sessionId", codecOptional(codecForString())) + .property("forcedCoinSel", codecForAny()) .build("ConfirmPay"); export type CoreApiResponse = CoreApiResponseSuccess | CoreApiResponseError; @@ -903,6 +908,7 @@ export interface WithdrawTestBalanceRequest { amount: string; bankBaseUrl: string; exchangeBaseUrl: string; + forcedDenomSel?: ForcedDenomSel; } export const withdrawTestBalanceDefaults = { @@ -976,6 +982,7 @@ export const codecForWithdrawTestBalance = .property("amount", codecForString()) .property("bankBaseUrl", codecForString()) .property("exchangeBaseUrl", codecForString()) + .property("forcedDenomSel", codecForAny()) .build("WithdrawTestBalanceRequest"); export interface ApplyRefundResponse { @@ -1026,8 +1033,6 @@ export const codecForForceRefreshRequest = (): Codec => .property("coinPubList", codecForList(codecForString())) .build("ForceRefreshRequest"); - - export interface PrepareRefundRequest { talerRefundUri: string; } @@ -1084,14 +1089,12 @@ export const codecForGetFeeForDeposit = (): Codec => export interface PrepareDepositRequest { depositPaytoUri: string; amount: AmountString; - } -export const codecForPrepareDepositRequest = - (): Codec => - buildCodecForObject() - .property("amount", codecForAmountString()) - .property("depositPaytoUri", codecForString()) - .build("PrepareDepositRequest"); +export const codecForPrepareDepositRequest = (): Codec => + buildCodecForObject() + .property("amount", codecForAmountString()) + .property("depositPaytoUri", codecForString()) + .build("PrepareDepositRequest"); export interface PrepareDepositResponse { totalDepositCost: AmountJson; @@ -1203,6 +1206,7 @@ export const codecForWithdrawFakebankRequest = export interface ImportDb { dump: any; } + export const codecForImportDbRequest = (): Codec => buildCodecForObject() .property("dump", codecForAny()) @@ -1214,3 +1218,49 @@ export interface ForcedDenomSel { count: number; }[]; } + +/** + * Forced coin selection for deposits/payments. + */ +export interface ForcedCoinSel { + coins: { + value: AmountString; + contribution: AmountString; + }[]; +} + +export interface TestPayResult { + payCoinSelection: PayCoinSelection, +} + + +/** + * Result of selecting coins, contains the exchange, and selected + * coins with their denomination. + */ + export interface PayCoinSelection { + /** + * Amount requested by the merchant. + */ + paymentAmount: AmountJson; + + /** + * Public keys of the coins that were selected. + */ + coinPubs: string[]; + + /** + * Amount that each coin contributes. + */ + coinContributions: AmountJson[]; + + /** + * How much of the wire fees is the customer paying? + */ + customerWireFees: AmountJson; + + /** + * How much of the deposit fees is the customer paying? + */ + customerDepositFees: AmountJson; +} \ No newline at end of file diff --git a/packages/taler-wallet-cli/src/integrationtests/test-forced-selection.ts b/packages/taler-wallet-cli/src/integrationtests/test-forced-selection.ts new file mode 100644 index 000000000..0fe5f639a --- /dev/null +++ b/packages/taler-wallet-cli/src/integrationtests/test-forced-selection.ts @@ -0,0 +1,94 @@ +/* + This file is part of GNU Taler + (C) 2020 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 + */ + +/** + * Imports. + */ +import { + ConfirmPayResultType, + j2s, + PreparePayResultType, +} from "@gnu-taler/taler-util"; +import { Wallet, WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { + GlobalTestState, + MerchantPrivateApi, + WithAuthorization, +} from "../harness/harness.js"; +import { createSimpleTestkudosEnvironment } from "../harness/helpers.js"; + +/** + * Run test for forced denom/coin selection. + */ +export async function runForcedSelectionTest(t: GlobalTestState) { + // Set up test environment + + const { wallet, bank, exchange, merchant } = + await createSimpleTestkudosEnvironment(t); + + await wallet.client.call(WalletApiOperation.AddExchange, { + exchangeBaseUrl: exchange.baseUrl, + }); + + await wallet.client.call(WalletApiOperation.WithdrawTestBalance, { + exchangeBaseUrl: exchange.baseUrl, + amount: "TESTKUDOS:10", + bankBaseUrl: bank.baseUrl, + forcedDenomSel: { + denoms: [ + { + value: "TESTKUDOS:2", + count: 3, + }, + ], + }, + }); + + await wallet.runUntilDone(); + + const coinDump = await wallet.client.call(WalletApiOperation.DumpCoins, {}); + console.log(coinDump); + t.assertDeepEqual(coinDump.coins.length, 3); + + const payResp = await wallet.client.call(WalletApiOperation.TestPay, { + amount: "TESTKUDOS:3", + merchantBaseUrl: merchant.makeInstanceBaseUrl(), + summary: "bla", + forcedCoinSel: { + coins: [ + { + value: "TESTKUDOS:2", + contribution: "TESTKUDOS:1", + }, + { + value: "TESTKUDOS:2", + contribution: "TESTKUDOS:1", + }, + { + value: "TESTKUDOS:2", + contribution: "TESTKUDOS:1", + }, + ], + }, + }); + + console.log(j2s(payResp)); + + // Without forced selection, we would only use 2 coins. + t.assertDeepEqual(payResp.payCoinSelection.coinContributions.length, 3); +} + +runForcedSelectionTest.suites = ["wallet"]; diff --git a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts index db66aa7d7..e8aef5136 100644 --- a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts +++ b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts @@ -34,6 +34,7 @@ import { runDepositTest } from "./test-deposit"; import { runExchangeManagementTest } from "./test-exchange-management"; import { runExchangeTimetravelTest } from "./test-exchange-timetravel.js"; import { runFeeRegressionTest } from "./test-fee-regression"; +import { runForcedSelectionTest } from "./test-forced-selection.js"; import { runLibeufinApiBankaccountTest } from "./test-libeufin-api-bankaccount"; import { runLibeufinApiBankconnectionTest } from "./test-libeufin-api-bankconnection"; import { runLibeufinApiFacadeTest } from "./test-libeufin-api-facade"; @@ -113,6 +114,7 @@ const allTests: TestMainFunction[] = [ runExchangeManagementTest, runExchangeTimetravelTest, runFeeRegressionTest, + runForcedSelectionTest, runLibeufinBasicTest, runLibeufinKeyrotationTest, runLibeufinTutorialTest, diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 8fe1937aa..b22bc585e 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -41,9 +41,9 @@ import { TalerProtocolTimestamp, TalerProtocolDuration, AgeCommitmentProof, + PayCoinSelection, } from "@gnu-taler/taler-util"; import { RetryInfo } from "./util/retries.js"; -import { PayCoinSelection } from "./util/coinSelection.js"; import { Event, IDBDatabase } from "@gnu-taler/idb-bridge"; /** diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index 16a88fe7c..3a9121502 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -18,7 +18,7 @@ import { AmountJson, Amounts, BackupCoinSourceType, BackupDenomSel, BackupProposalStatus, BackupPurchase, BackupRefreshReason, BackupRefundState, codecForContractTerms, - DenomKeyType, j2s, Logger, RefreshReason, TalerProtocolTimestamp, + DenomKeyType, j2s, Logger, PayCoinSelection, RefreshReason, TalerProtocolTimestamp, WalletBackupContentV1 } from "@gnu-taler/taler-util"; import { @@ -29,7 +29,6 @@ import { ReserveRecordStatus, WalletContractData, WalletRefundItem, WalletStoresV1, WireInfo } from "../../db.js"; import { InternalWalletState } from "../../internal-wallet-state.js"; -import { PayCoinSelection } from "../../util/coinSelection.js"; import { checkDbInvariant, checkLogicInvariant diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts index 41f051cb3..a016cb8e5 100644 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -35,6 +35,7 @@ import { Logger, NotificationType, parsePaytoUri, + PayCoinSelection, PrepareDepositRequest, PrepareDepositResponse, TalerErrorDetail, @@ -45,7 +46,7 @@ import { } from "@gnu-taler/taler-util"; import { DepositGroupRecord, OperationStatus, WireFee } from "../db.js"; import { InternalWalletState } from "../internal-wallet-state.js"; -import { PayCoinSelection, selectPayCoins } from "../util/coinSelection.js"; +import { selectPayCoins } from "../util/coinSelection.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { RetryInfo } from "../util/retries.js"; import { guardOperationException } from "./common.js"; diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index f22d51a9d..b6bae7518 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -40,12 +40,14 @@ import { durationMin, durationMul, encodeCrock, + ForcedCoinSel, getRandomBytes, HttpStatusCode, j2s, Logger, NotificationType, parsePayUri, + PayCoinSelection, PreparePayResult, PreparePayResultType, RefreshReason, @@ -81,8 +83,8 @@ import { import { AvailableCoinInfo, CoinCandidateSelection, - PayCoinSelection, PreviousPayCoins, + selectForcedPayCoins, selectPayCoins, } from "../util/coinSelection.js"; import { ContractTermsUtil } from "../util/contractTerms.js"; @@ -305,6 +307,7 @@ export async function getCandidatePayCoins( } candidateCoins.push({ availableAmount: coin.currentAmount, + value: denom.value, coinPub: coin.coinPub, denomPub: denom.denomPub, feeDeposit: denom.feeDeposit, @@ -1423,6 +1426,7 @@ export async function confirmPay( ws: InternalWalletState, proposalId: string, sessionIdOverride?: string, + forcedCoinSel?: ForcedCoinSel, ): Promise { logger.trace( `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, @@ -1479,15 +1483,28 @@ export async function confirmPay( wireMethod: contractData.wireMethod, }); - const res = selectPayCoins({ - candidates, - contractTermsAmount: contractData.amount, - depositFeeLimit: contractData.maxDepositFee, - wireFeeAmortization: contractData.wireFeeAmortization ?? 1, - wireFeeLimit: contractData.maxWireFee, - prevPayCoins: [], - requiredMinimumAge: contractData.minimumAge, - }); + let res: PayCoinSelection | undefined = undefined; + + if (forcedCoinSel) { + res = selectForcedPayCoins(forcedCoinSel, { + candidates, + contractTermsAmount: contractData.amount, + depositFeeLimit: contractData.maxDepositFee, + wireFeeAmortization: contractData.wireFeeAmortization ?? 1, + wireFeeLimit: contractData.maxWireFee, + requiredMinimumAge: contractData.minimumAge, + }); + } else { + res = selectPayCoins({ + candidates, + contractTermsAmount: contractData.amount, + depositFeeLimit: contractData.maxDepositFee, + wireFeeAmortization: contractData.wireFeeAmortization ?? 1, + wireFeeLimit: contractData.maxWireFee, + prevPayCoins: [], + requiredMinimumAge: contractData.minimumAge, + }); + } logger.trace("coin selection result", res); diff --git a/packages/taler-wallet-core/src/operations/reserves.ts b/packages/taler-wallet-core/src/operations/reserves.ts index d9fc8cf45..b33f574f4 100644 --- a/packages/taler-wallet-core/src/operations/reserves.ts +++ b/packages/taler-wallet-core/src/operations/reserves.ts @@ -15,6 +15,7 @@ */ import { + AbsoluteTime, AcceptWithdrawalResponse, addPaytoQueryParams, Amounts, @@ -28,6 +29,7 @@ import { durationMax, durationMin, encodeCrock, + ForcedDenomSel, getRandomBytes, j2s, Logger, @@ -35,13 +37,10 @@ import { randomBytes, TalerErrorCode, TalerErrorDetail, - AbsoluteTime, URL, - AmountString, - ForcedDenomSel, } from "@gnu-taler/taler-util"; -import { InternalWalletState } from "../internal-wallet-state.js"; import { + DenomSelectionState, OperationStatus, ReserveBankInfo, ReserveRecord, @@ -50,6 +49,7 @@ import { WithdrawalGroupRecord, } from "../db.js"; import { TalerError } from "../errors.js"; +import { InternalWalletState } from "../internal-wallet-state.js"; import { assertUnreachable } from "../util/assertUnreachable.js"; import { readSuccessResponseJsonOrErrorCode, @@ -57,9 +57,8 @@ import { throwUnexpectedRequestError, } from "../util/http.js"; import { GetReadOnlyAccess } from "../util/query.js"; -import { - RetryInfo, -} from "../util/retries.js"; +import { RetryInfo } from "../util/retries.js"; +import { guardOperationException } from "./common.js"; import { getExchangeDetails, getExchangePaytoUri, @@ -70,10 +69,10 @@ import { getBankWithdrawalInfo, getCandidateWithdrawalDenoms, processWithdrawGroup, + selectForcedWithdrawalDenominations, selectWithdrawalDenominations, updateWithdrawalDenoms, } from "./withdraw.js"; -import { guardOperationException } from "./common.js"; const logger = new Logger("taler-wallet-core:reserves.ts"); @@ -178,7 +177,18 @@ export async function createReserve( await updateWithdrawalDenoms(ws, canonExchange); const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange); - const initialDenomSel = selectWithdrawalDenominations(req.amount, denoms); + + let initialDenomSel: DenomSelectionState; + if (req.forcedDenomSel) { + logger.warn("using forced denom selection"); + initialDenomSel = selectForcedWithdrawalDenominations( + req.amount, + denoms, + req.forcedDenomSel, + ); + } else { + initialDenomSel = selectWithdrawalDenominations(req.amount, denoms); + } const reserveRecord: ReserveRecord = { instructedAmount: req.amount, @@ -436,7 +446,7 @@ async function processReserveBankStatus( ); if (status.aborted) { - logger.trace("bank aborted the withdrawal"); + logger.info("bank aborted the withdrawal"); await ws.db .mktx((x) => ({ reserves: x.reserves, @@ -463,12 +473,14 @@ async function processReserveBankStatus( return; } - if (status.selection_done) { - if (reserve.reserveStatus === ReserveRecordStatus.RegisteringBank) { - await registerReserveWithBank(ws, reservePub); - return await processReserveBankStatus(ws, reservePub); - } - } else { + // Bank still needs to know our reserve info + if (!status.selection_done) { + await registerReserveWithBank(ws, reservePub); + return await processReserveBankStatus(ws, reservePub); + } + + // FIXME: Why do we do this?! + if (reserve.reserveStatus === ReserveRecordStatus.RegisteringBank) { await registerReserveWithBank(ws, reservePub); return await processReserveBankStatus(ws, reservePub); } @@ -482,29 +494,26 @@ async function processReserveBankStatus( if (!r) { return; } + // Re-check reserve status within transaction + switch (r.reserveStatus) { + case ReserveRecordStatus.RegisteringBank: + case ReserveRecordStatus.WaitConfirmBank: + break; + default: + return; + } if (status.transfer_done) { - switch (r.reserveStatus) { - case ReserveRecordStatus.RegisteringBank: - case ReserveRecordStatus.WaitConfirmBank: - break; - default: - return; - } const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); r.timestampBankConfirmed = now; r.reserveStatus = ReserveRecordStatus.QueryingStatus; r.operationStatus = OperationStatus.Pending; r.retryInfo = RetryInfo.reset(); } else { - switch (r.reserveStatus) { - case ReserveRecordStatus.WaitConfirmBank: - break; - default: - return; - } + logger.info("Withdrawal operation not yet confirmed by bank"); if (r.bankInfo) { r.bankInfo.confirmUrl = status.confirm_transfer_url; } + r.retryInfo = RetryInfo.increment(r.retryInfo); } await tx.reserves.put(r); }); @@ -540,6 +549,8 @@ async function updateReserve( const reserveUrl = new URL(`reserves/${reservePub}`, reserve.exchangeBaseUrl); reserveUrl.searchParams.set("timeout_ms", "30000"); + logger.info(`querying reserve status via ${reserveUrl}`); + const resp = await ws.http.get(reserveUrl.href, { timeout: getReserveRequestTimeout(reserve), }); @@ -553,7 +564,7 @@ async function updateReserve( if ( resp.status === 404 && result.talerErrorResponse.code === - TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN + TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN ) { ws.notify({ type: NotificationType.ReserveNotYetFound, @@ -589,6 +600,7 @@ async function updateReserve( if (!newReserve) { return; } + let amountReservePlus = reserveBalance; let amountReserveMinus = Amounts.getZero(currency); @@ -628,30 +640,33 @@ async function updateReserve( amountReservePlus, amountReserveMinus, ).amount; - const denomSel = selectWithdrawalDenominations(remainingAmount, denoms); - - logger.trace( - `Remaining unclaimed amount in reseve is ${Amounts.stringify( - remainingAmount, - )} and can be withdrawn with ${denomSel.selectedDenoms.length} coins`, - ); - - if (denomSel.selectedDenoms.length === 0) { - newReserve.reserveStatus = ReserveRecordStatus.Dormant; - newReserve.operationStatus = OperationStatus.Finished; - delete newReserve.lastError; - delete newReserve.retryInfo; - await tx.reserves.put(newReserve); - return; - } let withdrawalGroupId: string; + let denomSel: DenomSelectionState; if (!newReserve.initialWithdrawalStarted) { withdrawalGroupId = newReserve.initialWithdrawalGroupId; newReserve.initialWithdrawalStarted = true; + denomSel = newReserve.initialDenomSel; } else { withdrawalGroupId = encodeCrock(randomBytes(32)); + + denomSel = selectWithdrawalDenominations(remainingAmount, denoms); + + logger.trace( + `Remaining unclaimed amount in reseve is ${Amounts.stringify( + remainingAmount, + )} and can be withdrawn with ${denomSel.selectedDenoms.length} coins`, + ); + + if (denomSel.selectedDenoms.length === 0) { + newReserve.reserveStatus = ReserveRecordStatus.Dormant; + newReserve.operationStatus = OperationStatus.Finished; + delete newReserve.lastError; + delete newReserve.retryInfo; + await tx.reserves.put(newReserve); + return; + } } const withdrawalRecord: WithdrawalGroupRecord = { @@ -768,6 +783,7 @@ export async function createTalerWithdrawReserve( senderWire: withdrawInfo.senderWire, exchangePaytoUri: exchangePaytoUri, restrictAge: options.restrictAge, + forcedDenomSel: options.forcedDenomSel, }); // We do this here, as the reserve should be registered before we return, // so that we can redirect the user to the bank's status page. diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts index 555e2d73d..d609011ca 100644 --- a/packages/taler-wallet-core/src/operations/testing.ts +++ b/packages/taler-wallet-core/src/operations/testing.ts @@ -17,7 +17,12 @@ /** * Imports. */ -import { Logger } from "@gnu-taler/taler-util"; +import { + ConfirmPayResultType, + Logger, + TestPayResult, + WithdrawTestBalanceRequest, +} from "@gnu-taler/taler-util"; import { HttpRequestLibrary, readSuccessResponseJsonOrThrow, @@ -39,6 +44,7 @@ import { InternalWalletState } from "../internal-wallet-state.js"; import { confirmPay, preparePayForUri } from "./pay.js"; import { getBalances } from "./balance.js"; import { applyRefund } from "./refund.js"; +import { checkLogicInvariant } from "../util/invariants.js"; const logger = new Logger("operations/testing.ts"); @@ -82,10 +88,12 @@ function makeBasicAuthHeader(username: string, password: string): string { export async function withdrawTestBalance( ws: InternalWalletState, - amount = "TESTKUDOS:10", - bankBaseUrl = "https://bank.test.taler.net/", - exchangeBaseUrl = "https://exchange.test.taler.net/", + req: WithdrawTestBalanceRequest, ): Promise { + const bankBaseUrl = req.bankBaseUrl; + const amount = req.amount; + const exchangeBaseUrl = req.exchangeBaseUrl; + const bankUser = await registerRandomBankUser(ws.http, bankBaseUrl); logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`); @@ -100,6 +108,9 @@ export async function withdrawTestBalance( ws, wresp.taler_withdraw_uri, exchangeBaseUrl, + { + forcedDenomSel: req.forcedDenomSel, + }, ); await confirmBankWithdrawalUri( @@ -140,7 +151,10 @@ export async function createDemoBankWithdrawalUri( }, { headers: { - Authorization: makeBasicAuthHeader(bankUser.username, bankUser.password), + Authorization: makeBasicAuthHeader( + bankUser.username, + bankUser.password, + ), }, }, ); @@ -163,7 +177,10 @@ async function confirmBankWithdrawalUri( {}, { headers: { - Authorization: makeBasicAuthHeader(bankUser.username, bankUser.password), + Authorization: makeBasicAuthHeader( + bankUser.username, + bankUser.password, + ), }, }, ); @@ -331,12 +348,11 @@ export async function runIntegrationTest( const currency = parsedSpendAmount.currency; logger.info("withdrawing test balance"); - await withdrawTestBalance( - ws, - args.amountToWithdraw, - args.bankBaseUrl, - args.exchangeBaseUrl, - ); + await withdrawTestBalance(ws, { + amount: args.amountToWithdraw, + bankBaseUrl: args.bankBaseUrl, + exchangeBaseUrl: args.exchangeBaseUrl, + }); await ws.runUntilDone(); logger.info("done withdrawing test balance"); @@ -360,12 +376,11 @@ export async function runIntegrationTest( const refundAmount = Amounts.parseOrThrow(`${currency}:6`); const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`); - await withdrawTestBalance( - ws, - Amounts.stringify(withdrawAmountTwo), - args.bankBaseUrl, - args.exchangeBaseUrl, - ); + await withdrawTestBalance(ws, { + amount: Amounts.stringify(withdrawAmountTwo), + bankBaseUrl: args.bankBaseUrl, + exchangeBaseUrl: args.exchangeBaseUrl, + }); // Wait until the withdraw is done await ws.runUntilDone(); @@ -410,7 +425,10 @@ export async function runIntegrationTest( logger.trace("integration test: all done!"); } -export async function testPay(ws: InternalWalletState, args: TestPayArgs) { +export async function testPay( + ws: InternalWalletState, + args: TestPayArgs, +): Promise { logger.trace("creating order"); const merchant = { authToken: args.merchantAuthToken, @@ -429,12 +447,28 @@ export async function testPay(ws: InternalWalletState, args: TestPayArgs) { if (!talerPayUri) { console.error("fatal: no taler pay URI received from backend"); process.exit(1); - return; } logger.trace("taler pay URI:", talerPayUri); const result = await preparePayForUri(ws, talerPayUri); if (result.status !== PreparePayResultType.PaymentPossible) { throw Error(`unexpected prepare pay status: ${result.status}`); } - await confirmPay(ws, result.proposalId, undefined); + const r = await confirmPay( + ws, + result.proposalId, + undefined, + args.forcedCoinSel, + ); + if (r.type != ConfirmPayResultType.Done) { + throw Error("payment not done"); + } + const purchase = await ws.db + .mktx((x) => ({ purchases: x.purchases })) + .runReadOnly(async (tx) => { + return tx.purchases.get(result.proposalId); + }); + checkLogicInvariant(!!purchase); + return { + payCoinSelection: purchase.payCoinSelection, + }; } diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/util/coinSelection.test.ts index ca7b76eb5..55c007bbc 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.test.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.test.ts @@ -31,6 +31,7 @@ function a(x: string): AmountJson { function fakeAci(current: string, feeDeposit: string): AvailableCoinInfo { return { + value: a(current), availableAmount: a(current), coinPub: "foobar", denomPub: { @@ -45,6 +46,7 @@ function fakeAci(current: string, feeDeposit: string): AvailableCoinInfo { function fakeAciWithAgeRestriction(current: string, feeDeposit: string): AvailableCoinInfo { return { + value: a(current), availableAmount: a(current), coinPub: "foobar", denomPub: { diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts index 080a5049d..b3439067e 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -29,42 +29,14 @@ import { AmountJson, Amounts, DenominationPubKey, + ForcedCoinSel, Logger, + PayCoinSelection, } from "@gnu-taler/taler-util"; +import { checkLogicInvariant } from "./invariants.js"; const logger = new Logger("coinSelection.ts"); -/** - * Result of selecting coins, contains the exchange, and selected - * coins with their denomination. - */ -export interface PayCoinSelection { - /** - * Amount requested by the merchant. - */ - paymentAmount: AmountJson; - - /** - * Public keys of the coins that were selected. - */ - coinPubs: string[]; - - /** - * Amount that each coin contributes. - */ - coinContributions: AmountJson[]; - - /** - * How much of the wire fees is the customer paying? - */ - customerWireFees: AmountJson; - - /** - * How much of the deposit fees is the customer paying? - */ - customerDepositFees: AmountJson; -} - /** * Structure to describe a coin that is available to be * used in a payment. @@ -82,6 +54,11 @@ export interface AvailableCoinInfo { */ denomPub: DenominationPubKey; + /** + * Full value of the coin. + */ + value: AmountJson; + /** * Amount still remaining (typically the full amount, * as coins are always refreshed after use.) @@ -356,3 +333,102 @@ export function selectPayCoins( } return undefined; } + +export function selectForcedPayCoins( + forcedCoinSel: ForcedCoinSel, + req: SelectPayCoinRequest, +): PayCoinSelection | undefined { + const { + candidates, + contractTermsAmount, + depositFeeLimit, + wireFeeLimit, + wireFeeAmortization, + } = req; + + if (candidates.candidateCoins.length === 0) { + return undefined; + } + const coinPubs: string[] = []; + const coinContributions: AmountJson[] = []; + const currency = contractTermsAmount.currency; + + let tally: CoinSelectionTally = { + amountPayRemaining: contractTermsAmount, + amountWireFeeLimitRemaining: wireFeeLimit, + amountDepositFeeLimitRemaining: depositFeeLimit, + customerDepositFees: Amounts.getZero(currency), + customerWireFees: Amounts.getZero(currency), + wireFeeCoveredForExchange: new Set(), + }; + + // Not supported by forced coin selection + checkLogicInvariant(!req.prevPayCoins); + + // Sort by available amount (descending), deposit fee (ascending) and + // denomPub (ascending) if deposit fee is the same + // (to guarantee deterministic results) + const candidateCoins = [...candidates.candidateCoins].sort( + (o1, o2) => + -Amounts.cmp(o1.availableAmount, o2.availableAmount) || + Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || + DenominationPubKey.cmp(o1.denomPub, o2.denomPub), + ); + + // FIXME: Here, we should select coins in a smarter way. + // Instead of always spending the next-largest coin, + // we should try to find the smallest coin that covers the + // amount. + + // Set of spent coin indices from candidate coins + const spentSet: Set = new Set(); + + for (const forcedCoin of forcedCoinSel.coins) { + let aci: AvailableCoinInfo | undefined = undefined; + for (let i = 0; i < candidateCoins.length; i++) { + if (spentSet.has(i)) { + continue; + } + if ( + Amounts.cmp(forcedCoin.value, candidateCoins[i].availableAmount) != 0 + ) { + continue; + } + spentSet.add(i); + aci = candidateCoins[i]; + break; + } + + if (!aci) { + throw Error("can't find coin for forced coin selection"); + } + + tally = tallyFees( + tally, + candidates.wireFeesPerExchange, + wireFeeAmortization, + aci.exchangeBaseUrl, + aci.feeDeposit, + ); + + let coinSpend = Amounts.parseOrThrow(forcedCoin.contribution); + + tally.amountPayRemaining = Amounts.sub( + tally.amountPayRemaining, + coinSpend, + ).amount; + coinPubs.push(aci.coinPub); + coinContributions.push(coinSpend); + } + + if (Amounts.isZero(tally.amountPayRemaining)) { + return { + paymentAmount: contractTermsAmount, + coinContributions, + coinPubs, + customerDepositFees: tally.customerDepositFees, + customerWireFees: tally.customerWireFees, + }; + } + return undefined; +} diff --git a/packages/taler-wallet-core/src/util/retries.ts b/packages/taler-wallet-core/src/util/retries.ts index 2fe18cb2c..13a05b385 100644 --- a/packages/taler-wallet-core/src/util/retries.ts +++ b/packages/taler-wallet-core/src/util/retries.ts @@ -37,7 +37,7 @@ export interface RetryPolicy { const defaultRetryPolicy: RetryPolicy = { backoffBase: 1.5, - backoffDelta: Duration.fromSpec({ seconds: 30 }), + backoffDelta: Duration.fromSpec({ seconds: 1 }), maxTimeout: Duration.fromSpec({ minutes: 2 }), }; diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index 0555b0ced..9acfbf103 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -57,6 +57,7 @@ import { SetCoinSuspendedRequest, SetWalletDeviceIdRequest, TestPayArgs, + TestPayResult, TrackDepositGroupRequest, TrackDepositGroupResponse, TransactionsRequest, @@ -270,7 +271,7 @@ export type WalletOperations = { }; [WalletApiOperation.TestPay]: { request: TestPayArgs; - response: {}; + response: TestPayResult; }; [WalletApiOperation.ExportDb]: { request: {}; @@ -279,12 +280,12 @@ export type WalletOperations = { }; export type RequestType< - Op extends WalletApiOperation & keyof WalletOperations - > = WalletOperations[Op] extends { request: infer T } ? T : never; + Op extends WalletApiOperation & keyof WalletOperations, +> = WalletOperations[Op] extends { request: infer T } ? T : never; export type ResponseType< - Op extends WalletApiOperation & keyof WalletOperations - > = WalletOperations[Op] extends { response: infer T } ? T : never; + Op extends WalletApiOperation & keyof WalletOperations, +> = WalletOperations[Op] extends { response: infer T } ? T : never; export interface WalletCoreApiClient { call( diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index a0eaca2e9..c7b94138e 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -23,7 +23,9 @@ * Imports. */ import { - AbsoluteTime, AcceptManualWithdrawalResult, AmountJson, + AbsoluteTime, + AcceptManualWithdrawalResult, + AmountJson, Amounts, BalancesResponse, codecForAbortPayWithRefundRequest, @@ -48,7 +50,9 @@ import { codecForIntegrationTestArgs, codecForListKnownBankAccounts, codecForPrepareDepositRequest, - codecForPreparePayRequest, codecForPrepareRefundRequest, codecForPrepareTipRequest, + codecForPreparePayRequest, + codecForPrepareRefundRequest, + codecForPrepareTipRequest, codecForRetryTransactionRequest, codecForSetCoinSuspendedRequest, codecForSetWalletDeviceIdRequest, @@ -58,7 +62,9 @@ import { codecForWithdrawFakebankRequest, codecForWithdrawTestBalance, CoinDumpJson, - CoreApiResponse, Duration, durationFromSpec, + CoreApiResponse, + Duration, + durationFromSpec, durationMin, ExchangeListItem, ExchangesListRespose, @@ -71,13 +77,14 @@ import { parsePaytoUri, PaytoUri, RefreshReason, - TalerErrorCode, URL, - WalletNotification + TalerErrorCode, + URL, + WalletNotification, } from "@gnu-taler/taler-util"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { CryptoDispatcher, - CryptoWorkerFactory + CryptoWorkerFactory, } from "./crypto/workers/cryptoDispatcher.js"; import { AuditorTrustRecord, @@ -85,7 +92,7 @@ import { exportDb, importDb, ReserveRecordStatus, - WalletStoresV1 + WalletStoresV1, } from "./db.js"; import { getErrorDetailFromException, TalerError } from "./errors.js"; import { @@ -96,7 +103,7 @@ import { MerchantOperations, NotificationListener, RecoupOperations, - ReserveOperations + ReserveOperations, } from "./internal-wallet-state.js"; import { exportBackup } from "./operations/backup/export.js"; import { @@ -109,7 +116,7 @@ import { loadBackupRecovery, processBackupForProvider, removeBackupProvider, - runBackupCycle + runBackupCycle, } from "./operations/backup/index.js"; import { setWalletDeviceId } from "./operations/backup/state.js"; import { getBalances } from "./operations/balance.js"; @@ -118,7 +125,7 @@ import { getFeeForDeposit, prepareDepositGroup, processDepositGroup, - trackDepositGroup + trackDepositGroup, } from "./operations/deposits.js"; import { acceptExchangeTermsOfService, @@ -127,66 +134,66 @@ import { getExchangeRequestTimeout, getExchangeTrust, updateExchangeFromUrl, - updateExchangeTermsOfService + updateExchangeTermsOfService, } from "./operations/exchanges.js"; import { getMerchantInfo } from "./operations/merchants.js"; import { confirmPay, preparePayForUri, processDownloadProposal, - processPurchasePay + processPurchasePay, } from "./operations/pay.js"; import { getPendingOperations } from "./operations/pending.js"; import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js"; import { autoRefresh, createRefreshGroup, - processRefreshGroup + processRefreshGroup, } from "./operations/refresh.js"; import { abortFailedPayWithRefund, applyRefund, applyRefundFromPurchaseId, prepareRefund, - processPurchaseQueryRefund + processPurchaseQueryRefund, } from "./operations/refund.js"; import { createReserve, createTalerWithdrawReserve, getFundingPaytoUris, - processReserve + processReserve, } from "./operations/reserves.js"; import { runIntegrationTest, testPay, - withdrawTestBalance + withdrawTestBalance, } from "./operations/testing.js"; import { acceptTip, prepareTip, processTip } from "./operations/tip.js"; import { deleteTransaction, getTransactions, - retryTransaction + retryTransaction, } from "./operations/transactions.js"; import { getExchangeWithdrawalInfo, getWithdrawalDetailsForUri, - processWithdrawGroup + processWithdrawGroup, } from "./operations/withdraw.js"; import { PendingOperationsResponse, PendingTaskInfo, - PendingTaskType + PendingTaskType, } from "./pending-types.js"; import { assertUnreachable } from "./util/assertUnreachable.js"; import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js"; import { HttpRequestLibrary, - readSuccessResponseJsonOrThrow + readSuccessResponseJsonOrThrow, } from "./util/http.js"; import { AsyncCondition, OpenedPromise, - openPromise + openPromise, } from "./util/promiseUtils.js"; import { DbAccess, GetReadWriteAccess } from "./util/query.js"; import { TimerAPI, TimerGroup } from "./util/timer.js"; @@ -355,7 +362,6 @@ async function runTaskLoop( if (p.givesLifeness) { numGivingLiveness++; } - } if (opts.stopWhenDone && numGivingLiveness === 0 && iteration !== 0) { @@ -459,13 +465,12 @@ async function acceptManualWithdrawal( exchangeBaseUrl: string, amount: AmountJson, restrictAge?: number, - ): Promise { try { const resp = await createReserve(ws, { amount, exchange: exchangeBaseUrl, - restrictAge + restrictAge, }); const exchangePaytoUris = await ws.db .mktx((x) => ({ @@ -688,7 +693,7 @@ async function dumpCoins(ws: InternalWalletState): Promise { c.denomPubHash, ); if (!denomInfo) { - console.error("no denomination found for coin") + console.error("no denomination found for coin"); continue; } coinsJson.coins.push({ @@ -749,22 +754,16 @@ async function dispatchRequestInternal( return {}; } case "withdrawTestkudos": { - await withdrawTestBalance( - ws, - "TESTKUDOS:10", - "https://bank.test.taler.net/", - "https://exchange.test.taler.net/", - ); + await withdrawTestBalance(ws, { + amount: "TESTKUDOS:10", + bankBaseUrl: "https://bank.test.taler.net/", + exchangeBaseUrl: "https://exchange.test.taler.net/", + }); return {}; } case "withdrawTestBalance": { const req = codecForWithdrawTestBalance().decode(payload); - await withdrawTestBalance( - ws, - req.amount, - req.bankBaseUrl, - req.exchangeBaseUrl, - ); + await withdrawTestBalance(ws, req); return {}; } case "runIntegrationTest": { @@ -774,8 +773,7 @@ async function dispatchRequestInternal( } case "testPay": { const req = codecForTestPayArgs().decode(payload); - await testPay(ws, req); - return {}; + return await testPay(ws, req); } case "getTransactions": { const req = codecForTransactionsRequest().decode(payload); @@ -813,7 +811,7 @@ async function dispatchRequestInternal( ws, req.exchangeBaseUrl, Amounts.parseOrThrow(req.amount), - req.restrictAge + req.restrictAge, ); return res; } -- cgit v1.2.3