harness,wallet-cli: notification-based testing with RPC wallet

This commit is contained in:
Florian Dold 2023-02-02 20:20:58 +01:00
parent ab9a5e1e8a
commit 96101238af
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
9 changed files with 609 additions and 152 deletions

View File

@ -21,8 +21,6 @@
* @author Florian Dold <dold@taler.net> * @author Florian Dold <dold@taler.net>
*/ */
const logger = new Logger("harness.ts");
/** /**
* Imports * Imports
*/ */
@ -43,6 +41,7 @@ import {
parsePaytoUri, parsePaytoUri,
stringToBytes, stringToBytes,
TalerProtocolDuration, TalerProtocolDuration,
WalletNotification,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
BankAccessApi, BankAccessApi,
@ -57,9 +56,9 @@ import {
import { deepStrictEqual } from "assert"; import { deepStrictEqual } from "assert";
import axiosImp, { AxiosError } from "axios"; import axiosImp, { AxiosError } from "axios";
import { ChildProcess, spawn } from "child_process"; import { ChildProcess, spawn } from "child_process";
import * as child_process from "child_process";
import * as fs from "fs"; import * as fs from "fs";
import * as http from "http"; import * as http from "http";
import * as net from "node:net";
import * as path from "path"; import * as path from "path";
import * as readline from "readline"; import * as readline from "readline";
import { URL } from "url"; import { URL } from "url";
@ -76,6 +75,15 @@ import {
TipCreateRequest, TipCreateRequest,
TippingReserveStatus, TippingReserveStatus,
} from "./merchantApiTypes.js"; } from "./merchantApiTypes.js";
import {
createRemoteWallet,
getClientFromRemoteWallet,
makeNotificationWaiter,
RemoteWallet,
WalletNotificationWaiter,
} from "@gnu-taler/taler-wallet-core/remote";
const logger = new Logger("harness.ts");
const axios = axiosImp.default; const axios = axiosImp.default;
@ -1831,7 +1839,7 @@ export async function runTestWithState(
const handleSignal = (s: string) => { const handleSignal = (s: string) => {
logger.warn( logger.warn(
`**** received fatal process event, terminating test ${testName}`, `**** received fatal process event (${s}), terminating test ${testName}`,
); );
gc.shutdownSync(); gc.shutdownSync();
process.exit(1); process.exit(1);
@ -1885,6 +1893,107 @@ export interface WalletCliOpts {
cryptoWorkerType?: "sync" | "node-worker-thread"; cryptoWorkerType?: "sync" | "node-worker-thread";
} }
function tryUnixConnect(socketPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const client = net.createConnection(socketPath);
client.on("error", (e) => {
reject(e);
});
client.on("connect", () => {
client.end();
resolve();
});
});
}
export class WalletService {
walletProc: ProcessWrapper | undefined;
constructor(private globalState: GlobalTestState, private name: string) {}
get socketPath() {
const unixPath = path.join(this.globalState.testDir, `${this.name}.sock`);
return unixPath;
}
async start(): Promise<void> {
const dbPath = path.join(
this.globalState.testDir,
`walletdb-${this.name}.json`,
);
const unixPath = this.socketPath;
this.globalState.spawnService(
"taler-wallet-cli",
[
"--wallet-db",
dbPath,
"advanced",
"serve",
"--unix-path",
unixPath,
],
`wallet-${this.name}`,
);
}
async pingUntilAvailable(): Promise<void> {
while (1) {
try {
await tryUnixConnect(this.socketPath);
} catch (e) {
logger.info(`connection attempt failed: ${e}`);
await delayMs(200);
continue;
}
logger.info("connection to wallet-core succeeded");
break;
}
}
}
export interface WalletClientArgs {
unixPath: string;
onNotification?(n: WalletNotification): void;
}
export class WalletClient {
remoteWallet: RemoteWallet | undefined = undefined;
waiter: WalletNotificationWaiter = makeNotificationWaiter();
constructor(private args: WalletClientArgs) {}
async connect(): Promise<void> {
const waiter = this.waiter;
const walletClient = this;
const w = await createRemoteWallet({
socketFilename: this.args.unixPath,
notificationHandler(n) {
if (walletClient.args.onNotification) {
walletClient.args.onNotification(n);
}
waiter.notify(n);
console.log("got notification from wallet-core in WalletClient");
},
});
this.remoteWallet = w;
this.waiter.waitForNotificationCond;
}
get client() {
if (!this.remoteWallet) {
throw Error("wallet not connected");
}
return getClientFromRemoteWallet(this.remoteWallet);
}
waitForNotificationCond(
cond: (n: WalletNotification) => boolean,
): Promise<void> {
return this.waiter.waitForNotificationCond(cond);
}
}
export class WalletCli { export class WalletCli {
private currentTimetravel: Duration | undefined; private currentTimetravel: Duration | undefined;
private _client: WalletCoreApiClient; private _client: WalletCoreApiClient;

View File

@ -180,6 +180,114 @@ export async function createSimpleTestkudosEnvironment(
}; };
} }
/**
* Run a test case with a simple TESTKUDOS Taler environment, consisting
* of one exchange, one bank and one merchant.
*
* V2 uses a daemonized wallet instead of the CLI wallet.
*/
export async function createSimpleTestkudosEnvironmentV2(
t: GlobalTestState,
coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
opts: EnvOptions = {},
): Promise<SimpleTestEnvironment> {
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,
});
const exchangeBankAccount = await bank.createExchangeAccount(
"myexchange",
"x",
);
await exchange.addBankAccount("1", exchangeBankAccount);
bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
await bank.start();
await bank.pingUntilAvailable();
const ageMaskSpec = opts.ageMaskSpec;
if (ageMaskSpec) {
exchange.enableAgeRestrictions(ageMaskSpec);
// Enable age restriction for all coins.
exchange.addCoinConfigList(
coinConfig.map((x) => ({
...x,
name: `${x.name}-age`,
ageRestricted: true,
})),
);
// For mixed age restrictions, we also offer coins without age restrictions
if (opts.mixedAgeRestriction) {
exchange.addCoinConfigList(
coinConfig.map((x) => ({ ...x, ageRestricted: false })),
);
}
} else {
exchange.addCoinConfigList(coinConfig);
}
await exchange.start();
await exchange.pingUntilAvailable();
merchant.addExchange(exchange);
await merchant.start();
await merchant.pingUntilAvailable();
await merchant.addInstance({
id: "default",
name: "Default Instance",
paytoUris: [getPayto("merchant-default")],
defaultWireTransferDelay: Duration.toTalerProtocolDuration(
Duration.fromSpec({ minutes: 1 }),
),
});
await merchant.addInstance({
id: "minst1",
name: "minst1",
paytoUris: [getPayto("minst1")],
defaultWireTransferDelay: Duration.toTalerProtocolDuration(
Duration.fromSpec({ minutes: 1 }),
),
});
console.log("setup done!");
const wallet = new WalletCli(t);
return {
commonDb: db,
exchange,
merchant,
wallet,
bank,
exchangeBankAccount,
};
}
export interface FaultyMerchantTestEnvironment { export interface FaultyMerchantTestEnvironment {
commonDb: DbInfo; commonDb: DbInfo;
bank: BankService; bank: BankService;

View File

@ -0,0 +1,163 @@
/*
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 {
Amounts,
Duration,
NotificationType,
PreparePayResultType,
} from "@gnu-taler/taler-util";
import {
BankAccessApi,
BankApi,
WalletApiOperation,
} from "@gnu-taler/taler-wallet-core";
import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
import {
ExchangeService,
FakebankService,
getRandomIban,
GlobalTestState,
MerchantService,
setupDb,
WalletClient,
WalletService,
} from "../harness/harness.js";
/**
* Test for wallet-core notifications.
*/
export async function runWalletNotificationsTest(t: GlobalTestState) {
// Set up test environment
const db = await setupDb(t);
const bank = await FakebankService.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,
});
const exchangeBankAccount = await bank.createExchangeAccount(
"myexchange",
"x",
);
exchange.addBankAccount("1", exchangeBankAccount);
bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
await bank.start();
await bank.pingUntilAvailable();
const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
exchange.addCoinConfigList(coinConfig);
await exchange.start();
await exchange.pingUntilAvailable();
merchant.addExchange(exchange);
await merchant.start();
await merchant.pingUntilAvailable();
// Fakebank uses x-taler-bank, but merchant is configured to only accept sepa!
const label = "mymerchant";
await merchant.addInstance({
id: "default",
name: "Default Instance",
paytoUris: [
`payto://iban/SANDBOXX/${getRandomIban(label)}?receiver-name=${label}`,
],
defaultWireTransferDelay: Duration.toTalerProtocolDuration(
Duration.fromSpec({ minutes: 1 }),
),
});
console.log("setup done!");
const walletService = new WalletService(t, "wallet");
await walletService.start();
await walletService.pingUntilAvailable();
const walletClient = new WalletClient({
unixPath: walletService.socketPath,
onNotification(n) {
console.log("got notification", n);
},
});
await walletClient.connect();
await walletClient.client.call(WalletApiOperation.InitWallet, {
skipDefaults: true,
});
const user = await BankApi.createRandomBankUser(bank);
const wop = await BankAccessApi.createWithdrawalOperation(
bank,
user,
"TESTKUDOS:20",
);
// Hand it to the wallet
await walletClient.client.call(
WalletApiOperation.GetWithdrawalDetailsForUri,
{
talerWithdrawUri: wop.taler_withdraw_uri,
},
);
// Withdraw (AKA select)
const withdrawalFinishedReceivedPromise =
walletClient.waitForNotificationCond((x) => {
return x.type === NotificationType.WithdrawGroupFinished;
});
await walletClient.client.call(
WalletApiOperation.AcceptBankIntegratedWithdrawal,
{
exchangeBaseUrl: exchange.baseUrl,
talerWithdrawUri: wop.taler_withdraw_uri,
},
);
// Confirm it
await BankApi.confirmWithdrawalOperation(bank, user, wop);
await withdrawalFinishedReceivedPromise;
}
runWalletNotificationsTest.suites = ["wallet"];

View File

@ -92,13 +92,14 @@ import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrat
import { runWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js"; import { runWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js";
import { runTestWithdrawalManualTest } from "./test-withdrawal-manual.js"; import { runTestWithdrawalManualTest } from "./test-withdrawal-manual.js";
import { runAgeRestrictionsPeerTest } from "./test-age-restrictions-peer.js"; import { runAgeRestrictionsPeerTest } from "./test-age-restrictions-peer.js";
import { runWalletBalanceTest } from "./test-wallet-balance.js"; import { runWalletNotificationsTest } from "./test-wallet-notifications.js";
import { runAgeRestrictionsMixedMerchantTest } from "./test-age-restrictions-mixed-merchant.js"; import { runAgeRestrictionsMixedMerchantTest } from "./test-age-restrictions-mixed-merchant.js";
import { runWalletCryptoWorkerTest } from "./test-wallet-cryptoworker.js"; import { runWalletCryptoWorkerTest } from "./test-wallet-cryptoworker.js";
import { runWithdrawalHighTest } from "./test-withdrawal-high.js"; import { runWithdrawalHighTest } from "./test-withdrawal-high.js";
import { runKycTest } from "./test-kyc.js"; import { runKycTest } from "./test-kyc.js";
import { runPaymentAbortTest } from "./test-payment-abort.js"; import { runPaymentAbortTest } from "./test-payment-abort.js";
import { runWithdrawalFeesTest } from "./test-withdrawal-fees.js"; import { runWithdrawalFeesTest } from "./test-withdrawal-fees.js";
import { runWalletBalanceTest } from "./test-wallet-balance.js";
/** /**
* Test runner. * Test runner.
@ -166,6 +167,7 @@ const allTests: TestMainFunction[] = [
runPaymentTransientTest, runPaymentTransientTest,
runPaymentZeroTest, runPaymentZeroTest,
runPayPaidTest, runPayPaidTest,
runWalletBalanceTest,
runPaywallFlowTest, runPaywallFlowTest,
runPeerToPeerPullTest, runPeerToPeerPullTest,
runPeerToPeerPushTest, runPeerToPeerPushTest,
@ -180,7 +182,7 @@ const allTests: TestMainFunction[] = [
runTippingTest, runTippingTest,
runWalletBackupBasicTest, runWalletBackupBasicTest,
runWalletBackupDoublespendTest, runWalletBackupDoublespendTest,
runWalletBalanceTest, runWalletNotificationsTest,
runWalletCryptoWorkerTest, runWalletCryptoWorkerTest,
runWalletDblessTest, runWalletDblessTest,
runWallettestingTest, runWallettestingTest,

View File

@ -54,6 +54,9 @@ export async function connectRpc<T>(args: RpcConnectArgs<T>): Promise<T> {
let sockFilename = args.socketFilename; let sockFilename = args.socketFilename;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const client = net.createConnection(sockFilename); const client = net.createConnection(sockFilename);
client.on("error", (e) => {
reject(e);
});
client.on("connect", () => { client.on("connect", () => {
let parsingBody: string | undefined = undefined; let parsingBody: string | undefined = undefined;
let bodyChunks: string[] = []; let bodyChunks: string[] = [];
@ -102,7 +105,8 @@ export async function connectRpc<T>(args: RpcConnectArgs<T>): Promise<T> {
try { try {
reqJson = JSON.parse(req); reqJson = JSON.parse(req);
} catch (e) { } catch (e) {
logger.warn("JSON request was invalid"); logger.warn("JSON message from server was invalid");
logger.info(`message was: ${req}`);
} }
if (reqJson !== undefined) { if (reqJson !== undefined) {
logger.info(`request: ${req}`); logger.info(`request: ${req}`);
@ -112,6 +116,7 @@ export async function connectRpc<T>(args: RpcConnectArgs<T>): Promise<T> {
client.end(); client.end();
} }
bodyChunks = []; bodyChunks = [];
parsingBody = undefined;
} else { } else {
bodyChunks.push(lineStr); bodyChunks.push(lineStr);
} }
@ -187,7 +192,7 @@ export async function runRpcServer(args: RpcServerArgs): Promise<void> {
try { try {
reqJson = JSON.parse(req); reqJson = JSON.parse(req);
} catch (e) { } catch (e) {
logger.warn("JSON request was invalid"); logger.warn("JSON request from client was invalid");
} }
if (reqJson !== undefined) { if (reqJson !== undefined) {
logger.info(`request: ${req}`); logger.info(`request: ${req}`);
@ -197,6 +202,7 @@ export async function runRpcServer(args: RpcServerArgs): Promise<void> {
sock.end(); sock.end();
} }
bodyChunks = []; bodyChunks = [];
parsingBody = undefined;
} else { } else {
bodyChunks.push(lineStr); bodyChunks.push(lineStr);
} }
@ -217,6 +223,6 @@ export async function runRpcServer(args: RpcServerArgs): Promise<void> {
handlers.onDisconnect(); handlers.onDisconnect();
}); });
}); });
server.listen("wallet-core.sock"); server.listen(args.socketFilename);
}); });
} }

View File

@ -14,6 +14,8 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { CoreApiResponse } from "./wallet-types.js";
/** /**
* Implementation for the wallet-core IPC protocol. * Implementation for the wallet-core IPC protocol.
* *

View File

@ -60,13 +60,15 @@ import {
WalletCoreApiClient, WalletCoreApiClient,
walletCoreDebugFlags, walletCoreDebugFlags,
} from "@gnu-taler/taler-wallet-core"; } from "@gnu-taler/taler-wallet-core";
import {
createRemoteWallet,
getClientFromRemoteWallet,
makeNotificationWaiter,
} from "@gnu-taler/taler-wallet-core/remote";
import fs from "fs"; import fs from "fs";
import os from "os"; import os from "os";
import { import { JsonMessage, runRpcServer } from "@gnu-taler/taler-util/twrpc";
connectRpc,
JsonMessage,
runRpcServer,
} from "@gnu-taler/taler-util/twrpc";
// This module also serves as the entry point for the crypto // This module also serves as the entry point for the crypto
// thread worker, and thus must expose these two handlers. // thread worker, and thus must expose these two handlers.
@ -280,162 +282,33 @@ async function createLocalWallet(
} }
} }
export interface RemoteWallet {
/**
* Low-level interface for making API requests to wallet-core.
*/
makeCoreApiRequest(
operation: string,
payload: unknown,
): Promise<CoreApiResponse>;
/**
* Close the connection to the remote wallet.
*/
close(): void;
}
async function createRemoteWallet(
notificationHandler?: (n: WalletNotification) => void,
): Promise<RemoteWallet> {
let nextRequestId = 1;
let requestMap: Map<
string,
{
promiseCapability: OpenedPromise<CoreApiResponse>;
}
> = new Map();
const ctx = await connectRpc<RemoteWallet>({
socketFilename: "wallet-core.sock",
onEstablished(connection) {
const ctx: RemoteWallet = {
makeCoreApiRequest(operation, payload) {
const id = `req-${nextRequestId}`;
const req: CoreApiRequestEnvelope = {
operation,
id,
args: payload,
};
const promiseCap = openPromise<CoreApiResponse>();
requestMap.set(id, {
promiseCapability: promiseCap,
});
connection.sendMessage(req as unknown as JsonMessage);
return promiseCap.promise;
},
close() {
connection.close();
},
};
return {
result: ctx,
onDisconnect() {
logger.info("remote wallet disconnected");
},
onMessage(m) {
// FIXME: use a codec for parsing the response envelope!
logger.info(`got message from remote wallet: ${j2s(m)}`);
if (typeof m !== "object" || m == null) {
logger.warn("message from wallet not understood (wrong type)");
return;
}
const type = (m as any).type;
if (type === "response" || type === "error") {
const id = (m as any).id;
if (typeof id !== "string") {
logger.warn(
"message from wallet not understood (no id in response)",
);
return;
}
const h = requestMap.get(id);
if (!h) {
logger.warn(`no handler registered for response id ${id}`);
return;
}
h.promiseCapability.resolve(m as any);
} else if (type === "notification") {
logger.info("got notification");
if (notificationHandler) {
notificationHandler((m as any).payload);
}
} else {
logger.warn("message from wallet not understood");
}
},
};
},
});
return ctx;
}
/**
* Get a high-level API client from a remove wallet.
*/
function getClientFromRemoteWallet(w: RemoteWallet): WalletCoreApiClient {
const client: WalletCoreApiClient = {
async call(op, payload): Promise<any> {
const res = await w.makeCoreApiRequest(op, payload);
switch (res.type) {
case "error":
throw TalerError.fromUncheckedDetail(res.error);
case "response":
return res.result;
}
},
};
return client;
}
async function withWallet<T>( async function withWallet<T>(
walletCliArgs: WalletCliArgsType, walletCliArgs: WalletCliArgsType,
f: (ctx: WalletContext) => Promise<T>, f: (ctx: WalletContext) => Promise<T>,
): Promise<T> { ): Promise<T> {
// Bookkeeping for waiting on notification conditions const waiter = makeNotificationWaiter();
let nextCondIndex = 1;
const condMap: Map<
number,
{
condition: (n: WalletNotification) => boolean;
promiseCapability: OpenedPromise<void>;
}
> = new Map();
function onNotification(n: WalletNotification) {
condMap.forEach((cond, condKey) => {
if (cond.condition(n)) {
cond.promiseCapability.resolve();
}
});
}
function waitForNotificationCond(cond: (n: WalletNotification) => boolean) {
const promCap = openPromise<void>();
condMap.set(nextCondIndex++, {
condition: cond,
promiseCapability: promCap,
});
return promCap.promise;
}
if (walletCliArgs.wallet.walletConnection) { if (walletCliArgs.wallet.walletConnection) {
logger.info("creating remote wallet"); logger.info("creating remote wallet");
const w = await createRemoteWallet(onNotification); const w = await createRemoteWallet({
notificationHandler: waiter.notify,
socketFilename: walletCliArgs.wallet.walletConnection,
});
const ctx: WalletContext = { const ctx: WalletContext = {
makeCoreApiRequest(operation, payload) { makeCoreApiRequest(operation, payload) {
return w.makeCoreApiRequest(operation, payload); return w.makeCoreApiRequest(operation, payload);
}, },
client: getClientFromRemoteWallet(w), client: getClientFromRemoteWallet(w),
waitForNotificationCond, waitForNotificationCond: waiter.waitForNotificationCond,
}; };
const res = await f(ctx); const res = await f(ctx);
w.close(); w.close();
return res; return res;
} else { } else {
const w = await createLocalWallet(walletCliArgs, onNotification); const w = await createLocalWallet(walletCliArgs, waiter.notify);
const ctx: WalletContext = { const ctx: WalletContext = {
client: w.client, client: w.client,
waitForNotificationCond, waitForNotificationCond: waiter.waitForNotificationCond,
makeCoreApiRequest(operation, payload) { makeCoreApiRequest(operation, payload) {
return w.handleCoreApiRequest(operation, "my-req", payload); return w.handleCoreApiRequest(operation, "my-req", payload);
}, },
@ -1053,7 +926,11 @@ advancedCli
.subcommand("serve", "serve", { .subcommand("serve", "serve", {
help: "Serve the wallet API via a unix domain socket.", help: "Serve the wallet API via a unix domain socket.",
}) })
.requiredOption("unixPath", ["--unix-path"], clk.STRING, {
default: "wallet-core.sock",
})
.action(async (args) => { .action(async (args) => {
logger.info(`serving at ${args.serve.unixPath}`);
const w = await createLocalWallet(args); const w = await createLocalWallet(args);
w.runTaskLoop() w.runTaskLoop()
.then((res) => { .then((res) => {
@ -1070,7 +947,7 @@ advancedCli
}); });
}); });
await runRpcServer({ await runRpcServer({
socketFilename: "wallet-core.sock", socketFilename: args.serve.unixPath,
onConnect(client) { onConnect(client) {
logger.info("connected"); logger.info("connected");
const clientId = nextClientId++; const clientId = nextClientId++;

View File

@ -36,6 +36,9 @@
"browser": "./lib/index.browser.js", "browser": "./lib/index.browser.js",
"node": "./lib/index.node.js", "node": "./lib/index.node.js",
"default": "./lib/index.js" "default": "./lib/index.js"
},
"./remote": {
"node": "./lib/remote.js"
} }
}, },
"devDependencies": { "devDependencies": {

View File

@ -0,0 +1,187 @@
/*
This file is part of GNU Taler
(C) 2023 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/>
*/
import {
CoreApiRequestEnvelope,
CoreApiResponse,
j2s,
Logger,
WalletNotification,
} from "@gnu-taler/taler-util";
import { connectRpc, JsonMessage } from "@gnu-taler/taler-util/twrpc";
import { TalerError } from "./errors.js";
import { OpenedPromise, openPromise } from "./index.js";
import { WalletCoreApiClient } from "./wallet-api-types.js";
const logger = new Logger("remote.ts");
export interface RemoteWallet {
/**
* Low-level interface for making API requests to wallet-core.
*/
makeCoreApiRequest(
operation: string,
payload: unknown,
): Promise<CoreApiResponse>;
/**
* Close the connection to the remote wallet.
*/
close(): void;
}
export interface RemoteWalletConnectArgs {
socketFilename: string;
notificationHandler?: (n: WalletNotification) => void;
}
export async function createRemoteWallet(
args: RemoteWalletConnectArgs,
): Promise<RemoteWallet> {
let nextRequestId = 1;
let requestMap: Map<
string,
{
promiseCapability: OpenedPromise<CoreApiResponse>;
}
> = new Map();
const ctx = await connectRpc<RemoteWallet>({
socketFilename: args.socketFilename,
onEstablished(connection) {
const ctx: RemoteWallet = {
makeCoreApiRequest(operation, payload) {
const id = `req-${nextRequestId}`;
const req: CoreApiRequestEnvelope = {
operation,
id,
args: payload,
};
const promiseCap = openPromise<CoreApiResponse>();
requestMap.set(id, {
promiseCapability: promiseCap,
});
connection.sendMessage(req as unknown as JsonMessage);
return promiseCap.promise;
},
close() {
connection.close();
},
};
return {
result: ctx,
onDisconnect() {
logger.info("remote wallet disconnected");
},
onMessage(m) {
// FIXME: use a codec for parsing the response envelope!
logger.info(`got message from remote wallet: ${j2s(m)}`);
if (typeof m !== "object" || m == null) {
logger.warn("message from wallet not understood (wrong type)");
return;
}
const type = (m as any).type;
if (type === "response" || type === "error") {
const id = (m as any).id;
if (typeof id !== "string") {
logger.warn(
"message from wallet not understood (no id in response)",
);
return;
}
const h = requestMap.get(id);
if (!h) {
logger.warn(`no handler registered for response id ${id}`);
return;
}
h.promiseCapability.resolve(m as any);
} else if (type === "notification") {
logger.info("got notification");
if (args.notificationHandler) {
args.notificationHandler((m as any).payload);
}
} else {
logger.warn("message from wallet not understood");
}
},
};
},
});
return ctx;
}
/**
* Get a high-level API client from a remove wallet.
*/
export function getClientFromRemoteWallet(
w: RemoteWallet,
): WalletCoreApiClient {
const client: WalletCoreApiClient = {
async call(op, payload): Promise<any> {
const res = await w.makeCoreApiRequest(op, payload);
switch (res.type) {
case "error":
throw TalerError.fromUncheckedDetail(res.error);
case "response":
return res.result;
}
},
};
return client;
}
export interface WalletNotificationWaiter {
notify(wn: WalletNotification): void;
waitForNotificationCond(
cond: (n: WalletNotification) => boolean,
): Promise<void>;
}
/**
* Helper that allows creating a promise that resolves when the
* wallet
*/
export function makeNotificationWaiter(): WalletNotificationWaiter {
// Bookkeeping for waiting on notification conditions
let nextCondIndex = 1;
const condMap: Map<
number,
{
condition: (n: WalletNotification) => boolean;
promiseCapability: OpenedPromise<void>;
}
> = new Map();
function onNotification(n: WalletNotification) {
condMap.forEach((cond, condKey) => {
if (cond.condition(n)) {
cond.promiseCapability.resolve();
}
});
}
function waitForNotificationCond(cond: (n: WalletNotification) => boolean) {
const promCap = openPromise<void>();
condMap.set(nextCondIndex++, {
condition: cond,
promiseCapability: promCap,
});
return promCap.promise;
}
return {
waitForNotificationCond,
notify: onNotification,
};
}