diff options
-rw-r--r-- | packages/taler-harness/src/harness/harness.ts | 117 | ||||
-rw-r--r-- | packages/taler-harness/src/harness/helpers.ts | 108 | ||||
-rw-r--r-- | packages/taler-harness/src/integrationtests/test-wallet-notifications.ts | 163 | ||||
-rw-r--r-- | packages/taler-harness/src/integrationtests/testrunner.ts | 6 | ||||
-rw-r--r-- | packages/taler-util/src/twrpc-impl.node.ts | 12 | ||||
-rw-r--r-- | packages/taler-util/src/twrpc.ts | 2 | ||||
-rw-r--r-- | packages/taler-wallet-cli/src/index.ts | 163 | ||||
-rw-r--r-- | packages/taler-wallet-core/package.json | 3 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/remote.ts | 187 |
9 files changed, 609 insertions, 152 deletions
diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts index 134709541..83c8f60d1 100644 --- a/packages/taler-harness/src/harness/harness.ts +++ b/packages/taler-harness/src/harness/harness.ts @@ -21,8 +21,6 @@ * @author Florian Dold <dold@taler.net> */ -const logger = new Logger("harness.ts"); - /** * Imports */ @@ -43,6 +41,7 @@ import { parsePaytoUri, stringToBytes, TalerProtocolDuration, + WalletNotification, } from "@gnu-taler/taler-util"; import { BankAccessApi, @@ -57,9 +56,9 @@ import { import { deepStrictEqual } from "assert"; import axiosImp, { AxiosError } from "axios"; import { ChildProcess, spawn } from "child_process"; -import * as child_process from "child_process"; import * as fs from "fs"; import * as http from "http"; +import * as net from "node:net"; import * as path from "path"; import * as readline from "readline"; import { URL } from "url"; @@ -76,6 +75,15 @@ import { TipCreateRequest, TippingReserveStatus, } 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; @@ -1831,7 +1839,7 @@ export async function runTestWithState( const handleSignal = (s: string) => { logger.warn( - `**** received fatal process event, terminating test ${testName}`, + `**** received fatal process event (${s}), terminating test ${testName}`, ); gc.shutdownSync(); process.exit(1); @@ -1885,6 +1893,107 @@ export interface WalletCliOpts { 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 { private currentTimetravel: Duration | undefined; private _client: WalletCoreApiClient; diff --git a/packages/taler-harness/src/harness/helpers.ts b/packages/taler-harness/src/harness/helpers.ts index 96b34f9d9..59a37e4b8 100644 --- a/packages/taler-harness/src/harness/helpers.ts +++ b/packages/taler-harness/src/harness/helpers.ts @@ -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 { commonDb: DbInfo; bank: BankService; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-notifications.ts b/packages/taler-harness/src/integrationtests/test-wallet-notifications.ts new file mode 100644 index 000000000..23c71ea2f --- /dev/null +++ b/packages/taler-harness/src/integrationtests/test-wallet-notifications.ts @@ -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"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts index f04bc2950..3d70e6860 100644 --- a/packages/taler-harness/src/integrationtests/testrunner.ts +++ b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -92,13 +92,14 @@ import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrat import { runWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js"; import { runTestWithdrawalManualTest } from "./test-withdrawal-manual.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 { runWalletCryptoWorkerTest } from "./test-wallet-cryptoworker.js"; import { runWithdrawalHighTest } from "./test-withdrawal-high.js"; import { runKycTest } from "./test-kyc.js"; import { runPaymentAbortTest } from "./test-payment-abort.js"; import { runWithdrawalFeesTest } from "./test-withdrawal-fees.js"; +import { runWalletBalanceTest } from "./test-wallet-balance.js"; /** * Test runner. @@ -166,6 +167,7 @@ const allTests: TestMainFunction[] = [ runPaymentTransientTest, runPaymentZeroTest, runPayPaidTest, + runWalletBalanceTest, runPaywallFlowTest, runPeerToPeerPullTest, runPeerToPeerPushTest, @@ -180,7 +182,7 @@ const allTests: TestMainFunction[] = [ runTippingTest, runWalletBackupBasicTest, runWalletBackupDoublespendTest, - runWalletBalanceTest, + runWalletNotificationsTest, runWalletCryptoWorkerTest, runWalletDblessTest, runWallettestingTest, diff --git a/packages/taler-util/src/twrpc-impl.node.ts b/packages/taler-util/src/twrpc-impl.node.ts index 52ab65b73..b6333da51 100644 --- a/packages/taler-util/src/twrpc-impl.node.ts +++ b/packages/taler-util/src/twrpc-impl.node.ts @@ -54,6 +54,9 @@ export async function connectRpc<T>(args: RpcConnectArgs<T>): Promise<T> { let sockFilename = args.socketFilename; return new Promise((resolve, reject) => { const client = net.createConnection(sockFilename); + client.on("error", (e) => { + reject(e); + }); client.on("connect", () => { let parsingBody: string | undefined = undefined; let bodyChunks: string[] = []; @@ -102,7 +105,8 @@ export async function connectRpc<T>(args: RpcConnectArgs<T>): Promise<T> { try { reqJson = JSON.parse(req); } catch (e) { - logger.warn("JSON request was invalid"); + logger.warn("JSON message from server was invalid"); + logger.info(`message was: ${req}`); } if (reqJson !== undefined) { logger.info(`request: ${req}`); @@ -112,6 +116,7 @@ export async function connectRpc<T>(args: RpcConnectArgs<T>): Promise<T> { client.end(); } bodyChunks = []; + parsingBody = undefined; } else { bodyChunks.push(lineStr); } @@ -187,7 +192,7 @@ export async function runRpcServer(args: RpcServerArgs): Promise<void> { try { reqJson = JSON.parse(req); } catch (e) { - logger.warn("JSON request was invalid"); + logger.warn("JSON request from client was invalid"); } if (reqJson !== undefined) { logger.info(`request: ${req}`); @@ -197,6 +202,7 @@ export async function runRpcServer(args: RpcServerArgs): Promise<void> { sock.end(); } bodyChunks = []; + parsingBody = undefined; } else { bodyChunks.push(lineStr); } @@ -217,6 +223,6 @@ export async function runRpcServer(args: RpcServerArgs): Promise<void> { handlers.onDisconnect(); }); }); - server.listen("wallet-core.sock"); + server.listen(args.socketFilename); }); } diff --git a/packages/taler-util/src/twrpc.ts b/packages/taler-util/src/twrpc.ts index 368e04e27..d221630d0 100644 --- a/packages/taler-util/src/twrpc.ts +++ b/packages/taler-util/src/twrpc.ts @@ -14,6 +14,8 @@ 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. * diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index 67d0e3784..cce982dfb 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -60,13 +60,15 @@ import { WalletCoreApiClient, walletCoreDebugFlags, } from "@gnu-taler/taler-wallet-core"; + +import { + createRemoteWallet, + getClientFromRemoteWallet, + makeNotificationWaiter, +} from "@gnu-taler/taler-wallet-core/remote"; import fs from "fs"; import os from "os"; -import { - connectRpc, - JsonMessage, - runRpcServer, -} from "@gnu-taler/taler-util/twrpc"; +import { JsonMessage, runRpcServer } from "@gnu-taler/taler-util/twrpc"; // This module also serves as the entry point for the crypto // 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>( walletCliArgs: WalletCliArgsType, f: (ctx: WalletContext) => Promise<T>, ): Promise<T> { - // 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; - } + const waiter = makeNotificationWaiter(); if (walletCliArgs.wallet.walletConnection) { logger.info("creating remote wallet"); - const w = await createRemoteWallet(onNotification); + const w = await createRemoteWallet({ + notificationHandler: waiter.notify, + socketFilename: walletCliArgs.wallet.walletConnection, + }); const ctx: WalletContext = { makeCoreApiRequest(operation, payload) { return w.makeCoreApiRequest(operation, payload); }, client: getClientFromRemoteWallet(w), - waitForNotificationCond, + waitForNotificationCond: waiter.waitForNotificationCond, }; const res = await f(ctx); w.close(); return res; } else { - const w = await createLocalWallet(walletCliArgs, onNotification); + const w = await createLocalWallet(walletCliArgs, waiter.notify); const ctx: WalletContext = { client: w.client, - waitForNotificationCond, + waitForNotificationCond: waiter.waitForNotificationCond, makeCoreApiRequest(operation, payload) { return w.handleCoreApiRequest(operation, "my-req", payload); }, @@ -1053,7 +926,11 @@ advancedCli .subcommand("serve", "serve", { help: "Serve the wallet API via a unix domain socket.", }) + .requiredOption("unixPath", ["--unix-path"], clk.STRING, { + default: "wallet-core.sock", + }) .action(async (args) => { + logger.info(`serving at ${args.serve.unixPath}`); const w = await createLocalWallet(args); w.runTaskLoop() .then((res) => { @@ -1070,7 +947,7 @@ advancedCli }); }); await runRpcServer({ - socketFilename: "wallet-core.sock", + socketFilename: args.serve.unixPath, onConnect(client) { logger.info("connected"); const clientId = nextClientId++; diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json index a0047a03f..4f1692872 100644 --- a/packages/taler-wallet-core/package.json +++ b/packages/taler-wallet-core/package.json @@ -36,6 +36,9 @@ "browser": "./lib/index.browser.js", "node": "./lib/index.node.js", "default": "./lib/index.js" + }, + "./remote": { + "node": "./lib/remote.js" } }, "devDependencies": { diff --git a/packages/taler-wallet-core/src/remote.ts b/packages/taler-wallet-core/src/remote.ts new file mode 100644 index 000000000..a240d4606 --- /dev/null +++ b/packages/taler-wallet-core/src/remote.ts @@ -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, + }; +} |