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 5b7460f..0515fdc 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,15 @@ "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:coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage" + "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns tests/integration", + "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 --maxWorkers=4 --testTimeout=60000" }, "jest": { + "setupFiles": [ + "/tests/setup.js" + ], "testEnvironment": "node", "testMatch": [ "/tests/**/*.test.js" 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/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/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() } /** 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) } 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. * 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/helpers.js b/tests/integration/helpers.js new file mode 100644 index 0000000..d50de7e --- /dev/null +++ b/tests/integration/helpers.js @@ -0,0 +1,227 @@ +// 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 { 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' +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') +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. + * @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. + * @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, + * 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. */ + 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')}` + } + + /** + * 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. + * @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. + * @throws {Error} When the command does not exit within the harness timeout. + */ + run (args, options = {}) { + return new Promise((resolve, reject) => { + 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', + NODE_OPTIONS: '--disable-warning=ExperimentalWarning' + }, + 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', (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. + code: code ?? (signal ? 1 : 0), + signal, + 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') + } + + /** + * 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 && isOurDaemon(pid)) process.kill(pid, 'SIGTERM') + } catch { + // 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..f6e65f3 --- /dev/null +++ b/tests/integration/module.test.js @@ -0,0 +1,211 @@ +// 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 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 list', () => { + 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) + }) + + 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']) + + expect(modules.find((m) => m.module === INSTALLED_PACKAGE)).toMatchObject({ + pinned: '9.9.9', + installed: INSTALLED_VERSION, + status: 'version mismatch', + source: 'custom' + }) + }) + + 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 } } } }) + + const { modules } = await cli.json(['module', 'list']) + + expect(modules.find((m) => m.module === '@ghost/gone')).toMatchObject({ + pinned: '-', + status: 'stale override', + source: 'override' + }) + }) +}) + +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') + }) +}) + +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 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('module disable', () => { + it('marks the module disabled and hides the providers it serves', async () => { + await cli.run(['module', 'disable', '--name', MOONPAY_MODULE]) + + const { modules } = await cli.json(['module', 'list']) + const { providers } = await cli.json(['provider', 'list']) + + expect(modules.find((m) => m.module === MOONPAY_MODULE).status).toBe('disabled') + expect(providers.find((p) => p.name === 'moonpay')).toBeUndefined() + }) + + it('refuses one that is already disabled', async () => { + await cli.run(['module', 'disable', '--name', MOONPAY_MODULE]) + + 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.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(result.code).toBe(1) + expect(result.output).toContain(`'${name}' is not a module.`) + expect(result.output).toContain(hint) + }) +}) + +describe('module enable', () => { + it('brings a disabled module back', async () => { + await cli.run(['module', 'disable', '--name', MOONPAY_MODULE]) + + await cli.run(['module', 'enable', '--name', MOONPAY_MODULE]) + const { modules } = await cli.json(['module', 'list']) + + expect(modules.find((m) => m.module === MOONPAY_MODULE).status).toBe('ok') + }) + + it('refuses one that is already enabled', async () => { + const result = await cli.run(['module', 'enable', '--name', MOONPAY_MODULE], { unlocked: true }) + + expect(result.code).toBe(1) + expect(result.output).toContain(`'${MOONPAY_MODULE}' is already enabled.`) + }) +}) + +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).toHaveLength(SPARK_METHOD_COUNT) + 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: [] }) + }) + + 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 new file mode 100644 index 0000000..743ac04 --- /dev/null +++ b/tests/integration/network.test.js @@ -0,0 +1,433 @@ +// 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 { 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. */ +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(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 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 }) + + // 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('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: EVM_MODULE, 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, displayName: 'Mine' + })]) + + expect(result.code).toBe(1) + expect(result.output).toContain('Network spec "module" must be one of') + }) +}) + +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).toEqual({ + name: 'ethereum', + displayName: 'Ethereum', + 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 } + }) + }) + + 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.") + }) +}) + +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']) + + 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('refuses one that is already disabled', async () => { + await cli.json(['network', 'disable', '--name', 'polygon']) + + const again = await cli.run(['network', 'disable', '--name', 'polygon']) + + expect(again.code).toBe(1) + expect(again.output).toContain("'polygon' is already disabled.") + }) + + 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]) + + 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}`) + + 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('network enable', () => { + it('brings a disabled network back', async () => { + await cli.json(['network', 'disable', '--name', 'polygon']) + + const enabled = await cli.json(['network', 'enable', '--name', 'polygon']) + + 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('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 result = await cli.json(['network', 'enable', '--name', 'atlantis']) + + expect(result).toEqual({ + network: 'atlantis', + enabled: true, + stale: true, + walletsLocked: false + }) + expect(cli.readConfig().overrides).toBeUndefined() + }) +}) + +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']) + }) + + 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('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 }) + 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) + }) + + 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) + + // 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).toHaveLength(TOTAL_NETWORKS) + expect(networks.find((n) => n.name === 'arbitrum').enabled).toBe(false) + expect(networks.find((n) => n.name === 'ethereum').enabled).toBe(true) + }) + + it('tells every command that touches the network to enable the module', async () => { + await cli.json(['module', 'disable', '--name', EVM_MODULE], { 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}`) + } + }) + + 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/provider.test.js b/tests/integration/provider.test.js new file mode 100644 index 0000000..197a7de --- /dev/null +++ b/tests/integration/provider.test.js @@ -0,0 +1,526 @@ +// 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 { 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 () => { + 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('provider list', () => { + 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' + ]) + }) +}) + +describe('provider info', () => { + 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.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 + })]) + + 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 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('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', () => { + 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('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 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 { 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('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 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('provider delete', () => { + 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('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 }) + + 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('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'], + { 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('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.") + }) +}) + +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']) + + 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) + }) +}) + +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.") + }) +}) + +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 new file mode 100644 index 0000000..e1c456c --- /dev/null +++ b/tests/integration/read-operations.test.js @@ -0,0 +1,318 @@ +// 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 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' + +/** + * Names the registry networks matching a predicate. + * + * @param {(network: Record) => boolean} predicate - The filter. + * @returns {string[]} The matching network names. + */ +function networkNames (predicate) { + return Object.entries(catalog.networks).filter(([, n]) => predicate(n)).map(([name]) => name) +} + +/** + * 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 + +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 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('get address', () => { + it('derives the address this 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 the account at the index it is given', async () => { + await importAndUnlock() + + const result = await cli.json( + ['get', 'address', '--network', 'ethereum', '--index', '1'], { unlocked: true } + ) + + expect(result).toEqual({ network: 'ethereum', index: 1, address: ETHEREUM_1 }) + }) + + it('derives every mainnet address in one call', async () => { + await importAndUnlock() + await disableSpark() + + const result = await cli.json(['get', 'address', '--all'], { unlocked: true }) + + expect(result.type).toBe('mainnet') + 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 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) + 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 () => { + 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 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, + 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.") + }) +}) + +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 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 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_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(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 () => { + await importAndUnlock() + + const result = await cli.json( + ['message', 'verify', '--network', 'ethereum', '--message', 'tampered', + '--signature', HELLO_SIGNATURE_0], + { unlocked: true } + ) + + expect(result).toMatchObject({ message: 'tampered', address: ETHEREUM_0, valid: 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') + }) + + 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 new file mode 100644 index 0000000..366a5c1 --- /dev/null +++ b/tests/integration/token.test.js @@ -0,0 +1,436 @@ +// 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 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. 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 +}) + +/** An entry under a packaged key, to override it. */ +const OVERRIDE = JSON.stringify({ + network: 'ethereum', token: 'usdt', symbol: 'USDT2', decimals: 8, isNative: false, + address: OVERRIDE_ADDRESS +}) + +/** @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 }) +} + +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)).toEqual(['eth', 'usdt', 'xaut']) + expect(tokens.eth.isNative).toBe(true) + expect(tokens.usdt.address).toBe(USDT) + expect(disabled).toEqual([]) + }) + + 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 }) + + const { tokens } = await cli.json(['token', 'list']) + expect(Object.keys(tokens)).toHaveLength(TOTAL_NETWORKS - 1) + expect(Object.keys(tokens)).not.toContain('ethereum') + }) + + 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("Network 'atlantis' is not supported.") + }) +}) + +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("Token 'nope' not found on 'ethereum'.") + }) +}) + +describe('token add', () => { + it('adds a custom token alongside the packaged ones', async () => { + const added = await cli.json(['token', 'add', CUSTOM_TOKEN]) + + 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']) + + // 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('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 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('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(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." + ) + + const replaced = await cli.json(['token', 'add', JSON.stringify({ + network: 'ethereum', token: 'eth', symbol: 'ETH2', decimals: 18, isNative: true + })]) + + expect(replaced.added).toBe(true) + expect(replaced.overridesBuiltin).toBe(true) + expect(replaced.symbol).toBe('ETH2') + }) +}) + +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 deleted = await cli.json(['token', 'delete', '--network', 'ethereum', '--token', 'dai']) + expect(deleted).toEqual({ network: 'ethereum', token: 'dai', deleted: true }) + + // 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 entry when an 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) + }) + + 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 disable', () => { + it('flags the token in the listing and in its own info', async () => { + await cli.json(['token', 'disable', '--network', 'ethereum', '--token', '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.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 + }) + + 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') + }) + + 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']) + }) +}) + +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'.") + }) +}) + +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 new file mode 100644 index 0000000..1741703 --- /dev/null +++ b/tests/integration/wallet.test.js @@ -0,0 +1,544 @@ +// 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' + +/** 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' + +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 + +/** @type {Cli} */ +let cli + +beforeEach(() => { + cli = new Cli() +}) + +afterEach(async () => { + await cli.run(['wallet', 'lock', '--all'], { unlocked: true }) + cli.cleanup() +}) + +/** + * 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', seed = SEED) { + 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 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('generates a twenty-four word seed phrase 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 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.filter((w) => w.default).map((w) => w.name)).toEqual(['w1']) + }) + + 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 }) + + 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 () => { + const result = await cli.run( + ['wallet', 'create', '--name', 'w1', '--words', '18'], { unlocked: true } + ) + + expect(result.code).toBe(1) + 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.') + }) +}) + +describe('wallet import', () => { + it('imports a known seed', async () => { + await importAndUnlock() + + const { wallets } = await cli.json(['wallet', 'list']) + + expect(wallets).toHaveLength(1) + expect(wallets[0]).toMatchObject({ name: 'main', default: true, unlocked: true }) + }) + + 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) + expect(result.output).toContain('Invalid seed phrase. Must be 12 or 24 valid BIP-39 words.') + }) + + 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 'w1' already exists.") + }) +}) + +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[0]).toMatchObject({ unlocked: true, ttlMs: DEFAULT_TTL_MS }) + expect(wallets[0].ttlRemaining).toBeLessThanOrEqual(DEFAULT_TTL_MS) + }) + + 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( + `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', async () => { + await cli.run(['wallet', 'import', '--name', 'main', '--seed-stdin'], { + unlocked: true, + stdin: SEED + '\n' + }) + + const result = await cli.run(['wallet', 'unlock', '--name', 'main'], { passphrase: 'wrong-one' }) + + expect(result.code).toBe(1) + expect(result.output).toContain('Incorrect passphrase.') + }) + + 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('wallet lock', () => { + it('locks one 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) + }) + + 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('wallet export', () => { + 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 without the passphrase, printing no part of the seed', 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('reports 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('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 }) + + 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('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('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).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('wallet change-passphrase', () => { + 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.json(['wallet', 'export', '--name', 'main'], { + passphrase: 'second-passphrase' + }) + + expect(exported.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.') + }) + + it('reports a wallet that does not exist', async () => { + const result = await cli.run( + ['wallet', 'change-passphrase', '--name', 'ghost', '--new-passphrase-stdin'], + { unlocked: true, stdin: 'second-passphrase\n' } + ) + + expect(result.code).toBe(1) + 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 new file mode 100644 index 0000000..7b93977 --- /dev/null +++ b/tests/integration/write-operations.test.js @@ -0,0 +1,267 @@ +// 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. + +// 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 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 + +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 sign', () => { + 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 result = await cli.json( + ['message', 'sign', '--network', 'ethereum', '--message', 'hello', '--index', '1'], + { unlocked: true } + ) + + 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 () => { + 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.") + }) +}) + +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') + }) +}) 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/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) + }) +}) 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 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": {