From 644e57056f552e5da9e829f6110bc66b056bb528 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Thu, 24 Sep 2026 17:39:21 +0700 Subject: [PATCH 01/15] remove dead code --- src/ui/formatters.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/ui/formatters.js b/src/ui/formatters.js index 36a9ad1..4afb6e2 100644 --- a/src/ui/formatters.js +++ b/src/ui/formatters.js @@ -68,17 +68,6 @@ export function formatNetworkLabel (network) { return `${config.displayName} (${config.nativeSymbol})` } -/** - * Formats an ISO 8601 date string into a locale-appropriate date/time string. - * - * @param {string} dateStr - ISO 8601 date string. - * @returns {string} Locale-formatted date string. - */ -export function formatDate (dateStr) { - const date = new Date(dateStr) - return date.toLocaleString() -} - /** * Formats a token or native asset amount for display. * From 435464338b88d078306324a774dc37bb8d4934f2 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Thu, 24 Sep 2026 17:39:47 +0700 Subject: [PATCH 02/15] change permisison to 600 --- src/services/config-service.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/services/config-service.js b/src/services/config-service.js index b3258d7..77e42fd 100644 --- a/src/services/config-service.js +++ b/src/services/config-service.js @@ -12,17 +12,39 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { chmodSync } from 'node:fs' import Conf from 'conf' import { APP_NAME, CONFIG_DEFAULTS, getConfigDir } from '../config/constants.js' +/** + * Narrows the config directory to the owner. The files inside carry provider + * credentials and wallet names; without this another local account can read + * them. Windows has no POSIX mode and uses a per-user ACL instead, and a + * directory on a read-only mount cannot be changed — neither is worth failing + * a command over. + * + * @returns {void} + */ +function restrictConfigDir () { + if (process.platform === 'win32') return + try { + chmodSync(getConfigDir(), 0o700) + } catch { + // Best effort: the config itself is already written owner-only. + } +} + /** Persistent user-config store backed by `Conf`. */ class ConfigService { constructor () { this.conf = new Conf({ projectName: APP_NAME, cwd: getConfigDir(), + // Owner-only: this file holds provider API keys in clear text. + configFileMode: 0o600, defaults: CONFIG_DEFAULTS }) + restrictConfigDir() } /** From 20ad6410ca5372786a64889f008a5e510e154602 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Thu, 24 Sep 2026 17:44:02 +0700 Subject: [PATCH 03/15] Gracefully shuts down the daemon --- src/daemon/server.js | 18 +++- tests/unit/daemon/server.test.js | 139 +++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 tests/unit/daemon/server.test.js diff --git a/src/daemon/server.js b/src/daemon/server.js index 5e58c18..45de790 100644 --- a/src/daemon/server.js +++ b/src/daemon/server.js @@ -717,11 +717,25 @@ export class WalletDaemon { } /** - * Gracefully shuts down the daemon: disposes all wallets, closes the server, removes socket/PID files, and exits. + * Gracefully shuts down the daemon and exits the process. Split from + * {@link WalletDaemon#close} so a caller that owns the process — a test, or + * an embedder — can release the daemon's resources without terminating. * * @returns {Promise} */ async shutdown () { + await this.close() + process.exit(0) + } + + /** + * Releases everything the daemon holds: disposes each unlocked wallet, stops + * accepting connections, and removes the socket and PID files. Leaves the + * process running. + * + * @returns {Promise} + */ + async close () { for (const [, state] of this.#wallets) { if (state.timer) clearTimeout(state.timer) state.wdk.dispose() @@ -746,8 +760,6 @@ export class WalletDaemon { } catch { /* */ } - - process.exit(0) } } diff --git a/tests/unit/daemon/server.test.js b/tests/unit/daemon/server.test.js new file mode 100644 index 0000000..75508a2 --- /dev/null +++ b/tests/unit/daemon/server.test.js @@ -0,0 +1,139 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { jest } from '@jest/globals' +import { connect } from 'node:net' +import { mkdtempSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const tempDir = mkdtempSync(join(tmpdir(), 'wdk-daemon-test-')) +const SOCKET_PATH = join(tempDir, 'daemon.sock') +const PID_PATH = join(tempDir, 'daemon.pid') + +const constants = await import('../../../src/config/constants.js') +jest.unstable_mockModule('../../../src/config/constants.js', () => ({ + ...constants, + getDaemonSocketPath: () => SOCKET_PATH, + getDaemonPidPath: () => PID_PATH +})) + +const { WalletDaemon } = await import('../../../src/daemon/server.js') + +/** @type {WalletDaemon} */ +let daemon + +/** + * Sends one request over the socket and resolves the daemon's reply, exactly + * as `DaemonClient` does: one newline-delimited JSON object each way. + * + * @param {Record} request - The request to send. + * @returns {Promise>} The parsed reply. + */ +function send (request) { + return new Promise((resolve, reject) => { + const socket = connect(SOCKET_PATH) + let buffer = '' + socket.on('connect', () => socket.write(JSON.stringify(request) + '\n')) + socket.on('data', (chunk) => { + buffer += chunk.toString() + const newline = buffer.indexOf('\n') + if (newline === -1) return + socket.destroy() + resolve(JSON.parse(buffer.slice(0, newline))) + }) + socket.on('error', reject) + }) +} + +beforeAll(async () => { + daemon = new WalletDaemon() + await daemon.start() +}) + +afterAll(async () => { + await daemon.close() + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe('the daemon IPC endpoint', () => { + it('restricts the socket to the owner', () => { + expect(statSync(SOCKET_PATH).mode & 0o077).toBe(0) + }) + + it('answers status without an unlocked wallet', async () => { + const reply = await send({ action: 'status' }) + + expect(reply.ok).toBe(true) + }) + + it('lists no wallets before any unlock', async () => { + const reply = await send({ action: 'list_wallets' }) + + expect(reply).toEqual({ ok: true, data: { wallets: [] } }) + }) + + it('refuses an action it does not know', async () => { + const reply = await send({ action: 'drain_funds' }) + + expect(reply.ok).toBe(false) + }) + + it('refuses a send that names no recipient', async () => { + const reply = await send({ action: 'send', network: 'ethereum' }) + + expect(reply).toEqual({ ok: false, error: 'Missing required fields: network, to, amount' }) + }) + + it('refuses a call_method that names no method', async () => { + const reply = await send({ action: 'call_method', network: 'ethereum' }) + + expect(reply).toEqual({ ok: false, error: 'Missing required field: method' }) + }) + + it('refuses a sign_message that carries no message', async () => { + const reply = await send({ action: 'sign_message', network: 'ethereum' }) + + expect(reply).toEqual({ ok: false, error: 'Missing required field: message' }) + }) + + it('refuses a verify_message that carries no signature', async () => { + const reply = await send({ action: 'verify_message', network: 'ethereum', message: 'hi' }) + + expect(reply).toEqual({ ok: false, error: 'Missing required fields: message, signature' }) + }) + + it('refuses a get_transaction that carries no hash', async () => { + const reply = await send({ action: 'get_transaction', network: 'ethereum' }) + + expect(reply).toEqual({ ok: false, error: 'Missing required field: hash' }) + }) + + it('rejects a line that is not JSON instead of closing the connection', async () => { + const reply = await new Promise((resolve, reject) => { + const socket = connect(SOCKET_PATH) + let buffer = '' + socket.on('connect', () => socket.write('not json\n')) + socket.on('data', (chunk) => { + buffer += chunk.toString() + if (!buffer.includes('\n')) return + socket.destroy() + resolve(JSON.parse(buffer.slice(0, buffer.indexOf('\n')))) + }) + socket.on('error', reject) + }) + + expect(reply.ok).toBe(false) + }) +}) From adf32d5ccec298b8bc2192a0cf719f1837d5d0b9 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Fri, 25 Sep 2026 06:44:23 +0700 Subject: [PATCH 04/15] integration test --- package.json | 3 +- tests/integration/helpers.js | 161 +++++++++++++++ tests/integration/registry.test.js | 312 +++++++++++++++++++++++++++++ tests/integration/wallet.test.js | 236 ++++++++++++++++++++++ 4 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 tests/integration/helpers.js create mode 100644 tests/integration/registry.test.js create mode 100644 tests/integration/wallet.test.js diff --git a/package.json b/package.json index 5b7460f..a3ab7d1 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "lint:fix": "standard --fix", "sync-modules": "node scripts/sync-module-deps.mjs", "typecheck": "tsc --noEmit -p jsconfig.json", - "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest", + "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns tests/integration", + "test:integration": "cross-env NODE_OPTIONS=--experimental-vm-modules jest tests/integration --testTimeout=60000", "test:coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage" }, "jest": { diff --git a/tests/integration/helpers.js b/tests/integration/helpers.js new file mode 100644 index 0000000..8fdfcb8 --- /dev/null +++ b/tests/integration/helpers.js @@ -0,0 +1,161 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { spawn } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const CLI_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const BIN = join(CLI_ROOT, 'bin', 'wdk.mjs') + +/** + * @typedef {Object} CliResult + * @property {number} code - The process exit code. + * @property {string} stdout - Everything written to stdout, ANSI stripped. + * @property {string} stderr - Everything written to stderr, ANSI stripped. + * @property {string} output - stdout and stderr joined, for matching a message + * without caring which stream carried it. + */ + +/** Matches the ANSI colour codes chalk writes when stdout is a TTY. */ +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +/** + * An isolated CLI installation: its own config directory, torn down by + * {@link Cli#cleanup}. A directory with no wallets means no passphrase prompt, + * so every registry and config command runs unattended. + */ +export class Cli { + /** The config directory this instance runs against. */ + configHome + /** The passphrase every wallet in this instance is created with. */ + passphrase + + constructor () { + this.configHome = mkdtempSync(join(tmpdir(), 'wdk-it-')) + // Generated per instance and thrown away with the directory: these wallets + // exist only for the length of one test file. + this.passphrase = `pw-${randomBytes(12).toString('hex')}` + } + + /** + * Runs one `wdk` command to completion. + * + * @param {string[]} args - Arguments after the binary name. + * @param {Object} [options] - Run options. + * @param {string} [options.stdin] - Text to write to stdin, for the + * `--*-stdin` flags. + * @param {boolean} [options.unlocked] - Pass the instance passphrase through + * `WDK_PASSPHRASE`, the documented way to run wallet commands unattended. + * @returns {Promise} What the command printed and exited with. + */ + run (args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [BIN, ...args], { + env: { + ...process.env, + XDG_CONFIG_HOME: this.configHome, + ...(options.unlocked ? { WDK_PASSPHRASE: this.passphrase } : { WDK_PASSPHRASE: undefined }), + NO_COLOR: '1', + NODE_OPTIONS: '--disable-warning=ExperimentalWarning' + }, + stdio: ['pipe', 'pipe', 'pipe'] + }) + + let stdout = '' + let stderr = '' + child.stdout.on('data', (d) => { stdout += d }) + child.stderr.on('data', (d) => { stderr += d }) + child.on('error', reject) + child.on('close', (code) => { + const clean = (s) => s.replace(ANSI, '') + resolve({ + code: code ?? 0, + stdout: clean(stdout), + stderr: clean(stderr), + output: clean(stdout + stderr) + }) + }) + + if (options.stdin !== undefined) child.stdin.write(options.stdin) + child.stdin.end() + }) + } + + /** + * Runs a command with `--json` and parses the object it prints. + * + * @param {string[]} args - Arguments after the binary name. + * @param {Object} [options] - Run options, as {@link Cli#run} takes them. + * @returns {Promise>} The parsed payload. + * @throws {Error} When the command printed something other than one JSON object. + */ + async json (args, options = {}) { + const result = await this.run([...args, '--json'], options) + const line = result.stdout.trim().split('\n').filter(Boolean).pop() + if (line === undefined) throw new Error(`no JSON on stdout: ${result.output}`) + try { + return JSON.parse(line) + } catch { + throw new Error(`stdout was not JSON: ${result.output}`) + } + } + + /** + * Reads the persisted user config. + * + * @returns {Record} The parsed config file, or `{}` before + * the CLI has written one. + */ + readConfig () { + try { + return JSON.parse(readFileSync(this.configPath(), 'utf8')) + } catch { + return {} + } + } + + /** + * Replaces the persisted user config, for states the CLI refuses to create — + * a second provider of a single-instance kind, say. + * + * @param {Record} config - The config to write. + * @returns {void} + */ + writeConfig (config) { + mkdirSync(dirname(this.configPath()), { recursive: true }) + writeFileSync(this.configPath(), JSON.stringify(config, null, 2)) + } + + /** + * Returns the path of the persisted user config. + * + * @returns {string} Absolute path to `config.json`. + */ + configPath () { + return join(this.configHome, 'wdk-cli', 'config.json') + } + + /** + * Removes the config directory. + * + * @returns {void} + */ + cleanup () { + rmSync(this.configHome, { recursive: true, force: true }) + } +} diff --git a/tests/integration/registry.test.js b/tests/integration/registry.test.js new file mode 100644 index 0000000..3b7f4d7 --- /dev/null +++ b/tests/integration/registry.test.js @@ -0,0 +1,312 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createRequire } from 'node:module' +import { Cli } from './helpers.js' + +const catalog = createRequire(import.meta.url)('../../wdk.config.json') +const MOONPAY_MODULE = catalog.providers.moonpay.module +const BITFINEX_MODULE = catalog.providers.bitfinex.module + +/** @type {Cli} */ +let cli + +beforeEach(() => { cli = new Cli() }) +afterEach(() => cli.cleanup()) + +describe('provider list and info', () => { + it('lists every packaged provider, pricing first', async () => { + const { providers, count } = await cli.json(['provider', 'list']) + + expect(count).toBe(8) + expect(providers.map((p) => p.name)).toEqual([ + 'wdk-indexer', 'bitfinex', 'moonpay', 'transak', 'velora', 'usdt0', 'rhinofi', 'symbiosis' + ]) + }) + + it('reports the indexer with the client package backing it', async () => { + const info = await cli.json(['provider', 'info', '--name', 'wdk-indexer']) + + expect(info).toMatchObject({ + name: 'wdk-indexer', + kind: 'indexer', + module: '@tetherto/wdk-indexer-http', + source: 'built-in' + }) + }) + + it('reports an unknown provider by name', async () => { + const result = await cli.run(['provider', 'info', '--name', 'nope']) + + expect(result.code).toBe(1) + expect(result.output).toContain("Unknown provider 'nope'.") + }) +}) + +describe('provider add', () => { + it('refuses a kind the registry does not define', async () => { + const result = await cli.run(['provider', 'add', JSON.stringify({ + name: 'x', kind: 'lending', module: MOONPAY_MODULE + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain('Provider spec "kind" must be one of') + }) + + it('refuses an indexer, which ships with the CLI', async () => { + const result = await cli.run(['provider', 'add', JSON.stringify({ + name: 'myidx', kind: 'indexer', module: MOONPAY_MODULE + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain('Indexer providers cannot be added.') + }) + + it('refuses a second price feed while the packaged one is enabled', async () => { + const result = await cli.run(['provider', 'add', JSON.stringify({ + name: 'cg', kind: 'pricing', module: BITFINEX_MODULE + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain('A price feed is already enabled: bitfinex.') + }) + + it('accepts a price feed once the packaged one is disabled', async () => { + await cli.run(['provider', 'disable', '--name', 'bitfinex']) + + const added = await cli.json(['provider', 'add', JSON.stringify({ + name: 'cg', kind: 'pricing', module: BITFINEX_MODULE + })]) + + expect(added).toMatchObject({ name: 'cg', kind: 'pricing', added: true }) + }) + + it('refuses a module that is not registered', async () => { + const result = await cli.run(['provider', 'add', JSON.stringify({ + name: 'x', kind: 'swap', module: '@nobody/not-installed' + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain("Module '@nobody/not-installed' is not registered.") + }) + + it('refuses a name that is already taken', async () => { + const result = await cli.run(['provider', 'add', JSON.stringify({ + name: 'moonpay', kind: 'fiat', module: MOONPAY_MODULE + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain("'moonpay' is a built-in provider.") + }) + + it('refuses a spec that is not an object', async () => { + const result = await cli.run(['provider', 'add', '"just a string"']) + + expect(result.code).toBe(1) + }) + + it('refuses malformed JSON', async () => { + const result = await cli.run(['provider', 'add', '{not json']) + + expect(result.code).toBe(1) + }) + + it('persists endpointKeys so the module is handed a callback', async () => { + await cli.json(['provider', 'add', JSON.stringify({ + name: 'banxa', + kind: 'fiat', + module: MOONPAY_MODULE, + endpointKeys: ['widgetUrl'], + config: { apiKey: 'dummy-key', widgetUrl: 'https://dummy-signer.test/sign' } + })]) + + expect(cli.readConfig().customProviders.banxa.endpointKeys).toEqual(['widgetUrl']) + }) + + it('refuses endpointKeys that are not strings', async () => { + const result = await cli.run(['provider', 'add', JSON.stringify({ + name: 'banxa', kind: 'fiat', module: MOONPAY_MODULE, endpointKeys: [1] + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain('Provider spec "endpointKeys" must be an array of non-empty strings.') + }) +}) + +describe('provider enable, disable and delete', () => { + it('refuses to delete a packaged provider', async () => { + const result = await cli.run(['provider', 'delete', '--name', 'bitfinex']) + + expect(result.code).toBe(1) + expect(result.output).toContain("'bitfinex' is a built-in provider and cannot be deleted.") + }) + + it('reports a delete for a provider that was never added', async () => { + const result = await cli.run(['provider', 'delete', '--name', 'ghost']) + + expect(result.code).toBe(1) + }) + + it('marks a disabled provider in the listing', async () => { + await cli.run(['provider', 'disable', '--name', 'moonpay']) + + const { providers } = await cli.json(['provider', 'list']) + + expect(providers.find((p) => p.name === 'moonpay').enabled).toBe(false) + }) + + it('refuses to enable a second indexer', async () => { + const config = cli.readConfig() + config.customProviders = { myidx: { kind: 'indexer', config: {} } } + config.overrides = { providers: { myidx: { enabled: false } } } + cli.writeConfig(config) + + const result = await cli.run(['provider', 'enable', '--name', 'myidx']) + + expect(result.code).toBe(1) + expect(result.output).toContain('An indexer is already enabled: wdk-indexer.') + }) + + it('always allows disabling, so a feed can be swapped', async () => { + const result = await cli.run(['provider', 'disable', '--name', 'wdk-indexer']) + + expect(result.code).toBe(0) + }) + + it('refuses to disable something already disabled', async () => { + await cli.run(['provider', 'disable', '--name', 'moonpay']) + + const again = await cli.run(['provider', 'disable', '--name', 'moonpay']) + + expect(again.code).toBe(1) + }) +}) + +describe('token registry', () => { + it('lists the packaged tokens for a network', async () => { + const { tokens } = await cli.json(['token', 'list', '--network', 'ethereum']) + + expect(Object.keys(tokens).sort()).toEqual(['eth', 'usdt', 'xaut']) + }) + + it('shows a token with its slugs', async () => { + const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'usdt']) + + expect(info).toMatchObject({ symbol: 'USDT', decimals: 6, isNative: false }) + }) + + it('reports a token the registry does not carry', async () => { + const result = await cli.run(['token', 'info', '--network', 'ethereum', '--token', 'nope']) + + expect(result.code).toBe(1) + }) + + it('reports a network that does not exist', async () => { + const result = await cli.run(['token', 'list', '--network', 'atlantis']) + + expect(result.code).toBe(1) + }) + + it('adds a custom token and reads it back', async () => { + await cli.json(['token', 'add', JSON.stringify({ + network: 'ethereum', token: 'dai', symbol: 'DAI', decimals: 18, isNative: false, + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F' + })]) + + const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'dai']) + + expect(info).toMatchObject({ symbol: 'DAI', decimals: 18 }) + }) + + it('warns when a custom token shadows a packaged one', async () => { + const result = await cli.run(['token', 'add', JSON.stringify({ + network: 'ethereum', token: 'usdt', symbol: 'USDT', decimals: 6, isNative: false, + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7' + })]) + + expect(result.output).toContain('overrides it') + }) + + it('refuses to delete a packaged token', async () => { + const result = await cli.run(['token', 'delete', '--network', 'ethereum', '--token', 'usdt']) + + expect(result.code).toBe(1) + expect(result.output).toContain('built-in token') + }) + + it('reverts to the packaged token when the override is deleted', async () => { + await cli.json(['token', 'add', JSON.stringify({ + network: 'ethereum', token: 'usdt', symbol: 'FAKE', decimals: 2, isNative: false, + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7' + })]) + + await cli.run(['token', 'delete', '--network', 'ethereum', '--token', 'usdt']) + const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'usdt']) + + expect(info.symbol).toBe('USDT') + }) +}) + +describe('network registry', () => { + it('lists the packaged networks', async () => { + const { networks } = await cli.json(['network', 'list']) + + expect(networks.length).toBe(22) + }) + + it('separates testnets behind a flag', async () => { + const { networks } = await cli.json(['network', 'list', '--testnet']) + + expect(networks.every((n) => n.testnet)).toBe(true) + }) + + it('refuses a network name that already exists', async () => { + const result = await cli.run(['network', 'create', JSON.stringify({ + network: 'ethereum', module: '@tetherto/wdk-wallet-evm', chainId: 'eip155:1', displayName: 'X' + })]) + + expect(result.code).toBe(1) + }) + + it('refuses a module that is not a wallet module', async () => { + const result = await cli.run(['network', 'create', JSON.stringify({ + network: 'mychain', module: MOONPAY_MODULE, chainId: 'eip155:99', displayName: 'Mine' + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain('Network spec "module" must be one of') + }) + + it('refuses to delete a packaged network', async () => { + const result = await cli.run(['network', 'delete', '--name', 'ethereum']) + + expect(result.code).toBe(1) + }) +}) + +describe('module registry', () => { + it('reports every catalog module as installed and ok', async () => { + const { modules } = await cli.json(['module', 'list']) + + expect(modules.every((m) => m.status === 'ok')).toBe(true) + }) + + it('treats a git-pinned module as satisfied rather than mismatched', async () => { + const { modules } = await cli.json(['module', 'list']) + const indexer = modules.find((m) => m.module === '@tetherto/wdk-indexer-http') + + expect(indexer.pinned).toContain('github:') + expect(indexer.status).toBe('ok') + }) +}) diff --git a/tests/integration/wallet.test.js b/tests/integration/wallet.test.js new file mode 100644 index 0000000..4d64ed4 --- /dev/null +++ b/tests/integration/wallet.test.js @@ -0,0 +1,236 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Cli } from './helpers.js' + +// The seed the WDK wallet modules use in their own integration tests, so the +// addresses below match what `wdk-wallet-evm` asserts for the same derivation. +const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' +const ETHEREUM_0 = '0x405005C7c4422390F4B334F64Cf20E0b767131d0' +/** Mainnet networks in the packaged registry. */ +const MAINNET_COUNT = 15 + +/** @type {Cli} */ +let cli + +beforeEach(async () => { + cli = new Cli() +}) + +afterEach(async () => { + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + cli.cleanup() +}) + +/** + * Imports the fixed test seed under a name and unlocks it. + * + * @param {string} [name] - The wallet name. + * @returns {Promise} + */ +async function importAndUnlock (name = 'main') { + await cli.run(['wallet', 'import', '--name', name, '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + await cli.run(['wallet', 'unlock', '--name', name], { unlocked: true }) +} + +describe('wallet lifecycle', () => { + it('creates a wallet and returns a seed phrase of the requested length', async () => { + const created = await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + expect(created.wallet).toBe('w1') + expect(created.seedPhrase.split(' ')).toHaveLength(12) + }) + + it('creates a 24-word wallet when asked', async () => { + const created = await cli.json( + ['wallet', 'create', '--name', 'w1', '--words', '24'], { unlocked: true } + ) + + expect(created.seedPhrase.split(' ')).toHaveLength(24) + }) + + it('makes the first wallet the default', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + const { wallets } = await cli.json(['wallet', 'list']) + + expect(wallets).toEqual([{ name: 'w1', default: true, unlocked: false }]) + }) + + it('refuses a second wallet with the same name', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + const again = await cli.run(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + expect(again.code).toBe(1) + }) + + it('refuses a word count that is not 12 or 24', async () => { + const result = await cli.run( + ['wallet', 'create', '--name', 'w1', '--words', '18'], { unlocked: true } + ) + + expect(result.code).toBe(1) + }) + + it('cannot prompt for a passphrase when stdin is piped', async () => { + const result = await cli.run(['wallet', 'create', '--name', 'w1']) + + expect(result.code).toBe(1) + expect(result.output).toContain('Cannot prompt for a passphrase when stdin is piped.') + }) + + it('renames a wallet, keeping it usable', async () => { + await importAndUnlock('old') + + await cli.run(['wallet', 'rename', '--name', 'old', '--new-name', 'new'], { unlocked: true }) + const { wallets } = await cli.json(['wallet', 'list']) + + expect(wallets.map((w) => w.name)).toEqual(['new']) + }) + + it('deletes a wallet', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + await cli.run(['wallet', 'delete', '--name', 'w1'], { unlocked: true }) + const { wallets } = await cli.json(['wallet', 'list']) + + expect(wallets).toEqual([]) + }) + + it('reports a wallet that does not exist', async () => { + const result = await cli.run(['wallet', 'unlock', '--name', 'ghost'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("Wallet 'ghost' not found.") + }) +}) + +describe('import, unlock and lock', () => { + it('imports a known seed and reports it unlocked, with the session ttl', async () => { + await importAndUnlock() + + const { wallets } = await cli.json(['wallet', 'list']) + + expect(wallets).toHaveLength(1) + expect(wallets[0]).toMatchObject({ name: 'main', default: true, unlocked: true, ttlMs: 300_000 }) + }) + + it('refuses a seed that is not valid BIP-39', async () => { + const result = await cli.run(['wallet', 'import', '--name', 'bad', '--seed-stdin'], { + unlocked: true, + stdin: 'not a real seed phrase at all here friend\n' + }) + + expect(result.code).toBe(1) + }) + + it('refuses the wrong passphrase on unlock', async () => { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + + const result = await cli.run(['wallet', 'unlock', '--name', 'main']) + + expect(result.code).toBe(1) + }) + + it('requires a target for lock', async () => { + const result = await cli.run(['wallet', 'lock'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain('Provide --name or --all.') + }) + + it('locks a wallet by name', async () => { + await importAndUnlock() + + await cli.run(['wallet', 'lock', '--name', 'main'], { unlocked: true }) + const { wallets } = await cli.json(['wallet', 'list']) + + expect(wallets[0].unlocked).toBe(false) + }) +}) + +describe('address derivation, which needs no chain', () => { + it('derives the same address the seed always produces', async () => { + await importAndUnlock() + + const result = await cli.json(['get', 'address', '--network', 'ethereum'], { unlocked: true }) + + expect(result).toEqual({ network: 'ethereum', index: 0, address: ETHEREUM_0 }) + }) + + it('derives a different address at another index', async () => { + await importAndUnlock() + + const zero = await cli.json(['get', 'address', '--network', 'ethereum'], { unlocked: true }) + const one = await cli.json( + ['get', 'address', '--network', 'ethereum', '--index', '1'], { unlocked: true } + ) + + expect(one.index).toBe(1) + expect(one.address).not.toBe(zero.address) + }) + + it('derives every mainnet address in one call', async () => { + await importAndUnlock() + + const result = await cli.json(['get', 'address', '--all'], { unlocked: true }) + + expect(result.type).toBe('mainnet') + expect(result.addresses).toHaveLength(MAINNET_COUNT) + expect(result.addresses.find((a) => a.network === 'ethereum').address).toBe(ETHEREUM_0) + }) + + it('derives testnet addresses only when asked', async () => { + await importAndUnlock() + + const result = await cli.json(['get', 'address', '--all', '--testnet'], { unlocked: true }) + + expect(result.type).toBe('testnet') + }) + + it('refuses a network that is not registered', async () => { + await importAndUnlock() + + const result = await cli.run(['get', 'address', '--network', 'atlantis'], { unlocked: true }) + + expect(result.code).toBe(1) + }) + + it('refuses to derive while locked', async () => { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + + const result = await cli.run(['get', 'address', '--network', 'ethereum']) + + expect(result.code).toBe(1) + }) + + it('refuses a network whose module the user disabled', async () => { + await importAndUnlock() + await cli.run(['network', 'disable', '--name', 'ethereum'], { unlocked: true }) + + const result = await cli.run(['get', 'address', '--network', 'ethereum'], { unlocked: true }) + + expect(result.code).toBe(1) + }) +}) From d47b585f44a062afcb31625e333a31fe8fa376f4 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Fri, 25 Sep 2026 15:53:33 +0700 Subject: [PATCH 05/15] add registry integration tests --- package.json | 5 +- tests/integration/helpers.js | 14 ++- tests/integration/registry.test.js | 138 +++++++++++++++++++++++++ tests/integration/wallet.test.js | 159 ++++++++++++++++++++++++++++- tests/setup.js | 19 ++++ tests/unit/ui/prompts.test.js | 19 ++++ 6 files changed, 347 insertions(+), 7 deletions(-) create mode 100644 tests/setup.js diff --git a/package.json b/package.json index a3ab7d1..b4da9f2 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,13 @@ "sync-modules": "node scripts/sync-module-deps.mjs", "typecheck": "tsc --noEmit -p jsconfig.json", "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns tests/integration", - "test:integration": "cross-env NODE_OPTIONS=--experimental-vm-modules jest tests/integration --testTimeout=60000", + "test:integration": "cross-env NODE_OPTIONS=--experimental-vm-modules jest tests/integration --runInBand --testTimeout=60000", "test:coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage" }, "jest": { + "setupFiles": [ + "/tests/setup.js" + ], "testEnvironment": "node", "testMatch": [ "/tests/**/*.test.js" diff --git a/tests/integration/helpers.js b/tests/integration/helpers.js index 8fdfcb8..11a4cca 100644 --- a/tests/integration/helpers.js +++ b/tests/integration/helpers.js @@ -61,6 +61,8 @@ export class Cli { * `--*-stdin` flags. * @param {boolean} [options.unlocked] - Pass the instance passphrase through * `WDK_PASSPHRASE`, the documented way to run wallet commands unattended. + * @param {string} [options.passphrase] - Use this passphrase instead of the + * instance one, for a wallet whose passphrase has been changed. * @returns {Promise} What the command printed and exited with. */ run (args, options = {}) { @@ -69,7 +71,7 @@ export class Cli { env: { ...process.env, XDG_CONFIG_HOME: this.configHome, - ...(options.unlocked ? { WDK_PASSPHRASE: this.passphrase } : { WDK_PASSPHRASE: undefined }), + WDK_PASSPHRASE: options.passphrase ?? (options.unlocked ? this.passphrase : undefined), NO_COLOR: '1', NODE_OPTIONS: '--disable-warning=ExperimentalWarning' }, @@ -151,11 +153,19 @@ export class Cli { } /** - * Removes the config directory. + * Stops the daemon this instance started, then removes its config directory. + * Without the kill each instance leaves a daemon holding a socket, and a + * suite's worth of them starves the next test of resources. * * @returns {void} */ cleanup () { + try { + const pid = Number(readFileSync(join(this.configHome, 'wdk-cli', 'daemon.pid'), 'utf8').trim()) + if (pid > 0) process.kill(pid, 'SIGTERM') + } catch { + // No daemon was started, or it is already gone. + } rmSync(this.configHome, { recursive: true, force: true }) } } diff --git a/tests/integration/registry.test.js b/tests/integration/registry.test.js index 3b7f4d7..927bdaf 100644 --- a/tests/integration/registry.test.js +++ b/tests/integration/registry.test.js @@ -13,6 +13,7 @@ // limitations under the License. import { createRequire } from 'node:module' +import { statSync } from 'node:fs' import { Cli } from './helpers.js' const catalog = createRequire(import.meta.url)('../../wdk.config.json') @@ -114,12 +115,14 @@ describe('provider add', () => { const result = await cli.run(['provider', 'add', '"just a string"']) expect(result.code).toBe(1) + expect(result.output).toContain('Cannot read ') }) it('refuses malformed JSON', async () => { const result = await cli.run(['provider', 'add', '{not json']) expect(result.code).toBe(1) + expect(result.output).toContain('Invalid JSON in ') }) it('persists endpointKeys so the module is handed a callback', async () => { @@ -156,6 +159,7 @@ describe('provider enable, disable and delete', () => { const result = await cli.run(['provider', 'delete', '--name', 'ghost']) expect(result.code).toBe(1) + expect(result.output).toContain("Provider 'ghost' is not a custom provider.") }) it('marks a disabled provider in the listing', async () => { @@ -190,6 +194,7 @@ describe('provider enable, disable and delete', () => { const again = await cli.run(['provider', 'disable', '--name', 'moonpay']) expect(again.code).toBe(1) + expect(again.output).toContain("'moonpay' is already disabled.") }) }) @@ -210,12 +215,14 @@ describe('token registry', () => { const result = await cli.run(['token', 'info', '--network', 'ethereum', '--token', 'nope']) expect(result.code).toBe(1) + expect(result.output).toContain("Token 'nope' not found on 'ethereum'.") }) it('reports a network that does not exist', async () => { const result = await cli.run(['token', 'list', '--network', 'atlantis']) expect(result.code).toBe(1) + expect(result.output).toContain("Network 'atlantis' is not supported.") }) it('adds a custom token and reads it back', async () => { @@ -277,6 +284,7 @@ describe('network registry', () => { })]) expect(result.code).toBe(1) + expect(result.output).toContain("Network 'ethereum' already exists.") }) it('refuses a module that is not a wallet module', async () => { @@ -292,6 +300,7 @@ describe('network registry', () => { const result = await cli.run(['network', 'delete', '--name', 'ethereum']) expect(result.code).toBe(1) + expect(result.output).toContain("'ethereum' is a built-in network and cannot be deleted.") }) }) @@ -310,3 +319,132 @@ describe('module registry', () => { expect(indexer.status).toBe('ok') }) }) + +describe('config', () => { + it('reports where the config file lives', async () => { + const result = await cli.run(['config', 'path']) + + expect(result.stdout.trim()).toBe(cli.configPath()) + }) + + it('round-trips a value through set and get', async () => { + await cli.run(['config', 'set', '--key', 'defaults.defaultIndex', '--value', '3']) + + const result = await cli.run(['config', 'get', '--key', 'defaults.defaultIndex']) + + expect(result.stdout.trim()).toBe('3') + }) + + it('reports a key that was never set, and still exits 0', async () => { + const result = await cli.run(['config', 'get', '--key', 'nope.missing']) + + expect(result.output).toContain("Key 'nope.missing' is not set.") + expect(result.code).toBe(0) + }) + + it('writes the value where the file can be read back', async () => { + await cli.run(['config', 'set', '--key', 'providers.bitfinex.config.apiKey', '--value', 'k']) + + expect(cli.readConfig().providers.bitfinex.config.apiKey).toBe('k') + }) + + it('clears everything on reset', async () => { + await cli.run(['config', 'set', '--key', 'defaults.defaultIndex', '--value', '3']) + + await cli.run(['config', 'reset', '--all']) + const result = await cli.run(['config', 'get', '--key', 'defaults.defaultIndex']) + + expect(result.output).toContain("Key 'defaults.defaultIndex' is not set.") + }) + + it('keeps the config owner-only after a write', async () => { + await cli.run(['config', 'set', '--key', 'defaults.defaultIndex', '--value', '1']) + + expect(statSync(cli.configPath()).mode & 0o077).toBe(0) + }) +}) + +describe('network info, enable and disable', () => { + it('shows a packaged network', async () => { + const info = await cli.json(['network', 'info', '--network', 'ethereum']) + + expect(info).toMatchObject({ + name: 'ethereum', + displayName: 'Ethereum', + module: '@tetherto/wdk-wallet-evm' + }) + }) + + it('reports a network that is not registered', async () => { + const result = await cli.run(['network', 'info', '--network', 'atlantis']) + + expect(result.code).toBe(1) + expect(result.output).toContain("Network 'atlantis' is not supported.") + }) + + it('drops a disabled network from the listing', async () => { + await cli.run(['network', 'disable', '--name', 'polygon']) + + const { networks } = await cli.json(['network', 'list']) + + expect(networks.find((n) => n.name === 'polygon').enabled).toBe(false) + }) + + it('brings a disabled network back', async () => { + await cli.run(['network', 'disable', '--name', 'polygon']) + + await cli.run(['network', 'enable', '--name', 'polygon']) + const { networks } = await cli.json(['network', 'list']) + + expect(networks.find((n) => n.name === 'polygon').enabled).toBe(true) + }) + + it('refuses to disable a network twice', async () => { + await cli.run(['network', 'disable', '--name', 'polygon']) + + const again = await cli.run(['network', 'disable', '--name', 'polygon']) + + expect(again.code).toBe(1) + }) +}) + +describe('token enable and disable', () => { + it('marks a disabled token in the listing', async () => { + await cli.run(['token', 'disable', '--network', 'ethereum', '--token', 'usdt']) + + const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) + + expect(disabled).toContain('ethereum/usdt') + }) + + it('brings a disabled token back', async () => { + await cli.run(['token', 'disable', '--network', 'ethereum', '--token', 'usdt']) + + await cli.run(['token', 'enable', '--network', 'ethereum', '--token', 'usdt']) + const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) + + expect(disabled).not.toContain('ethereum/usdt') + }) +}) + +describe('module enable and disable', () => { + it('reports a disabled module and hides its providers', async () => { + await cli.run(['module', 'disable', '--name', '@tetherto/wdk-protocol-fiat-moonpay']) + + const { modules } = await cli.json(['module', 'list']) + const { providers } = await cli.json(['provider', 'list']) + + expect(modules.find((m) => m.module === '@tetherto/wdk-protocol-fiat-moonpay').status) + .toBe('disabled') + expect(providers.find((p) => p.name === 'moonpay')).toBeUndefined() + }) + + it('brings a disabled module back', async () => { + await cli.run(['module', 'disable', '--name', '@tetherto/wdk-protocol-fiat-moonpay']) + + await cli.run(['module', 'enable', '--name', '@tetherto/wdk-protocol-fiat-moonpay']) + const { modules } = await cli.json(['module', 'list']) + + expect(modules.find((m) => m.module === '@tetherto/wdk-protocol-fiat-moonpay').status).toBe('ok') + }) +}) diff --git a/tests/integration/wallet.test.js b/tests/integration/wallet.test.js index 4d64ed4..4627858 100644 --- a/tests/integration/wallet.test.js +++ b/tests/integration/wallet.test.js @@ -14,8 +14,6 @@ import { Cli } from './helpers.js' -// The seed the WDK wallet modules use in their own integration tests, so the -// addresses below match what `wdk-wallet-evm` asserts for the same derivation. const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' const ETHEREUM_0 = '0x405005C7c4422390F4B334F64Cf20E0b767131d0' /** Mainnet networks in the packaged registry. */ @@ -77,6 +75,7 @@ describe('wallet lifecycle', () => { const again = await cli.run(['wallet', 'create', '--name', 'w1'], { unlocked: true }) expect(again.code).toBe(1) + expect(again.output).toContain("Wallet 'w1' already exists.") }) it('refuses a word count that is not 12 or 24', async () => { @@ -85,6 +84,7 @@ describe('wallet lifecycle', () => { ) expect(result.code).toBe(1) + expect(result.output).toContain('--words must be 12 or 24') }) it('cannot prompt for a passphrase when stdin is piped', async () => { @@ -137,6 +137,7 @@ describe('import, unlock and lock', () => { }) expect(result.code).toBe(1) + expect(result.output).toContain('Invalid seed phrase. Must be 12 or 24 valid BIP-39 words.') }) it('refuses the wrong passphrase on unlock', async () => { @@ -145,9 +146,10 @@ describe('import, unlock and lock', () => { stdin: SEED + '\n' }) - const result = await cli.run(['wallet', 'unlock', '--name', 'main']) + const result = await cli.run(['wallet', 'unlock', '--name', 'main'], { passphrase: 'wrong-one' }) expect(result.code).toBe(1) + expect(result.output).toContain('Incorrect passphrase.') }) it('requires a target for lock', async () => { @@ -212,6 +214,7 @@ describe('address derivation, which needs no chain', () => { const result = await cli.run(['get', 'address', '--network', 'atlantis'], { unlocked: true }) expect(result.code).toBe(1) + expect(result.output).toContain("Network 'atlantis' is not supported.") }) it('refuses to derive while locked', async () => { @@ -223,14 +226,162 @@ describe('address derivation, which needs no chain', () => { const result = await cli.run(['get', 'address', '--network', 'ethereum']) expect(result.code).toBe(1) + expect(result.output).toContain("Wallet 'main' is not unlocked.") }) - it('refuses a network whose module the user disabled', async () => { + it('refuses a network the user disabled', async () => { await importAndUnlock() await cli.run(['network', 'disable', '--name', 'ethereum'], { unlocked: true }) + // Disabling a network locks every wallet, so re-unlock: otherwise this + // passes on "not unlocked" and proves nothing about the network. + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) const result = await cli.run(['get', 'address', '--network', 'ethereum'], { unlocked: true }) expect(result.code).toBe(1) + expect(result.output).toContain("Network 'ethereum' is disabled.") + }) +}) + +describe('export, which decrypts the seed', () => { + it('returns exactly the seed that was imported', async () => { + await importAndUnlock() + + const exported = await cli.json(['wallet', 'export', '--name', 'main'], { unlocked: true }) + + expect(exported.seedPhrase).toBe(SEED) + }) + + it('refuses to export without the passphrase', async () => { + await importAndUnlock() + + const result = await cli.run(['wallet', 'export', '--name', 'main']) + + expect(result.code).toBe(1) + expect(result.output).not.toContain('cook voyage') + }) + + it('refuses to export a wallet that does not exist', async () => { + const result = await cli.run(['wallet', 'export', '--name', 'ghost'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("Wallet 'ghost' not found.") + }) +}) + +describe('the default wallet', () => { + it('moves the default to another wallet', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + await cli.json(['wallet', 'create', '--name', 'w2'], { unlocked: true }) + + await cli.run(['wallet', 'default', '--name', 'w2'], { unlocked: true }) + const { wallets } = await cli.json(['wallet', 'list']) + + expect(wallets.find((w) => w.default).name).toBe('w2') + }) + + it('refuses to default to a wallet that does not exist', async () => { + const result = await cli.run(['wallet', 'default', '--name', 'ghost'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("Wallet 'ghost' not found.") + }) + + it('leaves the second wallet non-default when created', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + await cli.json(['wallet', 'create', '--name', 'w2'], { unlocked: true }) + + const { wallets } = await cli.json(['wallet', 'list']) + + expect(wallets.filter((w) => w.default).map((w) => w.name)).toEqual(['w1']) + }) +}) + +describe('change-passphrase, which re-encrypts the seed', () => { + it('keeps the seed recoverable under the new passphrase', async () => { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + + const changed = await cli.run( + ['wallet', 'change-passphrase', '--name', 'main', '--new-passphrase-stdin'], + { unlocked: true, stdin: 'second-passphrase\n' } + ) + expect(changed.code).toBe(0) + + const exported = await cli.run(['wallet', 'export', '--name', 'main', '--json'], { + passphrase: 'second-passphrase' + }) + + expect(JSON.parse(exported.stdout.trim().split('\n').pop()).seedPhrase).toBe(SEED) + }) + + it('stops the old passphrase from working', async () => { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + await cli.run( + ['wallet', 'change-passphrase', '--name', 'main', '--new-passphrase-stdin'], + { unlocked: true, stdin: 'second-passphrase\n' } + ) + + const result = await cli.run(['wallet', 'export', '--name', 'main'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain('Incorrect passphrase.') + }) +}) + +describe('more than one wallet', () => { + it('locks every wallet at once', async () => { + await importAndUnlock('w1') + await cli.run(['wallet', 'import', '--name', 'w2', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + await cli.run(['wallet', 'unlock', '--name', 'w2'], { unlocked: true }) + + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + const { wallets } = await cli.json(['wallet', 'list']) + + expect(wallets.every((w) => !w.unlocked)).toBe(true) + }) + + it('derives from the wallet named by --wallet, not the default', async () => { + await importAndUnlock('w1') + await cli.json(['wallet', 'create', '--name', 'w2'], { unlocked: true }) + await cli.run(['wallet', 'unlock', '--name', 'w2'], { unlocked: true }) + + const fromNamed = await cli.json( + ['get', 'address', '--network', 'ethereum', '--wallet', 'w1'], { unlocked: true } + ) + + expect(fromNamed.address).toBe(ETHEREUM_0) + }) + + it('refuses a rename onto a name already taken', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + await cli.json(['wallet', 'create', '--name', 'w2'], { unlocked: true }) + + const result = await cli.run( + ['wallet', 'rename', '--name', 'w1', '--new-name', 'w2'], { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain("Wallet 'w2' already exists.") + }) + + it('refuses to import onto a name already taken', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + const result = await cli.run(['wallet', 'import', '--name', 'w1', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + + expect(result.code).toBe(1) + expect(result.output).toContain("Wallet 'w1' already exists.") }) }) diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..c7b56c9 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,19 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Chalk colours its output when it detects a terminal, so a suite that asserts +// on logged strings passes in CI (no TTY, no colour) and fails on a developer's +// machine, with a diff whose two sides look identical. Pinning it off here +// makes every run deterministic, wherever it runs. +process.env.FORCE_COLOR = '0' diff --git a/tests/unit/ui/prompts.test.js b/tests/unit/ui/prompts.test.js index 125a36b..b70487d 100644 --- a/tests/unit/ui/prompts.test.js +++ b/tests/unit/ui/prompts.test.js @@ -30,11 +30,17 @@ const originalIsTTY = process.stdin.isTTY const setStdinTTY = (isTTY) => Object.defineProperty(process.stdin, 'isTTY', { value: isTTY, configurable: true }) +/** Captures the env-var notice, which would otherwise print during the run. */ +let notices = [] + beforeEach(() => { setStdinTTY(true) + notices = [] + jest.spyOn(console, 'error').mockImplementation((line) => notices.push(String(line))) }) afterEach(() => { + jest.restoreAllMocks() jest.clearAllMocks() setStdinTTY(originalIsTTY) if (originalEnvPassphrase === undefined) { @@ -54,6 +60,19 @@ describe('promptPassphrase', () => { expect(password).not.toHaveBeenCalled() }) + it('says once that the passphrase came from the environment', async () => { + process.env.WDK_PASSPHRASE = DUMMY_ENV_PASSPHRASE + // The notice fires once per module instance, so take a fresh one: by now + // the import at the top of this file has already spent it. + jest.resetModules() + const { promptPassphrase: fresh } = await import('../../../src/ui/prompts.js') + + await fresh('Enter passphrase:') + await fresh('Enter passphrase:') + + expect(notices).toEqual(['Note: using passphrase from WDK_PASSPHRASE env var.']) + }) + it('prompts despite WDK_PASSPHRASE when allowEnv is false', async () => { process.env.WDK_PASSPHRASE = DUMMY_ENV_PASSPHRASE From 574f9178a85a34d5582d0d6b2a1e0217a1f9f659 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Fri, 25 Sep 2026 16:20:57 +0700 Subject: [PATCH 06/15] add network, token integration tests --- tests/integration/helpers.js | 7 + tests/integration/module.test.js | 128 ++++++ tests/integration/network.test.js | 109 +++++ tests/integration/provider.test.js | 197 +++++++++ tests/integration/read-operations.test.js | 164 ++++++++ tests/integration/registry.test.js | 450 --------------------- tests/integration/token.test.js | 112 +++++ tests/integration/wallet.test.js | 74 ---- tests/integration/write-operations.test.js | 80 ++++ 9 files changed, 797 insertions(+), 524 deletions(-) create mode 100644 tests/integration/module.test.js create mode 100644 tests/integration/network.test.js create mode 100644 tests/integration/provider.test.js create mode 100644 tests/integration/read-operations.test.js delete mode 100644 tests/integration/registry.test.js create mode 100644 tests/integration/token.test.js create mode 100644 tests/integration/write-operations.test.js diff --git a/tests/integration/helpers.js b/tests/integration/helpers.js index 11a4cca..6a0c3b8 100644 --- a/tests/integration/helpers.js +++ b/tests/integration/helpers.js @@ -42,11 +42,16 @@ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') export class Cli { /** The config directory this instance runs against. */ configHome + /** A throwaway home directory, so nothing can reach the real one. */ + home /** The passphrase every wallet in this instance is created with. */ passphrase constructor () { this.configHome = mkdtempSync(join(tmpdir(), 'wdk-it-')) + // `mcp setup` and friends resolve paths from homedir(), not XDG_CONFIG_HOME, + // so without this they would edit the developer's own AI-tool config. + this.home = mkdtempSync(join(tmpdir(), 'wdk-home-')) // Generated per instance and thrown away with the directory: these wallets // exist only for the length of one test file. this.passphrase = `pw-${randomBytes(12).toString('hex')}` @@ -70,6 +75,7 @@ export class Cli { const child = spawn(process.execPath, [BIN, ...args], { env: { ...process.env, + HOME: this.home, XDG_CONFIG_HOME: this.configHome, WDK_PASSPHRASE: options.passphrase ?? (options.unlocked ? this.passphrase : undefined), NO_COLOR: '1', @@ -167,5 +173,6 @@ export class Cli { // No daemon was started, or it is already gone. } rmSync(this.configHome, { recursive: true, force: true }) + rmSync(this.home, { recursive: true, force: true }) } } diff --git a/tests/integration/module.test.js b/tests/integration/module.test.js new file mode 100644 index 0000000..6d1fa3c --- /dev/null +++ b/tests/integration/module.test.js @@ -0,0 +1,128 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createRequire } from 'node:module' +import { statSync } from 'node:fs' +import { Cli } from './helpers.js' + +const catalog = createRequire(import.meta.url)('../../wdk.config.json') +const MOONPAY_MODULE = catalog.providers.moonpay.module +const BITFINEX_MODULE = catalog.providers.bitfinex.module + +/** @type {Cli} */ +let cli + +beforeEach(() => { cli = new Cli() }) +afterEach(() => cli.cleanup()) + +describe('module registry', () => { + it('reports every catalog module as installed and ok', async () => { + const { modules } = await cli.json(['module', 'list']) + + expect(modules.every((m) => m.status === 'ok')).toBe(true) + }) + + it('treats a git-pinned module as satisfied rather than mismatched', async () => { + const { modules } = await cli.json(['module', 'list']) + const indexer = modules.find((m) => m.module === '@tetherto/wdk-indexer-http') + + expect(indexer.pinned).toContain('github:') + expect(indexer.status).toBe('ok') + }) +}) +describe('module enable and disable', () => { + it('reports a disabled module and hides its providers', async () => { + await cli.run(['module', 'disable', '--name', '@tetherto/wdk-protocol-fiat-moonpay']) + + const { modules } = await cli.json(['module', 'list']) + const { providers } = await cli.json(['provider', 'list']) + + expect(modules.find((m) => m.module === '@tetherto/wdk-protocol-fiat-moonpay').status) + .toBe('disabled') + expect(providers.find((p) => p.name === 'moonpay')).toBeUndefined() + }) + + it('brings a disabled module back', async () => { + await cli.run(['module', 'disable', '--name', '@tetherto/wdk-protocol-fiat-moonpay']) + + await cli.run(['module', 'enable', '--name', '@tetherto/wdk-protocol-fiat-moonpay']) + const { modules } = await cli.json(['module', 'list']) + + expect(modules.find((m) => m.module === '@tetherto/wdk-protocol-fiat-moonpay').status).toBe('ok') + }) +}) +describe('config', () => { + it('reports where the config file lives', async () => { + const result = await cli.run(['config', 'path']) + + expect(result.stdout.trim()).toBe(cli.configPath()) + }) + + it('round-trips a value through set and get', async () => { + await cli.run(['config', 'set', '--key', 'defaults.defaultIndex', '--value', '3']) + + const result = await cli.run(['config', 'get', '--key', 'defaults.defaultIndex']) + + expect(result.stdout.trim()).toBe('3') + }) + + it('reports a key that was never set, and still exits 0', async () => { + const result = await cli.run(['config', 'get', '--key', 'nope.missing']) + + expect(result.output).toContain("Key 'nope.missing' is not set.") + expect(result.code).toBe(0) + }) + + it('writes the value where the file can be read back', async () => { + await cli.run(['config', 'set', '--key', 'providers.bitfinex.config.apiKey', '--value', 'k']) + + expect(cli.readConfig().providers.bitfinex.config.apiKey).toBe('k') + }) + + it('clears everything on reset', async () => { + await cli.run(['config', 'set', '--key', 'defaults.defaultIndex', '--value', '3']) + + await cli.run(['config', 'reset', '--all']) + const result = await cli.run(['config', 'get', '--key', 'defaults.defaultIndex']) + + expect(result.output).toContain("Key 'defaults.defaultIndex' is not set.") + }) + + it('keeps the config owner-only after a write', async () => { + await cli.run(['config', 'set', '--key', 'defaults.defaultIndex', '--value', '1']) + + expect(statSync(cli.configPath()).mode & 0o077).toBe(0) + }) +}) + +describe('module methods', () => { + it('lists the methods a wallet module declares', async () => { + const { network, methods } = await cli.json(['method', 'list', '--network', 'spark']) + + expect(network).toBe('spark') + expect(methods.map((m) => m.name)).toContain('getStaticDepositAddress') + }) + + it('marks each method read or write', async () => { + const { methods } = await cli.json(['method', 'list', '--network', 'spark']) + + expect(methods.every((m) => m.kind === 'read' || m.kind === 'write')).toBe(true) + }) + + it('returns an empty list for a network that declares none', async () => { + const result = await cli.json(['method', 'list', '--network', 'bitcoin']) + + expect(result).toEqual({ network: 'bitcoin', methods: [] }) + }) +}) diff --git a/tests/integration/network.test.js b/tests/integration/network.test.js new file mode 100644 index 0000000..f05503f --- /dev/null +++ b/tests/integration/network.test.js @@ -0,0 +1,109 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createRequire } from 'node:module' +import { statSync } from 'node:fs' +import { Cli } from './helpers.js' + +const catalog = createRequire(import.meta.url)('../../wdk.config.json') +const MOONPAY_MODULE = catalog.providers.moonpay.module +const BITFINEX_MODULE = catalog.providers.bitfinex.module + +/** @type {Cli} */ +let cli + +beforeEach(() => { cli = new Cli() }) +afterEach(() => cli.cleanup()) + +describe('network registry', () => { + it('lists the packaged networks', async () => { + const { networks } = await cli.json(['network', 'list']) + + expect(networks.length).toBe(22) + }) + + it('separates testnets behind a flag', async () => { + const { networks } = await cli.json(['network', 'list', '--testnet']) + + expect(networks.every((n) => n.testnet)).toBe(true) + }) + + it('refuses a network name that already exists', async () => { + const result = await cli.run(['network', 'create', JSON.stringify({ + network: 'ethereum', module: '@tetherto/wdk-wallet-evm', chainId: 'eip155:1', displayName: 'X' + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain("Network 'ethereum' already exists.") + }) + + it('refuses a module that is not a wallet module', async () => { + const result = await cli.run(['network', 'create', JSON.stringify({ + network: 'mychain', module: MOONPAY_MODULE, chainId: 'eip155:99', displayName: 'Mine' + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain('Network spec "module" must be one of') + }) + + it('refuses to delete a packaged network', async () => { + const result = await cli.run(['network', 'delete', '--name', 'ethereum']) + + expect(result.code).toBe(1) + expect(result.output).toContain("'ethereum' is a built-in network and cannot be deleted.") + }) +}) +describe('network info, enable and disable', () => { + it('shows a packaged network', async () => { + const info = await cli.json(['network', 'info', '--network', 'ethereum']) + + expect(info).toMatchObject({ + name: 'ethereum', + displayName: 'Ethereum', + module: '@tetherto/wdk-wallet-evm' + }) + }) + + it('reports a network that is not registered', async () => { + const result = await cli.run(['network', 'info', '--network', 'atlantis']) + + expect(result.code).toBe(1) + expect(result.output).toContain("Network 'atlantis' is not supported.") + }) + + it('drops a disabled network from the listing', async () => { + await cli.run(['network', 'disable', '--name', 'polygon']) + + const { networks } = await cli.json(['network', 'list']) + + expect(networks.find((n) => n.name === 'polygon').enabled).toBe(false) + }) + + it('brings a disabled network back', async () => { + await cli.run(['network', 'disable', '--name', 'polygon']) + + await cli.run(['network', 'enable', '--name', 'polygon']) + const { networks } = await cli.json(['network', 'list']) + + expect(networks.find((n) => n.name === 'polygon').enabled).toBe(true) + }) + + it('refuses to disable a network twice', async () => { + await cli.run(['network', 'disable', '--name', 'polygon']) + + const again = await cli.run(['network', 'disable', '--name', 'polygon']) + + expect(again.code).toBe(1) + }) +}) diff --git a/tests/integration/provider.test.js b/tests/integration/provider.test.js new file mode 100644 index 0000000..300e203 --- /dev/null +++ b/tests/integration/provider.test.js @@ -0,0 +1,197 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createRequire } from 'node:module' +import { statSync } from 'node:fs' +import { Cli } from './helpers.js' + +const catalog = createRequire(import.meta.url)('../../wdk.config.json') +const MOONPAY_MODULE = catalog.providers.moonpay.module +const BITFINEX_MODULE = catalog.providers.bitfinex.module + +/** @type {Cli} */ +let cli + +beforeEach(() => { cli = new Cli() }) +afterEach(() => cli.cleanup()) + +describe('provider list and info', () => { + it('lists every packaged provider, pricing first', async () => { + const { providers, count } = await cli.json(['provider', 'list']) + + expect(count).toBe(8) + expect(providers.map((p) => p.name)).toEqual([ + 'wdk-indexer', 'bitfinex', 'moonpay', 'transak', 'velora', 'usdt0', 'rhinofi', 'symbiosis' + ]) + }) + + it('reports the indexer with the client package backing it', async () => { + const info = await cli.json(['provider', 'info', '--name', 'wdk-indexer']) + + expect(info).toMatchObject({ + name: 'wdk-indexer', + kind: 'indexer', + module: '@tetherto/wdk-indexer-http', + source: 'built-in' + }) + }) + + it('reports an unknown provider by name', async () => { + const result = await cli.run(['provider', 'info', '--name', 'nope']) + + expect(result.code).toBe(1) + expect(result.output).toContain("Unknown provider 'nope'.") + }) +}) +describe('provider add', () => { + it('refuses a kind the registry does not define', async () => { + const result = await cli.run(['provider', 'add', JSON.stringify({ + name: 'x', kind: 'lending', module: MOONPAY_MODULE + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain('Provider spec "kind" must be one of') + }) + + it('refuses an indexer, which ships with the CLI', async () => { + const result = await cli.run(['provider', 'add', JSON.stringify({ + name: 'myidx', kind: 'indexer', module: MOONPAY_MODULE + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain('Indexer providers cannot be added.') + }) + + it('refuses a second price feed while the packaged one is enabled', async () => { + const result = await cli.run(['provider', 'add', JSON.stringify({ + name: 'cg', kind: 'pricing', module: BITFINEX_MODULE + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain('A price feed is already enabled: bitfinex.') + }) + + it('accepts a price feed once the packaged one is disabled', async () => { + await cli.run(['provider', 'disable', '--name', 'bitfinex']) + + const added = await cli.json(['provider', 'add', JSON.stringify({ + name: 'cg', kind: 'pricing', module: BITFINEX_MODULE + })]) + + expect(added).toMatchObject({ name: 'cg', kind: 'pricing', added: true }) + }) + + it('refuses a module that is not registered', async () => { + const result = await cli.run(['provider', 'add', JSON.stringify({ + name: 'x', kind: 'swap', module: '@nobody/not-installed' + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain("Module '@nobody/not-installed' is not registered.") + }) + + it('refuses a name that is already taken', async () => { + const result = await cli.run(['provider', 'add', JSON.stringify({ + name: 'moonpay', kind: 'fiat', module: MOONPAY_MODULE + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain("'moonpay' is a built-in provider.") + }) + + it('refuses a spec that is not an object', async () => { + const result = await cli.run(['provider', 'add', '"just a string"']) + + expect(result.code).toBe(1) + expect(result.output).toContain('Cannot read ') + }) + + it('refuses malformed JSON', async () => { + const result = await cli.run(['provider', 'add', '{not json']) + + expect(result.code).toBe(1) + expect(result.output).toContain('Invalid JSON in ') + }) + + it('persists endpointKeys so the module is handed a callback', async () => { + await cli.json(['provider', 'add', JSON.stringify({ + name: 'banxa', + kind: 'fiat', + module: MOONPAY_MODULE, + endpointKeys: ['widgetUrl'], + config: { apiKey: 'dummy-key', widgetUrl: 'https://dummy-signer.test/sign' } + })]) + + expect(cli.readConfig().customProviders.banxa.endpointKeys).toEqual(['widgetUrl']) + }) + + it('refuses endpointKeys that are not strings', async () => { + const result = await cli.run(['provider', 'add', JSON.stringify({ + name: 'banxa', kind: 'fiat', module: MOONPAY_MODULE, endpointKeys: [1] + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain('Provider spec "endpointKeys" must be an array of non-empty strings.') + }) +}) +describe('provider enable, disable and delete', () => { + it('refuses to delete a packaged provider', async () => { + const result = await cli.run(['provider', 'delete', '--name', 'bitfinex']) + + expect(result.code).toBe(1) + expect(result.output).toContain("'bitfinex' is a built-in provider and cannot be deleted.") + }) + + it('reports a delete for a provider that was never added', async () => { + const result = await cli.run(['provider', 'delete', '--name', 'ghost']) + + expect(result.code).toBe(1) + expect(result.output).toContain("Provider 'ghost' is not a custom provider.") + }) + + it('marks a disabled provider in the listing', async () => { + await cli.run(['provider', 'disable', '--name', 'moonpay']) + + const { providers } = await cli.json(['provider', 'list']) + + expect(providers.find((p) => p.name === 'moonpay').enabled).toBe(false) + }) + + it('refuses to enable a second indexer', async () => { + const config = cli.readConfig() + config.customProviders = { myidx: { kind: 'indexer', config: {} } } + config.overrides = { providers: { myidx: { enabled: false } } } + cli.writeConfig(config) + + const result = await cli.run(['provider', 'enable', '--name', 'myidx']) + + expect(result.code).toBe(1) + expect(result.output).toContain('An indexer is already enabled: wdk-indexer.') + }) + + it('always allows disabling, so a feed can be swapped', async () => { + const result = await cli.run(['provider', 'disable', '--name', 'wdk-indexer']) + + expect(result.code).toBe(0) + }) + + it('refuses to disable something already disabled', async () => { + await cli.run(['provider', 'disable', '--name', 'moonpay']) + + const again = await cli.run(['provider', 'disable', '--name', 'moonpay']) + + expect(again.code).toBe(1) + expect(again.output).toContain("'moonpay' is already disabled.") + }) +}) diff --git a/tests/integration/read-operations.test.js b/tests/integration/read-operations.test.js new file mode 100644 index 0000000..f23f450 --- /dev/null +++ b/tests/integration/read-operations.test.js @@ -0,0 +1,164 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Cli } from './helpers.js' + +const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' +const ETHEREUM_0 = '0x405005C7c4422390F4B334F64Cf20E0b767131d0' +/** Mainnet networks in the packaged registry. */ +const MAINNET_COUNT = 15 + +// ECDSA over a fixed seed and message is deterministic, so this is an exact +// value rather than a shape. If it ever changes, the derivation changed. +const HELLO_SIGNATURE = + '0x05c005143100eff1f58be46b0a7fb7fe1a47f20d29d7c14e913dc9ae798e00ab' + + '6e769cf61784923262ba5bda8f0f5140d661a9b3f14df4d17837dc06dea555ac1b' + +/** @type {Cli} */ +let cli + +beforeEach(() => { + cli = new Cli() +}) + +afterEach(async () => { + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + cli.cleanup() +}) + +/** + * Imports the fixed test seed under a name and unlocks it. + * + * @param {string} [name] - The wallet name. + * @returns {Promise} + */ +async function importAndUnlock (name = 'main') { + await cli.run(['wallet', 'import', '--name', name, '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + await cli.run(['wallet', 'unlock', '--name', name], { unlocked: true }) +} + +describe('address derivation', () => { + it('derives the same address the seed always produces', async () => { + await importAndUnlock() + + const result = await cli.json(['get', 'address', '--network', 'ethereum'], { unlocked: true }) + + expect(result).toEqual({ network: 'ethereum', index: 0, address: ETHEREUM_0 }) + }) + + it('derives a different address at another index', async () => { + await importAndUnlock() + + const zero = await cli.json(['get', 'address', '--network', 'ethereum'], { unlocked: true }) + const one = await cli.json( + ['get', 'address', '--network', 'ethereum', '--index', '1'], { unlocked: true } + ) + + expect(one.index).toBe(1) + expect(one.address).not.toBe(zero.address) + }) + + it('derives every mainnet address in one call', async () => { + await importAndUnlock() + + const result = await cli.json(['get', 'address', '--all'], { unlocked: true }) + + expect(result.type).toBe('mainnet') + expect(result.addresses).toHaveLength(MAINNET_COUNT) + expect(result.addresses.find((a) => a.network === 'ethereum').address).toBe(ETHEREUM_0) + }) + + it('derives testnet addresses only when asked', async () => { + await importAndUnlock() + + const result = await cli.json(['get', 'address', '--all', '--testnet'], { unlocked: true }) + + expect(result.type).toBe('testnet') + }) + + it('refuses a network that is not registered', async () => { + await importAndUnlock() + + const result = await cli.run(['get', 'address', '--network', 'atlantis'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("Network 'atlantis' is not supported.") + }) + + it('refuses to derive while locked', async () => { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + + const result = await cli.run(['get', 'address', '--network', 'ethereum']) + + expect(result.code).toBe(1) + expect(result.output).toContain("Wallet 'main' is not unlocked.") + }) + + it('refuses a network the user disabled', async () => { + await importAndUnlock() + await cli.run(['network', 'disable', '--name', 'ethereum'], { unlocked: true }) + // Disabling a network locks every wallet, so re-unlock: otherwise this + // passes on "not unlocked" and proves nothing about the network. + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + const result = await cli.run(['get', 'address', '--network', 'ethereum'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("Network 'ethereum' is disabled.") + }) +}) + +describe('message verification', () => { + it('accepts a signature over the message that was signed', async () => { + await importAndUnlock() + + const result = await cli.json( + ['message', 'verify', '--network', 'ethereum', '--message', 'hello', + '--signature', HELLO_SIGNATURE], + { unlocked: true } + ) + + expect(result).toMatchObject({ address: ETHEREUM_0, valid: true }) + }) + + it('rejects the same signature over a different message', async () => { + await importAndUnlock() + + const result = await cli.json( + ['message', 'verify', '--network', 'ethereum', '--message', 'tampered', + '--signature', HELLO_SIGNATURE], + { unlocked: true } + ) + + expect(result.valid).toBe(false) + }) + + it('reports a signature that is not well formed', async () => { + await importAndUnlock() + + const result = await cli.run( + ['message', 'verify', '--network', 'ethereum', '--message', 'hello', '--signature', '0xdead'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain('invalid raw signature length') + }) +}) diff --git a/tests/integration/registry.test.js b/tests/integration/registry.test.js deleted file mode 100644 index 927bdaf..0000000 --- a/tests/integration/registry.test.js +++ /dev/null @@ -1,450 +0,0 @@ -// Copyright 2026 Tether Operations Limited -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { createRequire } from 'node:module' -import { statSync } from 'node:fs' -import { Cli } from './helpers.js' - -const catalog = createRequire(import.meta.url)('../../wdk.config.json') -const MOONPAY_MODULE = catalog.providers.moonpay.module -const BITFINEX_MODULE = catalog.providers.bitfinex.module - -/** @type {Cli} */ -let cli - -beforeEach(() => { cli = new Cli() }) -afterEach(() => cli.cleanup()) - -describe('provider list and info', () => { - it('lists every packaged provider, pricing first', async () => { - const { providers, count } = await cli.json(['provider', 'list']) - - expect(count).toBe(8) - expect(providers.map((p) => p.name)).toEqual([ - 'wdk-indexer', 'bitfinex', 'moonpay', 'transak', 'velora', 'usdt0', 'rhinofi', 'symbiosis' - ]) - }) - - it('reports the indexer with the client package backing it', async () => { - const info = await cli.json(['provider', 'info', '--name', 'wdk-indexer']) - - expect(info).toMatchObject({ - name: 'wdk-indexer', - kind: 'indexer', - module: '@tetherto/wdk-indexer-http', - source: 'built-in' - }) - }) - - it('reports an unknown provider by name', async () => { - const result = await cli.run(['provider', 'info', '--name', 'nope']) - - expect(result.code).toBe(1) - expect(result.output).toContain("Unknown provider 'nope'.") - }) -}) - -describe('provider add', () => { - it('refuses a kind the registry does not define', async () => { - const result = await cli.run(['provider', 'add', JSON.stringify({ - name: 'x', kind: 'lending', module: MOONPAY_MODULE - })]) - - expect(result.code).toBe(1) - expect(result.output).toContain('Provider spec "kind" must be one of') - }) - - it('refuses an indexer, which ships with the CLI', async () => { - const result = await cli.run(['provider', 'add', JSON.stringify({ - name: 'myidx', kind: 'indexer', module: MOONPAY_MODULE - })]) - - expect(result.code).toBe(1) - expect(result.output).toContain('Indexer providers cannot be added.') - }) - - it('refuses a second price feed while the packaged one is enabled', async () => { - const result = await cli.run(['provider', 'add', JSON.stringify({ - name: 'cg', kind: 'pricing', module: BITFINEX_MODULE - })]) - - expect(result.code).toBe(1) - expect(result.output).toContain('A price feed is already enabled: bitfinex.') - }) - - it('accepts a price feed once the packaged one is disabled', async () => { - await cli.run(['provider', 'disable', '--name', 'bitfinex']) - - const added = await cli.json(['provider', 'add', JSON.stringify({ - name: 'cg', kind: 'pricing', module: BITFINEX_MODULE - })]) - - expect(added).toMatchObject({ name: 'cg', kind: 'pricing', added: true }) - }) - - it('refuses a module that is not registered', async () => { - const result = await cli.run(['provider', 'add', JSON.stringify({ - name: 'x', kind: 'swap', module: '@nobody/not-installed' - })]) - - expect(result.code).toBe(1) - expect(result.output).toContain("Module '@nobody/not-installed' is not registered.") - }) - - it('refuses a name that is already taken', async () => { - const result = await cli.run(['provider', 'add', JSON.stringify({ - name: 'moonpay', kind: 'fiat', module: MOONPAY_MODULE - })]) - - expect(result.code).toBe(1) - expect(result.output).toContain("'moonpay' is a built-in provider.") - }) - - it('refuses a spec that is not an object', async () => { - const result = await cli.run(['provider', 'add', '"just a string"']) - - expect(result.code).toBe(1) - expect(result.output).toContain('Cannot read ') - }) - - it('refuses malformed JSON', async () => { - const result = await cli.run(['provider', 'add', '{not json']) - - expect(result.code).toBe(1) - expect(result.output).toContain('Invalid JSON in ') - }) - - it('persists endpointKeys so the module is handed a callback', async () => { - await cli.json(['provider', 'add', JSON.stringify({ - name: 'banxa', - kind: 'fiat', - module: MOONPAY_MODULE, - endpointKeys: ['widgetUrl'], - config: { apiKey: 'dummy-key', widgetUrl: 'https://dummy-signer.test/sign' } - })]) - - expect(cli.readConfig().customProviders.banxa.endpointKeys).toEqual(['widgetUrl']) - }) - - it('refuses endpointKeys that are not strings', async () => { - const result = await cli.run(['provider', 'add', JSON.stringify({ - name: 'banxa', kind: 'fiat', module: MOONPAY_MODULE, endpointKeys: [1] - })]) - - expect(result.code).toBe(1) - expect(result.output).toContain('Provider spec "endpointKeys" must be an array of non-empty strings.') - }) -}) - -describe('provider enable, disable and delete', () => { - it('refuses to delete a packaged provider', async () => { - const result = await cli.run(['provider', 'delete', '--name', 'bitfinex']) - - expect(result.code).toBe(1) - expect(result.output).toContain("'bitfinex' is a built-in provider and cannot be deleted.") - }) - - it('reports a delete for a provider that was never added', async () => { - const result = await cli.run(['provider', 'delete', '--name', 'ghost']) - - expect(result.code).toBe(1) - expect(result.output).toContain("Provider 'ghost' is not a custom provider.") - }) - - it('marks a disabled provider in the listing', async () => { - await cli.run(['provider', 'disable', '--name', 'moonpay']) - - const { providers } = await cli.json(['provider', 'list']) - - expect(providers.find((p) => p.name === 'moonpay').enabled).toBe(false) - }) - - it('refuses to enable a second indexer', async () => { - const config = cli.readConfig() - config.customProviders = { myidx: { kind: 'indexer', config: {} } } - config.overrides = { providers: { myidx: { enabled: false } } } - cli.writeConfig(config) - - const result = await cli.run(['provider', 'enable', '--name', 'myidx']) - - expect(result.code).toBe(1) - expect(result.output).toContain('An indexer is already enabled: wdk-indexer.') - }) - - it('always allows disabling, so a feed can be swapped', async () => { - const result = await cli.run(['provider', 'disable', '--name', 'wdk-indexer']) - - expect(result.code).toBe(0) - }) - - it('refuses to disable something already disabled', async () => { - await cli.run(['provider', 'disable', '--name', 'moonpay']) - - const again = await cli.run(['provider', 'disable', '--name', 'moonpay']) - - expect(again.code).toBe(1) - expect(again.output).toContain("'moonpay' is already disabled.") - }) -}) - -describe('token registry', () => { - it('lists the packaged tokens for a network', async () => { - const { tokens } = await cli.json(['token', 'list', '--network', 'ethereum']) - - expect(Object.keys(tokens).sort()).toEqual(['eth', 'usdt', 'xaut']) - }) - - it('shows a token with its slugs', async () => { - const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'usdt']) - - expect(info).toMatchObject({ symbol: 'USDT', decimals: 6, isNative: false }) - }) - - it('reports a token the registry does not carry', async () => { - const result = await cli.run(['token', 'info', '--network', 'ethereum', '--token', 'nope']) - - expect(result.code).toBe(1) - expect(result.output).toContain("Token 'nope' not found on 'ethereum'.") - }) - - it('reports a network that does not exist', async () => { - const result = await cli.run(['token', 'list', '--network', 'atlantis']) - - expect(result.code).toBe(1) - expect(result.output).toContain("Network 'atlantis' is not supported.") - }) - - it('adds a custom token and reads it back', async () => { - await cli.json(['token', 'add', JSON.stringify({ - network: 'ethereum', token: 'dai', symbol: 'DAI', decimals: 18, isNative: false, - address: '0x6B175474E89094C44Da98b954EedeAC495271d0F' - })]) - - const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'dai']) - - expect(info).toMatchObject({ symbol: 'DAI', decimals: 18 }) - }) - - it('warns when a custom token shadows a packaged one', async () => { - const result = await cli.run(['token', 'add', JSON.stringify({ - network: 'ethereum', token: 'usdt', symbol: 'USDT', decimals: 6, isNative: false, - address: '0xdAC17F958D2ee523a2206206994597C13D831ec7' - })]) - - expect(result.output).toContain('overrides it') - }) - - it('refuses to delete a packaged token', async () => { - const result = await cli.run(['token', 'delete', '--network', 'ethereum', '--token', 'usdt']) - - expect(result.code).toBe(1) - expect(result.output).toContain('built-in token') - }) - - it('reverts to the packaged token when the override is deleted', async () => { - await cli.json(['token', 'add', JSON.stringify({ - network: 'ethereum', token: 'usdt', symbol: 'FAKE', decimals: 2, isNative: false, - address: '0xdAC17F958D2ee523a2206206994597C13D831ec7' - })]) - - await cli.run(['token', 'delete', '--network', 'ethereum', '--token', 'usdt']) - const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'usdt']) - - expect(info.symbol).toBe('USDT') - }) -}) - -describe('network registry', () => { - it('lists the packaged networks', async () => { - const { networks } = await cli.json(['network', 'list']) - - expect(networks.length).toBe(22) - }) - - it('separates testnets behind a flag', async () => { - const { networks } = await cli.json(['network', 'list', '--testnet']) - - expect(networks.every((n) => n.testnet)).toBe(true) - }) - - it('refuses a network name that already exists', async () => { - const result = await cli.run(['network', 'create', JSON.stringify({ - network: 'ethereum', module: '@tetherto/wdk-wallet-evm', chainId: 'eip155:1', displayName: 'X' - })]) - - expect(result.code).toBe(1) - expect(result.output).toContain("Network 'ethereum' already exists.") - }) - - it('refuses a module that is not a wallet module', async () => { - const result = await cli.run(['network', 'create', JSON.stringify({ - network: 'mychain', module: MOONPAY_MODULE, chainId: 'eip155:99', displayName: 'Mine' - })]) - - expect(result.code).toBe(1) - expect(result.output).toContain('Network spec "module" must be one of') - }) - - it('refuses to delete a packaged network', async () => { - const result = await cli.run(['network', 'delete', '--name', 'ethereum']) - - expect(result.code).toBe(1) - expect(result.output).toContain("'ethereum' is a built-in network and cannot be deleted.") - }) -}) - -describe('module registry', () => { - it('reports every catalog module as installed and ok', async () => { - const { modules } = await cli.json(['module', 'list']) - - expect(modules.every((m) => m.status === 'ok')).toBe(true) - }) - - it('treats a git-pinned module as satisfied rather than mismatched', async () => { - const { modules } = await cli.json(['module', 'list']) - const indexer = modules.find((m) => m.module === '@tetherto/wdk-indexer-http') - - expect(indexer.pinned).toContain('github:') - expect(indexer.status).toBe('ok') - }) -}) - -describe('config', () => { - it('reports where the config file lives', async () => { - const result = await cli.run(['config', 'path']) - - expect(result.stdout.trim()).toBe(cli.configPath()) - }) - - it('round-trips a value through set and get', async () => { - await cli.run(['config', 'set', '--key', 'defaults.defaultIndex', '--value', '3']) - - const result = await cli.run(['config', 'get', '--key', 'defaults.defaultIndex']) - - expect(result.stdout.trim()).toBe('3') - }) - - it('reports a key that was never set, and still exits 0', async () => { - const result = await cli.run(['config', 'get', '--key', 'nope.missing']) - - expect(result.output).toContain("Key 'nope.missing' is not set.") - expect(result.code).toBe(0) - }) - - it('writes the value where the file can be read back', async () => { - await cli.run(['config', 'set', '--key', 'providers.bitfinex.config.apiKey', '--value', 'k']) - - expect(cli.readConfig().providers.bitfinex.config.apiKey).toBe('k') - }) - - it('clears everything on reset', async () => { - await cli.run(['config', 'set', '--key', 'defaults.defaultIndex', '--value', '3']) - - await cli.run(['config', 'reset', '--all']) - const result = await cli.run(['config', 'get', '--key', 'defaults.defaultIndex']) - - expect(result.output).toContain("Key 'defaults.defaultIndex' is not set.") - }) - - it('keeps the config owner-only after a write', async () => { - await cli.run(['config', 'set', '--key', 'defaults.defaultIndex', '--value', '1']) - - expect(statSync(cli.configPath()).mode & 0o077).toBe(0) - }) -}) - -describe('network info, enable and disable', () => { - it('shows a packaged network', async () => { - const info = await cli.json(['network', 'info', '--network', 'ethereum']) - - expect(info).toMatchObject({ - name: 'ethereum', - displayName: 'Ethereum', - module: '@tetherto/wdk-wallet-evm' - }) - }) - - it('reports a network that is not registered', async () => { - const result = await cli.run(['network', 'info', '--network', 'atlantis']) - - expect(result.code).toBe(1) - expect(result.output).toContain("Network 'atlantis' is not supported.") - }) - - it('drops a disabled network from the listing', async () => { - await cli.run(['network', 'disable', '--name', 'polygon']) - - const { networks } = await cli.json(['network', 'list']) - - expect(networks.find((n) => n.name === 'polygon').enabled).toBe(false) - }) - - it('brings a disabled network back', async () => { - await cli.run(['network', 'disable', '--name', 'polygon']) - - await cli.run(['network', 'enable', '--name', 'polygon']) - const { networks } = await cli.json(['network', 'list']) - - expect(networks.find((n) => n.name === 'polygon').enabled).toBe(true) - }) - - it('refuses to disable a network twice', async () => { - await cli.run(['network', 'disable', '--name', 'polygon']) - - const again = await cli.run(['network', 'disable', '--name', 'polygon']) - - expect(again.code).toBe(1) - }) -}) - -describe('token enable and disable', () => { - it('marks a disabled token in the listing', async () => { - await cli.run(['token', 'disable', '--network', 'ethereum', '--token', 'usdt']) - - const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) - - expect(disabled).toContain('ethereum/usdt') - }) - - it('brings a disabled token back', async () => { - await cli.run(['token', 'disable', '--network', 'ethereum', '--token', 'usdt']) - - await cli.run(['token', 'enable', '--network', 'ethereum', '--token', 'usdt']) - const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) - - expect(disabled).not.toContain('ethereum/usdt') - }) -}) - -describe('module enable and disable', () => { - it('reports a disabled module and hides its providers', async () => { - await cli.run(['module', 'disable', '--name', '@tetherto/wdk-protocol-fiat-moonpay']) - - const { modules } = await cli.json(['module', 'list']) - const { providers } = await cli.json(['provider', 'list']) - - expect(modules.find((m) => m.module === '@tetherto/wdk-protocol-fiat-moonpay').status) - .toBe('disabled') - expect(providers.find((p) => p.name === 'moonpay')).toBeUndefined() - }) - - it('brings a disabled module back', async () => { - await cli.run(['module', 'disable', '--name', '@tetherto/wdk-protocol-fiat-moonpay']) - - await cli.run(['module', 'enable', '--name', '@tetherto/wdk-protocol-fiat-moonpay']) - const { modules } = await cli.json(['module', 'list']) - - expect(modules.find((m) => m.module === '@tetherto/wdk-protocol-fiat-moonpay').status).toBe('ok') - }) -}) diff --git a/tests/integration/token.test.js b/tests/integration/token.test.js new file mode 100644 index 0000000..d745e1a --- /dev/null +++ b/tests/integration/token.test.js @@ -0,0 +1,112 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createRequire } from 'node:module' +import { statSync } from 'node:fs' +import { Cli } from './helpers.js' + +const catalog = createRequire(import.meta.url)('../../wdk.config.json') +const MOONPAY_MODULE = catalog.providers.moonpay.module +const BITFINEX_MODULE = catalog.providers.bitfinex.module + +/** @type {Cli} */ +let cli + +beforeEach(() => { cli = new Cli() }) +afterEach(() => cli.cleanup()) + +describe('token registry', () => { + it('lists the packaged tokens for a network', async () => { + const { tokens } = await cli.json(['token', 'list', '--network', 'ethereum']) + + expect(Object.keys(tokens).sort()).toEqual(['eth', 'usdt', 'xaut']) + }) + + it('shows a token with its slugs', async () => { + const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'usdt']) + + expect(info).toMatchObject({ symbol: 'USDT', decimals: 6, isNative: false }) + }) + + it('reports a token the registry does not carry', async () => { + const result = await cli.run(['token', 'info', '--network', 'ethereum', '--token', 'nope']) + + expect(result.code).toBe(1) + expect(result.output).toContain("Token 'nope' not found on 'ethereum'.") + }) + + it('reports a network that does not exist', async () => { + const result = await cli.run(['token', 'list', '--network', 'atlantis']) + + expect(result.code).toBe(1) + expect(result.output).toContain("Network 'atlantis' is not supported.") + }) + + it('adds a custom token and reads it back', async () => { + await cli.json(['token', 'add', JSON.stringify({ + network: 'ethereum', token: 'dai', symbol: 'DAI', decimals: 18, isNative: false, + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F' + })]) + + const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'dai']) + + expect(info).toMatchObject({ symbol: 'DAI', decimals: 18 }) + }) + + it('warns when a custom token shadows a packaged one', async () => { + const result = await cli.run(['token', 'add', JSON.stringify({ + network: 'ethereum', token: 'usdt', symbol: 'USDT', decimals: 6, isNative: false, + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7' + })]) + + expect(result.output).toContain('overrides it') + }) + + it('refuses to delete a packaged token', async () => { + const result = await cli.run(['token', 'delete', '--network', 'ethereum', '--token', 'usdt']) + + expect(result.code).toBe(1) + expect(result.output).toContain('built-in token') + }) + + it('reverts to the packaged token when the override is deleted', async () => { + await cli.json(['token', 'add', JSON.stringify({ + network: 'ethereum', token: 'usdt', symbol: 'FAKE', decimals: 2, isNative: false, + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7' + })]) + + await cli.run(['token', 'delete', '--network', 'ethereum', '--token', 'usdt']) + const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'usdt']) + + expect(info.symbol).toBe('USDT') + }) +}) +describe('token enable and disable', () => { + it('marks a disabled token in the listing', async () => { + await cli.run(['token', 'disable', '--network', 'ethereum', '--token', 'usdt']) + + const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) + + expect(disabled).toContain('ethereum/usdt') + }) + + it('brings a disabled token back', async () => { + await cli.run(['token', 'disable', '--network', 'ethereum', '--token', 'usdt']) + + await cli.run(['token', 'enable', '--network', 'ethereum', '--token', 'usdt']) + const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) + + expect(disabled).not.toContain('ethereum/usdt') + }) +}) diff --git a/tests/integration/wallet.test.js b/tests/integration/wallet.test.js index 4627858..1bc36d0 100644 --- a/tests/integration/wallet.test.js +++ b/tests/integration/wallet.test.js @@ -169,80 +169,6 @@ describe('import, unlock and lock', () => { }) }) -describe('address derivation, which needs no chain', () => { - it('derives the same address the seed always produces', async () => { - await importAndUnlock() - - const result = await cli.json(['get', 'address', '--network', 'ethereum'], { unlocked: true }) - - expect(result).toEqual({ network: 'ethereum', index: 0, address: ETHEREUM_0 }) - }) - - it('derives a different address at another index', async () => { - await importAndUnlock() - - const zero = await cli.json(['get', 'address', '--network', 'ethereum'], { unlocked: true }) - const one = await cli.json( - ['get', 'address', '--network', 'ethereum', '--index', '1'], { unlocked: true } - ) - - expect(one.index).toBe(1) - expect(one.address).not.toBe(zero.address) - }) - - it('derives every mainnet address in one call', async () => { - await importAndUnlock() - - const result = await cli.json(['get', 'address', '--all'], { unlocked: true }) - - expect(result.type).toBe('mainnet') - expect(result.addresses).toHaveLength(MAINNET_COUNT) - expect(result.addresses.find((a) => a.network === 'ethereum').address).toBe(ETHEREUM_0) - }) - - it('derives testnet addresses only when asked', async () => { - await importAndUnlock() - - const result = await cli.json(['get', 'address', '--all', '--testnet'], { unlocked: true }) - - expect(result.type).toBe('testnet') - }) - - it('refuses a network that is not registered', async () => { - await importAndUnlock() - - const result = await cli.run(['get', 'address', '--network', 'atlantis'], { unlocked: true }) - - expect(result.code).toBe(1) - expect(result.output).toContain("Network 'atlantis' is not supported.") - }) - - it('refuses to derive while locked', async () => { - await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { - unlocked: true, - stdin: SEED + '\n' - }) - - const result = await cli.run(['get', 'address', '--network', 'ethereum']) - - expect(result.code).toBe(1) - expect(result.output).toContain("Wallet 'main' is not unlocked.") - }) - - it('refuses a network the user disabled', async () => { - await importAndUnlock() - await cli.run(['network', 'disable', '--name', 'ethereum'], { unlocked: true }) - // Disabling a network locks every wallet, so re-unlock: otherwise this - // passes on "not unlocked" and proves nothing about the network. - await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) - - const result = await cli.run(['get', 'address', '--network', 'ethereum'], { unlocked: true }) - - expect(result.code).toBe(1) - expect(result.output).toContain("Network 'ethereum' is disabled.") - }) -}) - describe('export, which decrypts the seed', () => { it('returns exactly the seed that was imported', async () => { await importAndUnlock() diff --git a/tests/integration/write-operations.test.js b/tests/integration/write-operations.test.js new file mode 100644 index 0000000..8b851f3 --- /dev/null +++ b/tests/integration/write-operations.test.js @@ -0,0 +1,80 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Cli } from './helpers.js' + +const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' +const ADDRESS = '0x405005C7c4422390F4B334F64Cf20E0b767131d0' + +// ECDSA over a fixed seed and message is deterministic, so this is an exact +// value rather than a shape. If it ever changes, the derivation changed. +const HELLO_SIGNATURE = + '0x05c005143100eff1f58be46b0a7fb7fe1a47f20d29d7c14e913dc9ae798e00ab' + + '6e769cf61784923262ba5bda8f0f5140d661a9b3f14df4d17837dc06dea555ac1b' + +/** @type {Cli} */ +let cli + +beforeEach(async () => { + cli = new Cli() + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) +}) + +afterEach(async () => { + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + cli.cleanup() +}) + +describe('message signing', () => { + it('produces the signature this seed always produces', async () => { + const result = await cli.json( + ['message', 'sign', '--network', 'ethereum', '--message', 'hello'], { unlocked: true } + ) + + expect(result).toEqual({ + network: 'ethereum', + index: 0, + address: ADDRESS, + message: 'hello', + signature: HELLO_SIGNATURE + }) + }) + + it('signs with the account at the index it was given', async () => { + const zero = await cli.json( + ['message', 'sign', '--network', 'ethereum', '--message', 'hello'], { unlocked: true } + ) + const one = await cli.json( + ['message', 'sign', '--network', 'ethereum', '--message', 'hello', '--index', '1'], + { unlocked: true } + ) + + expect(one.index).toBe(1) + expect(one.signature).not.toBe(zero.signature) + expect(one.address).not.toBe(zero.address) + }) + + it('refuses to sign while locked', async () => { + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + + const result = await cli.run(['message', 'sign', '--network', 'ethereum', '--message', 'hi']) + + expect(result.code).toBe(1) + expect(result.output).toContain("Wallet 'main' is not unlocked.") + }) +}) From fdbcac443cc84537f37f59c2abba95fbc1db74ff Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Mon, 28 Sep 2026 10:08:56 +0700 Subject: [PATCH 07/15] add integration tests --- package.json | 3 ++- tests/integration/helpers.js | 32 ++++++++++++++++++++--- tests/integration/module.test.js | 6 +++-- tests/integration/network.test.js | 4 +-- tests/integration/provider.test.js | 1 - tests/integration/read-operations.test.js | 6 +++-- tests/integration/token.test.js | 6 ----- tests/integration/wallet.test.js | 3 +-- 8 files changed, 41 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index b4da9f2..dfe1d46 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "typecheck": "tsc --noEmit -p jsconfig.json", "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns tests/integration", "test:integration": "cross-env NODE_OPTIONS=--experimental-vm-modules jest tests/integration --runInBand --testTimeout=60000", - "test:coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage" + "test:coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage --testPathIgnorePatterns tests/integration", + "test:integration:coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest tests/integration --coverage --runInBand --testTimeout=60000" }, "jest": { "setupFiles": [ diff --git a/tests/integration/helpers.js b/tests/integration/helpers.js index 6a0c3b8..b9b4fab 100644 --- a/tests/integration/helpers.js +++ b/tests/integration/helpers.js @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { spawn } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import { randomBytes } from 'node:crypto' import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -21,6 +21,7 @@ import { fileURLToPath } from 'node:url' const CLI_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const BIN = join(CLI_ROOT, 'bin', 'wdk.mjs') +const DAEMON = join(CLI_ROOT, 'bin', 'wdk-daemon.mjs') /** * @typedef {Object} CliResult @@ -29,11 +30,31 @@ const BIN = join(CLI_ROOT, 'bin', 'wdk.mjs') * @property {string} stderr - Everything written to stderr, ANSI stripped. * @property {string} output - stdout and stderr joined, for matching a message * without caring which stream carried it. + * @property {NodeJS.Signals | null} signal - The signal that killed the process, + * or `null` when it exited on its own. */ /** Matches the ANSI colour codes chalk writes when stdout is a TTY. */ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') +/** + * Reports whether a pid is still one of this harness's daemons. A pid file + * outlives the process it names and the system reuses pids, so a bare kill can + * reach an unrelated process. + * + * @param {number} pid - The pid read from the daemon pid file. + * @returns {boolean} True when that process is running the daemon script. + */ +function isOurDaemon (pid) { + try { + const command = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' }) + return command.includes(DAEMON) + } catch { + // No such process, or `ps` is unavailable: either way, do not signal it. + return false + } +} + /** * An isolated CLI installation: its own config directory, torn down by * {@link Cli#cleanup}. A directory with no wallets means no passphrase prompt, @@ -69,6 +90,7 @@ export class Cli { * @param {string} [options.passphrase] - Use this passphrase instead of the * instance one, for a wallet whose passphrase has been changed. * @returns {Promise} What the command printed and exited with. + * @throws {Error} When the CLI binary cannot be spawned. */ run (args, options = {}) { return new Promise((resolve, reject) => { @@ -89,10 +111,12 @@ export class Cli { child.stdout.on('data', (d) => { stdout += d }) child.stderr.on('data', (d) => { stderr += d }) child.on('error', reject) - child.on('close', (code) => { + child.on('close', (code, signal) => { const clean = (s) => s.replace(ANSI, '') resolve({ - code: code ?? 0, + // A signal kill reports a null code; 0 would read as success. + code: code ?? (signal ? 1 : 0), + signal, stdout: clean(stdout), stderr: clean(stderr), output: clean(stdout + stderr) @@ -168,7 +192,7 @@ export class Cli { cleanup () { try { const pid = Number(readFileSync(join(this.configHome, 'wdk-cli', 'daemon.pid'), 'utf8').trim()) - if (pid > 0) process.kill(pid, 'SIGTERM') + if (pid > 0 && isOurDaemon(pid)) process.kill(pid, 'SIGTERM') } catch { // No daemon was started, or it is already gone. } diff --git a/tests/integration/module.test.js b/tests/integration/module.test.js index 6d1fa3c..39c0e7b 100644 --- a/tests/integration/module.test.js +++ b/tests/integration/module.test.js @@ -17,8 +17,8 @@ import { statSync } from 'node:fs' import { Cli } from './helpers.js' const catalog = createRequire(import.meta.url)('../../wdk.config.json') -const MOONPAY_MODULE = catalog.providers.moonpay.module -const BITFINEX_MODULE = catalog.providers.bitfinex.module +const MODULE_COUNT = Object.keys(catalog.modules).length +const SPARK_METHOD_COUNT = Object.keys(catalog.modules[catalog.networks.spark.module].methods).length /** @type {Cli} */ let cli @@ -30,6 +30,7 @@ describe('module registry', () => { it('reports every catalog module as installed and ok', async () => { const { modules } = await cli.json(['module', 'list']) + expect(modules).toHaveLength(MODULE_COUNT) expect(modules.every((m) => m.status === 'ok')).toBe(true) }) @@ -117,6 +118,7 @@ describe('module methods', () => { it('marks each method read or write', async () => { const { methods } = await cli.json(['method', 'list', '--network', 'spark']) + expect(methods).toHaveLength(SPARK_METHOD_COUNT) expect(methods.every((m) => m.kind === 'read' || m.kind === 'write')).toBe(true) }) diff --git a/tests/integration/network.test.js b/tests/integration/network.test.js index f05503f..2750d90 100644 --- a/tests/integration/network.test.js +++ b/tests/integration/network.test.js @@ -13,12 +13,11 @@ // limitations under the License. import { createRequire } from 'node:module' -import { statSync } from 'node:fs' import { Cli } from './helpers.js' const catalog = createRequire(import.meta.url)('../../wdk.config.json') const MOONPAY_MODULE = catalog.providers.moonpay.module -const BITFINEX_MODULE = catalog.providers.bitfinex.module +const TESTNET_COUNT = Object.values(catalog.networks).filter((n) => n.testnet).length /** @type {Cli} */ let cli @@ -36,6 +35,7 @@ describe('network registry', () => { it('separates testnets behind a flag', async () => { const { networks } = await cli.json(['network', 'list', '--testnet']) + expect(networks).toHaveLength(TESTNET_COUNT) expect(networks.every((n) => n.testnet)).toBe(true) }) diff --git a/tests/integration/provider.test.js b/tests/integration/provider.test.js index 300e203..650fea6 100644 --- a/tests/integration/provider.test.js +++ b/tests/integration/provider.test.js @@ -13,7 +13,6 @@ // limitations under the License. import { createRequire } from 'node:module' -import { statSync } from 'node:fs' import { Cli } from './helpers.js' const catalog = createRequire(import.meta.url)('../../wdk.config.json') diff --git a/tests/integration/read-operations.test.js b/tests/integration/read-operations.test.js index f23f450..4d321a4 100644 --- a/tests/integration/read-operations.test.js +++ b/tests/integration/read-operations.test.js @@ -12,12 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { createRequire } from 'node:module' import { Cli } from './helpers.js' +const catalog = createRequire(import.meta.url)('../../wdk.config.json') + const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' const ETHEREUM_0 = '0x405005C7c4422390F4B334F64Cf20E0b767131d0' -/** Mainnet networks in the packaged registry. */ -const MAINNET_COUNT = 15 +const MAINNET_COUNT = Object.values(catalog.networks).filter((n) => !n.testnet).length // ECDSA over a fixed seed and message is deterministic, so this is an exact // value rather than a shape. If it ever changes, the derivation changed. diff --git a/tests/integration/token.test.js b/tests/integration/token.test.js index d745e1a..d4eb592 100644 --- a/tests/integration/token.test.js +++ b/tests/integration/token.test.js @@ -12,14 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { createRequire } from 'node:module' -import { statSync } from 'node:fs' import { Cli } from './helpers.js' -const catalog = createRequire(import.meta.url)('../../wdk.config.json') -const MOONPAY_MODULE = catalog.providers.moonpay.module -const BITFINEX_MODULE = catalog.providers.bitfinex.module - /** @type {Cli} */ let cli diff --git a/tests/integration/wallet.test.js b/tests/integration/wallet.test.js index 1bc36d0..f94b359 100644 --- a/tests/integration/wallet.test.js +++ b/tests/integration/wallet.test.js @@ -16,8 +16,6 @@ import { Cli } from './helpers.js' const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' const ETHEREUM_0 = '0x405005C7c4422390F4B334F64Cf20E0b767131d0' -/** Mainnet networks in the packaged registry. */ -const MAINNET_COUNT = 15 /** @type {Cli} */ let cli @@ -272,6 +270,7 @@ describe('more than one wallet', () => { await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) const { wallets } = await cli.json(['wallet', 'list']) + expect(wallets).toHaveLength(2) expect(wallets.every((w) => !w.unlocked)).toBe(true) }) From 536a895f7a53e7161c45292f18bd47f66a8c6486 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Tue, 29 Sep 2026 15:42:14 +0700 Subject: [PATCH 08/15] refactor: keep per-network provider config under the provider --- src/config/wdk-config.js | 7 +++---- wdk.config.json | 28 +++++++++------------------- 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/src/config/wdk-config.js b/src/config/wdk-config.js index 6319664..f66c74a 100644 --- a/src/config/wdk-config.js +++ b/src/config/wdk-config.js @@ -26,8 +26,6 @@ const walletsFileRaw = createRequire(import.meta.url)('../../wdk.config.json') * Per-token indexer slugs live in `wdk.tokens.json` under `metadata.slugs.indexer`. * @property {string} [chainId] - The CAIP-2 chain id (e.g. "eip155:1", "tron:mainnet"). * @property {Record} [config] - The per-network module configuration (RPC URL, chainId, etc.). - * @property {Record>} [providers] - Per-network provider config overrides, - * keyed by provider short name; shallow-merged over the provider's general `config`. */ /** @@ -66,9 +64,10 @@ const walletsFileRaw = createRequire(import.meta.url)('../../wdk.config.json') * @property {string} [module] - The protocol module package name; its version is pinned in * `modules`. Absent for a provider the CLI calls directly rather than through a module. * @property {Record} [config] - General protocol config applied on every network - * (e.g. API keys); shallow-merged under any per-network override in `networks..providers.`. + * (e.g. API keys); shallow-merged under any per-network override in `networks`. * @property {Record>} [networks] - Per-network config overrides keyed by - * network name. Used by user-added providers, which cannot edit the packaged network entries. + * network name, shallow-merged over `config`. Everything a provider needs lives under the provider, + * so removing it takes its configuration with it. * @property {string[]} [endpointKeys] - Config keys the module takes as a callback rather than a * value. The CLI stores a URL for each and POSTs to it when the module calls back. */ diff --git a/wdk.config.json b/wdk.config.json index 9bb2547..b6119f1 100644 --- a/wdk.config.json +++ b/wdk.config.json @@ -92,7 +92,15 @@ "symbiosis": { "kind": "swidge", "module": "@symbiosis-finance/wdk-protocol-swidge-symbiosis", - "config": { "partnerId": "wdk" } + "config": { "partnerId": "wdk" }, + "networks": { + "ethereum": { "chain": 1 }, + "polygon": { "chain": 137 }, + "arbitrum": { "chain": 42161 }, + "base": { "chain": 8453 }, + "bsc": { "chain": 56 }, + "avalanche": { "chain": 43114 } + } } }, "networks": { @@ -141,9 +149,6 @@ "chainId": 1, "provider": "https://ethereum-rpc.publicnode.com", "transferMaxFee": 5000000000000000 - }, - "providers": { - "symbiosis": { "chain": 1 } } }, "sepolia": { @@ -167,9 +172,6 @@ "chainId": 137, "provider": "https://polygon.drpc.org", "transferMaxFee": 5000000000000000 - }, - "providers": { - "symbiosis": { "chain": 137 } } }, "arbitrum": { @@ -181,9 +183,6 @@ "chainId": 42161, "provider": "https://arb1.arbitrum.io/rpc", "transferMaxFee": 1000000000000000 - }, - "providers": { - "symbiosis": { "chain": 42161 } } }, "base": { @@ -194,9 +193,6 @@ "chainId": 8453, "provider": "https://mainnet.base.org", "transferMaxFee": 1000000000000000 - }, - "providers": { - "symbiosis": { "chain": 8453 } } }, "bsc": { @@ -207,9 +203,6 @@ "chainId": 56, "provider": "https://bsc-dataseed.binance.org", "transferMaxFee": 5000000000000000 - }, - "providers": { - "symbiosis": { "chain": 56 } } }, "avalanche": { @@ -220,9 +213,6 @@ "chainId": 43114, "provider": "https://api.avax.network/ext/bc/C/rpc", "transferMaxFee": 5000000000000000 - }, - "providers": { - "symbiosis": { "chain": 43114 } } }, "solana": { From 15c54a32dbd4d6043f9f94b4c8961098115cf893 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Tue, 29 Sep 2026 15:42:38 +0700 Subject: [PATCH 09/15] test: cover registry cascades, lifecycles and the passphrase gate --- tests/integration/enable-disable.test.js | 452 ++++++++++++++++++++++ tests/integration/helpers.js | 22 +- tests/integration/module.test.js | 28 ++ tests/integration/network.test.js | 117 +++++- tests/integration/passphrase.test.js | 105 +++++ tests/integration/provider.test.js | 122 +++++- tests/integration/read-operations.test.js | 13 +- tests/integration/token.test.js | 130 ++++++- 8 files changed, 970 insertions(+), 19 deletions(-) create mode 100644 tests/integration/enable-disable.test.js create mode 100644 tests/integration/passphrase.test.js diff --git a/tests/integration/enable-disable.test.js b/tests/integration/enable-disable.test.js new file mode 100644 index 0000000..ccca7ef --- /dev/null +++ b/tests/integration/enable-disable.test.js @@ -0,0 +1,452 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Disabling one entry in the registry is felt in the others: a module hides the +// networks it backs, a network hides its tokens, a provider stops serving the +// command that consumes it. Each case here crosses at least two registries, so +// no single-service unit test covers it. + +import { createRequire } from 'node:module' +import { Cli } from './helpers.js' + +const catalog = createRequire(import.meta.url)('../../wdk.config.json') + +const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' + +const EVM_MODULE = '@tetherto/wdk-wallet-evm' +const INDEXER_MODULE = '@tetherto/wdk-indexer-http' +const PRICING_MODULE = '@tetherto/wdk-pricing-bitfinex-http' + +/** The networks the EVM wallet module backs, all of which it takes down with it. */ +const EVM_NETWORKS = Object.entries(catalog.networks) + .filter(([, entry]) => entry.module === EVM_MODULE) + .map(([name]) => name) +const TOTAL_NETWORKS = Object.keys(catalog.networks).length +const PROVIDER_COUNT = Object.keys(catalog.providers).length + +/** Every provider the catalog ships, with the module behind it. */ +const PROVIDERS = Object.entries(catalog.providers).map(([name, entry]) => ({ + name, + kind: entry.kind, + module: entry.module +})) + +/** @type {Cli} */ +let cli + +beforeEach(() => { + cli = new Cli() +}) + +afterEach(async () => { + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + cli.cleanup() +}) + +/** + * Imports the fixed test seed and unlocks it. + * + * @returns {Promise} + */ +async function importAndUnlock () { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) +} + +/** + * Disables a module and returns what the command reported. + * + * @param {string} name - The module package name. + * @returns {Promise>} The command's JSON payload. + */ +function disableModule (name) { + return cli.json(['module', 'disable', '--name', name], { unlocked: true }) +} + +describe('a disabled module and the networks it backs', () => { + it('takes down every network on that module and leaves the others alone', async () => { + const before = await cli.json(['network', 'list']) + expect(before.networks).toHaveLength(TOTAL_NETWORKS) + + await disableModule(EVM_MODULE) + + const { networks } = await cli.json(['network', 'list']) + const names = networks.map((n) => n.name) + expect(networks).toHaveLength(TOTAL_NETWORKS - EVM_NETWORKS.length) + for (const network of EVM_NETWORKS) expect(names).not.toContain(network) + expect(names).toContain('solana') + }) + + it('hides a module-disabled network but only flags a user-disabled one', async () => { + await cli.json(['network', 'disable', '--name', 'arbitrum'], { unlocked: true }) + + const flagged = await cli.json(['network', 'list']) + expect(flagged.networks.find((n) => n.name === 'arbitrum').enabled).toBe(false) + + await disableModule(EVM_MODULE) + + const hidden = await cli.json(['network', 'list']) + expect(hidden.networks.find((n) => n.name === 'arbitrum')).toBeUndefined() + }) + + it('tells every command that touches the network to enable the module', async () => { + await disableModule(EVM_MODULE) + + for (const args of [ + ['network', 'info', '--network', 'ethereum'], + ['token', 'list', '--network', 'ethereum'], + ['method', 'list', '--network', 'ethereum'] + ]) { + const result = await cli.run(args, { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("Network 'ethereum' is disabled.") + expect(result.output).toContain(`wdk module enable --name ${EVM_MODULE}`) + } + }) + + it('refuses to re-enable the network on its own', async () => { + await disableModule(EVM_MODULE) + + const result = await cli.run(['network', 'enable', '--name', 'ethereum'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("Network 'ethereum' is disabled by its module.") + }) + + it('keeps a network the user disabled off after the module comes back', async () => { + await cli.json(['network', 'disable', '--name', 'arbitrum'], { unlocked: true }) + await disableModule(EVM_MODULE) + await cli.json(['module', 'enable', '--name', EVM_MODULE], { unlocked: true }) + + const { networks } = await cli.json(['network', 'list']) + + expect(networks).toHaveLength(TOTAL_NETWORKS) + expect(networks.find((n) => n.name === 'arbitrum').enabled).toBe(false) + expect(networks.find((n) => n.name === 'ethereum').enabled).toBe(true) + }) +}) + +describe('a disabled module and the provider it serves', () => { + // Every kind is meant to behave the same here, so drive the same contract from + // the catalog: a new provider kind is covered the day it is registered. + it.each(PROVIDERS)('hides the $kind provider $name when its module goes', async (provider) => { + const before = await cli.json(['provider', 'list']) + expect(before.providers).toHaveLength(PROVIDER_COUNT) + expect(before.providers.find((p) => p.name === provider.name).enabled).toBe(true) + + await disableModule(provider.module) + + const { providers } = await cli.json(['provider', 'list']) + expect(providers).toHaveLength(PROVIDER_COUNT - 1) + expect(providers.find((p) => p.name === provider.name)).toBeUndefined() + + const info = await cli.json(['provider', 'info', '--name', provider.name]) + expect(info.enabled).toBe(false) + + const result = await cli.run(['provider', 'enable', '--name', provider.name], { unlocked: true }) + expect(result.code).toBe(1) + expect(result.output).toContain(`Provider '${provider.name}' is disabled by its module.`) + expect(result.output).toContain(`wdk module enable --name ${provider.module}`) + }) + + it('leaves a provider of the same kind on a different module alone', async () => { + await disableModule(catalog.providers.moonpay.module) + + const { providers } = await cli.json(['provider', 'list']) + + expect(providers.find((p) => p.name === 'moonpay')).toBeUndefined() + expect(providers.find((p) => p.name === 'transak').enabled).toBe(true) + }) + + it('fails the command that consumes the provider', async () => { + await disableModule(INDEXER_MODULE) + + const result = await cli.run(['get', 'history', '--network', 'ethereum'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain('No indexer is available.') + }) +}) + +describe('a provider disabled on its own', () => { + // The contrast with a module disable: this one stays visible and flagged, + // because the user can undo it directly. + it.each(PROVIDERS)('keeps the $kind provider $name listed but off', async (provider) => { + const off = await cli.json(['provider', 'disable', '--name', provider.name], { unlocked: true }) + expect(off.enabled).toBe(false) + + const { providers } = await cli.json(['provider', 'list']) + expect(providers).toHaveLength(PROVIDER_COUNT) + expect(providers.find((p) => p.name === provider.name).enabled).toBe(false) + + const on = await cli.json(['provider', 'enable', '--name', provider.name], { unlocked: true }) + expect(on.enabled).toBe(true) + }) + + it('refuses to disable one that is already off', async () => { + await cli.json(['provider', 'disable', '--name', 'velora'], { unlocked: true }) + + const result = await cli.run(['provider', 'disable', '--name', 'velora'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("'velora' is already disabled.") + }) + + it('keeps its own disable through a module cycle', async () => { + const { module: moduleName } = catalog.providers.velora + await cli.json(['provider', 'disable', '--name', 'velora'], { unlocked: true }) + + await disableModule(moduleName) + await cli.json(['module', 'enable', '--name', moduleName], { unlocked: true }) + + const { providers } = await cli.json(['provider', 'list']) + expect(providers.find((p) => p.name === 'velora').enabled).toBe(false) + }) +}) + +describe('a kind with no provider left enabled', () => { + it('fails buy and sell once every fiat provider is off', async () => { + await importAndUnlock() + await cli.json(['provider', 'disable', '--name', 'moonpay'], { unlocked: true }) + await cli.json(['provider', 'disable', '--name', 'transak'], { unlocked: true }) + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + for (const command of ['buy', 'sell']) { + const result = await cli.run( + [command, '--network', 'ethereum', '--token', 'usdt', '--fiat-amount', '100'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain('No fiat provider is available.') + } + }) + + it('fails swap once nothing can swap', async () => { + await importAndUnlock() + for (const name of ['velora', 'rhinofi', 'symbiosis']) { + await cli.json(['provider', 'disable', '--name', name], { unlocked: true }) + } + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + const result = await cli.run( + ['swap', '--network', 'ethereum', '--from-token', 'usdt', '--to-token', 'eth', '--amount-in', '1'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain('No installed protocol can swap.') + }) + + it('fails bridge once nothing can bridge', async () => { + await importAndUnlock() + for (const name of ['usdt0', 'rhinofi', 'symbiosis']) { + await cli.json(['provider', 'disable', '--name', name], { unlocked: true }) + } + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + const result = await cli.run( + ['bridge', '--network', 'ethereum', '--token', 'eth', '--to-network', 'arbitrum', '--amount', '1'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain('No installed protocol can bridge.') + }) +}) + +describe('kinds that allow only one provider at a time', () => { + const SECOND_FEED = JSON.stringify({ + name: 'second-feed', + kind: 'pricing', + module: catalog.providers.bitfinex.module, + config: {} + }) + + it('refuses a second price feed while one is enabled', async () => { + const result = await cli.run(['provider', 'add', SECOND_FEED], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain('A price feed is already enabled: bitfinex.') + }) + + it('accepts the second once the first is off, then guards the way back', async () => { + await cli.json(['provider', 'disable', '--name', 'bitfinex'], { unlocked: true }) + + const added = await cli.json(['provider', 'add', SECOND_FEED], { unlocked: true }) + expect(added.name).toBe('second-feed') + + const result = await cli.run(['provider', 'enable', '--name', 'bitfinex'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain('A price feed is already enabled: second-feed.') + }) + + it('lets a multi-instance kind run two providers at once', async () => { + const { providers } = await cli.json(['provider', 'list']) + + const fiat = providers.filter((p) => p.kind === 'fiat') + const swidge = providers.filter((p) => p.kind === 'swidge') + expect(fiat.every((p) => p.enabled)).toBe(true) + expect(swidge.every((p) => p.enabled)).toBe(true) + expect(fiat).toHaveLength(2) + expect(swidge).toHaveLength(2) + }) +}) + +describe('naming a provider explicitly', () => { + it('refuses a provider the user disabled', async () => { + await importAndUnlock() + await cli.json(['provider', 'disable', '--name', 'transak'], { unlocked: true }) + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + const result = await cli.run( + ['buy', '--network', 'ethereum', '--token', 'usdt', '--fiat-amount', '100', + '--provider', 'transak'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain("Protocol 'transak' is disabled.") + }) + + it('lists what is available when the name is unknown', async () => { + await importAndUnlock() + + const result = await cli.run( + ['buy', '--network', 'ethereum', '--token', 'usdt', '--fiat-amount', '100', + '--provider', 'nosuch'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain("Unknown protocol 'nosuch'.") + expect(result.output).toContain('moonpay') + }) +}) + +describe('a provider the user added', () => { + const CUSTOM = JSON.stringify({ + name: 'myramp', + kind: 'fiat', + module: catalog.providers.moonpay.module, + config: {} + }) + + it('can be disabled and then deleted, unlike a built-in', async () => { + const added = await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) + expect(added.name).toBe('myramp') + + const off = await cli.json(['provider', 'disable', '--name', 'myramp'], { unlocked: true }) + expect(off.enabled).toBe(false) + + const deleted = await cli.json(['provider', 'delete', '--name', 'myramp'], { unlocked: true }) + expect(deleted.deleted).toBe(true) + + const { providers } = await cli.json(['provider', 'list']) + expect(providers.find((p) => p.name === 'myramp')).toBeUndefined() + }) + + it('takes its disable override with it when deleted', async () => { + await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) + await cli.json(['provider', 'disable', '--name', 'myramp'], { unlocked: true }) + await cli.json(['provider', 'delete', '--name', 'myramp'], { unlocked: true }) + + const result = await cli.run(['provider', 'enable', '--name', 'myramp'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("'myramp' is not a provider.") + }) +}) + +describe('an override left behind by something that no longer exists', () => { + it('is cleared on the next enable, once', async () => { + cli.writeConfig({ overrides: { providers: { ghost: { enabled: false } } } }) + + const cleared = await cli.json(['provider', 'enable', '--name', 'ghost'], { unlocked: true }) + expect(cleared.stale).toBe(true) + + const again = await cli.run(['provider', 'enable', '--name', 'ghost'], { unlocked: true }) + expect(again.code).toBe(1) + expect(again.output).toContain("'ghost' is not a provider.") + }) +}) + +describe('provider config across a module cycle', () => { + it('keeps the general config and the per-network override', async () => { + const indexerKey = 'providers.wdk-indexer.config.apiKey' + const veloraKey = 'providers.velora.networks.ethereum.slippage' + await cli.json(['config', 'set', '--key', indexerKey, '--value', 'secret-abc'], { unlocked: true }) + await cli.json(['config', 'set', '--key', veloraKey, '--value', '50'], { unlocked: true }) + + await disableModule(INDEXER_MODULE) + await cli.json(['module', 'enable', '--name', INDEXER_MODULE], { unlocked: true }) + + expect((await cli.json(['config', 'get', '--key', indexerKey])).value).toBe('secret-abc') + expect((await cli.json(['config', 'get', '--key', veloraKey])).value).toBe(50) + }) +}) + +describe('a disabled module and an open wallet session', () => { + it('locks the open wallets when the module backs a network', async () => { + await importAndUnlock() + + const disabled = await disableModule(EVM_MODULE) + + expect(disabled.walletsLocked).toBe(true) + const { wallets } = await cli.json(['wallet', 'list']) + expect(wallets).toHaveLength(1) + expect(wallets[0].unlocked).toBe(false) + }) + + // applyToggle locks unconditionally, so even a price feed that has nothing to + // do with key derivation costs the user their session. Blunt, but safe: pinning + // it here means narrowing it later has to be a deliberate change. + it('locks them even when the module backs no network', async () => { + await importAndUnlock() + + const disabled = await disableModule(PRICING_MODULE) + + expect(disabled.walletsLocked).toBe(true) + const { wallets } = await cli.json(['wallet', 'list']) + expect(wallets).toHaveLength(1) + expect(wallets[0].unlocked).toBe(false) + }) +}) + +describe('a disabled token under a network and module cycle', () => { + it('stays disabled after its network is switched off and on', async () => { + await cli.json(['token', 'disable', '--network', 'ethereum', '--token', 'usdt'], { unlocked: true }) + + await cli.json(['network', 'disable', '--name', 'ethereum'], { unlocked: true }) + await cli.json(['network', 'enable', '--name', 'ethereum'], { unlocked: true }) + + const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) + expect(disabled).toEqual(['ethereum/usdt']) + }) + + it('stays disabled after its module is switched off and on', async () => { + await cli.json(['token', 'disable', '--network', 'ethereum', '--token', 'usdt'], { unlocked: true }) + + await disableModule(EVM_MODULE) + await cli.json(['module', 'enable', '--name', EVM_MODULE], { unlocked: true }) + + const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) + expect(disabled).toEqual(['ethereum/usdt']) + }) +}) diff --git a/tests/integration/helpers.js b/tests/integration/helpers.js index b9b4fab..712c93d 100644 --- a/tests/integration/helpers.js +++ b/tests/integration/helpers.js @@ -23,6 +23,13 @@ const CLI_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const BIN = join(CLI_ROOT, 'bin', 'wdk.mjs') const DAEMON = join(CLI_ROOT, 'bin', 'wdk-daemon.mjs') +/** + * How long a single command may take before the harness kills it. Every timeout + * inside the CLI is well under this, so exceeding it means the process wedged. + * Kept below jest's own timeout so the failure names the command that hung. + */ +const RUN_TIMEOUT_MS = 45_000 + /** * @typedef {Object} CliResult * @property {number} code - The process exit code. @@ -91,6 +98,7 @@ export class Cli { * instance one, for a wallet whose passphrase has been changed. * @returns {Promise} What the command printed and exited with. * @throws {Error} When the CLI binary cannot be spawned. + * @throws {Error} When the command does not exit within the harness timeout. */ run (args, options = {}) { return new Promise((resolve, reject) => { @@ -106,12 +114,24 @@ export class Cli { stdio: ['pipe', 'pipe', 'pipe'] }) + const timer = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error( + `wdk ${args.join(' ')} did not exit within ${RUN_TIMEOUT_MS}ms\n` + + `stdout so far: ${stdout}\nstderr so far: ${stderr}` + )) + }, RUN_TIMEOUT_MS) + let stdout = '' let stderr = '' child.stdout.on('data', (d) => { stdout += d }) child.stderr.on('data', (d) => { stderr += d }) - child.on('error', reject) + child.on('error', (error) => { + clearTimeout(timer) + reject(error) + }) child.on('close', (code, signal) => { + clearTimeout(timer) const clean = (s) => s.replace(ANSI, '') resolve({ // A signal kill reports a null code; 0 would read as success. diff --git a/tests/integration/module.test.js b/tests/integration/module.test.js index 39c0e7b..c01de3b 100644 --- a/tests/integration/module.test.js +++ b/tests/integration/module.test.js @@ -17,6 +17,8 @@ import { statSync } from 'node:fs' import { Cli } from './helpers.js' const catalog = createRequire(import.meta.url)('../../wdk.config.json') +const MOONPAY_MODULE = catalog.providers.moonpay.module +const BITFINEX_MODULE = catalog.providers.bitfinex.module const MODULE_COUNT = Object.keys(catalog.modules).length const SPARK_METHOD_COUNT = Object.keys(catalog.modules[catalog.networks.spark.module].methods).length @@ -63,6 +65,32 @@ describe('module enable and disable', () => { expect(modules.find((m) => m.module === '@tetherto/wdk-protocol-fiat-moonpay').status).toBe('ok') }) }) +describe('adding and removing modules', () => { + // `module add` of a new package installs it from the registry, so the + // successful add path belongs in a suite allowed to reach the network. The + // refusals below are decided before any install runs. + it('refuses to add a package that already ships with the CLI', async () => { + const result = await cli.run(['module', 'add', '--name', BITFINEX_MODULE], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain(`'${BITFINEX_MODULE}' is a built-in module.`) + expect(result.output).toContain('pass one explicitly') + }) + + it('refuses to remove a packaged module, which can only be disabled', async () => { + const result = await cli.run(['module', 'remove', '--name', MOONPAY_MODULE], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain(`'${MOONPAY_MODULE}' is a built-in module and cannot be removed.`) + }) + + it('reports a remove for a module that was never added', async () => { + const result = await cli.run(['module', 'remove', '--name', '@nobody/never-added'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("Module '@nobody/never-added' is not a custom module.") + }) +}) describe('config', () => { it('reports where the config file lives', async () => { const result = await cli.run(['config', 'path']) diff --git a/tests/integration/network.test.js b/tests/integration/network.test.js index 2750d90..e208f19 100644 --- a/tests/integration/network.test.js +++ b/tests/integration/network.test.js @@ -19,11 +19,42 @@ const catalog = createRequire(import.meta.url)('../../wdk.config.json') const MOONPAY_MODULE = catalog.providers.moonpay.module const TESTNET_COUNT = Object.values(catalog.networks).filter((n) => n.testnet).length +const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' +/** The address this seed derives on any EVM network at index 0. */ +const ETHEREUM_0 = '0x405005C7c4422390F4B334F64Cf20E0b767131d0' +const EVM_MODULE = '@tetherto/wdk-wallet-evm' +const DAI = '0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1' + +/** A custom EVM network with its native asset declared inline. */ +const SPEC = JSON.stringify({ + network: 'optimism', + module: EVM_MODULE, + displayName: 'Optimism', + config: { provider: 'https://mainnet.optimism.io', chainId: 10 }, + tokens: [{ token: 'eth', symbol: 'ETH', decimals: 18, isNative: true }] +}) + /** @type {Cli} */ let cli beforeEach(() => { cli = new Cli() }) -afterEach(() => cli.cleanup()) +afterEach(async () => { + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + cli.cleanup() +}) + +/** + * Imports the fixed test seed and unlocks it. + * + * @returns {Promise} + */ +async function importAndUnlock () { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) +} describe('network registry', () => { it('lists the packaged networks', async () => { @@ -107,3 +138,87 @@ describe('network info, enable and disable', () => { expect(again.code).toBe(1) }) }) + +describe('a network the user creates', () => { + it('derives on the real module, not a registry stub', async () => { + await cli.json(['network', 'create', SPEC], { unlocked: true }) + await importAndUnlock() + + const derived = await cli.json(['get', 'address', '--network', 'optimism'], { unlocked: true }) + + // Same module and BIP-44 path as the packaged EVM networks, so the same + // seed must produce the same address. A registry entry alone could not. + expect(derived.address).toBe(ETHEREUM_0) + expect(derived.index).toBe(0) + }) + + it('registers the tokens declared in its spec', async () => { + await cli.json(['network', 'create', SPEC], { unlocked: true }) + + const { tokens } = await cli.json(['token', 'list', '--network', 'optimism']) + + expect(Object.keys(tokens)).toEqual(['eth']) + expect(tokens.eth.symbol).toBe('ETH') + expect(tokens.eth.isNative).toBe(true) + }) + + it('accepts a token added after the fact', async () => { + await cli.json(['network', 'create', SPEC], { unlocked: true }) + + await cli.json(['token', 'add', JSON.stringify({ + network: 'optimism', token: 'dai', symbol: 'DAI', decimals: 18, isNative: false, address: DAI + })], { unlocked: true }) + + const { tokens } = await cli.json(['token', 'list', '--network', 'optimism']) + expect(Object.keys(tokens)).toEqual(['eth', 'dai']) + expect(tokens.dai.address).toBe(DAI) + }) + + it('follows its module into and out of a disable', async () => { + await cli.json(['network', 'create', SPEC], { unlocked: true }) + + await cli.json(['module', 'disable', '--name', EVM_MODULE], { unlocked: true }) + const hidden = await cli.json(['network', 'list']) + expect(hidden.networks.find((n) => n.name === 'optimism')).toBeUndefined() + + await cli.json(['module', 'enable', '--name', EVM_MODULE], { unlocked: true }) + const back = await cli.json(['network', 'list']) + expect(back.networks.find((n) => n.name === 'optimism').enabled).toBe(true) + }) + + // The spec is validated in full before anything is written, so this covers + // fail-fast validation. The rollback in the catch block — a token that passes + // validation but fails on save — is a separate path and is NOT covered here. + it('writes nothing when a token in the spec is rejected', async () => { + const bad = JSON.stringify({ + network: 'optimism', + module: EVM_MODULE, + tokens: [ + { token: 'eth', symbol: 'ETH', decimals: 18, isNative: true }, + { token: 'bad', symbol: 'BAD', decimals: 6, isNative: false } + ] + }) + + const result = await cli.run(['network', 'create', bad], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain('Non-native tokens require an "address".') + + const { networks } = await cli.json(['network', 'list']) + expect(networks.find((n) => n.name === 'optimism')).toBeUndefined() + }) + + it('takes its custom tokens with it when deleted', async () => { + await cli.json(['network', 'create', SPEC], { unlocked: true }) + await cli.json(['token', 'add', JSON.stringify({ + network: 'optimism', token: 'dai', symbol: 'DAI', decimals: 18, isNative: false, address: DAI + })], { unlocked: true }) + + await cli.json(['network', 'delete', '--name', 'optimism'], { unlocked: true }) + await cli.json(['network', 'create', SPEC], { unlocked: true }) + + // Recreated under the same name: only the spec's own token may come back. + const { tokens } = await cli.json(['token', 'list', '--network', 'optimism']) + expect(Object.keys(tokens)).toEqual(['eth']) + }) +}) diff --git a/tests/integration/passphrase.test.js b/tests/integration/passphrase.test.js new file mode 100644 index 0000000..ed66e77 --- /dev/null +++ b/tests/integration/passphrase.test.js @@ -0,0 +1,105 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The passphrase gate sits in front of commands that have nothing to do with +// keys: disabling a provider asks for it too. What it accepts is decided by the +// keyring, so these cases cross auth, the keyring and the registry. + +import { Cli } from './helpers.js' + +const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' +const NEW_PASSPHRASE = 'second-passphrase' + +/** @type {Cli} */ +let cli + +beforeEach(() => { + cli = new Cli() +}) + +afterEach(async () => { + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + cli.cleanup() +}) + +/** + * Imports the fixed test seed under the instance passphrase. + * + * @returns {Promise} + */ +async function importSeed () { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) +} + +describe('the passphrase gate on a registry change', () => { + it('does not ask when there is no wallet to protect', async () => { + const result = await cli.run(['provider', 'disable', '--name', 'velora']) + + expect(result.code).toBe(0) + expect(result.output).toContain("Provider 'velora' disabled.") + }) + + it('asks as soon as a wallet exists', async () => { + await importSeed() + + const result = await cli.run(['provider', 'disable', '--name', 'velora']) + + expect(result.code).toBe(1) + expect(result.output).toContain('Cannot prompt for a passphrase when stdin is piped.') + expect(result.output).toContain('Set WDK_PASSPHRASE, or run from a terminal.') + }) + + it('rejects the wrong one', async () => { + await importSeed() + + const result = await cli.run(['provider', 'disable', '--name', 'velora'], { + passphrase: 'not-the-passphrase' + }) + + expect(result.code).toBe(1) + expect(result.output).toContain('Incorrect passphrase.') + }) + + it('leaves the provider untouched when the passphrase is refused', async () => { + await importSeed() + + await cli.run(['provider', 'disable', '--name', 'velora'], { passphrase: 'not-the-passphrase' }) + + const { providers } = await cli.json(['provider', 'list']) + expect(providers.find((p) => p.name === 'velora').enabled).toBe(true) + }) +}) + +describe('after the wallet passphrase changes', () => { + it('the registry gate follows the new one', async () => { + await importSeed() + await cli.run( + ['wallet', 'change-passphrase', '--name', 'main', '--new-passphrase-stdin'], + { unlocked: true, stdin: NEW_PASSPHRASE + '\n' } + ) + + const withOld = await cli.run(['provider', 'disable', '--name', 'velora'], { unlocked: true }) + expect(withOld.code).toBe(1) + expect(withOld.output).toContain('Incorrect passphrase.') + + const withNew = await cli.run(['provider', 'disable', '--name', 'velora'], { + passphrase: NEW_PASSPHRASE + }) + expect(withNew.code).toBe(0) + expect(withNew.output).toContain("Provider 'velora' disabled.") + }) +}) diff --git a/tests/integration/provider.test.js b/tests/integration/provider.test.js index 650fea6..3d8e02d 100644 --- a/tests/integration/provider.test.js +++ b/tests/integration/provider.test.js @@ -22,8 +22,13 @@ const BITFINEX_MODULE = catalog.providers.bitfinex.module /** @type {Cli} */ let cli +const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' + beforeEach(() => { cli = new Cli() }) -afterEach(() => cli.cleanup()) +afterEach(async () => { + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + cli.cleanup() +}) describe('provider list and info', () => { it('lists every packaged provider, pricing first', async () => { @@ -194,3 +199,118 @@ describe('provider enable, disable and delete', () => { expect(again.output).toContain("'moonpay' is already disabled.") }) }) + +describe('deleting a provider the user added', () => { + const CUSTOM = JSON.stringify({ + name: 'myramp', kind: 'fiat', module: MOONPAY_MODULE, config: {} + }) + + it('deletes one that is still enabled, without disabling it first', async () => { + await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) + + const deleted = await cli.json(['provider', 'delete', '--name', 'myramp'], { unlocked: true }) + + expect(deleted).toMatchObject({ name: 'myramp', deleted: true }) + const { providers } = await cli.json(['provider', 'list']) + expect(providers.find((p) => p.name === 'myramp')).toBeUndefined() + }) + + it('leaves the packaged providers of the same kind alone', async () => { + await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) + + await cli.json(['provider', 'delete', '--name', 'myramp'], { unlocked: true }) + + const { providers } = await cli.json(['provider', 'list']) + expect(providers.find((p) => p.name === 'moonpay').enabled).toBe(true) + expect(providers.find((p) => p.name === 'transak').enabled).toBe(true) + }) + + it('frees the slot on a kind that allows only one', async () => { + await cli.json(['provider', 'disable', '--name', 'bitfinex'], { unlocked: true }) + await cli.json(['provider', 'add', JSON.stringify({ + name: 'feed2', kind: 'pricing', module: BITFINEX_MODULE, config: {} + })], { unlocked: true }) + + const blocked = await cli.run(['provider', 'enable', '--name', 'bitfinex'], { unlocked: true }) + expect(blocked.code).toBe(1) + expect(blocked.output).toContain('A price feed is already enabled: feed2.') + + await cli.json(['provider', 'delete', '--name', 'feed2'], { unlocked: true }) + + const freed = await cli.run(['provider', 'enable', '--name', 'bitfinex'], { unlocked: true }) + expect(freed.code).toBe(0) + expect(freed.output).toContain("Provider 'bitfinex' enabled.") + }) + + it('takes its stored config with it, so a later provider starts clean', async () => { + await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) + await cli.json( + ['config', 'set', '--key', 'providers.myramp.config.apiKey', '--value', 'SUPER-SECRET'], + { unlocked: true } + ) + + await cli.json(['provider', 'delete', '--name', 'myramp'], { unlocked: true }) + + const orphan = await cli.json(['config', 'get', '--key', 'providers.myramp.config.apiKey']) + expect(orphan.value).toBeNull() + + // The name is reusable, so a leftover key would be merged into whatever is + // registered next under it — a different service holding the old secret. + await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) + const info = await cli.json(['provider', 'info', '--name', 'myramp']) + expect(info.config).toEqual({}) + }) + + it('leaves the config of a packaged provider alone', async () => { + await cli.json( + ['config', 'set', '--key', 'providers.wdk-indexer.config.apiKey', '--value', 'keep-me'], + { unlocked: true } + ) + await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) + + await cli.json(['provider', 'delete', '--name', 'myramp'], { unlocked: true }) + + const kept = await cli.json(['config', 'get', '--key', 'providers.wdk-indexer.config.apiKey']) + expect(kept.value).toBe('keep-me') + }) + + it('releases the name for a later add', async () => { + await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) + await cli.json(['provider', 'delete', '--name', 'myramp'], { unlocked: true }) + + const readded = await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) + + expect(readded.name).toBe('myramp') + const { providers } = await cli.json(['provider', 'list']) + expect(providers.filter((p) => p.name === 'myramp')).toHaveLength(1) + }) +}) + +describe('a provider the user added, in use', () => { + // Registering a provider is only half of it: the resolver has to pick it. + // Exercising one for real needs its API, so this goes as far as selection, + // which is the part the CLI owns. + it('is selected once the packaged providers of its kind are off', async () => { + await cli.json(['provider', 'add', JSON.stringify({ + name: 'myramp', kind: 'fiat', module: MOONPAY_MODULE, config: {} + })], { unlocked: true }) + await cli.json(['provider', 'disable', '--name', 'moonpay'], { unlocked: true }) + await cli.json(['provider', 'disable', '--name', 'transak'], { unlocked: true }) + + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + const result = await cli.run( + ['buy', '--network', 'ethereum', '--token', 'usdt', '--fiat-amount', '100'], + { unlocked: true } + ) + + // Naming myramp's own missing mapping proves it was resolved and asked for + // its slug — not that the kind had no provider at all. + expect(result.code).toBe(1) + expect(result.output).toContain("Token 'usdt' on 'ethereum' has no myramp mapping.") + }) +}) diff --git a/tests/integration/read-operations.test.js b/tests/integration/read-operations.test.js index 4d321a4..f1ece31 100644 --- a/tests/integration/read-operations.test.js +++ b/tests/integration/read-operations.test.js @@ -19,7 +19,14 @@ const catalog = createRequire(import.meta.url)('../../wdk.config.json') const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' const ETHEREUM_0 = '0x405005C7c4422390F4B334F64Cf20E0b767131d0' -const MAINNET_COUNT = Object.values(catalog.networks).filter((n) => !n.testnet).length +/** + * Mainnet networks in the packaged registry, minus spark. Spark derives through + * its operator API, so counting it would tie this to a remote service — and + * `get address --all` drops a network it cannot derive without saying so, which + * surfaces as an off-by-one rather than an error. + */ +const MAINNET_COUNT = Object.values(catalog.networks) + .filter((n) => !n.testnet && n.module !== '@tetherto/wdk-wallet-spark').length // ECDSA over a fixed seed and message is deterministic, so this is an exact // value rather than a shape. If it ever changes, the derivation changed. @@ -76,11 +83,15 @@ describe('address derivation', () => { it('derives every mainnet address in one call', async () => { await importAndUnlock() + // Off, so the run stays local: see MAINNET_COUNT. + await cli.run(['network', 'disable', '--name', 'spark'], { unlocked: true }) + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) const result = await cli.json(['get', 'address', '--all'], { unlocked: true }) expect(result.type).toBe('mainnet') expect(result.addresses).toHaveLength(MAINNET_COUNT) + expect(result.addresses.some((a) => a.network === 'spark')).toBe(false) expect(result.addresses.find((a) => a.network === 'ethereum').address).toBe(ETHEREUM_0) }) diff --git a/tests/integration/token.test.js b/tests/integration/token.test.js index d4eb592..b6d5b29 100644 --- a/tests/integration/token.test.js +++ b/tests/integration/token.test.js @@ -14,11 +14,42 @@ import { Cli } from './helpers.js' +const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' +const DAI = '0x6B175474E89094C44Da98b954EedeAC495271d0F' +const USDT = '0xdAC17F958D2ee523a2206206994597C13D831ec7' + +/** A token the packaged registry does not carry. */ +const CUSTOM_TOKEN = JSON.stringify({ + network: 'ethereum', token: 'dai', symbol: 'DAI', decimals: 18, isNative: false, address: DAI +}) + +/** An entry under a packaged key, to override it. */ +const OVERRIDE = JSON.stringify({ + network: 'ethereum', token: 'usdt', symbol: 'USDT2', decimals: 8, isNative: false, + address: '0x1111111111111111111111111111111111111111' +}) + /** @type {Cli} */ let cli beforeEach(() => { cli = new Cli() }) -afterEach(() => cli.cleanup()) +afterEach(async () => { + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + cli.cleanup() +}) + +/** + * Imports the fixed test seed and unlocks it. + * + * @returns {Promise} + */ +async function importAndUnlock () { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) +} describe('token registry', () => { it('lists the packaged tokens for a network', async () => { @@ -47,15 +78,25 @@ describe('token registry', () => { expect(result.output).toContain("Network 'atlantis' is not supported.") }) - it('adds a custom token and reads it back', async () => { - await cli.json(['token', 'add', JSON.stringify({ - network: 'ethereum', token: 'dai', symbol: 'DAI', decimals: 18, isNative: false, - address: '0x6B175474E89094C44Da98b954EedeAC495271d0F' - })]) + it('adds a custom token, joining the packaged entries', async () => { + const added = await cli.json(['token', 'add', CUSTOM_TOKEN]) + expect(added.added).toBe(true) + + const { tokens } = await cli.json(['token', 'list', '--network', 'ethereum']) + expect(Object.keys(tokens)).toEqual(['eth', 'usdt', 'xaut', 'dai']) const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'dai']) + expect(info).toMatchObject({ symbol: 'DAI', decimals: 18, address: DAI }) + }) + + it('deletes a custom token, leaving the packaged entries untouched', async () => { + await cli.json(['token', 'add', CUSTOM_TOKEN]) + + const deleted = await cli.json(['token', 'delete', '--network', 'ethereum', '--token', 'dai']) + expect(deleted.deleted).toBe(true) - expect(info).toMatchObject({ symbol: 'DAI', decimals: 18 }) + const { tokens } = await cli.json(['token', 'list', '--network', 'ethereum']) + expect(Object.keys(tokens)).toEqual(['eth', 'usdt', 'xaut']) }) it('warns when a custom token shadows a packaged one', async () => { @@ -67,23 +108,37 @@ describe('token registry', () => { expect(result.output).toContain('overrides it') }) - it('refuses to delete a packaged token', async () => { + it('refuses to delete a packaged token, pointing at the override instead', async () => { const result = await cli.run(['token', 'delete', '--network', 'ethereum', '--token', 'usdt']) expect(result.code).toBe(1) - expect(result.output).toContain('built-in token') + expect(result.output).toContain("'usdt' on 'ethereum' is a built-in token and cannot be deleted.") + expect(result.output).toContain('wdk token add') }) - it('reverts to the packaged token when the override is deleted', async () => { - await cli.json(['token', 'add', JSON.stringify({ - network: 'ethereum', token: 'usdt', symbol: 'FAKE', decimals: 2, isNative: false, - address: '0xdAC17F958D2ee523a2206206994597C13D831ec7' - })]) + it('replaces the packaged fields under the same key', async () => { + const added = await cli.json(['token', 'add', OVERRIDE]) + expect(added.overridesBuiltin).toBe(true) - await cli.run(['token', 'delete', '--network', 'ethereum', '--token', 'usdt']) const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'usdt']) + expect(info.symbol).toBe('USDT2') + expect(info.decimals).toBe(8) + + // Still one entry, not two: the override replaces rather than appends. + const { tokens } = await cli.json(['token', 'list', '--network', 'ethereum']) + expect(Object.keys(tokens)).toEqual(['eth', 'usdt', 'xaut']) + }) + it('reverts to the packaged token when the override is deleted', async () => { + await cli.json(['token', 'add', OVERRIDE]) + + const deleted = await cli.json(['token', 'delete', '--network', 'ethereum', '--token', 'usdt']) + expect(deleted.revertedToBuiltin).toBe(true) + + const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'usdt']) expect(info.symbol).toBe('USDT') + expect(info.decimals).toBe(6) + expect(info.address).toBe(USDT) }) }) describe('token enable and disable', () => { @@ -104,3 +159,48 @@ describe('token enable and disable', () => { expect(disabled).not.toContain('ethereum/usdt') }) }) + +describe('a token a command has to resolve', () => { + // A registry entry that no command can resolve would pass every test above. + // Pin the provider first: the ambiguity check runs before the token lookup. + it('resolves a custom token, and reports only the missing provider mapping', async () => { + await cli.json(['token', 'add', CUSTOM_TOKEN]) + await importAndUnlock() + + const result = await cli.run( + ['buy', '--network', 'ethereum', '--token', 'dai', '--fiat-amount', '100', + '--provider', 'moonpay'], + { unlocked: true } + ) + + // Reached the slug lookup, which only happens once the token resolved. + expect(result.code).toBe(1) + expect(result.output).toContain("Token 'dai' on 'ethereum' has no moonpay mapping.") + }) + + it('reports a token that was never registered', async () => { + await importAndUnlock() + + const result = await cli.run( + ['buy', '--network', 'ethereum', '--token', 'zzz', '--fiat-amount', '100', + '--provider', 'moonpay'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain("Unknown token 'zzz' on 'ethereum'.") + }) + + it('stops resolving a token the user disabled', async () => { + await importAndUnlock() + await cli.json(['token', 'disable', '--network', 'ethereum', '--token', 'xaut'], { unlocked: true }) + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + const result = await cli.run( + ['get', 'balance', '--network', 'ethereum', '--token', 'xaut'], { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain("Token 'xaut' is disabled on 'ethereum'.") + }) +}) From f2f86a4a41ef1ae33a97c9cef0bd8aed5fd84973 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Tue, 29 Sep 2026 15:43:00 +0700 Subject: [PATCH 10/15] fix: delete a provider's config along with the provider --- src/services/protocol-service.js | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/services/protocol-service.js b/src/services/protocol-service.js index ffc6bb1..75ea4c9 100644 --- a/src/services/protocol-service.js +++ b/src/services/protocol-service.js @@ -210,9 +210,9 @@ function userObject (key) { } /** - * Returns the networks that override something for a protocol: the packaged - * network entries naming it, the entry's own `networks`, and the networks the - * user configured under `providers..networks`. + * Returns the networks that override something for a protocol: the entry's own + * `networks`, and the networks the user configured under + * `providers..networks`. * * @param {string} name - The protocol short name. * @param {WdkProtocolEntry} entry - The registry entry. @@ -221,9 +221,6 @@ function userObject (key) { export function getProviderNetworks (name, entry) { /** @type {Set} */ const names = new Set() - for (const [network, networkEntry] of Object.entries(walletsFile.networks)) { - if (hasOwn(networkEntry.providers, name)) names.add(network) - } for (const network of Object.keys(entry.networks || {})) names.add(network) for (const network of Object.keys(userObject(`providers.${name}.networks`))) names.add(network) return [...names] @@ -238,7 +235,7 @@ export function getProviderNetworks (name, entry) { * Resolves a protocol's effective config, shallow-merging four layers with the * later ones winning: the packaged general `config`, the user's * `providers..config`, the packaged per-network override in - * `networks..providers.` (or a user-added entry's own + * the entry's own `networks.` (or * `networks.`, since it cannot edit the packaged network entries), * and the user's `providers..networks.`. Without a network only * the two general layers apply. @@ -262,8 +259,7 @@ export function resolveProtocolConfig (name, network, options = {}) { const config = { ...(entry.config || {}), ...userObject(`providers.${name}.config`) } if (network === undefined) return config - const packagedPerNetwork = getOwn(getOwn(walletsFile.networks, network)?.providers, name) ?? - getOwn(entry.networks, network) ?? {} + const packagedPerNetwork = getOwn(entry.networks, network) ?? {} return { ...config, ...packagedPerNetwork, ...userObject(`providers.${name}.networks.${network}`) } } @@ -360,7 +356,9 @@ export function saveCustomProvider (name, entry) { } /** - * Removes a user-added provider entry from config. + * Removes a user-added provider: its registry entry, its stored config, and + * any enabled/disabled override. Packaged providers are rejected, so their + * config is never touched. * * @param {string} name - The protocol short name. * @returns {void} @@ -388,6 +386,10 @@ export function removeCustomProvider (name) { delete next[name] if (Object.keys(next).length === 0) configService.delete('customProviders') else configService.set('customProviders', next) + // Its config too, general and per-network. Left behind, it would be merged + // into the next provider registered under the same name — handing one + // service's credentials to another. + configService.delete(`providers.${name}`) clearOverride('providers', name) } From bc983585235c1dacf83518b35c7815e05285c5ec Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Tue, 29 Sep 2026 16:18:19 +0700 Subject: [PATCH 11/15] add more tests --- tests/integration/wallet.test.js | 345 ++++++++++++++++++++++--------- 1 file changed, 250 insertions(+), 95 deletions(-) diff --git a/tests/integration/wallet.test.js b/tests/integration/wallet.test.js index f94b359..e629587 100644 --- a/tests/integration/wallet.test.js +++ b/tests/integration/wallet.test.js @@ -12,15 +12,25 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Grouped by the command under test, so the surface each one covers is visible +// at a glance and a new flag has an obvious home. + import { Cli } from './helpers.js' const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' const ETHEREUM_0 = '0x405005C7c4422390F4B334F64Cf20E0b767131d0' +/** A second valid BIP-39 phrase, for telling one wallet's keys from another's. */ +const OTHER_SEED = 'legal winner thank year wave sausage worth useful legal winner thank yellow' +const OTHER_ETHEREUM_0 = '0x58A57ed9d8d624cBD12e2C467D34787555bB1b25' + +/** The default session length, in milliseconds. */ +const DEFAULT_TTL_MS = 300_000 + /** @type {Cli} */ let cli -beforeEach(async () => { +beforeEach(() => { cli = new Cli() }) @@ -30,28 +40,29 @@ afterEach(async () => { }) /** - * Imports the fixed test seed under a name and unlocks it. + * Imports a seed under a name and unlocks it. * * @param {string} [name] - The wallet name. + * @param {string} [seed] - The seed phrase to import. * @returns {Promise} */ -async function importAndUnlock (name = 'main') { +async function importAndUnlock (name = 'main', seed = SEED) { await cli.run(['wallet', 'import', '--name', name, '--seed-stdin'], { unlocked: true, - stdin: SEED + '\n' + stdin: seed + '\n' }) await cli.run(['wallet', 'unlock', '--name', name], { unlocked: true }) } -describe('wallet lifecycle', () => { - it('creates a wallet and returns a seed phrase of the requested length', async () => { +describe('wallet create', () => { + it('generates a twelve word seed phrase', async () => { const created = await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) expect(created.wallet).toBe('w1') expect(created.seedPhrase.split(' ')).toHaveLength(12) }) - it('creates a 24-word wallet when asked', async () => { + it('generates a twenty-four word seed phrase when asked', async () => { const created = await cli.json( ['wallet', 'create', '--name', 'w1', '--words', '24'], { unlocked: true } ) @@ -59,15 +70,16 @@ describe('wallet lifecycle', () => { expect(created.seedPhrase.split(' ')).toHaveLength(24) }) - it('makes the first wallet the default', async () => { + it('makes the first wallet the default and the second not', async () => { await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + await cli.json(['wallet', 'create', '--name', 'w2'], { unlocked: true }) const { wallets } = await cli.json(['wallet', 'list']) - expect(wallets).toEqual([{ name: 'w1', default: true, unlocked: false }]) + expect(wallets.filter((w) => w.default).map((w) => w.name)).toEqual(['w1']) }) - it('refuses a second wallet with the same name', async () => { + it('refuses a name already taken', async () => { await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) const again = await cli.run(['wallet', 'create', '--name', 'w1'], { unlocked: true }) @@ -85,60 +97,110 @@ describe('wallet lifecycle', () => { expect(result.output).toContain('--words must be 12 or 24') }) + // The name reaches the filesystem, so anything but a plain identifier is out. + it.each(['a b', '../evil', ''])('refuses the name %p', async (name) => { + const result = await cli.run(['wallet', 'create', '--name', name], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain(`Invalid wallet name: '${name}'.`) + }) + it('cannot prompt for a passphrase when stdin is piped', async () => { const result = await cli.run(['wallet', 'create', '--name', 'w1']) expect(result.code).toBe(1) expect(result.output).toContain('Cannot prompt for a passphrase when stdin is piped.') }) +}) - it('renames a wallet, keeping it usable', async () => { - await importAndUnlock('old') +describe('wallet import', () => { + it('imports a known seed', async () => { + await importAndUnlock() - await cli.run(['wallet', 'rename', '--name', 'old', '--new-name', 'new'], { unlocked: true }) const { wallets } = await cli.json(['wallet', 'list']) - expect(wallets.map((w) => w.name)).toEqual(['new']) + expect(wallets).toHaveLength(1) + expect(wallets[0]).toMatchObject({ name: 'main', default: true, unlocked: true }) }) - it('deletes a wallet', async () => { - await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) - - await cli.run(['wallet', 'delete', '--name', 'w1'], { unlocked: true }) - const { wallets } = await cli.json(['wallet', 'list']) + it('refuses a seed that is not valid BIP-39', async () => { + const result = await cli.run(['wallet', 'import', '--name', 'bad', '--seed-stdin'], { + unlocked: true, + stdin: 'not a real seed phrase at all here friend\n' + }) - expect(wallets).toEqual([]) + expect(result.code).toBe(1) + expect(result.output).toContain('Invalid seed phrase. Must be 12 or 24 valid BIP-39 words.') }) - it('reports a wallet that does not exist', async () => { - const result = await cli.run(['wallet', 'unlock', '--name', 'ghost'], { unlocked: true }) + it('refuses a name already taken', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + const result = await cli.run(['wallet', 'import', '--name', 'w1', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) expect(result.code).toBe(1) - expect(result.output).toContain("Wallet 'ghost' not found.") + expect(result.output).toContain("Wallet 'w1' already exists.") }) }) -describe('import, unlock and lock', () => { - it('imports a known seed and reports it unlocked, with the session ttl', async () => { +describe('wallet unlock and the session it opens', () => { + it('opens a session of the default length', async () => { await importAndUnlock() const { wallets } = await cli.json(['wallet', 'list']) - expect(wallets).toHaveLength(1) - expect(wallets[0]).toMatchObject({ name: 'main', default: true, unlocked: true, ttlMs: 300_000 }) + expect(wallets[0]).toMatchObject({ unlocked: true, ttlMs: DEFAULT_TTL_MS }) + expect(wallets[0].ttlRemaining).toBeLessThanOrEqual(DEFAULT_TTL_MS) }) - it('refuses a seed that is not valid BIP-39', async () => { - const result = await cli.run(['wallet', 'import', '--name', 'bad', '--seed-stdin'], { - unlocked: true, - stdin: 'not a real seed phrase at all here friend\n' - }) + it('opens a session for the number of minutes it was given', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + const unlocked = await cli.json( + ['wallet', 'unlock', '--name', 'w1', '--ttl', '2'], { unlocked: true } + ) + + expect(unlocked).toMatchObject({ wallet: 'w1', unlocked: true, alreadyUnlocked: false, ttl: 2 }) + }) + + it('treats a ttl of zero as unlimited', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + const unlocked = await cli.json( + ['wallet', 'unlock', '--name', 'w1', '--ttl', '0'], { unlocked: true } + ) + + expect(unlocked.ttl).toBe(0) + expect(unlocked.unlocked).toBe(true) + }) + + // The ttl decides how long a decrypted seed stays in memory, so a value that + // cannot be honoured has to be rejected rather than rounded into something. + it.each(['-5', 'abc'])('refuses a ttl of %p', async (ttl) => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + const result = await cli.run( + ['wallet', 'unlock', '--name', 'w1', '--ttl', ttl], { unlocked: true } + ) expect(result.code).toBe(1) - expect(result.output).toContain('Invalid seed phrase. Must be 12 or 24 valid BIP-39 words.') + expect(result.output).toContain( + `option '--ttl ' argument '${ttl}' is invalid. Must be a non-negative integer.` + ) + }) + + it('reports a wallet that was already unlocked, rather than failing', async () => { + await importAndUnlock() + + const again = await cli.json(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + expect(again).toMatchObject({ wallet: 'main', unlocked: true, alreadyUnlocked: true }) }) - it('refuses the wrong passphrase on unlock', async () => { + it('refuses the wrong passphrase', async () => { await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { unlocked: true, stdin: SEED + '\n' @@ -150,14 +212,16 @@ describe('import, unlock and lock', () => { expect(result.output).toContain('Incorrect passphrase.') }) - it('requires a target for lock', async () => { - const result = await cli.run(['wallet', 'lock'], { unlocked: true }) + it('reports a wallet that does not exist', async () => { + const result = await cli.run(['wallet', 'unlock', '--name', 'ghost'], { unlocked: true }) expect(result.code).toBe(1) - expect(result.output).toContain('Provide --name or --all.') + expect(result.output).toContain("Wallet 'ghost' not found.") }) +}) - it('locks a wallet by name', async () => { +describe('wallet lock', () => { + it('locks one wallet by name', async () => { await importAndUnlock() await cli.run(['wallet', 'lock', '--name', 'main'], { unlocked: true }) @@ -165,9 +229,36 @@ describe('import, unlock and lock', () => { expect(wallets[0].unlocked).toBe(false) }) + + it('locks every wallet at once', async () => { + await importAndUnlock('w1') + await importAndUnlock('w2') + + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + const { wallets } = await cli.json(['wallet', 'list']) + + expect(wallets).toHaveLength(2) + expect(wallets.every((w) => !w.unlocked)).toBe(true) + }) + + it('succeeds on a wallet that was not unlocked', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + const result = await cli.run(['wallet', 'lock', '--name', 'w1'], { unlocked: true }) + + expect(result.code).toBe(0) + expect(result.output).toContain("Wallet 'w1' is already locked.") + }) + + it('requires a target', async () => { + const result = await cli.run(['wallet', 'lock'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain('Provide --name or --all.') + }) }) -describe('export, which decrypts the seed', () => { +describe('wallet export', () => { it('returns exactly the seed that was imported', async () => { await importAndUnlock() @@ -176,7 +267,7 @@ describe('export, which decrypts the seed', () => { expect(exported.seedPhrase).toBe(SEED) }) - it('refuses to export without the passphrase', async () => { + it('refuses without the passphrase, printing no part of the seed', async () => { await importAndUnlock() const result = await cli.run(['wallet', 'export', '--name', 'main']) @@ -185,7 +276,7 @@ describe('export, which decrypts the seed', () => { expect(result.output).not.toContain('cook voyage') }) - it('refuses to export a wallet that does not exist', async () => { + it('reports a wallet that does not exist', async () => { const result = await cli.run(['wallet', 'export', '--name', 'ghost'], { unlocked: true }) expect(result.code).toBe(1) @@ -193,7 +284,24 @@ describe('export, which decrypts the seed', () => { }) }) -describe('the default wallet', () => { +describe('wallet list', () => { + it('reports an empty registry before any wallet exists', async () => { + const result = await cli.json(['wallet', 'list']) + + expect(result).toEqual({ wallets: [], count: 0 }) + }) + + it('reports a locked wallet without session fields', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + const { wallets, count } = await cli.json(['wallet', 'list']) + + expect(count).toBe(1) + expect(wallets).toEqual([{ name: 'w1', default: true, unlocked: false }]) + }) +}) + +describe('wallet default', () => { it('moves the default to another wallet', async () => { await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) await cli.json(['wallet', 'create', '--name', 'w2'], { unlocked: true }) @@ -204,24 +312,114 @@ describe('the default wallet', () => { expect(wallets.find((w) => w.default).name).toBe('w2') }) - it('refuses to default to a wallet that does not exist', async () => { + it('reports a wallet that does not exist', async () => { const result = await cli.run(['wallet', 'default', '--name', 'ghost'], { unlocked: true }) expect(result.code).toBe(1) expect(result.output).toContain("Wallet 'ghost' not found.") }) - it('leaves the second wallet non-default when created', async () => { + it('derives from the wallet named by --wallet, not the default', async () => { + await importAndUnlock('w1') + await importAndUnlock('w2', OTHER_SEED) + await cli.run(['wallet', 'default', '--name', 'w2'], { unlocked: true }) + await cli.run(['wallet', 'unlock', '--name', 'w1'], { unlocked: true }) + + const fromNamed = await cli.json( + ['get', 'address', '--network', 'ethereum', '--wallet', 'w1'], { unlocked: true } + ) + + expect(fromNamed.address).toBe(ETHEREUM_0) + }) +}) + +describe('wallet rename', () => { + it('renames a wallet, keeping it usable', async () => { + await importAndUnlock('old') + + await cli.run(['wallet', 'rename', '--name', 'old', '--new-name', 'new'], { unlocked: true }) + const { wallets } = await cli.json(['wallet', 'list']) + + expect(wallets.map((w) => w.name)).toEqual(['new']) + }) + + it('carries the default flag to the new name', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + await cli.run(['wallet', 'rename', '--name', 'w1', '--new-name', 'w2'], { unlocked: true }) + const { wallets } = await cli.json(['wallet', 'list']) + + expect(wallets).toEqual([{ name: 'w2', default: true, unlocked: false }]) + }) + + it('refuses a name already taken', async () => { await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) await cli.json(['wallet', 'create', '--name', 'w2'], { unlocked: true }) + const result = await cli.run( + ['wallet', 'rename', '--name', 'w1', '--new-name', 'w2'], { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain("Wallet 'w2' already exists.") + }) +}) + +describe('wallet delete', () => { + it('removes the wallet', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + await cli.run(['wallet', 'delete', '--name', 'w1'], { unlocked: true }) const { wallets } = await cli.json(['wallet', 'list']) - expect(wallets.filter((w) => w.default).map((w) => w.name)).toEqual(['w1']) + expect(wallets).toEqual([]) + }) + + it('promotes another wallet when the default goes', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + await cli.json(['wallet', 'create', '--name', 'w2'], { unlocked: true }) + + const deleted = await cli.json(['wallet', 'delete', '--name', 'w1'], { unlocked: true }) + + expect(deleted).toMatchObject({ wallet: 'w1', deleted: true, newDefault: 'w2' }) + const { wallets } = await cli.json(['wallet', 'list']) + expect(wallets).toEqual([{ name: 'w2', default: true, unlocked: false }]) + }) + + it('promotes nothing when the last wallet goes', async () => { + await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) + + const deleted = await cli.json(['wallet', 'delete', '--name', 'w1'], { unlocked: true }) + + expect(deleted).toMatchObject({ wallet: 'w1', deleted: true }) + expect(deleted.newDefault).toBeUndefined() + }) + + // The daemon holds the decrypted seed for the length of the session. Deleting + // the wallet has to end that session: names are reusable, so a surviving one + // would answer for a wallet the user believes they destroyed. + it('ends the session, so the same name re-imported starts locked', async () => { + await importAndUnlock('c') + await cli.json(['wallet', 'delete', '--name', 'c'], { unlocked: true }) + + await cli.run(['wallet', 'import', '--name', 'c', '--seed-stdin'], { + unlocked: true, + stdin: OTHER_SEED + '\n' + }) + + const locked = await cli.run(['get', 'address', '--network', 'ethereum'], { unlocked: true }) + expect(locked.code).toBe(1) + expect(locked.output).toContain("Wallet 'c' is not unlocked.") + + await cli.run(['wallet', 'unlock', '--name', 'c'], { unlocked: true }) + const derived = await cli.json(['get', 'address', '--network', 'ethereum'], { unlocked: true }) + + // The new seed's address, never the deleted wallet's. + expect(derived.address).toBe(OTHER_ETHEREUM_0) }) }) -describe('change-passphrase, which re-encrypts the seed', () => { +describe('wallet change-passphrase', () => { it('keeps the seed recoverable under the new passphrase', async () => { await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { unlocked: true, @@ -234,11 +432,11 @@ describe('change-passphrase, which re-encrypts the seed', () => { ) expect(changed.code).toBe(0) - const exported = await cli.run(['wallet', 'export', '--name', 'main', '--json'], { + const exported = await cli.json(['wallet', 'export', '--name', 'main'], { passphrase: 'second-passphrase' }) - expect(JSON.parse(exported.stdout.trim().split('\n').pop()).seedPhrase).toBe(SEED) + expect(exported.seedPhrase).toBe(SEED) }) it('stops the old passphrase from working', async () => { @@ -256,57 +454,14 @@ describe('change-passphrase, which re-encrypts the seed', () => { expect(result.code).toBe(1) expect(result.output).toContain('Incorrect passphrase.') }) -}) - -describe('more than one wallet', () => { - it('locks every wallet at once', async () => { - await importAndUnlock('w1') - await cli.run(['wallet', 'import', '--name', 'w2', '--seed-stdin'], { - unlocked: true, - stdin: SEED + '\n' - }) - await cli.run(['wallet', 'unlock', '--name', 'w2'], { unlocked: true }) - - await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) - const { wallets } = await cli.json(['wallet', 'list']) - - expect(wallets).toHaveLength(2) - expect(wallets.every((w) => !w.unlocked)).toBe(true) - }) - - it('derives from the wallet named by --wallet, not the default', async () => { - await importAndUnlock('w1') - await cli.json(['wallet', 'create', '--name', 'w2'], { unlocked: true }) - await cli.run(['wallet', 'unlock', '--name', 'w2'], { unlocked: true }) - - const fromNamed = await cli.json( - ['get', 'address', '--network', 'ethereum', '--wallet', 'w1'], { unlocked: true } - ) - - expect(fromNamed.address).toBe(ETHEREUM_0) - }) - - it('refuses a rename onto a name already taken', async () => { - await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) - await cli.json(['wallet', 'create', '--name', 'w2'], { unlocked: true }) + it('reports a wallet that does not exist', async () => { const result = await cli.run( - ['wallet', 'rename', '--name', 'w1', '--new-name', 'w2'], { unlocked: true } + ['wallet', 'change-passphrase', '--name', 'ghost', '--new-passphrase-stdin'], + { unlocked: true, stdin: 'second-passphrase\n' } ) expect(result.code).toBe(1) - expect(result.output).toContain("Wallet 'w2' already exists.") - }) - - it('refuses to import onto a name already taken', async () => { - await cli.json(['wallet', 'create', '--name', 'w1'], { unlocked: true }) - - const result = await cli.run(['wallet', 'import', '--name', 'w1', '--seed-stdin'], { - unlocked: true, - stdin: SEED + '\n' - }) - - expect(result.code).toBe(1) - expect(result.output).toContain("Wallet 'w1' already exists.") + expect(result.output).toContain("Wallet 'ghost' not found.") }) }) From 6f32308e00cb13ff13ac5ada5f49cfb1ba32d7bc Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Tue, 29 Sep 2026 17:06:05 +0700 Subject: [PATCH 12/15] group integration tests --- tests/integration/config.test.js | 271 ++++++++++++ tests/integration/enable-disable.test.js | 452 --------------------- tests/integration/helpers.js | 5 + tests/integration/module.test.js | 153 ++++--- tests/integration/network.test.js | 369 +++++++++++++---- tests/integration/passphrase.test.js | 105 ----- tests/integration/provider.test.js | 346 ++++++++++++---- tests/integration/read-operations.test.js | 225 ++++++++-- tests/integration/token.test.js | 332 ++++++++++++--- tests/integration/wallet.test.js | 83 +++- tests/integration/write-operations.test.js | 207 +++++++++- 11 files changed, 1692 insertions(+), 856 deletions(-) create mode 100644 tests/integration/config.test.js delete mode 100644 tests/integration/enable-disable.test.js delete mode 100644 tests/integration/passphrase.test.js diff --git a/tests/integration/config.test.js b/tests/integration/config.test.js new file mode 100644 index 0000000..c051c3a --- /dev/null +++ b/tests/integration/config.test.js @@ -0,0 +1,271 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The config store itself: where it lives, what it accepts, the file it leaves +// on disk, and what changing it does to an unlocked wallet. Config belonging to +// one registry is tested with that registry; this covers the store every +// command writes through. + +import { chmodSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname } from 'node:path' +import { Cli } from './helpers.js' + +const catalog = createRequire(import.meta.url)('../../wdk.config.json') +/** The packaged Ethereum RPC endpoint, which is what `config reset` restores. */ +const ETHEREUM_PROVIDER = catalog.networks.ethereum.config.provider + +const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' + +/** A minimally valid custom-token entry, so the token registry passes validation. */ +const TOKEN = JSON.stringify({ + symbol: 'MYT', + address: '0x1111111111111111111111111111111111111111', + decimals: 6, + isNative: false +}) + +/** @type {Cli} */ +let cli + +beforeEach(() => { cli = new Cli() }) +afterEach(async () => { + // Locking stops the daemon and clears its pid file; without it the tests that + // unlock leave a daemon behind holding a socket in a deleted directory. + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + cli.cleanup() +}) + +/** + * Imports the fixed test seed, so the commands that confirm the passphrase have + * a default wallet to confirm against. + * + * @returns {Promise} + */ +async function importWallet () { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) +} + +describe('config path', () => { + it('prints the file the store writes through', async () => { + const result = await cli.run(['config', 'path']) + + expect(result.stdout.trim()).toBe(cli.configPath()) + }) +}) + +describe('config get', () => { + it('dumps the packaged defaults with --all', async () => { + const all = await cli.json(['config', 'get', '--all']) + + expect(all.defaultIndex).toBe(0) + expect(all.networks.ethereum.provider).toBe(ETHEREUM_PROVIDER) + }) + + it('leaves the token registry out of --all', async () => { + await cli.run(['config', 'set', '--key', 'customTokens.ethereum.MYT', '--value', TOKEN]) + + const all = await cli.json(['config', 'get', '--all']) + + expect(Object.keys(all)).not.toContain('customTokens') + expect(cli.readConfig().customTokens.ethereum.MYT.symbol).toBe('MYT') + }) + + it('reports a key that was never set, and still exits 0', async () => { + const result = await cli.run(['config', 'get', '--key', 'nope.missing']) + + expect(result.output).toContain("Key 'nope.missing' is not set.") + expect(result.code).toBe(0) + }) + + it('refuses to run with neither --key, --network nor --all', async () => { + const result = await cli.run(['config', 'get']) + + expect(result.output).toContain('Error: Either --key, --network, or --all is required.') + expect(result.code).toBe(1) + }) +}) + +describe('config set', () => { + it('parses the value as JSON, so objects, numbers and booleans keep their type', async () => { + await cli.run(['config', 'set', '--key', 'probe.object', '--value', '{"color":"blue","size":2}']) + await cli.run(['config', 'set', '--key', 'probe.number', '--value', '42']) + await cli.run(['config', 'set', '--key', 'probe.boolean', '--value', 'true']) + + expect(cli.readConfig().probe).toEqual({ + object: { color: 'blue', size: 2 }, + number: 42, + boolean: true + }) + }) + + it('stores a value that is not JSON as the raw string it was given', async () => { + await cli.run(['config', 'set', '--key', 'probe.spaced', '--value', 'hello world']) + await cli.run(['config', 'set', '--key', 'probe.empty', '--value', '']) + + expect(cli.readConfig().probe).toEqual({ spaced: 'hello world', empty: '' }) + }) + + it('scopes --network to networks., in both directions', async () => { + await cli.run(['config', 'set', '--network', 'ethereum', '--key', 'provider', '--value', 'https://scoped.example']) + + expect(cli.readConfig().networks.ethereum.provider).toBe('https://scoped.example') + expect(await cli.json(['config', 'get', '--network', 'ethereum', '--key', 'provider'])) + .toEqual({ key: 'provider', network: 'ethereum', value: 'https://scoped.example' }) + }) + + it('refuses a value with nothing to write it to', async () => { + const result = await cli.run(['config', 'set', '--value', 'orphan']) + + expect(result.output).toContain('Error: --key is required (or use --network to set network config)') + expect(result.code).toBe(1) + }) +}) + +describe('config reset', () => { + it('restores the packaged default of a key that has one', async () => { + await cli.run(['config', 'set', '--key', 'networks.ethereum.provider', '--value', 'https://mine.example']) + + await cli.run(['config', 'reset', '--key', 'networks.ethereum.provider']) + + expect((await cli.json(['config', 'get', '--key', 'networks.ethereum.provider'])).value) + .toBe(ETHEREUM_PROVIDER) + }) + + it('deletes a key the packaged defaults say nothing about', async () => { + await cli.run(['config', 'set', '--key', 'probe.theme', '--value', 'dark']) + + await cli.run(['config', 'reset', '--key', 'probe.theme']) + + const result = await cli.run(['config', 'get', '--key', 'probe.theme']) + expect(result.output).toContain("Key 'probe.theme' is not set.") + }) + + it('--all drops plain config but keeps the default wallet and the custom registries', async () => { + await cli.run(['config', 'set', '--key', 'probe.theme', '--value', 'dark']) + await cli.run(['config', 'set', '--key', 'defaultWallet', '--value', 'main']) + await cli.run(['config', 'set', '--key', 'customNetworks.optimism', '--value', '{"chainId":10}']) + await cli.run(['config', 'set', '--key', 'customProviders.myramp', '--value', '{"module":"x"}']) + await cli.run(['config', 'set', '--key', 'customTokens.ethereum.MYT', '--value', TOKEN]) + + await cli.run(['config', 'reset', '--all']) + + const after = cli.readConfig() + expect(Object.keys(after)).not.toContain('probe') + expect(after.defaultWallet).toBe('main') + expect(after.customNetworks).toEqual({ optimism: { chainId: 10 } }) + expect(after.customProviders).toEqual({ myramp: { module: 'x' } }) + expect(after.customTokens.ethereum.MYT.symbol).toBe('MYT') + }) + + it('refuses --key together with --all', async () => { + const result = await cli.run(['config', 'reset', '--key', 'probe.theme', '--all']) + + expect(result.output).toContain('Error: --key and --all are mutually exclusive.') + expect(result.code).toBe(1) + }) +}) + +describe('config file permissions', () => { + it('writes the config owner-only', async () => { + await cli.run(['config', 'set', '--key', 'probe.theme', '--value', 'dark']) + + expect(statSync(cli.configPath()).mode & 0o777).toBe(0o600) + }) + + it('tightens a config an older version left world-readable', async () => { + await cli.run(['config', 'set', '--key', 'probe.theme', '--value', 'dark']) + chmodSync(cli.configPath(), 0o644) + + await cli.run(['config', 'set', '--key', 'probe.theme', '--value', 'light']) + + expect(statSync(cli.configPath()).mode & 0o777).toBe(0o600) + }) + + it('tightens a world-readable config directory on the next command', async () => { + await cli.run(['config', 'set', '--key', 'probe.theme', '--value', 'dark']) + chmodSync(dirname(cli.configPath()), 0o755) + + await cli.run(['config', 'path']) + + expect(statSync(dirname(cli.configPath())).mode & 0o777).toBe(0o700) + }) +}) + +describe('the passphrase gate on config set', () => { + it('refuses the write when the passphrase is wrong', async () => { + await importWallet() + + const result = await cli.run( + ['config', 'set', '--key', 'probe.theme', '--value', 'dark'], + { passphrase: 'not-the-passphrase' } + ) + + expect(result.output).toContain('Error: Incorrect passphrase.') + expect(result.code).toBe(1) + expect(Object.keys(cli.readConfig())).not.toContain('probe') + }) + + it('does not gate reads behind the passphrase', async () => { + await importWallet() + + const result = await cli.run(['config', 'get', '--key', 'defaultIndex']) + + expect(result.stdout.trim()).toBe('0') + expect(result.code).toBe(0) + }) +}) + +describe('the wallet lock a config change forces', () => { + it('locks the unlocked wallets when a networks.* key changes', async () => { + await importWallet() + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + const result = await cli.json( + ['config', 'set', '--key', 'networks.ethereum.provider', '--value', 'https://mine.example'], + { unlocked: true } + ) + + expect(result).toEqual({ + key: 'networks.ethereum.provider', + value: 'https://mine.example', + success: true, + walletsLocked: true + }) + expect((await cli.json(['wallet', 'list'])).wallets).toEqual([ + { name: 'main', default: true, unlocked: false } + ]) + }) + + it('leaves the wallets unlocked when an unrelated key changes', async () => { + await importWallet() + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + const result = await cli.json( + ['config', 'set', '--key', 'probe.theme', '--value', 'dark'], + { unlocked: true } + ) + + expect(result).toEqual({ + key: 'probe.theme', + value: 'dark', + success: true, + walletsLocked: false + }) + expect((await cli.json(['wallet', 'list'])).wallets[0].unlocked).toBe(true) + }) +}) diff --git a/tests/integration/enable-disable.test.js b/tests/integration/enable-disable.test.js deleted file mode 100644 index ccca7ef..0000000 --- a/tests/integration/enable-disable.test.js +++ /dev/null @@ -1,452 +0,0 @@ -// Copyright 2026 Tether Operations Limited -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Disabling one entry in the registry is felt in the others: a module hides the -// networks it backs, a network hides its tokens, a provider stops serving the -// command that consumes it. Each case here crosses at least two registries, so -// no single-service unit test covers it. - -import { createRequire } from 'node:module' -import { Cli } from './helpers.js' - -const catalog = createRequire(import.meta.url)('../../wdk.config.json') - -const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' - -const EVM_MODULE = '@tetherto/wdk-wallet-evm' -const INDEXER_MODULE = '@tetherto/wdk-indexer-http' -const PRICING_MODULE = '@tetherto/wdk-pricing-bitfinex-http' - -/** The networks the EVM wallet module backs, all of which it takes down with it. */ -const EVM_NETWORKS = Object.entries(catalog.networks) - .filter(([, entry]) => entry.module === EVM_MODULE) - .map(([name]) => name) -const TOTAL_NETWORKS = Object.keys(catalog.networks).length -const PROVIDER_COUNT = Object.keys(catalog.providers).length - -/** Every provider the catalog ships, with the module behind it. */ -const PROVIDERS = Object.entries(catalog.providers).map(([name, entry]) => ({ - name, - kind: entry.kind, - module: entry.module -})) - -/** @type {Cli} */ -let cli - -beforeEach(() => { - cli = new Cli() -}) - -afterEach(async () => { - await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) - cli.cleanup() -}) - -/** - * Imports the fixed test seed and unlocks it. - * - * @returns {Promise} - */ -async function importAndUnlock () { - await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { - unlocked: true, - stdin: SEED + '\n' - }) - await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) -} - -/** - * Disables a module and returns what the command reported. - * - * @param {string} name - The module package name. - * @returns {Promise>} The command's JSON payload. - */ -function disableModule (name) { - return cli.json(['module', 'disable', '--name', name], { unlocked: true }) -} - -describe('a disabled module and the networks it backs', () => { - it('takes down every network on that module and leaves the others alone', async () => { - const before = await cli.json(['network', 'list']) - expect(before.networks).toHaveLength(TOTAL_NETWORKS) - - await disableModule(EVM_MODULE) - - const { networks } = await cli.json(['network', 'list']) - const names = networks.map((n) => n.name) - expect(networks).toHaveLength(TOTAL_NETWORKS - EVM_NETWORKS.length) - for (const network of EVM_NETWORKS) expect(names).not.toContain(network) - expect(names).toContain('solana') - }) - - it('hides a module-disabled network but only flags a user-disabled one', async () => { - await cli.json(['network', 'disable', '--name', 'arbitrum'], { unlocked: true }) - - const flagged = await cli.json(['network', 'list']) - expect(flagged.networks.find((n) => n.name === 'arbitrum').enabled).toBe(false) - - await disableModule(EVM_MODULE) - - const hidden = await cli.json(['network', 'list']) - expect(hidden.networks.find((n) => n.name === 'arbitrum')).toBeUndefined() - }) - - it('tells every command that touches the network to enable the module', async () => { - await disableModule(EVM_MODULE) - - for (const args of [ - ['network', 'info', '--network', 'ethereum'], - ['token', 'list', '--network', 'ethereum'], - ['method', 'list', '--network', 'ethereum'] - ]) { - const result = await cli.run(args, { unlocked: true }) - - expect(result.code).toBe(1) - expect(result.output).toContain("Network 'ethereum' is disabled.") - expect(result.output).toContain(`wdk module enable --name ${EVM_MODULE}`) - } - }) - - it('refuses to re-enable the network on its own', async () => { - await disableModule(EVM_MODULE) - - const result = await cli.run(['network', 'enable', '--name', 'ethereum'], { unlocked: true }) - - expect(result.code).toBe(1) - expect(result.output).toContain("Network 'ethereum' is disabled by its module.") - }) - - it('keeps a network the user disabled off after the module comes back', async () => { - await cli.json(['network', 'disable', '--name', 'arbitrum'], { unlocked: true }) - await disableModule(EVM_MODULE) - await cli.json(['module', 'enable', '--name', EVM_MODULE], { unlocked: true }) - - const { networks } = await cli.json(['network', 'list']) - - expect(networks).toHaveLength(TOTAL_NETWORKS) - expect(networks.find((n) => n.name === 'arbitrum').enabled).toBe(false) - expect(networks.find((n) => n.name === 'ethereum').enabled).toBe(true) - }) -}) - -describe('a disabled module and the provider it serves', () => { - // Every kind is meant to behave the same here, so drive the same contract from - // the catalog: a new provider kind is covered the day it is registered. - it.each(PROVIDERS)('hides the $kind provider $name when its module goes', async (provider) => { - const before = await cli.json(['provider', 'list']) - expect(before.providers).toHaveLength(PROVIDER_COUNT) - expect(before.providers.find((p) => p.name === provider.name).enabled).toBe(true) - - await disableModule(provider.module) - - const { providers } = await cli.json(['provider', 'list']) - expect(providers).toHaveLength(PROVIDER_COUNT - 1) - expect(providers.find((p) => p.name === provider.name)).toBeUndefined() - - const info = await cli.json(['provider', 'info', '--name', provider.name]) - expect(info.enabled).toBe(false) - - const result = await cli.run(['provider', 'enable', '--name', provider.name], { unlocked: true }) - expect(result.code).toBe(1) - expect(result.output).toContain(`Provider '${provider.name}' is disabled by its module.`) - expect(result.output).toContain(`wdk module enable --name ${provider.module}`) - }) - - it('leaves a provider of the same kind on a different module alone', async () => { - await disableModule(catalog.providers.moonpay.module) - - const { providers } = await cli.json(['provider', 'list']) - - expect(providers.find((p) => p.name === 'moonpay')).toBeUndefined() - expect(providers.find((p) => p.name === 'transak').enabled).toBe(true) - }) - - it('fails the command that consumes the provider', async () => { - await disableModule(INDEXER_MODULE) - - const result = await cli.run(['get', 'history', '--network', 'ethereum'], { unlocked: true }) - - expect(result.code).toBe(1) - expect(result.output).toContain('No indexer is available.') - }) -}) - -describe('a provider disabled on its own', () => { - // The contrast with a module disable: this one stays visible and flagged, - // because the user can undo it directly. - it.each(PROVIDERS)('keeps the $kind provider $name listed but off', async (provider) => { - const off = await cli.json(['provider', 'disable', '--name', provider.name], { unlocked: true }) - expect(off.enabled).toBe(false) - - const { providers } = await cli.json(['provider', 'list']) - expect(providers).toHaveLength(PROVIDER_COUNT) - expect(providers.find((p) => p.name === provider.name).enabled).toBe(false) - - const on = await cli.json(['provider', 'enable', '--name', provider.name], { unlocked: true }) - expect(on.enabled).toBe(true) - }) - - it('refuses to disable one that is already off', async () => { - await cli.json(['provider', 'disable', '--name', 'velora'], { unlocked: true }) - - const result = await cli.run(['provider', 'disable', '--name', 'velora'], { unlocked: true }) - - expect(result.code).toBe(1) - expect(result.output).toContain("'velora' is already disabled.") - }) - - it('keeps its own disable through a module cycle', async () => { - const { module: moduleName } = catalog.providers.velora - await cli.json(['provider', 'disable', '--name', 'velora'], { unlocked: true }) - - await disableModule(moduleName) - await cli.json(['module', 'enable', '--name', moduleName], { unlocked: true }) - - const { providers } = await cli.json(['provider', 'list']) - expect(providers.find((p) => p.name === 'velora').enabled).toBe(false) - }) -}) - -describe('a kind with no provider left enabled', () => { - it('fails buy and sell once every fiat provider is off', async () => { - await importAndUnlock() - await cli.json(['provider', 'disable', '--name', 'moonpay'], { unlocked: true }) - await cli.json(['provider', 'disable', '--name', 'transak'], { unlocked: true }) - await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) - - for (const command of ['buy', 'sell']) { - const result = await cli.run( - [command, '--network', 'ethereum', '--token', 'usdt', '--fiat-amount', '100'], - { unlocked: true } - ) - - expect(result.code).toBe(1) - expect(result.output).toContain('No fiat provider is available.') - } - }) - - it('fails swap once nothing can swap', async () => { - await importAndUnlock() - for (const name of ['velora', 'rhinofi', 'symbiosis']) { - await cli.json(['provider', 'disable', '--name', name], { unlocked: true }) - } - await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) - - const result = await cli.run( - ['swap', '--network', 'ethereum', '--from-token', 'usdt', '--to-token', 'eth', '--amount-in', '1'], - { unlocked: true } - ) - - expect(result.code).toBe(1) - expect(result.output).toContain('No installed protocol can swap.') - }) - - it('fails bridge once nothing can bridge', async () => { - await importAndUnlock() - for (const name of ['usdt0', 'rhinofi', 'symbiosis']) { - await cli.json(['provider', 'disable', '--name', name], { unlocked: true }) - } - await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) - - const result = await cli.run( - ['bridge', '--network', 'ethereum', '--token', 'eth', '--to-network', 'arbitrum', '--amount', '1'], - { unlocked: true } - ) - - expect(result.code).toBe(1) - expect(result.output).toContain('No installed protocol can bridge.') - }) -}) - -describe('kinds that allow only one provider at a time', () => { - const SECOND_FEED = JSON.stringify({ - name: 'second-feed', - kind: 'pricing', - module: catalog.providers.bitfinex.module, - config: {} - }) - - it('refuses a second price feed while one is enabled', async () => { - const result = await cli.run(['provider', 'add', SECOND_FEED], { unlocked: true }) - - expect(result.code).toBe(1) - expect(result.output).toContain('A price feed is already enabled: bitfinex.') - }) - - it('accepts the second once the first is off, then guards the way back', async () => { - await cli.json(['provider', 'disable', '--name', 'bitfinex'], { unlocked: true }) - - const added = await cli.json(['provider', 'add', SECOND_FEED], { unlocked: true }) - expect(added.name).toBe('second-feed') - - const result = await cli.run(['provider', 'enable', '--name', 'bitfinex'], { unlocked: true }) - - expect(result.code).toBe(1) - expect(result.output).toContain('A price feed is already enabled: second-feed.') - }) - - it('lets a multi-instance kind run two providers at once', async () => { - const { providers } = await cli.json(['provider', 'list']) - - const fiat = providers.filter((p) => p.kind === 'fiat') - const swidge = providers.filter((p) => p.kind === 'swidge') - expect(fiat.every((p) => p.enabled)).toBe(true) - expect(swidge.every((p) => p.enabled)).toBe(true) - expect(fiat).toHaveLength(2) - expect(swidge).toHaveLength(2) - }) -}) - -describe('naming a provider explicitly', () => { - it('refuses a provider the user disabled', async () => { - await importAndUnlock() - await cli.json(['provider', 'disable', '--name', 'transak'], { unlocked: true }) - await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) - - const result = await cli.run( - ['buy', '--network', 'ethereum', '--token', 'usdt', '--fiat-amount', '100', - '--provider', 'transak'], - { unlocked: true } - ) - - expect(result.code).toBe(1) - expect(result.output).toContain("Protocol 'transak' is disabled.") - }) - - it('lists what is available when the name is unknown', async () => { - await importAndUnlock() - - const result = await cli.run( - ['buy', '--network', 'ethereum', '--token', 'usdt', '--fiat-amount', '100', - '--provider', 'nosuch'], - { unlocked: true } - ) - - expect(result.code).toBe(1) - expect(result.output).toContain("Unknown protocol 'nosuch'.") - expect(result.output).toContain('moonpay') - }) -}) - -describe('a provider the user added', () => { - const CUSTOM = JSON.stringify({ - name: 'myramp', - kind: 'fiat', - module: catalog.providers.moonpay.module, - config: {} - }) - - it('can be disabled and then deleted, unlike a built-in', async () => { - const added = await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) - expect(added.name).toBe('myramp') - - const off = await cli.json(['provider', 'disable', '--name', 'myramp'], { unlocked: true }) - expect(off.enabled).toBe(false) - - const deleted = await cli.json(['provider', 'delete', '--name', 'myramp'], { unlocked: true }) - expect(deleted.deleted).toBe(true) - - const { providers } = await cli.json(['provider', 'list']) - expect(providers.find((p) => p.name === 'myramp')).toBeUndefined() - }) - - it('takes its disable override with it when deleted', async () => { - await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) - await cli.json(['provider', 'disable', '--name', 'myramp'], { unlocked: true }) - await cli.json(['provider', 'delete', '--name', 'myramp'], { unlocked: true }) - - const result = await cli.run(['provider', 'enable', '--name', 'myramp'], { unlocked: true }) - - expect(result.code).toBe(1) - expect(result.output).toContain("'myramp' is not a provider.") - }) -}) - -describe('an override left behind by something that no longer exists', () => { - it('is cleared on the next enable, once', async () => { - cli.writeConfig({ overrides: { providers: { ghost: { enabled: false } } } }) - - const cleared = await cli.json(['provider', 'enable', '--name', 'ghost'], { unlocked: true }) - expect(cleared.stale).toBe(true) - - const again = await cli.run(['provider', 'enable', '--name', 'ghost'], { unlocked: true }) - expect(again.code).toBe(1) - expect(again.output).toContain("'ghost' is not a provider.") - }) -}) - -describe('provider config across a module cycle', () => { - it('keeps the general config and the per-network override', async () => { - const indexerKey = 'providers.wdk-indexer.config.apiKey' - const veloraKey = 'providers.velora.networks.ethereum.slippage' - await cli.json(['config', 'set', '--key', indexerKey, '--value', 'secret-abc'], { unlocked: true }) - await cli.json(['config', 'set', '--key', veloraKey, '--value', '50'], { unlocked: true }) - - await disableModule(INDEXER_MODULE) - await cli.json(['module', 'enable', '--name', INDEXER_MODULE], { unlocked: true }) - - expect((await cli.json(['config', 'get', '--key', indexerKey])).value).toBe('secret-abc') - expect((await cli.json(['config', 'get', '--key', veloraKey])).value).toBe(50) - }) -}) - -describe('a disabled module and an open wallet session', () => { - it('locks the open wallets when the module backs a network', async () => { - await importAndUnlock() - - const disabled = await disableModule(EVM_MODULE) - - expect(disabled.walletsLocked).toBe(true) - const { wallets } = await cli.json(['wallet', 'list']) - expect(wallets).toHaveLength(1) - expect(wallets[0].unlocked).toBe(false) - }) - - // applyToggle locks unconditionally, so even a price feed that has nothing to - // do with key derivation costs the user their session. Blunt, but safe: pinning - // it here means narrowing it later has to be a deliberate change. - it('locks them even when the module backs no network', async () => { - await importAndUnlock() - - const disabled = await disableModule(PRICING_MODULE) - - expect(disabled.walletsLocked).toBe(true) - const { wallets } = await cli.json(['wallet', 'list']) - expect(wallets).toHaveLength(1) - expect(wallets[0].unlocked).toBe(false) - }) -}) - -describe('a disabled token under a network and module cycle', () => { - it('stays disabled after its network is switched off and on', async () => { - await cli.json(['token', 'disable', '--network', 'ethereum', '--token', 'usdt'], { unlocked: true }) - - await cli.json(['network', 'disable', '--name', 'ethereum'], { unlocked: true }) - await cli.json(['network', 'enable', '--name', 'ethereum'], { unlocked: true }) - - const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) - expect(disabled).toEqual(['ethereum/usdt']) - }) - - it('stays disabled after its module is switched off and on', async () => { - await cli.json(['token', 'disable', '--network', 'ethereum', '--token', 'usdt'], { unlocked: true }) - - await disableModule(EVM_MODULE) - await cli.json(['module', 'enable', '--name', EVM_MODULE], { unlocked: true }) - - const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) - expect(disabled).toEqual(['ethereum/usdt']) - }) -}) diff --git a/tests/integration/helpers.js b/tests/integration/helpers.js index 712c93d..d50de7e 100644 --- a/tests/integration/helpers.js +++ b/tests/integration/helpers.js @@ -66,6 +66,11 @@ function isOurDaemon (pid) { * An isolated CLI installation: its own config directory, torn down by * {@link Cli#cleanup}. A directory with no wallets means no passphrase prompt, * so every registry and config command runs unattended. + * + * The daemon socket lives inside that directory, so instances never share one + * and these suites run in parallel. Windows is the exception: the daemon uses a + * single fixed named pipe there, so workers would contend for it — run with + * `--runInBand` on Windows until the pipe is per-directory too. */ export class Cli { /** The config directory this instance runs against. */ diff --git a/tests/integration/module.test.js b/tests/integration/module.test.js index c01de3b..cc6e50c 100644 --- a/tests/integration/module.test.js +++ b/tests/integration/module.test.js @@ -18,17 +18,24 @@ import { Cli } from './helpers.js' const catalog = createRequire(import.meta.url)('../../wdk.config.json') const MOONPAY_MODULE = catalog.providers.moonpay.module +const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' const BITFINEX_MODULE = catalog.providers.bitfinex.module const MODULE_COUNT = Object.keys(catalog.modules).length const SPARK_METHOD_COUNT = Object.keys(catalog.modules[catalog.networks.spark.module].methods).length +/** A package that really is installed, for pinning against its own version. */ +const INSTALLED_PACKAGE = '@tetherto/wdk-utils' +const INSTALLED_VERSION = createRequire(import.meta.url)( + `../../node_modules/${INSTALLED_PACKAGE}/package.json` +).version + /** @type {Cli} */ let cli beforeEach(() => { cli = new Cli() }) afterEach(() => cli.cleanup()) -describe('module registry', () => { +describe('module list', () => { it('reports every catalog module as installed and ok', async () => { const { modules } = await cli.json(['module', 'list']) @@ -43,95 +50,125 @@ describe('module registry', () => { expect(indexer.pinned).toContain('github:') expect(indexer.status).toBe('ok') }) -}) -describe('module enable and disable', () => { - it('reports a disabled module and hides its providers', async () => { - await cli.run(['module', 'disable', '--name', '@tetherto/wdk-protocol-fiat-moonpay']) + + it('reports a custom module pinned to a version that is not installed', async () => { + cli.writeConfig({ customModules: { [INSTALLED_PACKAGE]: { version: '9.9.9' } } }) const { modules } = await cli.json(['module', 'list']) - const { providers } = await cli.json(['provider', 'list']) - expect(modules.find((m) => m.module === '@tetherto/wdk-protocol-fiat-moonpay').status) - .toBe('disabled') - expect(providers.find((p) => p.name === 'moonpay')).toBeUndefined() + expect(modules.find((m) => m.module === INSTALLED_PACKAGE)).toMatchObject({ + pinned: '9.9.9', + installed: INSTALLED_VERSION, + status: 'version mismatch', + source: 'custom' + }) }) - it('brings a disabled module back', async () => { - await cli.run(['module', 'disable', '--name', '@tetherto/wdk-protocol-fiat-moonpay']) + it('reports a custom module that was never installed', async () => { + cli.writeConfig({ customModules: { '@ghost/never-installed': { version: '1.0.0' } } }) + + const { modules } = await cli.json(['module', 'list']) + + expect(modules.find((m) => m.module === '@ghost/never-installed')).toMatchObject({ + pinned: '1.0.0', + installed: null, + status: 'not installed', + source: 'custom' + }) + }) + + it('reports an override left behind by a module that is gone', async () => { + cli.writeConfig({ overrides: { modules: { '@ghost/gone': { enabled: false } } } }) - await cli.run(['module', 'enable', '--name', '@tetherto/wdk-protocol-fiat-moonpay']) const { modules } = await cli.json(['module', 'list']) - expect(modules.find((m) => m.module === '@tetherto/wdk-protocol-fiat-moonpay').status).toBe('ok') + expect(modules.find((m) => m.module === '@ghost/gone')).toMatchObject({ + pinned: '-', + status: 'stale override', + source: 'override' + }) }) }) -describe('adding and removing modules', () => { - // `module add` of a new package installs it from the registry, so the - // successful add path belongs in a suite allowed to reach the network. The - // refusals below are decided before any install runs. - it('refuses to add a package that already ships with the CLI', async () => { + +describe('module add', () => { + // Adding a new package installs it from the registry, so the successful path + // belongs in a suite allowed to reach the network. This refusal is decided + // before any install runs. + it('refuses a package that already ships with the CLI', async () => { const result = await cli.run(['module', 'add', '--name', BITFINEX_MODULE], { unlocked: true }) expect(result.code).toBe(1) expect(result.output).toContain(`'${BITFINEX_MODULE}' is a built-in module.`) expect(result.output).toContain('pass one explicitly') }) +}) - it('refuses to remove a packaged module, which can only be disabled', async () => { +describe('module remove', () => { + it('refuses a packaged module, which can only be disabled', async () => { const result = await cli.run(['module', 'remove', '--name', MOONPAY_MODULE], { unlocked: true }) expect(result.code).toBe(1) expect(result.output).toContain(`'${MOONPAY_MODULE}' is a built-in module and cannot be removed.`) }) - it('reports a remove for a module that was never added', async () => { - const result = await cli.run(['module', 'remove', '--name', '@nobody/never-added'], { unlocked: true }) + it('reports a module that was never added', async () => { + const result = await cli.run( + ['module', 'remove', '--name', '@nobody/never-added'], { unlocked: true } + ) expect(result.code).toBe(1) expect(result.output).toContain("Module '@nobody/never-added' is not a custom module.") }) }) -describe('config', () => { - it('reports where the config file lives', async () => { - const result = await cli.run(['config', 'path']) - expect(result.stdout.trim()).toBe(cli.configPath()) - }) +describe('module disable', () => { + it('marks the module disabled and hides the providers it serves', async () => { + await cli.run(['module', 'disable', '--name', MOONPAY_MODULE]) - it('round-trips a value through set and get', async () => { - await cli.run(['config', 'set', '--key', 'defaults.defaultIndex', '--value', '3']) - - const result = await cli.run(['config', 'get', '--key', 'defaults.defaultIndex']) + const { modules } = await cli.json(['module', 'list']) + const { providers } = await cli.json(['provider', 'list']) - expect(result.stdout.trim()).toBe('3') + expect(modules.find((m) => m.module === MOONPAY_MODULE).status).toBe('disabled') + expect(providers.find((p) => p.name === 'moonpay')).toBeUndefined() }) - it('reports a key that was never set, and still exits 0', async () => { - const result = await cli.run(['config', 'get', '--key', 'nope.missing']) + it('refuses one that is already disabled', async () => { + await cli.run(['module', 'disable', '--name', MOONPAY_MODULE]) - expect(result.output).toContain("Key 'nope.missing' is not set.") - expect(result.code).toBe(0) + const again = await cli.run(['module', 'disable', '--name', MOONPAY_MODULE]) + + expect(again.code).toBe(1) + expect(again.output).toContain(`'${MOONPAY_MODULE}' is already disabled.`) }) - it('writes the value where the file can be read back', async () => { - await cli.run(['config', 'set', '--key', 'providers.bitfinex.config.apiKey', '--value', 'k']) + it.each([ + ['ethereum', "'ethereum' is a network. Use: wdk network disable --name ethereum"], + ['velora', "'velora' is a provider. Use: wdk provider disable --name velora"], + ['@no/such', 'See package names with: wdk module list'] + ])('points at the right registry for %p', async (name, hint) => { + const result = await cli.run(['module', 'disable', '--name', name], { unlocked: true }) - expect(cli.readConfig().providers.bitfinex.config.apiKey).toBe('k') + expect(result.code).toBe(1) + expect(result.output).toContain(`'${name}' is not a module.`) + expect(result.output).toContain(hint) }) +}) - it('clears everything on reset', async () => { - await cli.run(['config', 'set', '--key', 'defaults.defaultIndex', '--value', '3']) +describe('module enable', () => { + it('brings a disabled module back', async () => { + await cli.run(['module', 'disable', '--name', MOONPAY_MODULE]) - await cli.run(['config', 'reset', '--all']) - const result = await cli.run(['config', 'get', '--key', 'defaults.defaultIndex']) + await cli.run(['module', 'enable', '--name', MOONPAY_MODULE]) + const { modules } = await cli.json(['module', 'list']) - expect(result.output).toContain("Key 'defaults.defaultIndex' is not set.") + expect(modules.find((m) => m.module === MOONPAY_MODULE).status).toBe('ok') }) - it('keeps the config owner-only after a write', async () => { - await cli.run(['config', 'set', '--key', 'defaults.defaultIndex', '--value', '1']) + it('refuses one that is already enabled', async () => { + const result = await cli.run(['module', 'enable', '--name', MOONPAY_MODULE], { unlocked: true }) - expect(statSync(cli.configPath()).mode & 0o077).toBe(0) + expect(result.code).toBe(1) + expect(result.output).toContain(`'${MOONPAY_MODULE}' is already enabled.`) }) }) @@ -155,4 +192,28 @@ describe('module methods', () => { expect(result).toEqual({ network: 'bitcoin', methods: [] }) }) + + it('reports a network that is not registered', async () => { + const result = await cli.run(['method', 'list', '--network', 'atlantis']) + + expect(result.code).toBe(1) + expect(result.output).toContain("Network 'atlantis' is not supported.") + }) +}) + +describe('the passphrase gate', () => { + it('stops a module change without the passphrase', async () => { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + + const result = await cli.run(['module', 'disable', '--name', MOONPAY_MODULE]) + + expect(result.code).toBe(1) + expect(result.output).toContain('Cannot prompt for a passphrase when stdin is piped.') + + const { modules } = await cli.json(['module', 'list']) + expect(modules.find((m) => m.module === MOONPAY_MODULE).status).toBe('ok') + }) }) diff --git a/tests/integration/network.test.js b/tests/integration/network.test.js index e208f19..743ac04 100644 --- a/tests/integration/network.test.js +++ b/tests/integration/network.test.js @@ -12,17 +12,25 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { writeFileSync } from 'node:fs' import { createRequire } from 'node:module' +import { join } from 'node:path' import { Cli } from './helpers.js' const catalog = createRequire(import.meta.url)('../../wdk.config.json') const MOONPAY_MODULE = catalog.providers.moonpay.module const TESTNET_COUNT = Object.values(catalog.networks).filter((n) => n.testnet).length +const TOTAL_NETWORKS = Object.keys(catalog.networks).length +const MAINNET_COUNT = TOTAL_NETWORKS - TESTNET_COUNT const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' /** The address this seed derives on any EVM network at index 0. */ const ETHEREUM_0 = '0x405005C7c4422390F4B334F64Cf20E0b767131d0' const EVM_MODULE = '@tetherto/wdk-wallet-evm' +/** The networks the EVM wallet module backs, all of which it takes down with it. */ +const EVM_NETWORKS = Object.entries(catalog.networks) + .filter(([, entry]) => entry.module === EVM_MODULE) + .map(([name]) => name) const DAI = '0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1' /** A custom EVM network with its native asset declared inline. */ @@ -56,23 +64,116 @@ async function importAndUnlock () { await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) } -describe('network registry', () => { - it('lists the packaged networks', async () => { - const { networks } = await cli.json(['network', 'list']) +describe('network list', () => { + it('lists every packaged network with its native asset', async () => { + const { networks, count } = await cli.json(['network', 'list']) + + expect(networks).toHaveLength(TOTAL_NETWORKS) + expect(count).toBe(TOTAL_NETWORKS) + expect(networks.every((n) => n.enabled)).toBe(true) + expect(networks.find((n) => n.name === 'ethereum')).toEqual({ + name: 'ethereum', + displayName: 'Ethereum', + module: EVM_MODULE, + type: EVM_MODULE, + symbol: 'ETH', + decimals: 18, + testnet: false, + custom: false, + enabled: true + }) + }) + + it('splits the catalog into testnets and mainnets', async () => { + const testnets = await cli.json(['network', 'list', '--testnet']) + const mainnets = await cli.json(['network', 'list', '--mainnet']) + + expect(testnets.networks).toHaveLength(TESTNET_COUNT) + expect(testnets.networks.every((n) => n.testnet)).toBe(true) + expect(mainnets.networks).toHaveLength(MAINNET_COUNT) + expect(mainnets.networks.every((n) => !n.testnet)).toBe(true) + }) +}) + +describe('network create', () => { + it('fills the defaults a minimal spec leaves out', async () => { + const created = await cli.json([ + 'network', 'create', JSON.stringify({ network: 'optimism', module: EVM_MODULE }) + ], { unlocked: true }) + + expect(created).toEqual({ + name: 'optimism', + displayName: 'optimism', + type: EVM_MODULE, + module: EVM_MODULE, + custom: true, + testnet: false, + config: {}, + tokens: [] + }) + + const { networks, count } = await cli.json(['network', 'list']) + expect(count).toBe(TOTAL_NETWORKS + 1) + expect(networks.find((n) => n.name === 'optimism').custom).toBe(true) + }) + + it('derives on the real module, not a registry stub', async () => { + await cli.json(['network', 'create', SPEC], { unlocked: true }) + await importAndUnlock() + + const derived = await cli.json(['get', 'address', '--network', 'optimism'], { unlocked: true }) - expect(networks.length).toBe(22) + // Same module and BIP-44 path as the packaged EVM networks, so the same + // seed must produce the same address. A registry entry alone could not. + expect(derived.address).toBe(ETHEREUM_0) + expect(derived.index).toBe(0) }) - it('separates testnets behind a flag', async () => { - const { networks } = await cli.json(['network', 'list', '--testnet']) + it('registers the tokens declared in its spec', async () => { + await cli.json(['network', 'create', SPEC], { unlocked: true }) + + const { tokens } = await cli.json(['token', 'list', '--network', 'optimism']) - expect(networks).toHaveLength(TESTNET_COUNT) - expect(networks.every((n) => n.testnet)).toBe(true) + expect(Object.keys(tokens)).toEqual(['eth']) + expect(tokens.eth.symbol).toBe('ETH') + expect(tokens.eth.isNative).toBe(true) + }) + + it('reads the spec from a file as well as from the argument', async () => { + const path = join(cli.configHome, 'optimism.json') + writeFileSync(path, SPEC) + + const created = await cli.json(['network', 'create', path], { unlocked: true }) + + expect(created.name).toBe('optimism') + expect(created.config).toEqual({ provider: 'https://mainnet.optimism.io', chainId: 10 }) + }) + + // The spec is validated in full before anything is written, so this covers + // fail-fast validation. The rollback in the catch block — a token that passes + // validation but fails on save — is a separate path and is NOT covered here. + it('writes nothing when a token in the spec is rejected', async () => { + const bad = JSON.stringify({ + network: 'optimism', + module: EVM_MODULE, + tokens: [ + { token: 'eth', symbol: 'ETH', decimals: 18, isNative: true }, + { token: 'bad', symbol: 'BAD', decimals: 6, isNative: false } + ] + }) + + const result = await cli.run(['network', 'create', bad], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain('Non-native tokens require an "address".') + + const { networks } = await cli.json(['network', 'list']) + expect(networks.find((n) => n.name === 'optimism')).toBeUndefined() }) it('refuses a network name that already exists', async () => { const result = await cli.run(['network', 'create', JSON.stringify({ - network: 'ethereum', module: '@tetherto/wdk-wallet-evm', chainId: 'eip155:1', displayName: 'X' + network: 'ethereum', module: EVM_MODULE, displayName: 'X' })]) expect(result.code).toBe(1) @@ -81,28 +182,48 @@ describe('network registry', () => { it('refuses a module that is not a wallet module', async () => { const result = await cli.run(['network', 'create', JSON.stringify({ - network: 'mychain', module: MOONPAY_MODULE, chainId: 'eip155:99', displayName: 'Mine' + network: 'mychain', module: MOONPAY_MODULE, displayName: 'Mine' })]) expect(result.code).toBe(1) expect(result.output).toContain('Network spec "module" must be one of') }) - - it('refuses to delete a packaged network', async () => { - const result = await cli.run(['network', 'delete', '--name', 'ethereum']) - - expect(result.code).toBe(1) - expect(result.output).toContain("'ethereum' is a built-in network and cannot be deleted.") - }) }) -describe('network info, enable and disable', () => { - it('shows a packaged network', async () => { + +describe('network info', () => { + it('shows a packaged network with the configuration it ships', async () => { const info = await cli.json(['network', 'info', '--network', 'ethereum']) - expect(info).toMatchObject({ + expect(info).toEqual({ name: 'ethereum', displayName: 'Ethereum', - module: '@tetherto/wdk-wallet-evm' + type: EVM_MODULE, + module: EVM_MODULE, + nativeSymbol: 'ETH', + decimals: 18, + testnet: false, + enabled: true, + config: catalog.networks.ethereum.config + }) + }) + + it('shows a custom network with the configuration from its spec', async () => { + await cli.json(['network', 'create', SPEC], { unlocked: true }) + + const info = await cli.json(['network', 'info', '--network', 'optimism']) + + expect(info).toEqual({ + name: 'optimism', + displayName: 'Optimism', + type: EVM_MODULE, + module: EVM_MODULE, + custom: true, + testnet: false, + // Resolved from the token the spec registered, not from the spec itself. + nativeSymbol: 'ETH', + decimals: 18, + enabled: true, + config: { provider: 'https://mainnet.optimism.io', chainId: 10 } }) }) @@ -112,69 +233,133 @@ describe('network info, enable and disable', () => { expect(result.code).toBe(1) expect(result.output).toContain("Network 'atlantis' is not supported.") }) +}) - it('drops a disabled network from the listing', async () => { - await cli.run(['network', 'disable', '--name', 'polygon']) +describe('network disable', () => { + it('flags the network in the listing and in its own info', async () => { + const disabled = await cli.json(['network', 'disable', '--name', 'polygon']) - const { networks } = await cli.json(['network', 'list']) + expect(disabled).toEqual({ + network: 'polygon', + enabled: false, + stale: false, + walletsLocked: false + }) + const { networks } = await cli.json(['network', 'list']) expect(networks.find((n) => n.name === 'polygon').enabled).toBe(false) + + const info = await cli.json(['network', 'info', '--network', 'polygon']) + expect(info.enabled).toBe(false) }) - it('brings a disabled network back', async () => { - await cli.run(['network', 'disable', '--name', 'polygon']) + it('refuses one that is already disabled', async () => { + await cli.json(['network', 'disable', '--name', 'polygon']) - await cli.run(['network', 'enable', '--name', 'polygon']) - const { networks } = await cli.json(['network', 'list']) + const again = await cli.run(['network', 'disable', '--name', 'polygon']) - expect(networks.find((n) => n.name === 'polygon').enabled).toBe(true) + expect(again.code).toBe(1) + expect(again.output).toContain("'polygon' is already disabled.") }) - it('refuses to disable a network twice', async () => { - await cli.run(['network', 'disable', '--name', 'polygon']) + it('sends a module name to the module command and an unknown name to the listing', async () => { + const asModule = await cli.run(['network', 'disable', '--name', EVM_MODULE]) - const again = await cli.run(['network', 'disable', '--name', 'polygon']) + expect(asModule.code).toBe(1) + expect(asModule.output).toContain(`'${EVM_MODULE}' is not a network.`) + expect(asModule.output).toContain(`wdk module disable --name ${EVM_MODULE}`) - expect(again.code).toBe(1) + const unknown = await cli.run(['network', 'disable', '--name', 'atlantis']) + + expect(unknown.code).toBe(1) + expect(unknown.output).toContain("'atlantis' is not a network.") + expect(unknown.output).toContain('See network names with: wdk network list') }) }) -describe('a network the user creates', () => { - it('derives on the real module, not a registry stub', async () => { - await cli.json(['network', 'create', SPEC], { unlocked: true }) - await importAndUnlock() +describe('network enable', () => { + it('brings a disabled network back', async () => { + await cli.json(['network', 'disable', '--name', 'polygon']) - const derived = await cli.json(['get', 'address', '--network', 'optimism'], { unlocked: true }) + const enabled = await cli.json(['network', 'enable', '--name', 'polygon']) - // Same module and BIP-44 path as the packaged EVM networks, so the same - // seed must produce the same address. A registry entry alone could not. - expect(derived.address).toBe(ETHEREUM_0) - expect(derived.index).toBe(0) + expect(enabled).toEqual({ + network: 'polygon', + enabled: true, + stale: false, + walletsLocked: false + }) + const { networks } = await cli.json(['network', 'list']) + expect(networks.find((n) => n.name === 'polygon').enabled).toBe(true) }) - it('registers the tokens declared in its spec', async () => { - await cli.json(['network', 'create', SPEC], { unlocked: true }) + it('clears an override left behind by a network that no longer exists', async () => { + // Nothing the CLI still knows about answers to this name, so enabling it + // can only mean discarding the override: there is no entry to switch on. + cli.writeConfig({ overrides: { networks: { atlantis: { enabled: false } } } }) - const { tokens } = await cli.json(['token', 'list', '--network', 'optimism']) + const result = await cli.json(['network', 'enable', '--name', 'atlantis']) - expect(Object.keys(tokens)).toEqual(['eth']) - expect(tokens.eth.symbol).toBe('ETH') - expect(tokens.eth.isNative).toBe(true) + expect(result).toEqual({ + network: 'atlantis', + enabled: true, + stale: true, + walletsLocked: false + }) + expect(cli.readConfig().overrides).toBeUndefined() }) +}) - it('accepts a token added after the fact', async () => { +describe('network delete', () => { + it('takes its custom tokens with it', async () => { await cli.json(['network', 'create', SPEC], { unlocked: true }) - await cli.json(['token', 'add', JSON.stringify({ network: 'optimism', token: 'dai', symbol: 'DAI', decimals: 18, isNative: false, address: DAI })], { unlocked: true }) + await cli.json(['network', 'delete', '--name', 'optimism'], { unlocked: true }) + await cli.json(['network', 'create', SPEC], { unlocked: true }) + + // Recreated under the same name: only the spec's own token may come back. const { tokens } = await cli.json(['token', 'list', '--network', 'optimism']) - expect(Object.keys(tokens)).toEqual(['eth', 'dai']) - expect(tokens.dai.address).toBe(DAI) + expect(Object.keys(tokens)).toEqual(['eth']) + }) + + it('takes the disabled flag with it, so the name comes back enabled', async () => { + await cli.json(['network', 'create', SPEC], { unlocked: true }) + await cli.json(['network', 'disable', '--name', 'optimism'], { unlocked: true }) + + await cli.json(['network', 'delete', '--name', 'optimism'], { unlocked: true }) + + expect(cli.readConfig().overrides).toBeUndefined() + await cli.json(['network', 'create', SPEC], { unlocked: true }) + const { networks } = await cli.json(['network', 'list']) + expect(networks.find((n) => n.name === 'optimism').enabled).toBe(true) + }) + + it('refuses to delete a packaged network', async () => { + const result = await cli.run(['network', 'delete', '--name', 'ethereum']) + + expect(result.code).toBe(1) + expect(result.output).toContain("'ethereum' is a built-in network and cannot be deleted.") + }) +}) + +describe('networks hidden by a disabled module', () => { + it('takes down every network on that module and leaves the others alone', async () => { + const before = await cli.json(['network', 'list']) + expect(before.networks).toHaveLength(TOTAL_NETWORKS) + + await cli.json(['module', 'disable', '--name', EVM_MODULE], { unlocked: true }) + + const { networks } = await cli.json(['network', 'list']) + const names = networks.map((n) => n.name) + expect(networks).toHaveLength(TOTAL_NETWORKS - EVM_NETWORKS.length) + for (const network of EVM_NETWORKS) expect(names).not.toContain(network) + expect(names).toContain('solana') }) - it('follows its module into and out of a disable', async () => { + it('takes a custom network on that module down with it, and brings it back', async () => { await cli.json(['network', 'create', SPEC], { unlocked: true }) await cli.json(['module', 'disable', '--name', EVM_MODULE], { unlocked: true }) @@ -186,39 +371,63 @@ describe('a network the user creates', () => { expect(back.networks.find((n) => n.name === 'optimism').enabled).toBe(true) }) - // The spec is validated in full before anything is written, so this covers - // fail-fast validation. The rollback in the catch block — a token that passes - // validation but fails on save — is a separate path and is NOT covered here. - it('writes nothing when a token in the spec is rejected', async () => { - const bad = JSON.stringify({ - network: 'optimism', - module: EVM_MODULE, - tokens: [ - { token: 'eth', symbol: 'ETH', decimals: 18, isNative: true }, - { token: 'bad', symbol: 'BAD', decimals: 6, isNative: false } - ] - }) - - const result = await cli.run(['network', 'create', bad], { unlocked: true }) + it('hides a network the user disabled, then hands it back still disabled', async () => { + await cli.json(['network', 'disable', '--name', 'arbitrum'], { unlocked: true }) + const flagged = await cli.json(['network', 'list']) + expect(flagged.networks.find((n) => n.name === 'arbitrum').enabled).toBe(false) - expect(result.code).toBe(1) - expect(result.output).toContain('Non-native tokens require an "address".') + // A network the module hides is not listed at all: the module is what you + // re-enable, so its own disabled flag has nothing to say until then. + await cli.json(['module', 'disable', '--name', EVM_MODULE], { unlocked: true }) + const hidden = await cli.json(['network', 'list']) + expect(hidden.networks.find((n) => n.name === 'arbitrum')).toBeUndefined() + await cli.json(['module', 'enable', '--name', EVM_MODULE], { unlocked: true }) const { networks } = await cli.json(['network', 'list']) - expect(networks.find((n) => n.name === 'optimism')).toBeUndefined() + expect(networks).toHaveLength(TOTAL_NETWORKS) + expect(networks.find((n) => n.name === 'arbitrum').enabled).toBe(false) + expect(networks.find((n) => n.name === 'ethereum').enabled).toBe(true) }) - it('takes its custom tokens with it when deleted', async () => { - await cli.json(['network', 'create', SPEC], { unlocked: true }) - await cli.json(['token', 'add', JSON.stringify({ - network: 'optimism', token: 'dai', symbol: 'DAI', decimals: 18, isNative: false, address: DAI - })], { unlocked: true }) + it('tells every command that touches the network to enable the module', async () => { + await cli.json(['module', 'disable', '--name', EVM_MODULE], { unlocked: true }) - await cli.json(['network', 'delete', '--name', 'optimism'], { unlocked: true }) - await cli.json(['network', 'create', SPEC], { unlocked: true }) + for (const args of [ + ['network', 'info', '--network', 'ethereum'], + ['token', 'list', '--network', 'ethereum'], + ['method', 'list', '--network', 'ethereum'] + ]) { + const result = await cli.run(args, { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("Network 'ethereum' is disabled.") + expect(result.output).toContain(`wdk module enable --name ${EVM_MODULE}`) + } + }) - // Recreated under the same name: only the spec's own token may come back. - const { tokens } = await cli.json(['token', 'list', '--network', 'optimism']) - expect(Object.keys(tokens)).toEqual(['eth']) + it('refuses to re-enable the network on its own', async () => { + await cli.json(['module', 'disable', '--name', EVM_MODULE], { unlocked: true }) + + const result = await cli.run(['network', 'enable', '--name', 'ethereum'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("Network 'ethereum' is disabled by its module.") + }) +}) + +describe('the passphrase gate', () => { + it('stops a network change without the passphrase', async () => { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + + const result = await cli.run(['network', 'disable', '--name', 'ethereum']) + + expect(result.code).toBe(1) + expect(result.output).toContain('Cannot prompt for a passphrase when stdin is piped.') + + const { networks } = await cli.json(['network', 'list']) + expect(networks.find((n) => n.name === 'ethereum').enabled).toBe(true) }) }) diff --git a/tests/integration/passphrase.test.js b/tests/integration/passphrase.test.js deleted file mode 100644 index ed66e77..0000000 --- a/tests/integration/passphrase.test.js +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright 2026 Tether Operations Limited -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// The passphrase gate sits in front of commands that have nothing to do with -// keys: disabling a provider asks for it too. What it accepts is decided by the -// keyring, so these cases cross auth, the keyring and the registry. - -import { Cli } from './helpers.js' - -const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' -const NEW_PASSPHRASE = 'second-passphrase' - -/** @type {Cli} */ -let cli - -beforeEach(() => { - cli = new Cli() -}) - -afterEach(async () => { - await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) - cli.cleanup() -}) - -/** - * Imports the fixed test seed under the instance passphrase. - * - * @returns {Promise} - */ -async function importSeed () { - await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { - unlocked: true, - stdin: SEED + '\n' - }) -} - -describe('the passphrase gate on a registry change', () => { - it('does not ask when there is no wallet to protect', async () => { - const result = await cli.run(['provider', 'disable', '--name', 'velora']) - - expect(result.code).toBe(0) - expect(result.output).toContain("Provider 'velora' disabled.") - }) - - it('asks as soon as a wallet exists', async () => { - await importSeed() - - const result = await cli.run(['provider', 'disable', '--name', 'velora']) - - expect(result.code).toBe(1) - expect(result.output).toContain('Cannot prompt for a passphrase when stdin is piped.') - expect(result.output).toContain('Set WDK_PASSPHRASE, or run from a terminal.') - }) - - it('rejects the wrong one', async () => { - await importSeed() - - const result = await cli.run(['provider', 'disable', '--name', 'velora'], { - passphrase: 'not-the-passphrase' - }) - - expect(result.code).toBe(1) - expect(result.output).toContain('Incorrect passphrase.') - }) - - it('leaves the provider untouched when the passphrase is refused', async () => { - await importSeed() - - await cli.run(['provider', 'disable', '--name', 'velora'], { passphrase: 'not-the-passphrase' }) - - const { providers } = await cli.json(['provider', 'list']) - expect(providers.find((p) => p.name === 'velora').enabled).toBe(true) - }) -}) - -describe('after the wallet passphrase changes', () => { - it('the registry gate follows the new one', async () => { - await importSeed() - await cli.run( - ['wallet', 'change-passphrase', '--name', 'main', '--new-passphrase-stdin'], - { unlocked: true, stdin: NEW_PASSPHRASE + '\n' } - ) - - const withOld = await cli.run(['provider', 'disable', '--name', 'velora'], { unlocked: true }) - expect(withOld.code).toBe(1) - expect(withOld.output).toContain('Incorrect passphrase.') - - const withNew = await cli.run(['provider', 'disable', '--name', 'velora'], { - passphrase: NEW_PASSPHRASE - }) - expect(withNew.code).toBe(0) - expect(withNew.output).toContain("Provider 'velora' disabled.") - }) -}) diff --git a/tests/integration/provider.test.js b/tests/integration/provider.test.js index 3d8e02d..197a7de 100644 --- a/tests/integration/provider.test.js +++ b/tests/integration/provider.test.js @@ -12,17 +12,39 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { writeFileSync } from 'node:fs' import { createRequire } from 'node:module' +import { join } from 'node:path' import { Cli } from './helpers.js' const catalog = createRequire(import.meta.url)('../../wdk.config.json') const MOONPAY_MODULE = catalog.providers.moonpay.module const BITFINEX_MODULE = catalog.providers.bitfinex.module +const VELORA_MODULE = catalog.providers.velora.module /** @type {Cli} */ let cli const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' +const INDEXER_MODULE = catalog.providers['wdk-indexer'].module +const PROVIDER_COUNT = Object.keys(catalog.providers).length + +/** A provider the user adds, for the add/delete paths. */ +const CUSTOM = JSON.stringify({ + name: 'myramp', kind: 'fiat', module: MOONPAY_MODULE, config: {} +}) + +/** A second price feed, for the single-instance rule. */ +const SECOND_FEED = JSON.stringify({ + name: 'second-feed', kind: 'pricing', module: BITFINEX_MODULE, config: {} +}) + +/** Every provider the catalog ships, with the module behind it. */ +const PROVIDERS = Object.entries(catalog.providers).map(([name, entry]) => ({ + name, + kind: entry.kind, + module: entry.module +})) beforeEach(() => { cli = new Cli() }) afterEach(async () => { @@ -30,7 +52,30 @@ afterEach(async () => { cli.cleanup() }) -describe('provider list and info', () => { +/** + * Imports the fixed test seed and unlocks it. + * + * @returns {Promise} + */ +async function importAndUnlock () { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) +} + +/** + * Disables a module and returns what the command reported. + * + * @param {string} name - The module package name. + * @returns {Promise>} The command's JSON payload. + */ +function disableModule (name) { + return cli.json(['module', 'disable', '--name', name], { unlocked: true }) +} + +describe('provider list', () => { it('lists every packaged provider, pricing first', async () => { const { providers, count } = await cli.json(['provider', 'list']) @@ -39,7 +84,9 @@ describe('provider list and info', () => { 'wdk-indexer', 'bitfinex', 'moonpay', 'transak', 'velora', 'usdt0', 'rhinofi', 'symbiosis' ]) }) +}) +describe('provider info', () => { it('reports the indexer with the client package backing it', async () => { const info = await cli.json(['provider', 'info', '--name', 'wdk-indexer']) @@ -58,7 +105,57 @@ describe('provider list and info', () => { expect(result.output).toContain("Unknown provider 'nope'.") }) }) + describe('provider add', () => { + it.each([ + ['swap', 'velora'], + ['bridge', 'usdt0'], + ['swidge', 'rhinofi'], + ['fiat', 'moonpay'] + ])('accepts a %s provider', async (kind, packaged) => { + const added = await cli.json(['provider', 'add', JSON.stringify({ + name: `my-${kind}`, kind, module: catalog.providers[packaged].module, config: {} + })], { unlocked: true }) + + expect(added).toMatchObject({ name: `my-${kind}`, kind, added: true }) + const { providers } = await cli.json(['provider', 'list']) + expect(providers.find((p) => p.name === `my-${kind}`).enabled).toBe(true) + }) + + it('reads the spec from a file', async () => { + const spec = join(cli.configHome, 'spec.json') + writeFileSync(spec, JSON.stringify({ + name: 'fromfile', kind: 'swap', module: VELORA_MODULE, config: {} + })) + + const added = await cli.json(['provider', 'add', spec], { unlocked: true }) + + expect(added).toMatchObject({ name: 'fromfile', kind: 'swap', added: true }) + }) + + it('stores per-network config given in the spec', async () => { + await cli.json(['provider', 'add', JSON.stringify({ + name: 'withnets', kind: 'swap', module: VELORA_MODULE, config: {}, + networks: { ethereum: { slippage: 25 } } + })], { unlocked: true }) + + const info = await cli.json(['provider', 'info', '--name', 'withnets']) + + expect(info.networks.ethereum).toMatchObject({ slippage: 25 }) + }) + + it('persists endpointKeys so the module is handed a callback', async () => { + await cli.json(['provider', 'add', JSON.stringify({ + name: 'banxa', + kind: 'fiat', + module: MOONPAY_MODULE, + endpointKeys: ['widgetUrl'], + config: { apiKey: 'dummy-key', widgetUrl: 'https://dummy-signer.test/sign' } + })]) + + expect(cli.readConfig().customProviders.banxa.endpointKeys).toEqual(['widgetUrl']) + }) + it('refuses a kind the registry does not define', async () => { const result = await cli.run(['provider', 'add', JSON.stringify({ name: 'x', kind: 'lending', module: MOONPAY_MODULE @@ -77,25 +174,6 @@ describe('provider add', () => { expect(result.output).toContain('Indexer providers cannot be added.') }) - it('refuses a second price feed while the packaged one is enabled', async () => { - const result = await cli.run(['provider', 'add', JSON.stringify({ - name: 'cg', kind: 'pricing', module: BITFINEX_MODULE - })]) - - expect(result.code).toBe(1) - expect(result.output).toContain('A price feed is already enabled: bitfinex.') - }) - - it('accepts a price feed once the packaged one is disabled', async () => { - await cli.run(['provider', 'disable', '--name', 'bitfinex']) - - const added = await cli.json(['provider', 'add', JSON.stringify({ - name: 'cg', kind: 'pricing', module: BITFINEX_MODULE - })]) - - expect(added).toMatchObject({ name: 'cg', kind: 'pricing', added: true }) - }) - it('refuses a module that is not registered', async () => { const result = await cli.run(['provider', 'add', JSON.stringify({ name: 'x', kind: 'swap', module: '@nobody/not-installed' @@ -128,18 +206,6 @@ describe('provider add', () => { expect(result.output).toContain('Invalid JSON in ') }) - it('persists endpointKeys so the module is handed a callback', async () => { - await cli.json(['provider', 'add', JSON.stringify({ - name: 'banxa', - kind: 'fiat', - module: MOONPAY_MODULE, - endpointKeys: ['widgetUrl'], - config: { apiKey: 'dummy-key', widgetUrl: 'https://dummy-signer.test/sign' } - })]) - - expect(cli.readConfig().customProviders.banxa.endpointKeys).toEqual(['widgetUrl']) - }) - it('refuses endpointKeys that are not strings', async () => { const result = await cli.run(['provider', 'add', JSON.stringify({ name: 'banxa', kind: 'fiat', module: MOONPAY_MODULE, endpointKeys: [1] @@ -149,39 +215,45 @@ describe('provider add', () => { expect(result.output).toContain('Provider spec "endpointKeys" must be an array of non-empty strings.') }) }) -describe('provider enable, disable and delete', () => { - it('refuses to delete a packaged provider', async () => { - const result = await cli.run(['provider', 'delete', '--name', 'bitfinex']) - expect(result.code).toBe(1) - expect(result.output).toContain("'bitfinex' is a built-in provider and cannot be deleted.") - }) +describe('provider enable', () => { + it('refuses to enable a second indexer', async () => { + const config = cli.readConfig() + config.customProviders = { myidx: { kind: 'indexer', config: {} } } + config.overrides = { providers: { myidx: { enabled: false } } } + cli.writeConfig(config) - it('reports a delete for a provider that was never added', async () => { - const result = await cli.run(['provider', 'delete', '--name', 'ghost']) + const result = await cli.run(['provider', 'enable', '--name', 'myidx']) expect(result.code).toBe(1) - expect(result.output).toContain("Provider 'ghost' is not a custom provider.") + expect(result.output).toContain('An indexer is already enabled: wdk-indexer.') }) - it('marks a disabled provider in the listing', async () => { - await cli.run(['provider', 'disable', '--name', 'moonpay']) + it('is cleared on the next enable, once', async () => { + cli.writeConfig({ overrides: { providers: { ghost: { enabled: false } } } }) - const { providers } = await cli.json(['provider', 'list']) + const cleared = await cli.json(['provider', 'enable', '--name', 'ghost'], { unlocked: true }) + expect(cleared.stale).toBe(true) - expect(providers.find((p) => p.name === 'moonpay').enabled).toBe(false) + const again = await cli.run(['provider', 'enable', '--name', 'ghost'], { unlocked: true }) + expect(again.code).toBe(1) + expect(again.output).toContain("'ghost' is not a provider.") }) +}) - it('refuses to enable a second indexer', async () => { - const config = cli.readConfig() - config.customProviders = { myidx: { kind: 'indexer', config: {} } } - config.overrides = { providers: { myidx: { enabled: false } } } - cli.writeConfig(config) +describe('provider disable', () => { + // The contrast with a module disable: this one stays visible and flagged, + // because the user can undo it directly. + it.each(PROVIDERS)('keeps the $kind provider $name listed but off', async (provider) => { + const off = await cli.json(['provider', 'disable', '--name', provider.name], { unlocked: true }) + expect(off.enabled).toBe(false) - const result = await cli.run(['provider', 'enable', '--name', 'myidx']) + const { providers } = await cli.json(['provider', 'list']) + expect(providers).toHaveLength(PROVIDER_COUNT) + expect(providers.find((p) => p.name === provider.name).enabled).toBe(false) - expect(result.code).toBe(1) - expect(result.output).toContain('An indexer is already enabled: wdk-indexer.') + const on = await cli.json(['provider', 'enable', '--name', provider.name], { unlocked: true }) + expect(on.enabled).toBe(true) }) it('always allows disabling, so a feed can be swapped', async () => { @@ -190,21 +262,28 @@ describe('provider enable, disable and delete', () => { expect(result.code).toBe(0) }) - it('refuses to disable something already disabled', async () => { - await cli.run(['provider', 'disable', '--name', 'moonpay']) + it('refuses to disable one that is already off', async () => { + await cli.json(['provider', 'disable', '--name', 'velora'], { unlocked: true }) - const again = await cli.run(['provider', 'disable', '--name', 'moonpay']) + const result = await cli.run(['provider', 'disable', '--name', 'velora'], { unlocked: true }) - expect(again.code).toBe(1) - expect(again.output).toContain("'moonpay' is already disabled.") + expect(result.code).toBe(1) + expect(result.output).toContain("'velora' is already disabled.") }) -}) -describe('deleting a provider the user added', () => { - const CUSTOM = JSON.stringify({ - name: 'myramp', kind: 'fiat', module: MOONPAY_MODULE, config: {} + it('keeps its own disable through a module cycle', async () => { + const { module: moduleName } = catalog.providers.velora + await cli.json(['provider', 'disable', '--name', 'velora'], { unlocked: true }) + + await disableModule(moduleName) + await cli.json(['module', 'enable', '--name', moduleName], { unlocked: true }) + + const { providers } = await cli.json(['provider', 'list']) + expect(providers.find((p) => p.name === 'velora').enabled).toBe(false) }) +}) +describe('provider delete', () => { it('deletes one that is still enabled, without disabling it first', async () => { await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) @@ -215,6 +294,17 @@ describe('deleting a provider the user added', () => { expect(providers.find((p) => p.name === 'myramp')).toBeUndefined() }) + it('releases the name for a later add', async () => { + await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) + await cli.json(['provider', 'delete', '--name', 'myramp'], { unlocked: true }) + + const readded = await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) + + expect(readded.name).toBe('myramp') + const { providers } = await cli.json(['provider', 'list']) + expect(providers.filter((p) => p.name === 'myramp')).toHaveLength(1) + }) + it('leaves the packaged providers of the same kind alone', async () => { await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) @@ -261,6 +351,17 @@ describe('deleting a provider the user added', () => { expect(info.config).toEqual({}) }) + it('takes its disable override with it when deleted', async () => { + await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) + await cli.json(['provider', 'disable', '--name', 'myramp'], { unlocked: true }) + await cli.json(['provider', 'delete', '--name', 'myramp'], { unlocked: true }) + + const result = await cli.run(['provider', 'enable', '--name', 'myramp'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("'myramp' is not a provider.") + }) + it('leaves the config of a packaged provider alone', async () => { await cli.json( ['config', 'set', '--key', 'providers.wdk-indexer.config.apiKey', '--value', 'keep-me'], @@ -274,15 +375,107 @@ describe('deleting a provider the user added', () => { expect(kept.value).toBe('keep-me') }) - it('releases the name for a later add', async () => { - await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) - await cli.json(['provider', 'delete', '--name', 'myramp'], { unlocked: true }) + it('refuses to delete a packaged provider', async () => { + const result = await cli.run(['provider', 'delete', '--name', 'bitfinex']) - const readded = await cli.json(['provider', 'add', CUSTOM], { unlocked: true }) + expect(result.code).toBe(1) + expect(result.output).toContain("'bitfinex' is a built-in provider and cannot be deleted.") + }) - expect(readded.name).toBe('myramp') + it('reports a delete for a provider that was never added', async () => { + const result = await cli.run(['provider', 'delete', '--name', 'ghost']) + + expect(result.code).toBe(1) + expect(result.output).toContain("Provider 'ghost' is not a custom provider.") + }) +}) + +describe('kinds that allow only one provider', () => { + it('refuses a second price feed while one is enabled', async () => { + const result = await cli.run(['provider', 'add', SECOND_FEED], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain('A price feed is already enabled: bitfinex.') + }) + + it('accepts the second once the first is off, then guards the way back', async () => { + await cli.json(['provider', 'disable', '--name', 'bitfinex'], { unlocked: true }) + + const added = await cli.json(['provider', 'add', SECOND_FEED], { unlocked: true }) + expect(added.name).toBe('second-feed') + + const result = await cli.run(['provider', 'enable', '--name', 'bitfinex'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain('A price feed is already enabled: second-feed.') + }) + + it('lets a multi-instance kind run two providers at once', async () => { const { providers } = await cli.json(['provider', 'list']) - expect(providers.filter((p) => p.name === 'myramp')).toHaveLength(1) + + const fiat = providers.filter((p) => p.kind === 'fiat') + const swidge = providers.filter((p) => p.kind === 'swidge') + expect(fiat.every((p) => p.enabled)).toBe(true) + expect(swidge.every((p) => p.enabled)).toBe(true) + expect(fiat).toHaveLength(2) + expect(swidge).toHaveLength(2) + }) +}) + +describe('a provider hidden by a disabled module', () => { + // Every kind is meant to behave the same here, so drive the same contract from + // the catalog: a new provider kind is covered the day it is registered. + it.each(PROVIDERS)('hides the $kind provider $name when its module goes', async (provider) => { + const before = await cli.json(['provider', 'list']) + expect(before.providers).toHaveLength(PROVIDER_COUNT) + expect(before.providers.find((p) => p.name === provider.name).enabled).toBe(true) + + await disableModule(provider.module) + + const { providers } = await cli.json(['provider', 'list']) + expect(providers).toHaveLength(PROVIDER_COUNT - 1) + expect(providers.find((p) => p.name === provider.name)).toBeUndefined() + + const info = await cli.json(['provider', 'info', '--name', provider.name]) + expect(info.enabled).toBe(false) + + const result = await cli.run(['provider', 'enable', '--name', provider.name], { unlocked: true }) + expect(result.code).toBe(1) + expect(result.output).toContain(`Provider '${provider.name}' is disabled by its module.`) + expect(result.output).toContain(`wdk module enable --name ${provider.module}`) + }) + + it('leaves a provider of the same kind on a different module alone', async () => { + await disableModule(catalog.providers.moonpay.module) + + const { providers } = await cli.json(['provider', 'list']) + + expect(providers.find((p) => p.name === 'moonpay')).toBeUndefined() + expect(providers.find((p) => p.name === 'transak').enabled).toBe(true) + }) + + it('fails the command that consumes the provider', async () => { + await disableModule(INDEXER_MODULE) + + const result = await cli.run(['get', 'history', '--network', 'ethereum'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain('No indexer is available.') + }) +}) + +describe('provider config through a module cycle', () => { + it('keeps the general config and the per-network override', async () => { + const indexerKey = 'providers.wdk-indexer.config.apiKey' + const veloraKey = 'providers.velora.networks.ethereum.slippage' + await cli.json(['config', 'set', '--key', indexerKey, '--value', 'secret-abc'], { unlocked: true }) + await cli.json(['config', 'set', '--key', veloraKey, '--value', '50'], { unlocked: true }) + + await disableModule(INDEXER_MODULE) + await cli.json(['module', 'enable', '--name', INDEXER_MODULE], { unlocked: true }) + + expect((await cli.json(['config', 'get', '--key', indexerKey])).value).toBe('secret-abc') + expect((await cli.json(['config', 'get', '--key', veloraKey])).value).toBe(50) }) }) @@ -314,3 +507,20 @@ describe('a provider the user added, in use', () => { expect(result.output).toContain("Token 'usdt' on 'ethereum' has no myramp mapping.") }) }) + +describe('the passphrase gate', () => { + it('stops a provider change without the passphrase', async () => { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + + const result = await cli.run(['provider', 'disable', '--name', 'velora']) + + expect(result.code).toBe(1) + expect(result.output).toContain('Cannot prompt for a passphrase when stdin is piped.') + + const { providers } = await cli.json(['provider', 'list']) + expect(providers.find((p) => p.name === 'velora').enabled).toBe(true) + }) +}) diff --git a/tests/integration/read-operations.test.js b/tests/integration/read-operations.test.js index f1ece31..8184562 100644 --- a/tests/integration/read-operations.test.js +++ b/tests/integration/read-operations.test.js @@ -18,21 +18,47 @@ import { Cli } from './helpers.js' const catalog = createRequire(import.meta.url)('../../wdk.config.json') const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' + +// Derivation is deterministic, so every address here is the exact value this +// seed produces. A changed constant means the derivation changed. const ETHEREUM_0 = '0x405005C7c4422390F4B334F64Cf20E0b767131d0' +const ETHEREUM_1 = '0xcC81e04BadA16DEf9e1AFB027B859bec42BE49dB' +const ETHEREUM_3 = '0x74b455D1FE2DD6A23326A8B7bA8e935459754205' +const BITCOIN_0 = 'bc1qxn0te9ecv864wtu53cccjhuuy5dphvem6ykeyr' +const SOLANA_0 = '8fjz5DTqBYENAUfsWLpSF2DBb46DXFcmztNJGLVrFPit' +const TRON_0 = 'TXngH8bVadn9ZWtKBgjKQcqN1GsZ7A1jcb' +const BITCOIN_TESTNET_0 = 'tb1q8dqnpagwt9rtl7k38nuaa2ahf690avzkehv7q6' + +// ECDSA over a fixed seed and message is deterministic, so these are exact +// values rather than shapes. +const HELLO_SIGNATURE_0 = + '0x05c005143100eff1f58be46b0a7fb7fe1a47f20d29d7c14e913dc9ae798e00ab' + + '6e769cf61784923262ba5bda8f0f5140d661a9b3f14df4d17837dc06dea555ac1b' +const HELLO_SIGNATURE_1 = + '0xa55303f33be43846f0669a238c2fab3875a3751db963b2bcf813652ed965eacf' + + '26309379766241b8cdae2675fa59cc5c2c97afab5ef0c83e8d70948cf37ce77f1c' + +const SPARK_MODULE = '@tetherto/wdk-wallet-spark' +const EVM_MODULE = '@tetherto/wdk-wallet-evm' + /** - * Mainnet networks in the packaged registry, minus spark. Spark derives through - * its operator API, so counting it would tie this to a remote service — and - * `get address --all` drops a network it cannot derive without saying so, which - * surfaces as an off-by-one rather than an error. + * Names the registry networks matching a predicate. + * + * @param {(network: Record) => boolean} predicate - The filter. + * @returns {string[]} The matching network names. */ -const MAINNET_COUNT = Object.values(catalog.networks) - .filter((n) => !n.testnet && n.module !== '@tetherto/wdk-wallet-spark').length +function networkNames (predicate) { + return Object.entries(catalog.networks).filter(([, n]) => predicate(n)).map(([name]) => name) +} -// ECDSA over a fixed seed and message is deterministic, so this is an exact -// value rather than a shape. If it ever changes, the derivation changed. -const HELLO_SIGNATURE = - '0x05c005143100eff1f58be46b0a7fb7fe1a47f20d29d7c14e913dc9ae798e00ab' + - '6e769cf61784923262ba5bda8f0f5140d661a9b3f14df4d17837dc06dea555ac1b' +/** + * The packaged networks `get address --all` should return, minus the spark + * ones: see {@link disableSpark}. + */ +const MAINNETS = networkNames((n) => !n.testnet && n.module !== SPARK_MODULE) +const TESTNETS = networkNames((n) => n.testnet && n.module !== SPARK_MODULE) +/** Every EVM mainnet shares one address, which is what makes them a set. */ +const EVM_MAINNETS = networkNames((n) => !n.testnet && n.module === EVM_MODULE) /** @type {Cli} */ let cli @@ -57,11 +83,43 @@ async function importAndUnlock (name = 'main') { unlocked: true, stdin: SEED + '\n' }) - await cli.run(['wallet', 'unlock', '--name', name], { unlocked: true }) + await unlock(name) +} + +/** + * Unlocks a wallet, failing the test if the session did not open. Every test + * below reads through an unlocked wallet, so a silent unlock failure would + * turn each of them into an assertion about the lock message instead. + * + * @param {string} [name] - The wallet name. + * @returns {Promise} + * @throws {Error} When the unlock command did not succeed. + */ +async function unlock (name = 'main') { + const result = await cli.run(['wallet', 'unlock', '--name', name], { unlocked: true }) + if (result.code !== 0) throw new Error(`could not unlock '${name}': ${result.output}`) +} + +/** + * Turns off both spark networks, then unlocks the wallet again. + * + * Spark derives its address through a remote operator API, so leaving it on + * ties every `--all` assertion to a remote service — and `get address --all` + * drops a network it cannot derive without saying so, which surfaces as an + * off-by-one rather than an error. Disabling a network locks every wallet, so + * the unlock has to come last. + * + * @param {string} [name] - The wallet to unlock again. + * @returns {Promise} + */ +async function disableSpark (name = 'main') { + await cli.run(['network', 'disable', '--name', 'spark'], { unlocked: true }) + await cli.run(['network', 'disable', '--name', 'spark-regtest'], { unlocked: true }) + await unlock(name) } -describe('address derivation', () => { - it('derives the same address the seed always produces', async () => { +describe('get address', () => { + it('derives the address this seed always produces', async () => { await importAndUnlock() const result = await cli.json(['get', 'address', '--network', 'ethereum'], { unlocked: true }) @@ -69,38 +127,67 @@ describe('address derivation', () => { expect(result).toEqual({ network: 'ethereum', index: 0, address: ETHEREUM_0 }) }) - it('derives a different address at another index', async () => { + it('derives the account at the index it is given', async () => { await importAndUnlock() - const zero = await cli.json(['get', 'address', '--network', 'ethereum'], { unlocked: true }) - const one = await cli.json( + const result = await cli.json( ['get', 'address', '--network', 'ethereum', '--index', '1'], { unlocked: true } ) - expect(one.index).toBe(1) - expect(one.address).not.toBe(zero.address) + expect(result).toEqual({ network: 'ethereum', index: 1, address: ETHEREUM_1 }) }) it('derives every mainnet address in one call', async () => { await importAndUnlock() - // Off, so the run stays local: see MAINNET_COUNT. - await cli.run(['network', 'disable', '--name', 'spark'], { unlocked: true }) - await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + await disableSpark() const result = await cli.json(['get', 'address', '--all'], { unlocked: true }) expect(result.type).toBe('mainnet') - expect(result.addresses).toHaveLength(MAINNET_COUNT) - expect(result.addresses.some((a) => a.network === 'spark')).toBe(false) - expect(result.addresses.find((a) => a.network === 'ethereum').address).toBe(ETHEREUM_0) + expect(result.index).toBe(0) + expect(result.addresses.map((a) => a.network)).toEqual(MAINNETS) + // Every EVM chain is the same key under a different chain id. + const evm = result.addresses.filter((a) => EVM_MAINNETS.includes(a.network)) + expect(evm).toHaveLength(EVM_MAINNETS.length) + expect(evm.every((a) => a.address === ETHEREUM_0)).toBe(true) + expect(result.addresses.find((a) => a.network === 'bitcoin').address).toBe(BITCOIN_0) + expect(result.addresses.find((a) => a.network === 'solana').address).toBe(SOLANA_0) + expect(result.addresses.find((a) => a.network === 'tron').address).toBe(TRON_0) }) - it('derives testnet addresses only when asked', async () => { + it('derives testnet addresses instead of mainnet ones with --testnet', async () => { await importAndUnlock() + await disableSpark() const result = await cli.json(['get', 'address', '--all', '--testnet'], { unlocked: true }) expect(result.type).toBe('testnet') + expect(result.addresses.map((a) => a.network)).toEqual(TESTNETS) + expect(result.addresses.find((a) => a.network === 'bitcoin-testnet3').address) + .toBe(BITCOIN_TESTNET_0) + // Sepolia is the same EVM key as mainnet ethereum, which is the point: the + // flag changes which networks are listed, not how they derive. + expect(result.addresses.find((a) => a.network === 'sepolia').address).toBe(ETHEREUM_0) + }) + + it('applies --index to every network of an --all run', async () => { + await importAndUnlock() + await disableSpark() + + const result = await cli.json(['get', 'address', '--all', '--index', '3'], { unlocked: true }) + + expect(result.index).toBe(3) + expect(result.addresses).toHaveLength(MAINNETS.length) + expect(result.addresses.find((a) => a.network === 'ethereum').address).toBe(ETHEREUM_3) + }) + + it('asks for a selector when given neither --network nor --all', async () => { + await importAndUnlock() + + const result = await cli.run(['get', 'address'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain('Provide --network or --all.') }) it('refuses a network that is not registered', async () => { @@ -112,6 +199,19 @@ describe('address derivation', () => { expect(result.output).toContain("Network 'atlantis' is not supported.") }) + it('refuses a network the user disabled', async () => { + await importAndUnlock() + await cli.run(['network', 'disable', '--name', 'ethereum'], { unlocked: true }) + // Disabling a network locks every wallet, so re-unlock: otherwise this + // passes on "not unlocked" and proves nothing about the network. + await unlock() + + const result = await cli.run(['get', 'address', '--network', 'ethereum'], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain("Network 'ethereum' is disabled.") + }) + it('refuses to derive while locked', async () => { await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { unlocked: true, @@ -123,32 +223,63 @@ describe('address derivation', () => { expect(result.code).toBe(1) expect(result.output).toContain("Wallet 'main' is not unlocked.") }) +}) - it('refuses a network the user disabled', async () => { +describe('deriving on a network whose module is disabled', () => { + it('drops the module\'s networks from --all and names the module by network', async () => { await importAndUnlock() - await cli.run(['network', 'disable', '--name', 'ethereum'], { unlocked: true }) - // Disabling a network locks every wallet, so re-unlock: otherwise this - // passes on "not unlocked" and proves nothing about the network. - await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) - - const result = await cli.run(['get', 'address', '--network', 'ethereum'], { unlocked: true }) - - expect(result.code).toBe(1) - expect(result.output).toContain("Network 'ethereum' is disabled.") + await disableSpark() + await cli.run(['module', 'disable', '--name', EVM_MODULE], { unlocked: true }) + await unlock() + + const all = await cli.json(['get', 'address', '--all'], { unlocked: true }) + const named = await cli.run(['get', 'address', '--network', 'ethereum'], { unlocked: true }) + + expect(all.addresses.map((a) => a.network)) + .toEqual(MAINNETS.filter((n) => !EVM_MAINNETS.includes(n))) + expect(all.addresses.find((a) => a.network === 'solana').address).toBe(SOLANA_0) + expect(named.code).toBe(1) + expect(named.output).toContain("Network 'ethereum' is disabled.") + expect(named.output).toContain(`wdk module enable --name ${EVM_MODULE}`) }) }) -describe('message verification', () => { +describe('message verify', () => { it('accepts a signature over the message that was signed', async () => { await importAndUnlock() const result = await cli.json( ['message', 'verify', '--network', 'ethereum', '--message', 'hello', - '--signature', HELLO_SIGNATURE], + '--signature', HELLO_SIGNATURE_0], + { unlocked: true } + ) + + expect(result).toEqual({ + network: 'ethereum', + index: 0, + message: 'hello', + signature: HELLO_SIGNATURE_0, + address: ETHEREUM_0, + valid: true + }) + }) + + it('checks against the account at the index it is given', async () => { + await importAndUnlock() + + const atOne = await cli.json( + ['message', 'verify', '--network', 'ethereum', '--message', 'hello', + '--signature', HELLO_SIGNATURE_1, '--index', '1'], + { unlocked: true } + ) + const atZero = await cli.json( + ['message', 'verify', '--network', 'ethereum', '--message', 'hello', + '--signature', HELLO_SIGNATURE_1], { unlocked: true } ) - expect(result).toMatchObject({ address: ETHEREUM_0, valid: true }) + expect(atOne).toMatchObject({ index: 1, address: ETHEREUM_1, valid: true }) + expect(atZero).toMatchObject({ index: 0, address: ETHEREUM_0, valid: false }) }) it('rejects the same signature over a different message', async () => { @@ -156,11 +287,11 @@ describe('message verification', () => { const result = await cli.json( ['message', 'verify', '--network', 'ethereum', '--message', 'tampered', - '--signature', HELLO_SIGNATURE], + '--signature', HELLO_SIGNATURE_0], { unlocked: true } ) - expect(result.valid).toBe(false) + expect(result).toMatchObject({ message: 'tampered', address: ETHEREUM_0, valid: false }) }) it('reports a signature that is not well formed', async () => { @@ -174,4 +305,16 @@ describe('message verification', () => { expect(result.code).toBe(1) expect(result.output).toContain('invalid raw signature length') }) + + it('refuses an empty signature before reaching the wallet', async () => { + await importAndUnlock() + + const result = await cli.run( + ['message', 'verify', '--network', 'ethereum', '--message', 'hello', '--signature', ''], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain('Signature must be a non-empty string.') + }) }) diff --git a/tests/integration/token.test.js b/tests/integration/token.test.js index b6d5b29..366a5c1 100644 --- a/tests/integration/token.test.js +++ b/tests/integration/token.test.js @@ -12,13 +12,31 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { createRequire } from 'node:module' import { Cli } from './helpers.js' +const catalog = createRequire(import.meta.url)('../../wdk.config.json') +const TOTAL_NETWORKS = Object.keys(catalog.networks).length + const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' +const EVM_MODULE = '@tetherto/wdk-wallet-evm' const DAI = '0x6B175474E89094C44Da98b954EedeAC495271d0F' const USDT = '0xdAC17F958D2ee523a2206206994597C13D831ec7' +const OVERRIDE_ADDRESS = '0x1111111111111111111111111111111111111111' + +/** The packaged `ethereum/usdt` mappings, which the slug deltas merge into. */ +const USDT_SLUGS = { + indexer: 'usdt', + moonpay: 'usdt', + bitfinex: 'UST', + transak: { slug: 'USDT', network: 'ethereum' } +} -/** A token the packaged registry does not carry. */ +/** + * A token the packaged registry does not carry. Deliberately carries no + * `metadata.slugs`: the resolution tests below reach a provider with it, and a + * mapped token would send a real request. + */ const CUSTOM_TOKEN = JSON.stringify({ network: 'ethereum', token: 'dai', symbol: 'DAI', decimals: 18, isNative: false, address: DAI }) @@ -26,7 +44,7 @@ const CUSTOM_TOKEN = JSON.stringify({ /** An entry under a packaged key, to override it. */ const OVERRIDE = JSON.stringify({ network: 'ethereum', token: 'usdt', symbol: 'USDT2', decimals: 8, isNative: false, - address: '0x1111111111111111111111111111111111111111' + address: OVERRIDE_ADDRESS }) /** @type {Cli} */ @@ -51,85 +69,195 @@ async function importAndUnlock () { await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) } -describe('token registry', () => { - it('lists the packaged tokens for a network', async () => { - const { tokens } = await cli.json(['token', 'list', '--network', 'ethereum']) +describe('token list', () => { + it('lists the packaged tokens of one network', async () => { + const { tokens, disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) - expect(Object.keys(tokens).sort()).toEqual(['eth', 'usdt', 'xaut']) + expect(Object.keys(tokens)).toEqual(['eth', 'usdt', 'xaut']) + expect(tokens.eth.isNative).toBe(true) + expect(tokens.usdt.address).toBe(USDT) + expect(disabled).toEqual([]) }) - it('shows a token with its slugs', async () => { - const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'usdt']) + it('covers every network when none is named, minus the ones the user switched off', async () => { + const all = await cli.json(['token', 'list']) + expect(Object.keys(all.tokens)).toHaveLength(TOTAL_NETWORKS) + expect(Object.keys(all.tokens)).toContain('ethereum') + + await cli.json(['network', 'disable', '--name', 'ethereum'], { unlocked: true }) - expect(info).toMatchObject({ symbol: 'USDT', decimals: 6, isNative: false }) + const { tokens } = await cli.json(['token', 'list']) + expect(Object.keys(tokens)).toHaveLength(TOTAL_NETWORKS - 1) + expect(Object.keys(tokens)).not.toContain('ethereum') }) - it('reports a token the registry does not carry', async () => { - const result = await cli.run(['token', 'info', '--network', 'ethereum', '--token', 'nope']) + it('refuses a network the catalog does not carry', async () => { + const result = await cli.run(['token', 'list', '--network', 'atlantis']) expect(result.code).toBe(1) - expect(result.output).toContain("Token 'nope' not found on 'ethereum'.") + expect(result.output).toContain("Network 'atlantis' is not supported.") }) +}) - it('reports a network that does not exist', async () => { - const result = await cli.run(['token', 'list', '--network', 'atlantis']) +describe('token info', () => { + it('shows a packaged token with its external mappings', async () => { + const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'usdt']) + + expect(info).toEqual({ + network: 'ethereum', + token: 'usdt', + enabled: true, + symbol: 'USDT', + decimals: 6, + isNative: false, + address: USDT, + metadata: { slugs: USDT_SLUGS } + }) + }) + + it('refuses a token the registry does not carry', async () => { + const result = await cli.run(['token', 'info', '--network', 'ethereum', '--token', 'nope']) expect(result.code).toBe(1) - expect(result.output).toContain("Network 'atlantis' is not supported.") + expect(result.output).toContain("Token 'nope' not found on 'ethereum'.") }) +}) - it('adds a custom token, joining the packaged entries', async () => { +describe('token add', () => { + it('adds a custom token alongside the packaged ones', async () => { const added = await cli.json(['token', 'add', CUSTOM_TOKEN]) - expect(added.added).toBe(true) + + expect(added).toEqual({ + network: 'ethereum', token: 'dai', added: true, + symbol: 'DAI', decimals: 18, isNative: false, address: DAI + }) const { tokens } = await cli.json(['token', 'list', '--network', 'ethereum']) expect(Object.keys(tokens)).toEqual(['eth', 'usdt', 'xaut', 'dai']) - const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'dai']) - expect(info).toMatchObject({ symbol: 'DAI', decimals: 18, address: DAI }) + // The entry has to land where every other command reads custom tokens from. + expect(cli.readConfig().customTokens.ethereum.dai) + .toEqual({ symbol: 'DAI', decimals: 18, isNative: false, address: DAI }) }) - it('deletes a custom token, leaving the packaged entries untouched', async () => { - await cli.json(['token', 'add', CUSTOM_TOKEN]) + it('keeps the external mappings the spec declares', async () => { + await cli.json(['token', 'add', JSON.stringify({ + network: 'ethereum', token: 'link', symbol: 'LINK', decimals: 18, isNative: false, + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + metadata: { slugs: { indexer: 'link', transak: { slug: 'LINK', network: 'ethereum' } } } + })]) - const deleted = await cli.json(['token', 'delete', '--network', 'ethereum', '--token', 'dai']) - expect(deleted.deleted).toBe(true) + const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'link']) + + expect(info.metadata).toEqual({ + slugs: { indexer: 'link', transak: { slug: 'LINK', network: 'ethereum' } } + }) + }) + + it('rejects a malformed spec without writing anything', async () => { + const rejected = [ + [ + { network: 'ethereum', token: 'bad', symbol: 'BAD', decimals: 25, isNative: false, address: DAI }, + 'Token "decimals" must be an integer between 0 and 24.' + ], + [ + { network: 'ethereum', token: 'bad', symbol: 'BAD', decimals: 6, isNative: 'no', address: DAI }, + 'Token "isNative" must be a boolean.' + ], + [ + { network: 'ethereum', token: 'BAD', symbol: 'BAD', decimals: 6, isNative: false, address: DAI }, + "Invalid token name 'BAD'." + ], + [ + { + network: 'ethereum', + token: 'bad', + symbol: 'BAD', + decimals: 6, + isNative: false, + address: DAI, + metadata: { slugs: { transak: { network: 'ethereum' } } } + }, + 'Token "metadata.slugs.transak" is missing "slug".' + ], + [ + { + network: 'ethereum', + token: 'bad', + symbol: 'BAD', + decimals: 6, + isNative: false, + address: DAI, + metadata: { indexerSlug: 'bad' } + }, + 'Token "metadata" has unknown field(s): indexerSlug.' + ] + ] + + for (const [spec, message] of rejected) { + const result = await cli.run(['token', 'add', JSON.stringify(spec)]) + + expect(result.code).toBe(1) + expect(result.output).toContain(message) + } + + expect(cli.readConfig().customTokens).toBeUndefined() + }) + + it('overrides a packaged entry under the same key, warning that it did', async () => { + const result = await cli.run(['token', 'add', OVERRIDE]) + + expect(result.code).toBe(0) + expect(result.output).toContain( + "Warning: 'usdt' is a built-in token on 'ethereum'. This entry now overrides it. " + + 'Run `wdk token delete` to revert.' + ) + // One entry, not two: the override replaces the packaged fields wholesale, + // so the packaged slugs go with them. const { tokens } = await cli.json(['token', 'list', '--network', 'ethereum']) expect(Object.keys(tokens)).toEqual(['eth', 'usdt', 'xaut']) + expect(tokens.usdt).toEqual({ + symbol: 'USDT2', decimals: 8, isNative: false, address: OVERRIDE_ADDRESS + }) }) - it('warns when a custom token shadows a packaged one', async () => { - const result = await cli.run(['token', 'add', JSON.stringify({ - network: 'ethereum', token: 'usdt', symbol: 'USDT', decimals: 6, isNative: false, - address: '0xdAC17F958D2ee523a2206206994597C13D831ec7' + it('refuses a second native token, but takes a replacement for the one there', async () => { + const second = await cli.run(['token', 'add', JSON.stringify({ + network: 'ethereum', token: 'weth', symbol: 'WETH', decimals: 18, isNative: true })]) - expect(result.output).toContain('overrides it') - }) + expect(second.code).toBe(1) + expect(second.output).toContain( + "Network 'ethereum' already has native token 'eth' (ETH). " + + "Each network can have at most one native token. Delete 'eth' first if you want to replace it." + ) - it('refuses to delete a packaged token, pointing at the override instead', async () => { - const result = await cli.run(['token', 'delete', '--network', 'ethereum', '--token', 'usdt']) + const replaced = await cli.json(['token', 'add', JSON.stringify({ + network: 'ethereum', token: 'eth', symbol: 'ETH2', decimals: 18, isNative: true + })]) - expect(result.code).toBe(1) - expect(result.output).toContain("'usdt' on 'ethereum' is a built-in token and cannot be deleted.") - expect(result.output).toContain('wdk token add') + expect(replaced.added).toBe(true) + expect(replaced.overridesBuiltin).toBe(true) + expect(replaced.symbol).toBe('ETH2') }) +}) - it('replaces the packaged fields under the same key', async () => { - const added = await cli.json(['token', 'add', OVERRIDE]) - expect(added.overridesBuiltin).toBe(true) +describe('token delete', () => { + it('deletes a custom token, taking its disable override with it', async () => { + await cli.json(['token', 'add', CUSTOM_TOKEN]) + await cli.json(['token', 'disable', '--network', 'ethereum', '--token', 'dai']) - const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'usdt']) - expect(info.symbol).toBe('USDT2') - expect(info.decimals).toBe(8) + const deleted = await cli.json(['token', 'delete', '--network', 'ethereum', '--token', 'dai']) + expect(deleted).toEqual({ network: 'ethereum', token: 'dai', deleted: true }) - // Still one entry, not two: the override replaces rather than appends. - const { tokens } = await cli.json(['token', 'list', '--network', 'ethereum']) + // A leftover override would re-disable the token if it were added again. + const { tokens, disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) expect(Object.keys(tokens)).toEqual(['eth', 'usdt', 'xaut']) + expect(disabled).toEqual([]) }) - it('reverts to the packaged token when the override is deleted', async () => { + it('reverts to the packaged entry when an override is deleted', async () => { await cli.json(['token', 'add', OVERRIDE]) const deleted = await cli.json(['token', 'delete', '--network', 'ethereum', '--token', 'usdt']) @@ -140,23 +268,86 @@ describe('token registry', () => { expect(info.decimals).toBe(6) expect(info.address).toBe(USDT) }) + + it('refuses a packaged token, pointing at the override instead', async () => { + const result = await cli.run(['token', 'delete', '--network', 'ethereum', '--token', 'usdt']) + + expect(result.code).toBe(1) + expect(result.output).toContain("'usdt' on 'ethereum' is a built-in token and cannot be deleted.") + expect(result.output).toContain('Use `wdk token add` to override its fields instead.') + }) }) -describe('token enable and disable', () => { - it('marks a disabled token in the listing', async () => { - await cli.run(['token', 'disable', '--network', 'ethereum', '--token', 'usdt']) - const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) +describe('token disable', () => { + it('flags the token in the listing and in its own info', async () => { + await cli.json(['token', 'disable', '--network', 'ethereum', '--token', 'usdt']) - expect(disabled).toContain('ethereum/usdt') + // Still listed — the listing reports it as disabled rather than hiding it. + const { tokens, disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) + expect(Object.keys(tokens)).toEqual(['eth', 'usdt', 'xaut']) + expect(disabled).toEqual(['ethereum/usdt']) + + const info = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'usdt']) + expect(info.enabled).toBe(false) }) + it("refuses the network's native token", async () => { + const result = await cli.run(['token', 'disable', '--network', 'ethereum', '--token', 'eth']) + + expect(result.code).toBe(1) + expect(result.output).toContain("'eth' is the native token of 'ethereum'.") + expect(result.output).toContain('wdk network disable --name ethereum') + }) +}) + +describe('token enable', () => { it('brings a disabled token back', async () => { - await cli.run(['token', 'disable', '--network', 'ethereum', '--token', 'usdt']) + await cli.json(['token', 'disable', '--network', 'ethereum', '--token', 'usdt']) + + const enabled = await cli.json(['token', 'enable', '--network', 'ethereum', '--token', 'usdt']) + expect(enabled).toEqual({ + network: 'ethereum', token: 'usdt', enabled: true, stale: false, walletsLocked: false + }) - await cli.run(['token', 'enable', '--network', 'ethereum', '--token', 'usdt']) const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) + expect(disabled).toEqual([]) + }) +}) + +describe('a registry the user edited by hand', () => { + // `overrides.tokens..metadata.slugs` is written with `wdk config set`, + // which does not validate it, so both halves below come straight from config. + it('merges slug deltas one system at a time', async () => { + cli.writeConfig({ + overrides: { + tokens: { + 'ethereum/usdt': { metadata: { slugs: { moonpay: 'usdt_custom', myexchange: 'UST9' } } }, + 'ethereum/xaut': { metadata: { slugs: { indexer: { note: 'no slug here' } } } } + } + } + }) + + const usdt = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'usdt']) + expect(usdt.metadata.slugs).toEqual({ ...USDT_SLUGS, moonpay: 'usdt_custom', myexchange: 'UST9' }) + + // A delta carrying no slug is dropped, not allowed to shadow the packaged one. + const xaut = await cli.json(['token', 'info', '--network', 'ethereum', '--token', 'xaut']) + expect(xaut.metadata.slugs.indexer).toBe('xaut') + }) - expect(disabled).not.toContain('ethereum/usdt') + it('skips a custom entry the schema rejects and keeps the rest usable', async () => { + cli.writeConfig({ + customTokens: { + ethereum: { broken: { symbol: 'BROKEN', decimals: 'six', isNative: false, address: DAI } } + } + }) + + const result = await cli.run(['token', 'list', '--network', 'ethereum', '--json']) + + expect(result.code).toBe(0) + expect(result.stderr).toContain("Warning: ignoring invalid custom token 'ethereum/broken'") + expect(result.stderr).toContain("run 'wdk token delete --network ethereum --token broken'") + expect(Object.keys(JSON.parse(result.stdout.trim()).tokens)).toEqual(['eth', 'usdt', 'xaut']) }) }) @@ -204,3 +395,42 @@ describe('a token a command has to resolve', () => { expect(result.output).toContain("Token 'xaut' is disabled on 'ethereum'.") }) }) + +describe('a disabled token through network and module cycles', () => { + it('stays disabled after its network is switched off and on', async () => { + await cli.json(['token', 'disable', '--network', 'ethereum', '--token', 'usdt'], { unlocked: true }) + + await cli.json(['network', 'disable', '--name', 'ethereum'], { unlocked: true }) + await cli.json(['network', 'enable', '--name', 'ethereum'], { unlocked: true }) + + const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) + expect(disabled).toEqual(['ethereum/usdt']) + }) + + it('stays disabled after its module is switched off and on', async () => { + await cli.json(['token', 'disable', '--network', 'ethereum', '--token', 'usdt'], { unlocked: true }) + + await cli.json(['module', 'disable', '--name', EVM_MODULE], { unlocked: true }) + await cli.json(['module', 'enable', '--name', EVM_MODULE], { unlocked: true }) + + const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) + expect(disabled).toEqual(['ethereum/usdt']) + }) +}) + +describe('the passphrase gate', () => { + it('stops a token change without the passphrase', async () => { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + + const result = await cli.run(['token', 'disable', '--network', 'ethereum', '--token', 'usdt']) + + expect(result.code).toBe(1) + expect(result.output).toContain('Cannot prompt for a passphrase when stdin is piped.') + + const { disabled } = await cli.json(['token', 'list', '--network', 'ethereum']) + expect(disabled).toEqual([]) + }) +}) diff --git a/tests/integration/wallet.test.js b/tests/integration/wallet.test.js index e629587..1741703 100644 --- a/tests/integration/wallet.test.js +++ b/tests/integration/wallet.test.js @@ -12,9 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Grouped by the command under test, so the surface each one covers is visible -// at a glance and a new flag has an obvious home. - import { Cli } from './helpers.js' const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' @@ -24,6 +21,10 @@ const ETHEREUM_0 = '0x405005C7c4422390F4B334F64Cf20E0b767131d0' const OTHER_SEED = 'legal winner thank year wave sausage worth useful legal winner thank yellow' const OTHER_ETHEREUM_0 = '0x58A57ed9d8d624cBD12e2C467D34787555bB1b25' +const EVM_MODULE = '@tetherto/wdk-wallet-evm' +/** A module behind no network, so disabling it touches no wallet key. */ +const PRICING_MODULE = '@tetherto/wdk-pricing-bitfinex-http' + /** The default session length, in milliseconds. */ const DEFAULT_TTL_MS = 300_000 @@ -465,3 +466,79 @@ describe('wallet change-passphrase', () => { expect(result.output).toContain("Wallet 'ghost' not found.") }) }) + +describe('a session when a module is disabled', () => { + it('locks the open wallets when the module backs a network', async () => { + await importAndUnlock() + + const disabled = await cli.json(['module', 'disable', '--name', EVM_MODULE], { unlocked: true }) + + expect(disabled.walletsLocked).toBe(true) + const { wallets } = await cli.json(['wallet', 'list']) + expect(wallets).toHaveLength(1) + expect(wallets[0].unlocked).toBe(false) + }) + + // applyToggle locks unconditionally, so even a price feed that has nothing to + // do with key derivation costs the user their session. Blunt, but safe: pinning + // it here means narrowing it later has to be a deliberate change. + it('locks them even when the module backs no network', async () => { + await importAndUnlock() + + const disabled = await cli.json(['module', 'disable', '--name', PRICING_MODULE], { unlocked: true }) + + expect(disabled.walletsLocked).toBe(true) + const { wallets } = await cli.json(['wallet', 'list']) + expect(wallets).toHaveLength(1) + expect(wallets[0].unlocked).toBe(false) + }) +}) + +describe('the passphrase gate on other commands', () => { + // The gate exists to protect the keys, so its behaviour is a wallet concern + // even though it guards registry commands. Each registry file covers that its + // own commands are gated; these are the cases about the passphrase itself. + it('does not ask when there is no wallet to protect', async () => { + const result = await cli.run(['provider', 'disable', '--name', 'velora']) + + expect(result.code).toBe(0) + expect(result.output).toContain("Provider 'velora' disabled.") + }) + + it('rejects the wrong passphrase, changing nothing', async () => { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + + const result = await cli.run(['provider', 'disable', '--name', 'velora'], { + passphrase: 'not-the-passphrase' + }) + + expect(result.code).toBe(1) + expect(result.output).toContain('Incorrect passphrase.') + const { providers } = await cli.json(['provider', 'list']) + expect(providers.find((p) => p.name === 'velora').enabled).toBe(true) + }) + + it('follows the new passphrase after change-passphrase', async () => { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + await cli.run( + ['wallet', 'change-passphrase', '--name', 'main', '--new-passphrase-stdin'], + { unlocked: true, stdin: 'second-passphrase\n' } + ) + + const withOld = await cli.run(['provider', 'disable', '--name', 'velora'], { unlocked: true }) + expect(withOld.code).toBe(1) + expect(withOld.output).toContain('Incorrect passphrase.') + + const withNew = await cli.run(['provider', 'disable', '--name', 'velora'], { + passphrase: 'second-passphrase' + }) + expect(withNew.code).toBe(0) + expect(withNew.output).toContain("Provider 'velora' disabled.") + }) +}) diff --git a/tests/integration/write-operations.test.js b/tests/integration/write-operations.test.js index 8b851f3..7b93977 100644 --- a/tests/integration/write-operations.test.js +++ b/tests/integration/write-operations.test.js @@ -12,16 +12,25 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Spending is the one thing these tests must never do, so every command here +// is driven into a state it refuses before it signs anything or opens a +// socket: a locked wallet, an amount the token cannot express, a provider that +// does not serve the kind. `message sign` is the exception — it signs locally. + import { Cli } from './helpers.js' const SEED = 'cook voyage document eight skate token alien guide drink uncle term abuse' const ADDRESS = '0x405005C7c4422390F4B334F64Cf20E0b767131d0' +const ADDRESS_INDEX_1 = '0xcC81e04BadA16DEf9e1AFB027B859bec42BE49dB' -// ECDSA over a fixed seed and message is deterministic, so this is an exact -// value rather than a shape. If it ever changes, the derivation changed. +// ECDSA over a fixed seed and message is deterministic, so these are exact +// values rather than shapes. If either changes, the derivation changed. const HELLO_SIGNATURE = '0x05c005143100eff1f58be46b0a7fb7fe1a47f20d29d7c14e913dc9ae798e00ab' + '6e769cf61784923262ba5bda8f0f5140d661a9b3f14df4d17837dc06dea555ac1b' +const HELLO_SIGNATURE_INDEX_1 = + '0xa55303f33be43846f0669a238c2fab3875a3751db963b2bcf813652ed965eacf' + + '26309379766241b8cdae2675fa59cc5c2c97afab5ef0c83e8d70948cf37ce77f1c' /** @type {Cli} */ let cli @@ -40,7 +49,7 @@ afterEach(async () => { cli.cleanup() }) -describe('message signing', () => { +describe('message sign', () => { it('produces the signature this seed always produces', async () => { const result = await cli.json( ['message', 'sign', '--network', 'ethereum', '--message', 'hello'], { unlocked: true } @@ -56,17 +65,19 @@ describe('message signing', () => { }) it('signs with the account at the index it was given', async () => { - const zero = await cli.json( - ['message', 'sign', '--network', 'ethereum', '--message', 'hello'], { unlocked: true } - ) - const one = await cli.json( + const result = await cli.json( ['message', 'sign', '--network', 'ethereum', '--message', 'hello', '--index', '1'], { unlocked: true } ) - expect(one.index).toBe(1) - expect(one.signature).not.toBe(zero.signature) - expect(one.address).not.toBe(zero.address) + expect(result).toEqual({ + network: 'ethereum', + index: 1, + address: ADDRESS_INDEX_1, + message: 'hello', + signature: HELLO_SIGNATURE_INDEX_1 + }) + expect(result.signature).not.toBe(HELLO_SIGNATURE) }) it('refuses to sign while locked', async () => { @@ -78,3 +89,179 @@ describe('message signing', () => { expect(result.output).toContain("Wallet 'main' is not unlocked.") }) }) + +describe('send', () => { + it('refuses an amount finer than the token can represent', async () => { + const result = await cli.run( + ['send', '--network', 'ethereum', '--token', 'usdt', '--to', ADDRESS, '--amount', '1.1234567'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain("usdt '1.1234567' has more precision than 6 decimals allow.") + }) + + it('refuses a fractional amount when it was told the amount is base units', async () => { + const result = await cli.run( + ['send', '--network', 'ethereum', '--to', ADDRESS, '--amount', '1.5', '--base-units'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain( + "Amount '1.5' must be a non-negative integer when --base-units is set." + ) + }) + + // The amount is one wei, deliberately: if this gate ever regressed the test + // would broadcast, and one wei from an empty account is the cheapest way to + // find out. + it('refuses to send while locked', async () => { + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + + const result = await cli.run( + ['send', '--network', 'ethereum', '--to', ADDRESS, '--amount', '1', '--base-units'] + ) + + expect(result.code).toBe(1) + expect(result.output).toContain("Wallet 'main' is not unlocked.") + }) +}) + +describe('swap', () => { + it('needs exactly one of --amount-in and --amount-out', async () => { + const both = await cli.run( + ['swap', '--network', 'ethereum', '--from-token', 'usdt', '--to-token', 'eth', + '--amount-in', '1', '--amount-out', '1'], + { unlocked: true } + ) + const neither = await cli.run( + ['swap', '--network', 'ethereum', '--from-token', 'usdt', '--to-token', 'eth'], + { unlocked: true } + ) + + expect(both.code).toBe(1) + expect(both.output).toContain('Cannot specify both --amount-in and --amount-out.') + expect(neither.code).toBe(1) + expect(neither.output).toContain('Must specify either --amount-in or --amount-out.') + }) +}) + +describe('bridge', () => { + it('sends the user to `wdk send` when both ends are the same chain', async () => { + const result = await cli.run( + ['bridge', '--network', 'ethereum', '--token', 'usdt', '--to-network', 'ethereum', + '--amount', '1'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain( + 'Source and destination network are the same; use `wdk send` for same-chain transfers.' + ) + }) +}) + +describe('buy and sell', () => { + // Both packaged fiat providers ship enabled, so the default installation is + // exactly the ambiguous one. Picking either silently would send the user to + // a provider they did not choose. + it('asks which fiat provider to use while several are enabled', async () => { + const result = await cli.run( + ['buy', '--network', 'ethereum', '--token', 'usdt', '--fiat-amount', '100'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain('Several fiat providers are available: moonpay, transak.') + expect(result.output).toContain('Choose one with: --provider moonpay') + }) +}) + +describe('an operation with no provider for its kind', () => { + it('fails buy and sell once every fiat provider is off', async () => { + await cli.json(['provider', 'disable', '--name', 'moonpay'], { unlocked: true }) + await cli.json(['provider', 'disable', '--name', 'transak'], { unlocked: true }) + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + for (const command of ['buy', 'sell']) { + const result = await cli.run( + [command, '--network', 'ethereum', '--token', 'usdt', '--fiat-amount', '100'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain('No fiat provider is available.') + } + }) + + it('fails swap once nothing can swap', async () => { + for (const name of ['velora', 'rhinofi', 'symbiosis']) { + await cli.json(['provider', 'disable', '--name', name], { unlocked: true }) + } + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + const result = await cli.run( + ['swap', '--network', 'ethereum', '--from-token', 'usdt', '--to-token', 'eth', '--amount-in', '1'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain('No installed protocol can swap.') + }) + + it('fails bridge once nothing can bridge', async () => { + for (const name of ['usdt0', 'rhinofi', 'symbiosis']) { + await cli.json(['provider', 'disable', '--name', name], { unlocked: true }) + } + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + const result = await cli.run( + ['bridge', '--network', 'ethereum', '--token', 'eth', '--to-network', 'arbitrum', '--amount', '1'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain('No installed protocol can bridge.') + }) +}) + +describe('choosing the provider with --provider', () => { + it('refuses a provider the user disabled', async () => { + await cli.json(['provider', 'disable', '--name', 'transak'], { unlocked: true }) + await cli.run(['wallet', 'unlock', '--name', 'main'], { unlocked: true }) + + const result = await cli.run( + ['buy', '--network', 'ethereum', '--token', 'usdt', '--fiat-amount', '100', + '--provider', 'transak'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain("Protocol 'transak' is disabled.") + }) + + it('refuses a provider registered for another kind', async () => { + const result = await cli.run( + ['buy', '--network', 'ethereum', '--token', 'usdt', '--fiat-amount', '100', + '--provider', 'velora'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain("Provider 'velora' is not a fiat on/off-ramp.") + expect(result.output).toContain('It is a swap provider.') + }) + + it('lists what is available when the name is unknown', async () => { + const result = await cli.run( + ['buy', '--network', 'ethereum', '--token', 'usdt', '--fiat-amount', '100', + '--provider', 'nosuch'], + { unlocked: true } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain("Unknown protocol 'nosuch'.") + expect(result.output).toContain('moonpay') + }) +}) From 37cbedaca64482a669a34478b10c71ee192f7016 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Tue, 29 Sep 2026 17:06:47 +0700 Subject: [PATCH 13/15] add integration test to CI --- .github/workflows/build.yml | 8 ++++++++ package.json | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e75ee64..f31a2c1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,3 +25,11 @@ jobs: - uses: tetherto/oss-actions/node-base@v1 - name: Run tests run: npm test + integration: + needs: lint + runs-on: ubuntu-latest + name: Integration + steps: + - uses: tetherto/oss-actions/node-base@v1 + - name: Run integration tests + run: npm run test:integration diff --git a/package.json b/package.json index dfe1d46..0515fdc 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "sync-modules": "node scripts/sync-module-deps.mjs", "typecheck": "tsc --noEmit -p jsconfig.json", "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns tests/integration", - "test:integration": "cross-env NODE_OPTIONS=--experimental-vm-modules jest tests/integration --runInBand --testTimeout=60000", + "test:integration": "cross-env NODE_OPTIONS=--experimental-vm-modules jest tests/integration --maxWorkers=4 --testTimeout=60000", "test:coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage --testPathIgnorePatterns tests/integration", - "test:integration:coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest tests/integration --coverage --runInBand --testTimeout=60000" + "test:integration:coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest tests/integration --coverage --maxWorkers=4 --testTimeout=60000" }, "jest": { "setupFiles": [ From 072c13e5203a2c9cf4334f04dd12f608deb75ad9 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Tue, 29 Sep 2026 17:10:29 +0700 Subject: [PATCH 14/15] correct desc --- tests/integration/read-operations.test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/integration/read-operations.test.js b/tests/integration/read-operations.test.js index 8184562..e1c456c 100644 --- a/tests/integration/read-operations.test.js +++ b/tests/integration/read-operations.test.js @@ -165,8 +165,6 @@ describe('get address', () => { expect(result.addresses.map((a) => a.network)).toEqual(TESTNETS) expect(result.addresses.find((a) => a.network === 'bitcoin-testnet3').address) .toBe(BITCOIN_TESTNET_0) - // Sepolia is the same EVM key as mainnet ethereum, which is the point: the - // flag changes which networks are listed, not how they derive. expect(result.addresses.find((a) => a.network === 'sepolia').address).toBe(ETHEREUM_0) }) @@ -226,7 +224,7 @@ describe('get address', () => { }) describe('deriving on a network whose module is disabled', () => { - it('drops the module\'s networks from --all and names the module by network', async () => { + it("drops the module's networks from --all and names the module by network", async () => { await importAndUnlock() await disableSpark() await cli.run(['module', 'disable', '--name', EVM_MODULE], { unlocked: true }) From ce805b7fec16239aefb446c72b75d5659cbc8ab1 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Thu, 8 Oct 2026 08:08:24 +0700 Subject: [PATCH 15/15] remove a redundant test case --- tests/integration/module.test.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/integration/module.test.js b/tests/integration/module.test.js index cc6e50c..f6e65f3 100644 --- a/tests/integration/module.test.js +++ b/tests/integration/module.test.js @@ -43,14 +43,6 @@ describe('module list', () => { expect(modules.every((m) => m.status === 'ok')).toBe(true) }) - it('treats a git-pinned module as satisfied rather than mismatched', async () => { - const { modules } = await cli.json(['module', 'list']) - const indexer = modules.find((m) => m.module === '@tetherto/wdk-indexer-http') - - expect(indexer.pinned).toContain('github:') - expect(indexer.status).toBe('ok') - }) - it('reports a custom module pinned to a version that is not installed', async () => { cli.writeConfig({ customModules: { [INSTALLED_PACKAGE]: { version: '9.9.9' } } })