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 --- .../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 +++++++++++---- 5 files changed, 147 insertions(+), 80 deletions(-) (limited to 'packages/taler-wallet-core/src/operations') 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, + }; } -- cgit v1.2.3