wallet-core: implement and test forced coin/denom selection

This commit is contained in:
Florian Dold 2022-06-10 13:03:47 +02:00
parent 3ebb1d1815
commit f57dc7bf7a
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
14 changed files with 463 additions and 173 deletions

View File

@ -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;
}

View File

@ -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"];

View File

@ -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,

View File

@ -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";
/**

View File

@ -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

View File

@ -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";

View File

@ -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);

View File

@ -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.

View File

@ -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,
};
}

View File

@ -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: {

View File

@ -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<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;
}

View File

@ -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 }),
};

View File

@ -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>(

View File

@ -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;
}