aboutsummaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2022-06-10 13:03:47 +0200
committerFlorian Dold <florian@dold.me>2022-06-10 13:03:47 +0200
commitf57dc7bf7a1e3a14c67512ba67d92fa350c95c0e (patch)
treec4f94cd64373e787d8b43645e9fdca469e713a98 /packages
parent3ebb1d18154375471e329f2bad40534d858cbe1e (diff)
wallet-core: implement and test forced coin/denom selection
Diffstat (limited to 'packages')
-rw-r--r--packages/taler-util/src/walletTypes.ts80
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-forced-selection.ts94
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/testrunner.ts2
-rw-r--r--packages/taler-wallet-core/src/db.ts2
-rw-r--r--packages/taler-wallet-core/src/operations/backup/import.ts3
-rw-r--r--packages/taler-wallet-core/src/operations/deposits.ts3
-rw-r--r--packages/taler-wallet-core/src/operations/pay.ts37
-rw-r--r--packages/taler-wallet-core/src/operations/reserves.ts108
-rw-r--r--packages/taler-wallet-core/src/operations/testing.ts76
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.test.ts2
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.ts138
-rw-r--r--packages/taler-wallet-core/src/util/retries.ts2
-rw-r--r--packages/taler-wallet-core/src/wallet-api-types.ts11
-rw-r--r--packages/taler-wallet-core/src/wallet.ts78
14 files changed, 463 insertions, 173 deletions
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<CreateReserveRequest> =>
.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<TestPayArgs> =>
@@ -682,6 +683,7 @@ export const codecForTestPayArgs = (): Codec<TestPayArgs> =>
.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<GetExchangeTosRequest> =>
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<ApplyRefundFromPurchaseIdRequest> =>
- buildCodecForObject<ApplyRefundFromPurchaseIdRequest>()
- .property("purchaseId", codecForString())
- .build("ApplyRefundFromPurchaseIdRequest");
+export const codecForApplyRefundFromPurchaseIdRequest =
+ (): Codec<ApplyRefundFromPurchaseIdRequest> =>
+ buildCodecForObject<ApplyRefundFromPurchaseIdRequest>()
+ .property("purchaseId", codecForString())
+ .build("ApplyRefundFromPurchaseIdRequest");
export interface GetWithdrawalDetailsForUriRequest {
talerWithdrawUri: string;
@@ -866,12 +869,14 @@ export const codecForPreparePayRequest = (): Codec<PreparePayRequest> =>
export interface ConfirmPayRequest {
proposalId: string;
sessionId?: string;
+ forcedCoinSel?: ForcedCoinSel;
}
export const codecForConfirmPayRequest = (): Codec<ConfirmPayRequest> =>
buildCodecForObject<ConfirmPayRequest>()
.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<ForceRefreshRequest> =>
.property("coinPubList", codecForList(codecForString()))
.build("ForceRefreshRequest");
-
-
export interface PrepareRefundRequest {
talerRefundUri: string;
}
@@ -1084,14 +1089,12 @@ export const codecForGetFeeForDeposit = (): Codec<GetFeeForDepositRequest> =>
export interface PrepareDepositRequest {
depositPaytoUri: string;
amount: AmountString;
-
}
-export const codecForPrepareDepositRequest =
- (): Codec<PrepareDepositRequest> =>
- buildCodecForObject<PrepareDepositRequest>()
- .property("amount", codecForAmountString())
- .property("depositPaytoUri", codecForString())
- .build("PrepareDepositRequest");
+export const codecForPrepareDepositRequest = (): Codec<PrepareDepositRequest> =>
+ buildCodecForObject<PrepareDepositRequest>()
+ .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<ImportDb> =>
buildCodecForObject<ImportDb>()
.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 <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * 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<ConfirmPayResult> {
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<void> {
+ 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<TestPayResult> {
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,43 +29,15 @@ 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.
*/
@@ -83,6 +55,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<number> = 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<Op extends WalletApiOperation & keyof WalletOperations>(
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<AcceptManualWithdrawalResult> {
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<CoinDumpJson> {
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;
}