/*
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 {
AbsoluteTime,
AmountString,
Duration,
Logger,
TalerBankConversionApi,
TalerCorebankApiClient,
TransactionType,
WireGatewayApiClient,
WithdrawalType,
j2s,
} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import * as http from "node:http";
import { defaultCoinConfig } from "../harness/denomStructures.js";
import {
BankService,
ExchangeService,
FakebankService,
GlobalTestState,
HarnessExchangeBankAccount,
MerchantService,
generateRandomPayto,
setupDb,
} from "../harness/harness.js";
import { createWalletDaemonWithClient } from "../harness/helpers.js";
const logger = new Logger("test-withdrawal-conversion.ts");
interface TestfakeConversionService {
stop: () => void;
}
function splitInTwoAt(s: string, separator: string): [string, string] {
const idx = s.indexOf(separator);
if (idx === -1) {
return [s, ""];
}
return [s.slice(0, idx), s.slice(idx + 1)];
}
/**
* Testfake for the kyc service that the exchange talks to.
*/
async function runTestfakeConversionService(): Promise {
const server = http.createServer((req, res) => {
const requestUrl = req.url!;
logger.info(`kyc: got ${req.method} request, ${requestUrl}`);
const [path, query] = splitInTwoAt(requestUrl, "?");
const qp = new URLSearchParams(query);
if (path === "/config") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
version: "0:0:0",
name: "taler-conversion-info",
regional_currency: "FOO",
fiat_currency: "BAR",
regional_currency_specification: {
alt_unit_names: {},
name: "FOO",
num_fractional_input_digits: 2,
num_fractional_normal_digits: 2,
num_fractional_trailing_zero_digits: 2,
},
fiat_currency_specification: {
alt_unit_names: {},
name: "BAR",
num_fractional_input_digits: 2,
num_fractional_normal_digits: 2,
num_fractional_trailing_zero_digits: 2,
},
conversion_rate: {
cashin_fee: "A:1" as AmountString,
cashin_min_amount: "A:0.1" as AmountString,
cashin_ratio: "1",
cashin_rounding_mode: "zero",
cashin_tiny_amount: "A:1" as AmountString,
cashout_fee: "A:1" as AmountString,
cashout_min_amount: "A:0.1" as AmountString,
cashout_ratio: "1",
cashout_rounding_mode: "zero",
cashout_tiny_amount: "A:1" as AmountString,
},
} satisfies TalerBankConversionApi.IntegrationConfig),
);
} else if (path === "/cashin-rate") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
amount_debit: "FOO:123",
amount_credit: "BAR:123",
}),
);
} else {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: 1, message: "bad request" }));
}
});
await new Promise((resolve, reject) => {
server.listen(8071, () => resolve());
});
return {
stop() {
server.close();
},
};
}
/**
* Test for currency conversion during manual withdrawal.
*/
export async function runWithdrawalConversionTest(t: GlobalTestState) {
// Set up test environment
const db = await setupDb(t);
const bank = await BankService.create(t, {
allowRegistrations: true,
currency: "TESTKUDOS",
database: db.connStr,
httpPort: 8082,
});
const exchange = ExchangeService.create(t, {
name: "testexchange-1",
currency: "TESTKUDOS",
httpPort: 8081,
database: db.connStr,
});
const merchant = await MerchantService.create(t, {
name: "testmerchant-1",
currency: "TESTKUDOS",
httpPort: 8083,
database: db.connStr,
});
let exchangeBankAccount: HarnessExchangeBankAccount = {
wireGatewayApiBaseUrl: new URL(
"accounts/myexchange/taler-wire-gateway/",
bank.corebankApiBaseUrl,
).href,
accountName: "myexchange",
accountPassword: "x",
accountPaytoUri: generateRandomPayto("myexchange"),
conversionUrl: "http://localhost:8071/",
};
await exchange.addBankAccount("1", exchangeBankAccount);
await bank.start();
await bank.pingUntilAvailable();
const bankClientAuth = {
username: "admin",
password: "adminpw",
};
const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
auth: bankClientAuth,
});
await bankClient.registerAccountExtended({
name: exchangeBankAccount.accountName,
username: exchangeBankAccount.accountName,
password: exchangeBankAccount.accountPassword,
is_taler_exchange: true,
payto_uri: exchangeBankAccount.accountPaytoUri,
});
exchange.addOfferedCoins(defaultCoinConfig);
await exchange.start();
await exchange.pingUntilAvailable();
merchant.addExchange(exchange);
await merchant.start();
await merchant.pingUntilAvailable();
await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
paytoUris: [generateRandomPayto("merchant-default")],
defaultWireTransferDelay: Duration.toTalerProtocolDuration(
Duration.fromSpec({ minutes: 1 }),
),
});
await merchant.addInstanceWithWireAccount({
id: "minst1",
name: "minst1",
paytoUris: [generateRandomPayto("minst1")],
defaultWireTransferDelay: Duration.toTalerProtocolDuration(
Duration.fromSpec({ minutes: 1 }),
),
});
const { walletClient } = await createWalletDaemonWithClient(
t,
{ name: "wallet" },
);
await runTestfakeConversionService();
// Create a withdrawal operation
const user = await bankClient.createRandomBankUser();
await walletClient.call(WalletApiOperation.AddExchange, {
exchangeBaseUrl: exchange.baseUrl,
});
const infoRes = await walletClient.call(
WalletApiOperation.GetWithdrawalDetailsForAmount,
{
exchangeBaseUrl: exchange.baseUrl,
amount: "TESTKUDOS:20" as AmountString,
},
);
console.log(`withdrawal details: ${j2s(infoRes)}`);
const checkTransferAmount = infoRes.withdrawalAccountsList[0].transferAmount;
t.assertTrue(checkTransferAmount != null);
t.assertAmountEquals(checkTransferAmount, "FOO:123");
const tStart = AbsoluteTime.now();
logger.info("starting AcceptManualWithdrawal request");
// We expect this to return immediately.
const wres = await walletClient.call(
WalletApiOperation.AcceptManualWithdrawal,
{
exchangeBaseUrl: exchange.baseUrl,
amount: "TESTKUDOS:10" as AmountString,
},
);
logger.info("AcceptManualWithdrawal finished");
logger.info(`result: ${j2s(wres)}`);
const acceptedTransferAmount = wres.withdrawalAccountsList[0].transferAmount;
t.assertTrue(acceptedTransferAmount != null);
t.assertAmountEquals(acceptedTransferAmount, "FOO:123");
const txInfo = await walletClient.call(
WalletApiOperation.GetTransactionById,
{
transactionId: wres.transactionId,
},
);
t.assertDeepEqual(txInfo.type, TransactionType.Withdrawal);
t.assertDeepEqual(
txInfo.withdrawalDetails.type,
WithdrawalType.ManualTransfer,
);
t.assertTrue(!!txInfo.withdrawalDetails.exchangeCreditAccountDetails);
t.assertDeepEqual(
txInfo.withdrawalDetails.exchangeCreditAccountDetails[0].transferAmount,
"FOO:123",
);
// Check that the request did not go into long-polling.
const duration = AbsoluteTime.difference(tStart, AbsoluteTime.now());
if (typeof duration.d_ms !== "number" || duration.d_ms > 5 * 1000) {
throw Error("withdrawal took too long (longpolling issue)");
}
const reservePub: string = wres.reservePub;
const wireGatewayApiClient = new WireGatewayApiClient(
exchangeBankAccount.wireGatewayApiBaseUrl,
{
auth: bankClientAuth,
},
);
await wireGatewayApiClient.adminAddIncoming({
amount: "TESTKUDOS:10",
debitAccountPayto: user.accountPaytoUri,
reservePub: reservePub,
});
await exchange.runWirewatchOnce();
await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
// Check balance
const balResp = await walletClient.call(WalletApiOperation.GetBalances, {});
t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
}
runWithdrawalConversionTest.suites = ["wallet"];