diff --git a/packages/fast-usdc/demo/testnet/config.json b/packages/fast-usdc/demo/testnet/config.json new file mode 100644 index 00000000000..13b4420b23f --- /dev/null +++ b/packages/fast-usdc/demo/testnet/config.json @@ -0,0 +1,11 @@ +{ + "nobleSeed": "stamp later develop betray boss ranch abstract puzzle calm right bounce march orchard edge correct canal fault miracle void dutch lottery lucky observe armed", + "ethSeed": "a4b7f431465df5dc1458cd8a9be10c42da8e3729e3ce53f18814f48ae2a98a08", + "nobleToAgoricChannel": "channel-21", + "agoricRpc": "https://main.rpc.agoric.net", + "nobleRpc": "https://noble-rpc.polkachu.com", + "nobleApi": "https://noble-api.polkachu.com", + "ethRpc": "https://sepolia.drpc.org", + "tokenMessengerAddress": "0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5", + "tokenAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" +} diff --git a/packages/fast-usdc/package.json b/packages/fast-usdc/package.json index f4922c64da0..4bfb3860404 100644 --- a/packages/fast-usdc/package.json +++ b/packages/fast-usdc/package.json @@ -9,7 +9,7 @@ "src" ], "bin": { - "fast-usdc": "./src/cli.js" + "fast-usdc": "./src/cli/index.js" }, "scripts": { "build": "exit 0", @@ -31,6 +31,7 @@ "ts-blank-space": "^0.4.1" }, "dependencies": { + "@agoric/client-utils": "^0.1.0", "@agoric/ertp": "^0.16.2", "@agoric/internal": "^0.3.2", "@agoric/notifier": "^0.6.2", @@ -47,7 +48,14 @@ "@endo/pass-style": "^1.4.6", "@endo/patterns": "^1.4.6", "@endo/promise-kit": "^1.1.7", - "commander": "^12.1.0" + "@cosmjs/proto-signing": "^0.32.4", + "@cosmjs/stargate": "^0.32.4", + "@endo/init": "^1.1.6", + "@nick134-bit/noblejs": "0.0.2", + "agoric": "^0.21.1", + "bech32": "^2.0.0", + "commander": "^12.1.0", + "ethers": "^6.13.4" }, "ava": { "extensions": { @@ -61,9 +69,6 @@ "--import=ts-blank-space/register", "--no-warnings" ], - "require": [ - "@endo/init/debug.js" - ], "timeout": "20m" } } diff --git a/packages/fast-usdc/src/cli.js b/packages/fast-usdc/src/cli.js deleted file mode 100755 index 24846841cb7..00000000000 --- a/packages/fast-usdc/src/cli.js +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env node - -import { Command } from 'commander'; -import { readFileSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, resolve } from 'path'; - -const packageJson = JSON.parse( - readFileSync( - resolve(dirname(fileURLToPath(import.meta.url)), '../package.json'), - 'utf8', - ), -); - -const program = new Command(); - -program - .name('fast-usdc') - .description('CLI to interact with Fast USDC liquidity pool') - .version(packageJson.version); - -program - .command('deposit') - .description('Offer assets to the liquidity pool') - .action(() => { - console.error('TODO actually send deposit'); - // TODO: Implement deposit logic - }); - -program - .command('withdraw') - .description('Withdraw assets from the liquidity pool') - .action(() => { - console.error('TODO actually send withdrawal'); - // TODO: Implement withdraw logic - }); - -program.parse(); diff --git a/packages/fast-usdc/src/cli/cli.js b/packages/fast-usdc/src/cli/cli.js new file mode 100644 index 00000000000..c4504830833 --- /dev/null +++ b/packages/fast-usdc/src/cli/cli.js @@ -0,0 +1,163 @@ +import { Command } from 'commander'; +import { existsSync, mkdirSync, readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; +import { homedir } from 'os'; +import { + readFile as readAsync, + writeFile as writeAsync, +} from 'node:fs/promises'; +import configLib from './config.js'; +import transferLib from './transfer.js'; +import { makeFile } from '../util/file.js'; + +const packageJson = JSON.parse( + readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../../package.json'), + 'utf8', + ), +); + +const defaultHome = homedir(); + +export const initProgram = ( + configHelpers = configLib, + transferHelpers = transferLib, + readFile = readAsync, + writeFile = writeAsync, + mkdir = mkdirSync, + exists = existsSync, +) => { + const program = new Command(); + + program + .name('fast-usdc') + .description('CLI to interact with Fast USDC liquidity pool') + .version(packageJson.version) + .option( + '--home ', + `Home directory to use for config`, + `${defaultHome}/.fast-usdc/`, + ); + + const config = program.command('config').description('Manage config'); + + const configFilename = 'config.json'; + const getConfigPath = () => { + const { home: configDir } = program.opts(); + return configDir + configFilename; + }; + + const makeConfigFile = () => + makeFile(getConfigPath(), readFile, writeFile, mkdir, exists); + + config + .command('show') + .description('Show current config') + .action(async () => { + await configHelpers.show(makeConfigFile()); + }); + + config + .command('init') + .description('Set initial config values') + .requiredOption( + '--noble-seed ', + 'Seed phrase for Noble account. CAUTION: Stored unencrypted in file system', + ) + .requiredOption( + '--eth-seed ', + 'Seed phrase for Ethereum account. CAUTION: Stored unencrypted in file system', + ) + .option( + '--agoric-rpc [url]', + 'Agoric RPC endpoint', + 'http://127.0.0.1:1317', + ) + .option('--noble-api [url]', 'Noble API endpoint', 'http://127.0.0.1:1318') + .option( + '--noble-to-agoric-channel [channel]', + 'Channel ID on Noble for Agoric', + 'channel-21', + ) + .option('--noble-rpc [url]', 'Noble RPC endpoint', 'http://127.0.0.1:26657') + .option('--eth-rpc [url]', 'Ethereum RPC Endpoint', 'http://127.0.0.1:8545') + .option( + '--token-messenger-address [address]', + 'Address of TokenMessenger contract', + // Default to ETH mainnet contract address. For ETH sepolia, use 0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5 + '0xbd3fa81b58ba92a82136038b25adec7066af3155', + ) + .option( + '--token-contract-address [address]', + 'Address of USDC token contract', + // Detault to ETH mainnet token address. For ETH sepolia, use 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ) + .action(async options => { + await configHelpers.init(makeConfigFile(), options); + }); + + config + .command('update') + .description('Update config values') + .option( + '--noble-seed [string]', + 'Seed phrase for Noble account. CAUTION: Stored unencrypted in file system', + ) + .option( + '--eth-seed [string]', + 'Seed phrase for Ethereum account. CAUTION: Stored unencrypted in file system', + ) + .option('--agoric-rpc [url]', 'Agoric RPC endpoint') + .option('--noble-rpc [url]', 'Noble RPC endpoint') + .option('--eth-rpc [url]', 'Ethereum RPC Endpoint') + .option('--noble-api [url]', 'Noble API endpoint') + .option( + '--noble-to-agoric-channel [channel]', + 'Channel ID on Noble for Agoric', + ) + .option( + '--token-messenger-address [address]', + 'Address of TokenMessenger contract', + ) + .option( + '--token-contract-address [address]', + 'Address of USDC token contract', + ) + .action(async options => { + await configHelpers.update(makeConfigFile(), options); + }); + + program + .command('deposit') + .description('Offer assets to the liquidity pool') + .action(() => { + console.error('TODO actually send deposit'); + // TODO: Implement deposit logic + }); + + program + .command('withdraw') + .description('Withdraw assets from the liquidity pool') + .action(() => { + console.error('TODO actually send withdrawal'); + // TODO: Implement withdraw logic + }); + + program + .command('transfer') + .description('Transfer USDC from Ethereum/L2 to Cosmos via Fast USDC') + .argument('amount', 'Amount to transfer denominated in uusdc') + .argument('dest', 'Destination address in Cosmos') + .action( + async ( + /** @type {string} */ amount, + /** @type {string} */ destination, + ) => { + await transferHelpers.transfer(makeConfigFile(), amount, destination); + }, + ); + + return program; +}; diff --git a/packages/fast-usdc/src/cli/config.js b/packages/fast-usdc/src/cli/config.js new file mode 100644 index 00000000000..be11dad838d --- /dev/null +++ b/packages/fast-usdc/src/cli/config.js @@ -0,0 +1,101 @@ +import * as readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; + +/** + @typedef {{ + nobleSeed: string, + ethSeed: string, + nobleToAgoricChannel: string, + agoricRpc: string, + nobleApi: string, + nobleRpc: string, + ethRpc: string, + tokenMessengerAddress: string, + tokenAddress: string + }} ConfigOpts + */ + +/** @import { file } from '../util/file' */ + +const init = async ( + /** @type {file} */ configFile, + /** @type {ConfigOpts} */ options, + out = console, + rl = readline.createInterface({ input, output }), +) => { + const showOverrideWarning = async () => { + const answer = await rl.question( + `Config at ${configFile.path} already exists. Override it? (To partially update, use "update", or set "--home" to use a different config path.) y/N: `, + ); + rl.close(); + const confirmed = ['y', 'yes'].includes(answer.toLowerCase()); + if (!confirmed) { + throw new Error('User cancelled'); + } + }; + + const writeConfig = async () => { + await null; + try { + await configFile.write(JSON.stringify(options, null, 2)); + out.log(`Config initialized at ${configFile.path}`); + } catch (error) { + out.error(`An unexpected error has occurred: ${error}`); + throw error; + } + }; + + await null; + if (configFile.exists()) { + await showOverrideWarning(); + } + await writeConfig(); +}; + +const update = async ( + /** @type {file} */ configFile, + /** @type {Partial} */ options, + out = console, +) => { + const updateConfig = async (/** @type {ConfigOpts} */ data) => { + await null; + const stringified = JSON.stringify(data, null, 2); + try { + await configFile.write(stringified); + out.log(`Config updated at ${configFile.path}`); + out.log(stringified); + } catch (error) { + out.error(`An unexpected error has occurred: ${error}`); + throw error; + } + }; + + let file; + await null; + try { + file = await configFile.read(); + } catch { + out.error( + `No config found at ${configFile.path}. Use "init" to create one, or "--home" to specify config location.`, + ); + throw new Error(); + } + await updateConfig({ ...JSON.parse(file), ...options }); +}; + +const show = async (/** @type {file} */ configFile, out = console) => { + let contents; + await null; + try { + contents = await configFile.read(); + } catch { + out.error( + `No config found at ${configFile.path}. Use "init" to create one, or "--home" to specify config location.`, + ); + throw new Error(); + } + out.log(`Config found at ${configFile.path}:`); + out.log(contents); +}; + +export default { init, update, show }; diff --git a/packages/fast-usdc/src/cli/index.js b/packages/fast-usdc/src/cli/index.js new file mode 100755 index 00000000000..61aa8abd6ff --- /dev/null +++ b/packages/fast-usdc/src/cli/index.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import '@endo/init/legacy.js'; +import { initProgram } from './cli.js'; + +const program = initProgram(); +program.parse(); diff --git a/packages/fast-usdc/src/cli/transfer.js b/packages/fast-usdc/src/cli/transfer.js new file mode 100644 index 00000000000..fc6947181a2 --- /dev/null +++ b/packages/fast-usdc/src/cli/transfer.js @@ -0,0 +1,91 @@ +/* global globalThis */ + +import { makeVStorage } from '@agoric/client-utils'; +import { depositForBurn, makeProvider } from '../util/cctp.js'; +import { + makeSigner, + queryForwardingAccount, + registerFwdAccount, +} from '../util/noble.js'; +import { queryFastUSDCLocalChainAccount } from '../util/agoric.js'; + +/** @import { file } from '../util/file' */ +/** @import { VStorage } from '@agoric/client-utils' */ +/** @import { SigningStargateClient } from '@cosmjs/stargate' */ +/** @import { JsonRpcProvider as ethProvider } from 'ethers' */ + +const transfer = async ( + /** @type {file} */ configFile, + /** @type {string} */ amount, + /** @type {string} */ destination, + out = console, + fetch = globalThis.fetch, + /** @type {VStorage | undefined} */ vstorage, + /** @type {{signer: SigningStargateClient, address: string} | undefined} */ nobleSigner, + /** @type {ethProvider | undefined} */ ethProvider, +) => { + const execute = async ( + /** @type {import('./config').ConfigOpts} */ config, + ) => { + vstorage ||= makeVStorage( + { fetch }, + { chainName: 'agoric', rpcAddrs: [config.agoricRpc] }, + ); + const agoricAddr = await queryFastUSDCLocalChainAccount(vstorage, out); + const appendedAddr = `${agoricAddr}?EUD=${destination}`; + out.log(`forwarding destination ${appendedAddr}`); + + const { exists, address } = await queryForwardingAccount( + config.nobleApi, + config.nobleToAgoricChannel, + appendedAddr, + out, + fetch, + ); + + if (!exists) { + nobleSigner ||= await makeSigner(config.nobleSeed, config.nobleRpc, out); + const { address: signerAddress, signer } = nobleSigner; + try { + const res = await registerFwdAccount( + signer, + signerAddress, + config.nobleToAgoricChannel, + appendedAddr, + out, + ); + out.log(res); + } catch (e) { + out.error( + `Error registering noble forwarding account for ${appendedAddr} on channel ${config.nobleToAgoricChannel}`, + ); + throw e; + } + } + + ethProvider ||= makeProvider(config.ethRpc); + await depositForBurn( + ethProvider, + config.ethSeed, + config.tokenMessengerAddress, + config.tokenAddress, + address, + amount, + out, + ); + }; + + let config; + await null; + try { + config = JSON.parse(await configFile.read()); + } catch { + out.error( + `No config found at ${configFile.path}. Use "config init" to create one, or "--home" to specify config location.`, + ); + throw new Error(); + } + await execute(config); +}; + +export default { transfer }; diff --git a/packages/fast-usdc/src/util/agoric.js b/packages/fast-usdc/src/util/agoric.js new file mode 100644 index 00000000000..1439f60ffda --- /dev/null +++ b/packages/fast-usdc/src/util/agoric.js @@ -0,0 +1,12 @@ +/** @import { VStorage } from '@agoric/client-utils' */ + +export const queryFastUSDCLocalChainAccount = async ( + /** @type {VStorage} */ vstorage, + out = console, +) => { + const agoricAddr = await vstorage.readLatest( + 'published.fastUSDC.settlementAccount', + ); + out.log(`Got Fast USDC Local Chain Account ${agoricAddr}`); + return agoricAddr; +}; diff --git a/packages/fast-usdc/src/util/cctp.js b/packages/fast-usdc/src/util/cctp.js new file mode 100644 index 00000000000..08d8d475564 --- /dev/null +++ b/packages/fast-usdc/src/util/cctp.js @@ -0,0 +1,71 @@ +import { Buffer } from 'node:buffer'; +import { bech32 } from 'bech32'; +import { ethers } from 'ethers'; + +/** + * Adapted from https://docs.noble.xyz/cctp/mint#encoding + * + * @param {string} address + * @returns {string} + */ +export const encodeBech32Address = address => { + const decoded = bech32.decode(address); + const rawBytes = Buffer.from(bech32.fromWords(decoded.words)); + + const padded = Buffer.alloc(32); + rawBytes.copy(padded, 32 - rawBytes.length); + + return `0x${padded.toString('hex')}`; +}; + +const tokenAbi = ['function approve(address spender, uint256 value) external']; + +const contractAbi = [ + 'function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken) external', +]; + +export const makeProvider = (/** @type {string} */ rpc) => + new ethers.JsonRpcProvider(rpc); + +const USDC_DECIMALS = 6; +// For CCTP, noble's domain is universally "4" +const NOBLE_DOMAIN = 4; + +export const depositForBurn = async ( + /** @type {ethers.JsonRpcProvider} */ provider, + /** @type {string} */ ethSeed, + /** @type {string} */ tokenMessengerAddress, + /** @type {string} */ tokenAddress, + /** @type {string} */ destination, + /** @type {string} */ amount, + out = console, +) => { + const privateKey = ethSeed; + const wallet = new ethers.Wallet(privateKey, provider); + const contractAddress = tokenMessengerAddress; + const token = new ethers.Contract(tokenAddress, tokenAbi, wallet); + const contract = new ethers.Contract(contractAddress, contractAbi, wallet); + const parsedAmount = ethers.parseUnits(amount, USDC_DECIMALS); + out.log('approving'); + const approveTx = await token.approve(contractAddress, parsedAmount); + out.log('Transaction sent, waiting for confirmation...'); + const approveReceipt = await approveTx.wait(); + out.log('Transaction confirmed in block', approveReceipt.blockNumber); + out.log('Transaction hash:', approveReceipt.hash); + + const mintRecipient = encodeBech32Address(destination); + out.log('depositing for burn', parsedAmount, 4, mintRecipient, tokenAddress); + const tx = await contract.depositForBurn( + parsedAmount, + NOBLE_DOMAIN, + mintRecipient, + tokenAddress, + ); + + out.log('Transaction sent, waiting for confirmation...'); + const receipt = await tx.wait(); + + out.log('Transaction confirmed in block', receipt.blockNumber); + out.log('Transaction hash:', receipt.hash); + out.log('USDC transfer initiated successfully, our work here is done.'); +}; diff --git a/packages/fast-usdc/src/util/file.js b/packages/fast-usdc/src/util/file.js new file mode 100644 index 00000000000..a239988cf03 --- /dev/null +++ b/packages/fast-usdc/src/util/file.js @@ -0,0 +1,30 @@ +import { dirname } from 'path'; + +/** @import { readFile as readAsync } from 'node:fs/promises' */ +/** @import { writeFile as writeAsync } from 'node:fs/promises' */ +/** @import { mkdirSync } from 'node:fs' */ +/** @import { existsSync } from 'node:fs' */ + +export const makeFile = ( + /** @type {string} */ path, + /** @type {readAsync} */ readFile, + /** @type {writeAsync} */ writeFile, + /** @type {mkdirSync} */ mkdir, + /** @type {existsSync} */ pathExists, +) => { + const read = () => readFile(path, 'utf-8'); + + const write = async (/** @type {string} */ data) => { + const dir = dirname(path); + if (!pathExists(dir)) { + mkdir(dir); + } + await writeFile(path, data); + }; + + const exists = () => pathExists(path); + + return { read, write, exists, path }; +}; + +/** @typedef {ReturnType} file */ diff --git a/packages/fast-usdc/src/util/noble.js b/packages/fast-usdc/src/util/noble.js new file mode 100644 index 00000000000..3411c5827d9 --- /dev/null +++ b/packages/fast-usdc/src/util/noble.js @@ -0,0 +1,110 @@ +/* global globalThis */ + +import { DirectSecp256k1HdWallet, Registry } from '@cosmjs/proto-signing'; +import { AminoTypes, SigningStargateClient } from '@cosmjs/stargate'; +import { nobleAminoConverters, nobleProtoRegistry } from '@nick134-bit/noblejs'; + +export const makeSigner = async ( + /** @type {string} */ nobleSeed, + /** @type {string} */ nobleRpc, + out = console, +) => { + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(nobleSeed, { + prefix: 'noble', + }); + out.log('got noble wallet from seed'); + const accounts = await wallet.getAccounts(); + const address = accounts[0].address; + const signer = await SigningStargateClient.connectWithSigner( + nobleRpc, + wallet, + { + aminoTypes: new AminoTypes({ + ...nobleAminoConverters, + }), + registry: new Registry([...nobleProtoRegistry]), + }, + ); + return { address, signer }; +}; + +const createMsgRegisterAccount = ( + /** @type {string} */ signer, + /** @type {string} */ recipient, + /** @type {string} */ channel, +) => { + return { + typeUrl: '/noble.forwarding.v1.MsgRegisterAccount', + value: { + signer, + recipient, + channel, + }, + }; +}; + +export const registerFwdAccount = async ( + /** @type {SigningStargateClient} */ nobleSigner, + /** @type {string} */ nobleAddress, + /** @type {string} */ nobleToAgoricChannel, + /** @type {string} */ recipient, + out = console, +) => { + out.log('registering fwd account on noble'); + const msg = createMsgRegisterAccount( + nobleAddress, + recipient, + nobleToAgoricChannel, + ); + const fee = { + amount: [ + { + denom: 'uusdc', + amount: '20000', + }, + ], + gas: '200000', + }; + out.log('signing message', msg); + const txResult = await nobleSigner.signAndBroadcast( + nobleAddress, + [msg], + fee, + 'Register Account Transaction', + ); + if (txResult.code !== undefined && txResult.code !== 0) { + throw new Error( + `Transaction failed with code ${txResult.code}: ${txResult.events || ''}`, + ); + } + return `Transaction successful with hash: ${txResult.transactionHash}`; +}; + +export const queryForwardingAccount = async ( + /** @type {string} */ nobleApi, + /** @type {string} */ nobleToAgoricChannel, + /** @type {string} */ agoricAddr, + out = console, + fetch = globalThis.fetch, +) => { + /** + * https://github.com/noble-assets/forwarding/blob/9d7657a/proto/noble/forwarding/v1/query.proto + * v2.0.0 10 Nov 2024 + */ + const query = `${nobleApi}/noble/forwarding/v1/address/${nobleToAgoricChannel}/${encodeURIComponent(agoricAddr)}/`; + out.log(`querying forward address details from noble api: ${query}`); + let forwardingAddressRes; + await null; + try { + forwardingAddressRes = await fetch(query).then(res => res.json()); + } catch (e) { + out.error(`Error querying forwarding address from ${query}`); + throw e; + } + /** @type {{ address: string, exists: boolean }} */ + const { address, exists } = forwardingAddressRes; + out.log( + `got forwarding address details: ${JSON.stringify(forwardingAddressRes)}`, + ); + return { address, exists }; +}; diff --git a/packages/fast-usdc/test/cli.test.ts b/packages/fast-usdc/test/cli.test.ts deleted file mode 100644 index f14e70fa161..00000000000 --- a/packages/fast-usdc/test/cli.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import test from 'ava'; -import { spawn } from 'child_process'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const dir = dirname(fileURLToPath(import.meta.url)); -const CLI_PATH = join(dir, '../src/cli.js'); - -test('CLI shows help when run without arguments', async t => { - const output = await new Promise(resolve => { - const child = spawn('node', [CLI_PATH]); - let stderr = ''; - - child.stderr.on('data', data => { - stderr += data.toString(); - }); - - child.on('close', () => { - resolve(stderr); - }); - }); - - t.snapshot(output); -}); diff --git a/packages/fast-usdc/test/cli/cli.test.ts b/packages/fast-usdc/test/cli/cli.test.ts new file mode 100644 index 00000000000..2c98f9207c0 --- /dev/null +++ b/packages/fast-usdc/test/cli/cli.test.ts @@ -0,0 +1,287 @@ +import '@endo/init/legacy.js'; +import test from 'ava'; +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { initProgram } from '../../src/cli/cli.js'; + +const dir = dirname(fileURLToPath(import.meta.url)); +const CLI_PATH = join(dir, '../../src/cli/index.js'); + +const collectStdErr = (cmd: string[]) => + new Promise(resolve => { + const child = spawn('node', cmd); + let stderr = ''; + + child.stderr.on('data', data => { + stderr += data.toString(); + }); + + child.on('close', () => { + resolve(stderr); + }); + }); + +const collectStdOut = (cmd: string[]) => + new Promise(resolve => { + const child = spawn('node', cmd); + let stdout = ''; + + child.stdout.on('data', data => { + stdout += data.toString(); + }); + + child.on('close', () => { + resolve(stdout); + }); + }); + +const mockConfig = () => { + let initArgs: any[]; + let updateArgs: any[]; + let showArgs: any[]; + return { + init: async (...args: any[]) => { + initArgs = args; + }, + update: async (...args: any[]) => { + updateArgs = args; + }, + show: async (...args: any[]) => { + showArgs = args; + }, + getInitArgs: () => initArgs, + getUpdateArgs: () => updateArgs, + getShowArgs: () => showArgs, + }; +}; + +const mockTransfer = () => { + let transferArgs: any[]; + return { + transfer: async (...args: any[]) => { + transferArgs = args; + }, + getTransferArgs: () => transferArgs, + }; +}; + +test('shows help when run without arguments', async t => { + const output = await collectStdErr([CLI_PATH]); + // Replace home path (e.g. "/home/samsiegart/.fast-usdc") with "~/.fast-usdc" so snapshots work on different machines. + const regex = /"\/home\/[^/]+\/\.fast-usdc\/"/g; + const result = (output as string).replace(regex, '"~/.fast-usdc"'); + + t.snapshot(result); +}); + +test('shows help for transfer command', async t => { + const output = await collectStdOut([CLI_PATH, 'transfer', '-h']); + + t.snapshot(output); +}); + +test('shows help for config command', async t => { + const output = await collectStdOut([CLI_PATH, 'config', '-h']); + + t.snapshot(output); +}); + +test('shows help for config init command', async t => { + const output = await collectStdOut([CLI_PATH, 'config', 'init', '-h']); + + t.snapshot(output); +}); + +test('shows help for config update command', async t => { + const output = await collectStdOut([CLI_PATH, 'config', 'update', '-h']); + + t.snapshot(output); +}); + +test('shows help for config show command', async t => { + const output = await collectStdOut([CLI_PATH, 'config', 'show', '-h']); + + t.snapshot(output); +}); + +test('shows error when config init command is run without options', async t => { + const output = await collectStdErr([CLI_PATH, 'config', 'init']); + + t.snapshot(output); +}); + +test('shows error when config init command is run without eth seed', async t => { + const output = await collectStdErr([ + CLI_PATH, + 'config', + 'init', + '--noble-seed', + 'foo', + ]); + + t.snapshot(output); +}); + +test('calls config init with default args', t => { + const homeDir = './test/.fast-usdc/'; + const config = mockConfig(); + const program = initProgram(config, mockTransfer()); + + program.parse([ + 'node', + CLI_PATH, + '--home', + homeDir, + 'config', + 'init', + '--noble-seed', + 'foo', + '--eth-seed', + 'bar', + ]); + + const args = config.getInitArgs(); + t.is(args.shift().path, `${homeDir}config.json`); + t.deepEqual(args, [ + { + agoricRpc: 'http://127.0.0.1:1317', + ethRpc: 'http://127.0.0.1:8545', + ethSeed: 'bar', + nobleRpc: 'http://127.0.0.1:26657', + nobleSeed: 'foo', + nobleApi: 'http://127.0.0.1:1318', + nobleToAgoricChannel: 'channel-21', + tokenMessengerAddress: '0xbd3fa81b58ba92a82136038b25adec7066af3155', + tokenContractAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }, + ]); +}); + +test('calls config init with optional args', t => { + const homeDir = './test/.fast-usdc/'; + const config = mockConfig(); + const program = initProgram(config, mockTransfer()); + + program.parse([ + 'node', + CLI_PATH, + '--home', + homeDir, + 'config', + 'init', + '--noble-seed', + 'foo', + '--eth-seed', + 'bar', + '--agoric-rpc', + '127.0.0.1:1111', + '--eth-rpc', + '127.0.0.1:2222', + '--noble-rpc', + '127.0.0.1:3333', + '--noble-api', + '127.0.0.1:4444', + '--noble-to-agoric-channel', + 'channel-101', + '--token-messenger-address', + '0xmessenger123', + '--token-contract-address', + '0xtoken123', + ]); + + const args = config.getInitArgs(); + t.is(args.shift().path, `${homeDir}config.json`); + t.deepEqual(args, [ + { + agoricRpc: '127.0.0.1:1111', + ethRpc: '127.0.0.1:2222', + ethSeed: 'bar', + nobleRpc: '127.0.0.1:3333', + nobleSeed: 'foo', + nobleApi: '127.0.0.1:4444', + nobleToAgoricChannel: 'channel-101', + tokenMessengerAddress: '0xmessenger123', + tokenContractAddress: '0xtoken123', + }, + ]); +}); + +test('calls config update with args', t => { + const homeDir = './test/.fast-usdc/'; + const config = mockConfig(); + const program = initProgram(config, mockTransfer()); + + program.parse([ + 'node', + CLI_PATH, + '--home', + homeDir, + 'config', + 'update', + '--noble-seed', + 'foo', + '--eth-seed', + 'bar', + '--agoric-rpc', + '127.0.0.1:1111', + '--eth-rpc', + '127.0.0.1:2222', + '--noble-rpc', + '127.0.0.1:3333', + '--noble-api', + '127.0.0.1:4444', + '--noble-to-agoric-channel', + 'channel-101', + '--token-messenger-address', + '0xmessenger123', + '--token-contract-address', + '0xtoken123', + ]); + + const args = config.getUpdateArgs(); + t.is(args.shift().path, `${homeDir}config.json`); + t.deepEqual(args, [ + { + agoricRpc: '127.0.0.1:1111', + ethRpc: '127.0.0.1:2222', + ethSeed: 'bar', + nobleRpc: '127.0.0.1:3333', + nobleSeed: 'foo', + nobleApi: '127.0.0.1:4444', + nobleToAgoricChannel: 'channel-101', + tokenMessengerAddress: '0xmessenger123', + tokenContractAddress: '0xtoken123', + }, + ]); +}); + +test('calls config show', t => { + const homeDir = './test/.fast-usdc/'; + const config = mockConfig(); + const program = initProgram(config, mockTransfer()); + + program.parse(['node', CLI_PATH, '--home', homeDir, 'config', 'show']); + + t.is(config.getShowArgs()[0].path, './test/.fast-usdc/config.json'); +}); + +test('calls transfer with args', t => { + const homeDir = './test/.fast-usdc/'; + const transfer = mockTransfer(); + const program = initProgram(mockConfig(), transfer); + + program.parse([ + 'node', + CLI_PATH, + '--home', + homeDir, + 'transfer', + '450000', + 'dydx123456', + ]); + + const args = transfer.getTransferArgs(); + t.is(args.shift().path, `${homeDir}config.json`); + t.deepEqual(args, ['450000', 'dydx123456']); +}); diff --git a/packages/fast-usdc/test/cli/config.test.ts b/packages/fast-usdc/test/cli/config.test.ts new file mode 100644 index 00000000000..634a9636c9e --- /dev/null +++ b/packages/fast-usdc/test/cli/config.test.ts @@ -0,0 +1,117 @@ +import '@endo/init/legacy.js'; +import test from 'ava'; +import config from '../../src/cli/config.js'; +import { mockOut, mockrl, mockFile } from '../../testing/mocks.js'; + +test('show reads the config file', async t => { + const path = 'config/dir/.fast-usdc/config.json'; + const val = JSON.stringify({ hello: 'world!' }, null, 2); + const file = mockFile(path, val); + const out = mockOut(); + + // @ts-expect-error mocking partial Console + await config.show(file, out); + + t.is(out.getErrOut(), ''); + t.is(out.getLogOut(), `Config found at ${path}:\n${val}\n`); +}); + +test('show shows error if no config file', async t => { + const path = 'missing-config/dir/.fast-usdc/config.json'; + const out = mockOut(); + const file = mockFile(path); + + // @ts-expect-error mocking partial Console + await t.throwsAsync(config.show(file, out)); + + t.is( + out.getErrOut(), + `No config found at ${path}. Use "init" to create one, or "--home" to specify config location.\n`, + ); + t.is(out.getLogOut(), ''); +}); + +test('init creates the config file', async t => { + const dir = 'config/dir/.fast-usdc'; + const path = `${dir}/config.json`; + const file = mockFile(path); + const out = mockOut(); + const options = { foo: 'bar' }; + + // @ts-expect-error mock partial Console + await config.init(file, options, out, mockrl()); + + t.is(out.getLogOut(), `Config initialized at ${path}\n`); + t.is(out.getErrOut(), ''); + t.is(await file.read(), JSON.stringify(options, null, 2)); +}); + +test('init overwrites if config exists and user says yes', async t => { + const dir = 'config/dir/.fast-usdc'; + const path = `${dir}/config.json`; + const oldVal = JSON.stringify({ hello: 'world!' }, null, 2); + const file = mockFile(path, oldVal); + const out = mockOut(); + // Answer yes to prompt + const rl = mockrl('y'); + const newVal = { hello: 'universe!' }; + + // @ts-expect-error mock partial Console + await config.init(file, newVal, out, rl); + + t.is(out.getErrOut(), ''); + t.is(out.getLogOut(), `Config initialized at ${path}\n`); + t.is(await file.read(), JSON.stringify(newVal, null, 2)); +}); + +test('init does not overwrite if config exists and user says no', async t => { + const dir = 'config/dir/.fast-usdc'; + const path = `${dir}/config.json`; + const oldVal = JSON.stringify({ hello: 'world!' }, null, 2); + const file = mockFile(path, oldVal); + const out = mockOut(); + // Answer no to prompt + const rl = mockrl('n'); + const newVal = { hello: 'universe!' }; + + // @ts-expect-error mock partial Console + await t.throwsAsync(config.init(file, newVal, out, rl)); + + t.is(out.getErrOut(), ''); + t.is(out.getLogOut(), ''); + t.is(await file.read(), oldVal); +}); + +test('update errors if config does not exist', async t => { + const path = 'config/dir/.fast-usdc/config.json'; + // Path doesn't exist + const file = mockFile(path); + const out = mockOut(); + const newVal = { hello: 'universe!' }; + + // @ts-expect-error mock partial Console + await t.throwsAsync(config.update(file, newVal, out)); + + t.is( + out.getErrOut(), + `No config found at ${path}. Use "init" to create one, or "--home" to specify config location.\n`, + ); + t.is(out.getLogOut(), ''); + t.false(file.exists()); +}); + +test('update can update the config partially', async t => { + const path = 'config/dir/.fast-usdc/config.json'; + const oldVal = JSON.stringify({ hello: 'world!' }, null, 2); + const file = mockFile(path, oldVal); + const out = mockOut(); + const newVal = { hello: 'universe!', goodbye: 'world!' }; + const newValString = JSON.stringify(newVal, null, 2); + + // @ts-expect-error mock partial Console + await config.update(file, newVal, out); + + t.is(out.getErrOut(), ''); + t.is(out.getLogOut(), `Config updated at ${path}\n${newValString}\n`); + t.is(await file.read(), newValString); +}); diff --git a/packages/fast-usdc/test/cli/snapshots/cli.test.ts.md b/packages/fast-usdc/test/cli/snapshots/cli.test.ts.md new file mode 100644 index 00000000000..a96f75d613c --- /dev/null +++ b/packages/fast-usdc/test/cli/snapshots/cli.test.ts.md @@ -0,0 +1,144 @@ +# Snapshot report for `test/cli/cli.test.ts` + +The actual snapshot is saved in `cli.test.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## shows help when run without arguments + +> Snapshot 1 + + `Usage: fast-usdc [options] [command]␊ + ␊ + CLI to interact with Fast USDC liquidity pool␊ + ␊ + Options:␊ + -V, --version output the version number␊ + --home Home directory to use for config (default:␊ + "~/.fast-usdc")␊ + -h, --help display help for command␊ + ␊ + Commands:␊ + config Manage config␊ + deposit Offer assets to the liquidity pool␊ + withdraw Withdraw assets from the liquidity pool␊ + transfer Transfer USDC from Ethereum/L2 to Cosmos via Fast␊ + USDC␊ + help [command] display help for command␊ + ` + +## shows help for transfer command + +> Snapshot 1 + + `Usage: fast-usdc transfer [options] ␊ + ␊ + Transfer USDC from Ethereum/L2 to Cosmos via Fast USDC␊ + ␊ + Arguments:␊ + amount Amount to transfer denominated in uusdc␊ + dest Destination address in Cosmos␊ + ␊ + Options:␊ + -h, --help display help for command␊ + ` + +## shows help for config command + +> Snapshot 1 + + `Usage: fast-usdc config [options] [command]␊ + ␊ + Manage config␊ + ␊ + Options:␊ + -h, --help display help for command␊ + ␊ + Commands:␊ + show Show current config␊ + init [options] Set initial config values␊ + update [options] Update config values␊ + help [command] display help for command␊ + ` + +## shows help for config init command + +> Snapshot 1 + + `Usage: fast-usdc config init [options]␊ + ␊ + Set initial config values␊ + ␊ + Options:␊ + --noble-seed Seed phrase for Noble account. CAUTION:␊ + Stored unencrypted in file system␊ + --eth-seed Seed phrase for Ethereum account.␊ + CAUTION: Stored unencrypted in file␊ + system␊ + --agoric-rpc [url] Agoric RPC endpoint (default:␊ + "http://127.0.0.1:1317")␊ + --noble-api [url] Noble API endpoint (default:␊ + "http://127.0.0.1:1318")␊ + --noble-to-agoric-channel [channel] Channel ID on Noble for Agoric (default:␊ + "channel-21")␊ + --noble-rpc [url] Noble RPC endpoint (default:␊ + "http://127.0.0.1:26657")␊ + --eth-rpc [url] Ethereum RPC Endpoint (default:␊ + "http://127.0.0.1:8545")␊ + --token-messenger-address [address] Address of TokenMessenger contract␊ + (default:␊ + "0xbd3fa81b58ba92a82136038b25adec7066af3155")␊ + --token-contract-address [address] Address of USDC token contract (default:␊ + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")␊ + -h, --help display help for command␊ + ` + +## shows help for config update command + +> Snapshot 1 + + `Usage: fast-usdc config update [options]␊ + ␊ + Update config values␊ + ␊ + Options:␊ + --noble-seed [string] Seed phrase for Noble account. CAUTION:␊ + Stored unencrypted in file system␊ + --eth-seed [string] Seed phrase for Ethereum account.␊ + CAUTION: Stored unencrypted in file␊ + system␊ + --agoric-rpc [url] Agoric RPC endpoint␊ + --noble-rpc [url] Noble RPC endpoint␊ + --eth-rpc [url] Ethereum RPC Endpoint␊ + --noble-api [url] Noble API endpoint␊ + --noble-to-agoric-channel [channel] Channel ID on Noble for Agoric␊ + --token-messenger-address [address] Address of TokenMessenger contract␊ + --token-contract-address [address] Address of USDC token contract␊ + -h, --help display help for command␊ + ` + +## shows help for config show command + +> Snapshot 1 + + `Usage: fast-usdc config show [options]␊ + ␊ + Show current config␊ + ␊ + Options:␊ + -h, --help display help for command␊ + ` + +## shows error when config init command is run without options + +> Snapshot 1 + + `error: required option '--noble-seed ' not specified␊ + ` + +## shows error when config init command is run without eth seed + +> Snapshot 1 + + `error: required option '--eth-seed ' not specified␊ + ` diff --git a/packages/fast-usdc/test/cli/snapshots/cli.test.ts.snap b/packages/fast-usdc/test/cli/snapshots/cli.test.ts.snap new file mode 100644 index 00000000000..c2e3eb0d1bd Binary files /dev/null and b/packages/fast-usdc/test/cli/snapshots/cli.test.ts.snap differ diff --git a/packages/fast-usdc/test/cli/snapshots/transfer.test.ts.md b/packages/fast-usdc/test/cli/snapshots/transfer.test.ts.md new file mode 100644 index 00000000000..d88e5f3f969 --- /dev/null +++ b/packages/fast-usdc/test/cli/snapshots/transfer.test.ts.md @@ -0,0 +1,33 @@ +# Snapshot report for `test/cli/transfer.test.ts` + +The actual snapshot is saved in `transfer.test.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## Transfer registers the noble forwarding account if it does not exist + +> Snapshot 1 + + [ + 'noble09876', + [ + { + typeUrl: '/noble.forwarding.v1.MsgRegisterAccount', + value: { + channel: 'channel-test-7', + recipient: 'agoric123456?EUD=dydx1234', + signer: 'noble09876', + }, + }, + ], + { + amount: [ + { + amount: '20000', + denom: 'uusdc', + }, + ], + gas: '200000', + }, + 'Register Account Transaction', + ] diff --git a/packages/fast-usdc/test/cli/snapshots/transfer.test.ts.snap b/packages/fast-usdc/test/cli/snapshots/transfer.test.ts.snap new file mode 100644 index 00000000000..4bce5856ec4 Binary files /dev/null and b/packages/fast-usdc/test/cli/snapshots/transfer.test.ts.snap differ diff --git a/packages/fast-usdc/test/cli/transfer.test.ts b/packages/fast-usdc/test/cli/transfer.test.ts new file mode 100644 index 00000000000..6c5582d391d --- /dev/null +++ b/packages/fast-usdc/test/cli/transfer.test.ts @@ -0,0 +1,156 @@ +import '@endo/init/legacy.js'; +import test from 'ava'; +import transfer from '../../src/cli/transfer.js'; +import { + mockOut, + mockFile, + makeVstorageMock, + makeFetchMock, + makeMockSigner, +} from '../../testing/mocks.js'; + +test('Errors if config missing', async t => { + const path = 'config/dir/.fast-usdc/config.json'; + const out = mockOut(); + const file = mockFile(path); + + // @ts-expect-error mocking partial Console + await t.throwsAsync(transfer.transfer(file, '1500000', 'noble1234', out)); + + t.is( + out.getErrOut(), + `No config found at ${path}. Use "config init" to create one, or "--home" to specify config location.\n`, + ); + t.is(out.getLogOut(), ''); +}); + +const makeMockEthProvider = () => { + const txnArgs: any[] = []; + const provider = { + getTransactionCount: () => {}, + estimateGas: () => {}, + getNetwork: () => ({ chainId: 123 }), + getFeeData: () => ({ gasPrice: 1 }), + broadcastTransaction: (...args) => { + txnArgs.push(args); + return { + blockNumber: 9000, + }; + }, + getTransactionReceipt: () => ({ + blockNumber: 9000, + hash: 'SUCCESSHASH', + confirmations: () => [9000], + logs: [], + }), + }; + + return { provider, getTxnArgs: () => harden([...txnArgs]) }; +}; + +test('Transfer registers the noble forwarding account if it does not exist', async t => { + const path = 'config/dir/.fast-usdc/config.json'; + const nobleApi = 'http://api.noble.test'; + const nobleToAgoricChannel = 'channel-test-7'; + const config = { + agoricRpc: 'http://rpc.agoric.test', + nobleApi, + nobleToAgoricChannel, + nobleSeed: 'test noble seed', + ethRpc: 'http://rpc.eth.test', + ethSeed: 'a4b7f431465df5dc1458cd8a9be10c42da8e3729e3ce53f18814f48ae2a98a08', + tokenMessengerAddress: '0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5', + tokenAddress: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', + }; + const out = mockOut(); + const file = mockFile(path, JSON.stringify(config)); + const agoricSettlementAccount = 'agoric123456'; + const settlementAccountVstoragePath = 'published.fastUSDC.settlementAccount'; + const vstorageMock = makeVstorageMock({ + [settlementAccountVstoragePath]: agoricSettlementAccount, + }); + const amount = '150'; + const destination = 'dydx1234'; + const nobleFwdAccountQuery = `${nobleApi}/noble/forwarding/v1/address/${nobleToAgoricChannel}/${agoricSettlementAccount}${encodeURIComponent('?EUD=')}${destination}/`; + const fetchMock = makeFetchMock({ + [nobleFwdAccountQuery]: { + address: 'noble14lwerrcfzkzrv626w49pkzgna4dtga8c5x479h', + exists: false, + }, + }); + const nobleSignerAddress = 'noble09876'; + const signerMock = makeMockSigner(); + const mockEthProvider = makeMockEthProvider(); + + await transfer.transfer( + file, + amount, + destination, + // @ts-expect-error mocking console + out, + fetchMock.fetch, + vstorageMock.vstorage, + { signer: signerMock.signer, address: nobleSignerAddress }, + mockEthProvider.provider, + ); + + t.is(vstorageMock.getQueryCounts()[settlementAccountVstoragePath], 1); + t.is(fetchMock.getQueryCounts()[nobleFwdAccountQuery], 1); + t.snapshot(signerMock.getSigned()); +}); + +test('Transfer signs and broadcasts the depositForBurn message on Ethereum', async t => { + const path = 'config/dir/.fast-usdc/config.json'; + const nobleApi = 'http://api.noble.test'; + const nobleToAgoricChannel = 'channel-test-7'; + const config = { + agoricRpc: 'http://rpc.agoric.test', + nobleApi, + nobleToAgoricChannel, + nobleSeed: 'test noble seed', + ethRpc: 'http://rpc.eth.test', + ethSeed: 'a4b7f431465df5dc1458cd8a9be10c42da8e3729e3ce53f18814f48ae2a98a08', + tokenMessengerAddress: '0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5', + tokenAddress: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', + }; + const out = mockOut(); + const file = mockFile(path, JSON.stringify(config)); + const agoricSettlementAccount = 'agoric123456'; + const settlementAccountVstoragePath = 'published.fastUSDC.settlementAccount'; + const vstorageMock = makeVstorageMock({ + [settlementAccountVstoragePath]: agoricSettlementAccount, + }); + const amount = '150'; + const destination = 'dydx1234'; + const nobleFwdAccountQuery = `${nobleApi}/noble/forwarding/v1/address/${nobleToAgoricChannel}/${agoricSettlementAccount}${encodeURIComponent('?EUD=')}${destination}/`; + const fetchMock = makeFetchMock({ + [nobleFwdAccountQuery]: { + address: 'noble14lwerrcfzkzrv626w49pkzgna4dtga8c5x479h', + exists: true, + }, + }); + const nobleSignerAddress = 'noble09876'; + const signerMock = makeMockSigner(); + const mockEthProvider = makeMockEthProvider(); + + await transfer.transfer( + file, + amount, + destination, + // @ts-expect-error mocking console + out, + fetchMock.fetch, + vstorageMock.vstorage, + { signer: signerMock.signer, address: nobleSignerAddress }, + mockEthProvider.provider, + ); + + t.is(signerMock.getSigned(), undefined); + t.deepEqual(mockEthProvider.getTxnArgs()[0], [ + '0xf8a4800180941c7d4b196cb0c7b01d743fbc6116a902379c723880b844095ea7b30000000000000000000000009f3b8679c73c2fef8b59b4f3444d4e156fb70aa50000000000000000000000000000000000000000000000000000000008f0d18082011aa0b2d87eeb1cb36243f95662739e2a7bd4bddc2b8afe189ac4848ec71cc314335ba068136695c644f69474e2e30ea7059f9b380fbb1a09beb3580f73d3ea349912ab', + ]); + // Can be decoded using a tool like https://rawtxdecode.in/ and an ABI https://github.com/circlefin/evm-cctp-contracts/blob/e4e6e2fccd6820002eb4a5b4fabdc8ea11031ad9/docs/abis/cctp/TokenMessenger.json + t.deepEqual(mockEthProvider.getTxnArgs()[1], [ + '0xf8e4800180949f3b8679c73c2fef8b59b4f3444d4e156fb70aa580b8846fd3504e0000000000000000000000000000000000000000000000000000000008f0d1800000000000000000000000000000000000000000000000000000000000000004000000000000000000000000afdd918f09158436695a754a1b0913ed5ab474f80000000000000000000000001c7d4b196cb0c7b01d743fbc6116a902379c723882011aa09fc97790b2ba23fbb974554dbcee00df1a1f50e9fec4fdf370454773604aa477a038a1d86afc2a7afdc78088878a912f1a7c678b10c3120d308f8260a277b135a3', + ]); +}); diff --git a/packages/fast-usdc/test/snapshots/cli.test.ts.md b/packages/fast-usdc/test/snapshots/cli.test.ts.md deleted file mode 100644 index f99a1fe5746..00000000000 --- a/packages/fast-usdc/test/snapshots/cli.test.ts.md +++ /dev/null @@ -1,23 +0,0 @@ -# Snapshot report for `test/cli.test.ts` - -The actual snapshot is saved in `cli.test.ts.snap`. - -Generated by [AVA](https://avajs.dev). - -## CLI shows help when run without arguments - -> Snapshot 1 - - `Usage: fast-usdc [options] [command]␊ - ␊ - CLI to interact with Fast USDC liquidity pool␊ - ␊ - Options:␊ - -V, --version output the version number␊ - -h, --help display help for command␊ - ␊ - Commands:␊ - deposit Offer assets to the liquidity pool␊ - withdraw Withdraw assets from the liquidity pool␊ - help [command] display help for command␊ - ` diff --git a/packages/fast-usdc/test/snapshots/cli.test.ts.snap b/packages/fast-usdc/test/snapshots/cli.test.ts.snap deleted file mode 100644 index 7fb4649fe87..00000000000 Binary files a/packages/fast-usdc/test/snapshots/cli.test.ts.snap and /dev/null differ diff --git a/packages/fast-usdc/test/util/file.test.ts b/packages/fast-usdc/test/util/file.test.ts new file mode 100644 index 00000000000..c95f3790da0 --- /dev/null +++ b/packages/fast-usdc/test/util/file.test.ts @@ -0,0 +1,148 @@ +import test from 'ava'; +import { makeFile } from '../../src/util/file.js'; + +const makeReadMock = (content: string) => { + let filePathRead: string; + let encodingUsed: string; + const readFile = (filePath: string, encoding: string) => { + filePathRead = filePath; + encodingUsed = encoding; + return content; + }; + return { + getFilePathRead: () => filePathRead, + getEncodingUsed: () => encodingUsed, + readFile, + }; +}; + +const makeExistsMock = (exists: boolean) => { + let filePathRead: string; + const pathExists = (filePath: string) => { + filePathRead = filePath; + return exists; + }; + return { + getFilePathRead: () => filePathRead, + pathExists, + }; +}; + +const makeWriteMock = () => { + let filePathWritten: string; + let contentsWritten: string; + const writeFile = (filePath: string, contents: string) => { + filePathWritten = filePath; + contentsWritten = contents; + }; + return { + getFilePathWritten: () => filePathWritten, + getContentsWritten: () => contentsWritten, + writeFile, + }; +}; + +const makeMkdirMock = () => { + let dirPathMade: string; + const mkdir = (dirPath: string) => { + dirPathMade = dirPath; + }; + return { + getDirPathMade: () => dirPathMade, + mkdir, + }; +}; + +test('returns the path', t => { + const path = 'config/dir/.fast-usdc/config.json'; + const file = makeFile(path); + + t.is(file.path, path); +}); + +test('reads the file contents', async t => { + const path = 'config/dir/.fast-usdc/config.json'; + const content = 'foo'; + const readMock = makeReadMock(content); + // @ts-expect-error mocking readFile + const file = makeFile(path, readMock.readFile); + + const result = await file.read(); + t.is(result, content); + t.is(readMock.getEncodingUsed(), 'utf-8'); + t.is(readMock.getFilePathRead(), path); +}); + +test('can tell whether the file exists', t => { + const path = 'config/dir/.fast-usdc/config.json'; + const mock1 = makeExistsMock(false); + const mock2 = makeExistsMock(true); + const file1 = makeFile( + path, + // @ts-expect-error mocking + undefined, + undefined, + undefined, + mock1.pathExists, + ); + const file2 = makeFile( + path, + // @ts-expect-error mocking + undefined, + undefined, + undefined, + mock2.pathExists, + ); + + const res1 = file1.exists(); + const res2 = file2.exists(); + + t.is(res1, false); + t.is(mock1.getFilePathRead(), path); + t.is(res2, true); + t.is(mock2.getFilePathRead(), path); +}); + +test('writes the file if the directory exists', async t => { + const dir = 'config/dir/.fast-usdc'; + const path = `${dir}/config.json`; + const mockExists = makeExistsMock(true); + const mockWrite = makeWriteMock(); + const file = makeFile( + path, + // @ts-expect-error mocking + undefined, + mockWrite.writeFile, + undefined, + mockExists.pathExists, + ); + + await file.write('foo'); + + t.is(mockExists.getFilePathRead(), dir); + t.is(mockWrite.getContentsWritten(), 'foo'); + t.is(mockWrite.getFilePathWritten(), path); +}); + +test('creates the directory if it does not exist', async t => { + const dir = 'config/dir/.fast-usdc'; + const path = `${dir}/config.json`; + const mockExists = makeExistsMock(false); + const mockWrite = makeWriteMock(); + const mockMkdir = makeMkdirMock(); + const file = makeFile( + path, + // @ts-expect-error mocking + undefined, + mockWrite.writeFile, + mockMkdir.mkdir, + mockExists.pathExists, + ); + + await file.write('foo'); + + t.is(mockExists.getFilePathRead(), dir); + t.is(mockMkdir.getDirPathMade(), dir); + t.is(mockWrite.getContentsWritten(), 'foo'); + t.is(mockWrite.getFilePathWritten(), path); +}); diff --git a/packages/fast-usdc/testing/mocks.ts b/packages/fast-usdc/testing/mocks.ts new file mode 100644 index 00000000000..92f6c885d45 --- /dev/null +++ b/packages/fast-usdc/testing/mocks.ts @@ -0,0 +1,66 @@ +export const mockOut = () => { + let logOut = ''; + let errOut = ''; + return { + log: (s: string) => (logOut += `${s}\n`), + error: (s: string) => (errOut += `${s}\n`), + getLogOut: () => logOut, + getErrOut: () => errOut, + }; +}; + +export const mockrl = (answer: string) => { + return { + question: () => Promise.resolve(answer), + close: () => {}, + }; +}; + +export const mockFile = (path: string, contents = '') => { + const read = async () => { + if (!contents) { + throw new Error(); + } + return contents; + }; + const write = async (val: string) => { + contents = val; + }; + const exists = () => !!contents; + + return { read, write, exists, path }; +}; + +export const makeVstorageMock = (records: { [key: string]: any }) => { + const queryCounts = {}; + const vstorage = { + readLatest: async (path: string) => { + queryCounts[path] = (queryCounts[path] ?? 0) + 1; + return records[path]; + }, + }; + + return { vstorage, getQueryCounts: () => queryCounts }; +}; + +export const makeFetchMock = (records: { [key: string]: any }) => { + const queryCounts = {}; + const fetch = async (path: string) => { + queryCounts[path] = (queryCounts[path] ?? 0) + 1; + return { json: async () => records[path] }; + }; + + return { fetch, getQueryCounts: () => queryCounts }; +}; + +export const makeMockSigner = () => { + let signedArgs; + const signer = { + signAndBroadcast: async (...args) => { + signedArgs = harden(args); + return { code: 0, transactionHash: 'SUCCESSHASH' }; + }, + }; + + return { getSigned: () => signedArgs, signer }; +}; diff --git a/packages/fast-usdc/tsconfig.json b/packages/fast-usdc/tsconfig.json index f690da86a72..de2e8c94eb8 100644 --- a/packages/fast-usdc/tsconfig.json +++ b/packages/fast-usdc/tsconfig.json @@ -5,5 +5,6 @@ "scripts", "src", "test", + "testing" ], } diff --git a/yarn.lock b/yarn.lock index 9212b0e52af..ed1353fa106 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,6 +7,11 @@ resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== +"@adraffy/ens-normalize@1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz#63430d04bd8c5e74f8d7d049338f1cd9d4f02069" + integrity sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw== + "@agoric/babel-generator@^7.17.6": version "7.17.6" resolved "https://registry.yarnpkg.com/@agoric/babel-generator/-/babel-generator-7.17.6.tgz#75ff4629468a481d670b4154bcfade11af6de674" @@ -1160,6 +1165,16 @@ triple-beam "1.3.0" winston "3.3.3" +"@cosmjs/amino@0.32.3": + version "0.32.3" + resolved "https://registry.yarnpkg.com/@cosmjs/amino/-/amino-0.32.3.tgz#b81d4a2b8d61568431a1afcd871e1344a19d97ff" + integrity sha512-G4zXl+dJbqrz1sSJ56H/25l5NJEk/pAPIr8piAHgbXYw88OdAOlpA26PQvk2IbSN/rRgVbvlLTNgX2tzz1dyUA== + dependencies: + "@cosmjs/crypto" "^0.32.3" + "@cosmjs/encoding" "^0.32.3" + "@cosmjs/math" "^0.32.3" + "@cosmjs/utils" "^0.32.3" + "@cosmjs/amino@^0.32.2", "@cosmjs/amino@^0.32.3", "@cosmjs/amino@^0.32.4": version "0.32.4" resolved "https://registry.yarnpkg.com/@cosmjs/amino/-/amino-0.32.4.tgz#3908946c0394e6d431694c8992c5147079a1c860" @@ -1199,6 +1214,15 @@ elliptic "^6.5.4" libsodium-wrappers-sumo "^0.7.11" +"@cosmjs/encoding@0.32.3": + version "0.32.3" + resolved "https://registry.yarnpkg.com/@cosmjs/encoding/-/encoding-0.32.3.tgz#e245ff511fe4a0df7ba427b5187aab69e3468e5b" + integrity sha512-p4KF7hhv8jBQX3MkB3Defuhz/W0l3PwWVYU2vkVuBJ13bJcXyhU9nJjiMkaIv+XP+W2QgRceqNNgFUC5chNR7w== + dependencies: + base64-js "^1.3.0" + bech32 "^1.1.4" + readonly-date "^1.0.0" + "@cosmjs/encoding@^0.32.1", "@cosmjs/encoding@^0.32.2", "@cosmjs/encoding@^0.32.3", "@cosmjs/encoding@^0.32.4": version "0.32.4" resolved "https://registry.yarnpkg.com/@cosmjs/encoding/-/encoding-0.32.4.tgz#646e0e809f7f4f1414d8fa991fb0ffe6c633aede" @@ -1223,6 +1247,13 @@ "@cosmjs/stream" "^0.32.4" xstream "^11.14.0" +"@cosmjs/math@0.32.3": + version "0.32.3" + resolved "https://registry.yarnpkg.com/@cosmjs/math/-/math-0.32.3.tgz#16e4256f4da507b9352327da12ae64056a2ba6c9" + integrity sha512-amumUtZs8hCCnV+lSBaJIiZkGabQm22QGg/IotYrhcmoOEOjt82n7hMNlNXRs7V6WLMidGrGYcswB5zcmp0Meg== + dependencies: + bn.js "^5.2.0" + "@cosmjs/math@^0.32.1", "@cosmjs/math@^0.32.2", "@cosmjs/math@^0.32.3", "@cosmjs/math@^0.32.4": version "0.32.4" resolved "https://registry.yarnpkg.com/@cosmjs/math/-/math-0.32.4.tgz#87ac9eadc06696e30a30bdb562a495974bfd0a1a" @@ -1230,6 +1261,18 @@ dependencies: bn.js "^5.2.0" +"@cosmjs/proto-signing@0.32.3": + version "0.32.3" + resolved "https://registry.yarnpkg.com/@cosmjs/proto-signing/-/proto-signing-0.32.3.tgz#91ae149b747d18666a6ccc924165b306431f7c0d" + integrity sha512-kSZ0ZUY0DwcRT0NcIn2HkadH4NKlwjfZgbLj1ABwh/4l0RgeT84QCscZCu63tJYq3K6auwqTiZSZERwlO4/nbg== + dependencies: + "@cosmjs/amino" "^0.32.3" + "@cosmjs/crypto" "^0.32.3" + "@cosmjs/encoding" "^0.32.3" + "@cosmjs/math" "^0.32.3" + "@cosmjs/utils" "^0.32.3" + cosmjs-types "^0.9.0" + "@cosmjs/proto-signing@^0.32.1", "@cosmjs/proto-signing@^0.32.2", "@cosmjs/proto-signing@^0.32.3", "@cosmjs/proto-signing@^0.32.4": version "0.32.4" resolved "https://registry.yarnpkg.com/@cosmjs/proto-signing/-/proto-signing-0.32.4.tgz#5a06e087c6d677439c8c9b25b5223d5e72c4cd93" @@ -1252,7 +1295,23 @@ ws "^7" xstream "^11.14.0" -"@cosmjs/stargate@^0.32.1", "@cosmjs/stargate@^0.32.2", "@cosmjs/stargate@^0.32.3": +"@cosmjs/stargate@0.32.3": + version "0.32.3" + resolved "https://registry.yarnpkg.com/@cosmjs/stargate/-/stargate-0.32.3.tgz#5a92b222ada960ebecea72cc9f366370763f4b66" + integrity sha512-OQWzO9YWKerUinPIxrO1MARbe84XkeXJAW0lyMIjXIEikajuXZ+PwftiKA5yA+8OyditVmHVLtPud6Pjna2s5w== + dependencies: + "@confio/ics23" "^0.6.8" + "@cosmjs/amino" "^0.32.3" + "@cosmjs/encoding" "^0.32.3" + "@cosmjs/math" "^0.32.3" + "@cosmjs/proto-signing" "^0.32.3" + "@cosmjs/stream" "^0.32.3" + "@cosmjs/tendermint-rpc" "^0.32.3" + "@cosmjs/utils" "^0.32.3" + cosmjs-types "^0.9.0" + xstream "^11.14.0" + +"@cosmjs/stargate@^0.32.1", "@cosmjs/stargate@^0.32.2", "@cosmjs/stargate@^0.32.3", "@cosmjs/stargate@^0.32.4": version "0.32.4" resolved "https://registry.yarnpkg.com/@cosmjs/stargate/-/stargate-0.32.4.tgz#bd0e4d3bf613b629addbf5f875d3d3b50f640af1" integrity sha512-usj08LxBSsPRq9sbpCeVdyLx2guEcOHfJS9mHGCLCXpdAPEIEQEtWLDpEUc0LEhWOx6+k/ChXTc5NpFkdrtGUQ== @@ -1268,7 +1327,7 @@ cosmjs-types "^0.9.0" xstream "^11.14.0" -"@cosmjs/stream@^0.32.1", "@cosmjs/stream@^0.32.4": +"@cosmjs/stream@^0.32.1", "@cosmjs/stream@^0.32.3", "@cosmjs/stream@^0.32.4": version "0.32.4" resolved "https://registry.yarnpkg.com/@cosmjs/stream/-/stream-0.32.4.tgz#83e1f2285807467c56d9ea0e1113f79d9fa63802" integrity sha512-Gih++NYHEiP+oyD4jNEUxU9antoC0pFSg+33Hpp0JlHwH0wXhtD3OOKnzSfDB7OIoEbrzLJUpEjOgpCp5Z+W3A== @@ -1291,7 +1350,7 @@ readonly-date "^1.0.0" xstream "^11.14.0" -"@cosmjs/utils@^0.32.1", "@cosmjs/utils@^0.32.2", "@cosmjs/utils@^0.32.4": +"@cosmjs/utils@^0.32.1", "@cosmjs/utils@^0.32.2", "@cosmjs/utils@^0.32.3", "@cosmjs/utils@^0.32.4": version "0.32.4" resolved "https://registry.yarnpkg.com/@cosmjs/utils/-/utils-0.32.4.tgz#a9a717c9fd7b1984d9cefdd0ef6c6f254060c671" integrity sha512-D1Yc+Zy8oL/hkUkFUL/bwxvuDBzRGpc4cF7/SkdhxX4iHpSLgdOuTt1mhCh9+kl6NQREy9t7SYZ6xeW5gFe60w== @@ -2685,6 +2744,30 @@ npmlog "^6.0.2" write-file-atomic "^4.0.1" +"@nick134-bit/noblejs@0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@nick134-bit/noblejs/-/noblejs-0.0.2.tgz#97597a5d746a113b5493a8051d71ad8225f76a44" + integrity sha512-krCmMZtSifmYeAcuWIIulSnQH+c29CDHTW/6X5SM0UHw4ff6/xMTQFf1ZOlbpf04BCUI7A6j0etOmCdms5NHtg== + dependencies: + "@cosmjs/amino" "0.32.3" + "@cosmjs/encoding" "0.32.3" + "@cosmjs/math" "0.32.3" + "@cosmjs/proto-signing" "0.32.3" + "@cosmjs/stargate" "0.32.3" + typescript "^5.4.5" + +"@noble/curves@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.2.0.tgz#92d7e12e4e49b23105a2555c6984d41733d65c35" + integrity sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw== + dependencies: + "@noble/hashes" "1.3.2" + +"@noble/hashes@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" + integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== + "@noble/hashes@^1", "@noble/hashes@^1.0.0", "@noble/hashes@^1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.5.0.tgz#abadc5ca20332db2b1b2aa3e496e9af1213570b0" @@ -3687,6 +3770,13 @@ dependencies: undici-types "~6.11.1" +"@types/node@22.7.5": + version "22.7.5" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.5.tgz#cfde981727a7ab3611a481510b473ae54442b92b" + integrity sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ== + dependencies: + undici-types "~6.19.2" + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -3953,6 +4043,11 @@ add-stream@^1.0.0: resolved "https://registry.yarnpkg.com/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa" integrity sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ== +aes-js@4.0.0-beta.5: + version "4.0.0-beta.5" + resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-4.0.0-beta.5.tgz#8d2452c52adedebc3a3e28465d858c11ca315873" + integrity sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q== + agent-base@6, agent-base@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -4454,6 +4549,11 @@ bech32@^1.1.4: resolved "https://registry.yarnpkg.com/bech32/-/bech32-1.1.4.tgz#e38c9f37bf179b8eb16ae3a772b40c356d4832e9" integrity sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ== +bech32@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/bech32/-/bech32-2.0.0.tgz#078d3686535075c8c79709f054b1b226a133b355" + integrity sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg== + before-after-hook@^2.2.0: version "2.2.3" resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c" @@ -6282,6 +6382,19 @@ etag@^1.8.1, etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== +ethers@^6.13.4: + version "6.13.4" + resolved "https://registry.yarnpkg.com/ethers/-/ethers-6.13.4.tgz#bd3e1c3dc1e7dc8ce10f9ffb4ee40967a651b53c" + integrity sha512-21YtnZVg4/zKkCQPjrDj38B1r4nQvTZLopUGMLQ1ePU2zV/joCfDC3t3iKQjWRzjjjbzR+mdAIoikeBRNkdllA== + dependencies: + "@adraffy/ens-normalize" "1.10.1" + "@noble/curves" "1.2.0" + "@noble/hashes" "1.3.2" + "@types/node" "22.7.5" + aes-js "4.0.0-beta.5" + tslib "2.7.0" + ws "8.17.1" + event-emitter@^0.3.5: version "0.3.5" resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" @@ -11787,6 +11900,11 @@ tsimp@^2.0.11: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== +tslib@2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" + integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== + tslib@^1.8.1, tslib@^1.9.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" @@ -11970,7 +12088,7 @@ typescript-eslint@^7.18.0, typescript-eslint@^7.3.1: "@typescript-eslint/parser" "7.18.0" "@typescript-eslint/utils" "7.18.0" -"typescript@5.1.6 - 5.6.x", typescript@^5.6.2, typescript@~5.6.2: +"typescript@5.1.6 - 5.6.x", typescript@^5.4.5, typescript@^5.6.2, typescript@~5.6.2: version "5.6.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.3.tgz#5f3449e31c9d94febb17de03cc081dd56d81db5b" integrity sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw== @@ -12005,6 +12123,11 @@ undici-types@~6.11.1: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.11.1.tgz#432ea6e8efd54a48569705a699e62d8f4981b197" integrity sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ== +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" @@ -12392,6 +12515,11 @@ write-pkg@^4.0.0: type-fest "^0.4.1" write-json-file "^3.2.0" +ws@8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== + ws@^7, ws@^7.2.0: version "7.5.9" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"