From ecaf738cffbf5eacf4ba2bfbf446bfea08d14853 Mon Sep 17 00:00:00 2001 From: Obsidian <131651958+0xObsidian@users.noreply.github.com> Date: Tue, 17 Dec 2024 00:32:16 -0500 Subject: [PATCH] feat: added --solana-key flag to derive destination address Description ----------- - introduced --solana-key flag - added support for reading Solana keypair files and deriving public keys - implemented validation to prevent using both --solana-key and --destination - added test suite for the introduced feat - improved error handling for invalid key files Usage ----- Clone this PR branch and install dependencies and make sure you followed the readme to derive eth key `private-key.txt` and solana key `key.json` and then run ``` node bin/cli.js -k private-key.txt --solana-key key.json -a 0.002 --sepolia ``` Testing the introduced feat --------------------------- From the root direcoty, run: ``` yarn test ``` chore: format --- .gitignore | 2 + README.md | 21 ++-- bin/cli.js | 64 ++++++++---- package.json | 15 ++- test/cli.test.js | 259 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 331 insertions(+), 30 deletions(-) create mode 100644 test/cli.test.js diff --git a/.gitignore b/.gitignore index b657cbbd..e13d9def 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ yarn-error.log* lerna-debug.log* .pnpm-debug.log* private-key.txt +node_modules +key.json diff --git a/README.md b/README.md index 56c0c6ab..d29908a4 100644 --- a/README.md +++ b/README.md @@ -46,28 +46,35 @@ TODO ## Create a Deposit -1. Run the CLI tool with the necessary options: +1. Run the CLI tool with one of the following options: + + **Using a Solana address directly:** ```bash node bin/cli.js -k -d -a --mainnet|--sepolia ``` + **Using a Solana key.json file:** + ```bash + node bin/cli.js -k --solana-key -a --mainnet|--sepolia + ``` + For example: - **Mainnet Deposit:** + **Mainnet Deposit with address:** ```bash node bin/cli.js -k private-key.txt -d 6g8wB6cJbodeYaEb5aD9QYqhdxiS8igfcHpz36oHY7p8 -a 0.002 --mainnet ``` - **Sepolia Testnet Deposit:** + **Sepolia Testnet Deposit with key.json:** ```bash - node bin/cli.js -k private-key.txt -d 6g8wB6cJbodeYaEb5aD9QYqhdxiS8igfcHpz36oHY7p8 -a 0.002 --sepolia + node bin/cli.js -k private-key.txt --solana-key my-wallet.json -a 0.002 --sepolia ``` - - + - The `-k, --key-file` option specifies the path to the Ethereum private key file. - The `-d, --destination` option specifies the Solana destination address on the rollup (base58 encoded). + - The `--solana-key` option specifies the path to a Solana key.json file (alternative to -d). - The `-a, --amount` option specifies the amount of Ether to deposit. - - Use `--mainnet` or `--sepolia` to select the network. The tool will use different contract addresses depending on the network. - - The `-r, --rpc-url` option is optional and allows overriding the default JSON RPC URL. + - Use `--mainnet` or `--sepolia` to select the network. ## Security Note diff --git a/bin/cli.js b/bin/cli.js index 57796cfa..3f07bb83 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -3,19 +3,16 @@ /** * Script to deposit funds to the Eclipse rollup. * - * Usage: - * # To deposit on Sepolia - * node bin/cli.js --address [Solana Address] --amount [Amount Ether] --key-file [Private Key File] --sepolia - * - * # To deposit on Mainnet - * node bin/cli.js --address [Solana Address] --amount [Amount Ether] --key-file [Private Key File] --mainnet - * - * Example on Sepolia: - * > node bin/cli.js --address EAjFK3iWqYdRbCAuDhfCNHo2EMj3S7eg5QrU7DMcNEXD --amount 0.002 --key-file private-key.txt --sepolia - * > Transaction hash: 0x335c067c7280aa3bd2d688cd4c8695c86b3f7fe785be5379c5d98731db0269cf + * Usage with direct address: + * node bin/cli.js -k private-key.txt -d [Solana Address] -a [Amount Ether] --mainnet|--sepolia + * + * Usage with Solana key.json: + * node bin/cli.js -k private-key.txt --solana-key [key.json] -a [Amount Ether] --mainnet|--sepolia */ import { Command } from 'commander'; -import { runDeposit } from '../src/lib.js'; // Note the `.js` extension +import { runDeposit } from '../src/lib.js'; +import fs from 'fs'; +import { Keypair } from '@solana/web3.js'; const program = new Command(); @@ -26,7 +23,8 @@ program program .description('Deposit Ether into the Eclipse rollup') - .requiredOption('-d, --destination
', 'Destination address on the rollup (base58 encoded)') + .option('-d, --destination
', 'Destination address on the rollup (base58 encoded)') + .option('--solana-key ', 'Path to Solana key.json file (alternative to --destination)') .requiredOption('-a, --amount ', 'Amount in Ether to deposit') .option('--mainnet', 'Use Ethereum Mainnet') .option('--sepolia', 'Use Sepolia test network') @@ -36,18 +34,42 @@ program console.error('Error: You must specify either --mainnet or --sepolia'); process.exit(1); } - let chainName = ''; - if (options.mainnet) { - chainName = 'mainnet' - } else if (options.sepolia) { - chainName = 'sepolia' - } else { - throw new Error("Invalid chain name"); + + if (!options.destination && !options.solanaKey) { + console.error('Error: You must specify either --destination or --solana-key'); + process.exit(1); + } + + if (options.destination && options.solanaKey) { + console.error('Error: Cannot specify both --destination and --solana-key'); + process.exit(1); + } + + let destination = options.destination; + if (options.solanaKey) { + try { + // Reads the keypair from the JSON file + const keyData = JSON.parse(fs.readFileSync(options.solanaKey, 'utf8')); + + // Creates a Keypair from the array of bytes + const keypair = Keypair.fromSecretKey(new Uint8Array(keyData)); + + // Gets the public key in base58 format + destination = keypair.publicKey.toBase58(); + + console.log(`Using Solana public key: ${destination}`); + } catch (error) { + console.error(`Error reading/processing Solana key file: ${error.message}`); + process.exit(1); + } } + + let chainName = options.mainnet ? 'mainnet' : 'sepolia'; + runDeposit({ - destination: options.destination, + destination, amount: options.amount, - chainName: chainName, + chainName, keyFile: options.keyFile }); }); diff --git a/package.json b/package.json index 5fc48152..55809ceb 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,24 @@ "devDependencies": { "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.8.0", + "@jest/globals": "^29.7.0", "eslint": "^9.8.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", - "prettier": "^3.3.3" + "jest": "^29.7.0", + "prettier": "^3.3.3", + "glob": "^10.3.10" }, "scripts": { "lint": "eslint 'src/**/*.js'", - "format": "prettier --write 'src/**/*.js'" + "format": "prettier --write 'src/**/*.js'", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/cli.test.js" + }, + "jest": { + "transform": {}, + "testEnvironment": "node", + "moduleNameMapper": { + "^(\\.{1,2}/.*)\\.js$": "$1" + } } } diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 00000000..ad716ae4 --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,259 @@ +import { jest } from '@jest/globals'; +import fs from 'fs'; +import { Keypair } from '@solana/web3.js'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { Command } from 'commander'; + +// Creates mock functions +const mockRunDeposit = jest + .fn() + .mockImplementation(async ({ destination, amount, chainName, keyFile }) => { + if (!destination || !amount || !chainName || !keyFile) { + throw new Error('Missing required parameters'); + } + // Mock transaction hash + return '0x123...'; + }); + +// Mocks the entire lib.js module +jest.mock('../src/lib.js', () => ({ + runDeposit: mockRunDeposit, +})); + +// Gets the directory name in ES modules +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +class CLIError extends Error { + constructor(message, code) { + super(message); + this.code = code; + } +} + +function createCliProgram() { + const program = new Command(); + program + .option( + '-d, --destination
', + 'Destination address on the rollup (base58 encoded)', + ) + .option( + '--solana-key ', + 'Path to Solana key.json file (alternative to --destination)', + ) + .requiredOption('-a, --amount ', 'Amount in Ether to deposit') + .option('--mainnet', 'Use Ethereum Mainnet') + .option('--sepolia', 'Use Sepolia test network') + .requiredOption( + '-k, --key-file ', + 'Path to the Ethereum private key file', + ) + .action(async (options) => { + if (!options.mainnet && !options.sepolia) { + console.error('Error: You must specify either --mainnet or --sepolia'); + process.exit(1); + } + + if (!options.destination && !options.solanaKey) { + console.error( + 'Error: You must specify either --destination or --solana-key', + ); + process.exit(1); + } + + if (options.destination && options.solanaKey) { + console.error( + 'Error: Cannot specify both --destination and --solana-key', + ); + process.exit(1); + } + + let destination = options.destination; + if (options.solanaKey) { + try { + const keyData = JSON.parse( + fs.readFileSync(options.solanaKey, 'utf8'), + ); + const keypair = Keypair.fromSecretKey(new Uint8Array(keyData)); + destination = keypair.publicKey.toBase58(); + console.log(`Using Solana public key: ${destination}`); + } catch (error) { + console.error( + `Error reading/processing Solana key file: ${error.message}`, + ); + throw new CLIError('Failed to process key file', 1); + } + } + + let chainName = options.mainnet ? 'mainnet' : 'sepolia'; + + try { + const txHash = await mockRunDeposit({ + destination, + amount: options.amount, + chainName, + keyFile: options.keyFile, + }); + console.log(`Transaction hash: ${txHash}`); + return txHash; + } catch (error) { + console.error(`Transaction failed: ${error.message}`); + throw error; + } + }); + + return program; +} + +describe('CLI Solana Key Handling', () => { + const TEST_AMOUNT = '0.001'; + const TEST_ETH_KEY = 'eth-key.txt'; + const TEST_NETWORK = '--mainnet'; + + let mockExit; + let mockConsoleError; + let mockConsoleLog; + let testKeypair; + let testKeyPath; + + const createCliArgs = (keyPath, additionalArgs = []) => [ + 'node', + 'cli.js', + '-k', + TEST_ETH_KEY, + '--solana-key', + keyPath, + '-a', + TEST_AMOUNT, + TEST_NETWORK, + ...additionalArgs, + ]; + + const writeKeyFile = (path, data) => { + fs.writeFileSync(path, JSON.stringify(data)); + }; + + // Helper to clean up temporary test files + const cleanupFile = (path) => { + if (fs.existsSync(path)) { + fs.unlinkSync(path); + } + }; + + beforeEach(() => { + // Mocks process.exit + mockExit = jest.spyOn(process, 'exit').mockImplementation((code) => { + throw new CLIError('Process exit', code); + }); + + // Mocks console.error and console.log + mockConsoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => { }); + mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => { }); + + // Creates test keypair and path + testKeypair = Keypair.generate(); + testKeyPath = path.join(__dirname, 'test-key.json'); + + jest.clearAllMocks(); + }); + + afterEach(() => { + // Restores all mocks + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + mockConsoleLog.mockRestore(); + + cleanupFile(testKeyPath); + }); + + test('should correctly derive public key from Solana keypair file', async () => { + try { + writeKeyFile(testKeyPath, Array.from(testKeypair.secretKey)); + + const program = createCliProgram(); + await program.parseAsync(createCliArgs(testKeyPath)); + + expect(mockConsoleLog).toHaveBeenCalledWith( + `Using Solana public key: ${testKeypair.publicKey.toBase58()}`, + ); + + expect(mockRunDeposit).toHaveBeenCalledWith({ + destination: testKeypair.publicKey.toBase58(), + amount: TEST_AMOUNT, + chainName: 'mainnet', + keyFile: TEST_ETH_KEY, + }); + } finally { + cleanupFile(testKeyPath); + } + }); + + test('should fail gracefully with invalid Solana key file', async () => { + try { + writeKeyFile(testKeyPath, { invalid: 'data' }); + + const program = createCliProgram(); + await expect(async () => { + await program.parseAsync(createCliArgs(testKeyPath)); + }).rejects.toThrow('Failed to process key file'); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Error reading/processing Solana key file'), + ); + + expect(mockRunDeposit).not.toHaveBeenCalled(); + } finally { + cleanupFile(testKeyPath); + } + }); + + test('should handle transaction errors gracefully', async () => { + mockRunDeposit.mockImplementationOnce(async () => { + throw new Error('Transaction failed'); + }); + + try { + writeKeyFile(testKeyPath, Array.from(testKeypair.secretKey)); + + const program = createCliProgram(); + await expect( + program.parseAsync(createCliArgs(testKeyPath)), + ).rejects.toThrow('Transaction failed'); + + expect(mockRunDeposit).toHaveBeenCalledWith({ + destination: testKeypair.publicKey.toBase58(), + amount: TEST_AMOUNT, + chainName: 'mainnet', + keyFile: TEST_ETH_KEY, + }); + expect(mockConsoleError).toHaveBeenCalledWith( + 'Transaction failed: Transaction failed', + ); + } finally { + cleanupFile(testKeyPath); + } + }); + + test('should handle successful transaction', async () => { + try { + writeKeyFile(testKeyPath, Array.from(testKeypair.secretKey)); + + const program = createCliProgram(); + await program.parseAsync(createCliArgs(testKeyPath)); + + expect(mockRunDeposit).toHaveBeenCalledWith({ + destination: testKeypair.publicKey.toBase58(), + amount: TEST_AMOUNT, + chainName: 'mainnet', + keyFile: TEST_ETH_KEY, + }); + + expect(mockConsoleLog).toHaveBeenCalledWith('Transaction hash: 0x123...'); + } finally { + cleanupFile(testKeyPath); + } + }); +});