From 29c0b0f589276e4d8c102c78c3bf1488af1fcc59 Mon Sep 17 00:00:00 2001 From: janniks Date: Thu, 1 May 2025 13:28:12 +0200 Subject: [PATCH 1/3] fix: Fix a regression from the v7.x.x update where the localnet/mocknet flag stopped being respected in the CLI --- packages/cli/src/cli.ts | 44 ++++----- packages/cli/src/network.ts | 12 +++ packages/cli/tests/cli.test.ts | 168 ++++++++++++++++++++++++++++++++- 3 files changed, 199 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e4c2e8a78..4c0515439 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,7 +1,7 @@ import * as scureBip39 from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { buildPreorderNameTx, buildRegisterNameTx } from '@stacks/bns'; -import { bytesToHex, HIRO_MAINNET_URL, HIRO_TESTNET_URL } from '@stacks/common'; +import { bytesToHex } from '@stacks/common'; import { ACCOUNT_PATH, broadcastTransaction, @@ -86,11 +86,17 @@ import { import { decryptBackupPhrase, encryptBackupPhrase } from './encrypt'; -import { CLI_NETWORK_OPTS, CLINetworkAdapter, getNetwork, NameInfoType } from './network'; +import { + CLI_NETWORK_OPTS, + CLINetworkAdapter, + getNetwork, + getStacksNetwork, + NameInfoType, +} from './network'; import { gaiaAuth, gaiaConnect, gaiaUploadProfileAll, getGaiaAddressFromProfile } from './data'; -import { defaultUrlFromNetwork, STACKS_MAINNET, STACKS_TESTNET } from '@stacks/network'; +import { defaultUrlFromNetwork, STACKS_TESTNET } from '@stacks/network'; import { generateNewAccount, generateWallet, @@ -263,10 +269,7 @@ async function getAppKeys(_network: CLINetworkAdapter, args: string[]): Promise< } const account = wallet.accounts[index - 1]; const privateKey = getAppPrivateKey({ account, appDomain }); - const address = getAddressFromPrivateKey( - privateKey, - _network.isMainnet() ? STACKS_MAINNET : STACKS_TESTNET - ); + const address = getAddressFromPrivateKey(privateKey, getStacksNetwork(_network)); return JSON.stringify({ keyInfo: { privateKey, address } }); } @@ -331,7 +334,7 @@ async function getStacksWalletKey(_network: CLINetworkAdapter, args: string[]): async function migrateSubdomains(_network: CLINetworkAdapter, args: string[]): Promise { const mnemonic: string = await getBackupPhrase(args[0]); // args[0] is the cli argument for mnemonic const baseWallet = await generateWallet({ secretKey: mnemonic, password: '' }); - const network = _network.isMainnet() ? STACKS_MAINNET : STACKS_TESTNET; + const network = getStacksNetwork(_network); const wallet = await restoreWalletAccounts({ wallet: baseWallet, gaiaHubUrl: 'https://hub.blockstack.org', @@ -511,9 +514,7 @@ function balance(_network: CLINetworkAdapter, args: string[]): Promise { address = _network.coerceAddress(address); } - // temporary hack to use network config from stacks-transactions lib - const url = _network.isMainnet() ? HIRO_MAINNET_URL : HIRO_TESTNET_URL; - + const url = _network.nodeAPIUrl; return fetch(`${url}${ACCOUNT_PATH}/${address}?proof=0`) .then(response => { if (response.status === 404) { @@ -692,7 +693,7 @@ async function sendTokens(_network: CLINetworkAdapter, args: string[]): Promise< memo = args[5]; } - const network = _network.isMainnet() ? STACKS_MAINNET : STACKS_TESTNET; + const network = getStacksNetwork(_network); const options: SignedTokenTransferOptions = { recipient: recipientAddress, @@ -749,7 +750,7 @@ async function contractDeploy(_network: CLINetworkAdapter, args: string[]): Prom const source = fs.readFileSync(sourceFile).toString(); - const network = _network.isMainnet() ? STACKS_MAINNET : STACKS_TESTNET; + const network = getStacksNetwork(_network); const options: SignedContractDeployOptions = { contractName, @@ -807,8 +808,7 @@ async function contractFunctionCall(_network: CLINetworkAdapter, args: string[]) const nonce = BigInt(args[4]); const privateKey = args[5]; - // temporary hack to use network config from stacks-transactions lib - const network = _network.isMainnet() ? STACKS_MAINNET : STACKS_TESTNET; + const network = getStacksNetwork(_network); let abi: ClarityAbi; let abiArgs: ClarityFunctionArg[]; @@ -892,7 +892,7 @@ async function readOnlyContractFunctionCall( const functionName = args[2]; const senderAddress = args[3]; - const network = _network.isMainnet() ? STACKS_MAINNET : STACKS_TESTNET; + const network = getStacksNetwork(_network); let abi: ClarityAbi; let abiArgs: ClarityFunctionArg[]; @@ -1624,7 +1624,7 @@ function decryptMnemonic(_network: CLINetworkAdapter, args: string[]): Promise { const address = args[0]; - const network = _network.isMainnet() ? STACKS_MAINNET : STACKS_TESTNET; + const network = getStacksNetwork(_network); const stacker = new StackingClient({ address, network }); return stacker @@ -1656,7 +1656,7 @@ async function canStack(_network: CLINetworkAdapter, args: string[]): Promise { }); describe('CLI Main', () => { + let exitSpy: jest.SpyInstance; + let exit: Promise; + + beforeEach(() => { + exitSpy = jest.spyOn(process, 'exit'); + exit = new Promise(resolve => { + exitSpy.mockImplementation(() => resolve()); + }); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + test('argparse should work', () => { process.argv = ['node', 'stx', 'make_keychain']; jest.spyOn(process, 'exit').mockImplementation(); expect(() => CLIMain()).not.toThrow(); }); + + test('Commands should use custom API URL from -H flag', async () => { + const customApiUrl = 'http://localhost:3999'; + const contractAddress = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'; + const contractName = 'test-contract'; + const functionName = 'test-func-string-ascii-argument'; // Same as in the ABI + const fee = 100; + const nonce = 0; + const privateKey = randomPrivateKey(); + + // Store original argv and redefine + const originalArgv = process.argv; + process.argv = [ + 'node', + 'stx', + '-H', + customApiUrl, + '-t', // Use testnet flag + 'call_contract_func', + contractAddress, + contractName, + functionName, + String(fee), + String(nonce), + privateKey, + ]; + + const mockAbi = { ...TEST_ABI }; + mockAbi.functions[0].args = []; // Remove args from ABI, so we don't need to mock inquirer + const mockTxid = `0x${bytesToHex(randomBytes(32))}`; + + fetchMock.once(JSON.stringify(mockAbi)); + fetchMock.once(mockTxid); + + CLIMain(); + await exit; + + process.argv = originalArgv; + + // Call 1: ABI fetch + expect(fetchMock.mock.calls[0][0]).toContain(customApiUrl); + expect(fetchMock.mock.calls[0][0]).toContain( + `/v2/contracts/interface/${contractAddress}/${contractName}` + ); + // Call 2: Broadcast + expect(fetchMock.mock.calls[1][0]).toEqual(`${customApiUrl}/v2/transactions`); + + expect(exitSpy).toHaveBeenCalledWith(0); // success + }); + + test('Commands should use localnet API URL from -l flag', async () => { + const localnetApiUrl = 'http://localhost:20443'; // From argparse.ts CONFIG_LOCALNET_DEFAULTS + const contractAddress = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'; + const contractName = 'test-contract'; + const functionName = 'test-func-string-ascii-argument'; // Same as in the ABI + const fee = 100; + const nonce = 0; + const privateKey = randomPrivateKey(); + + // Store original argv and redefine + const originalArgv = process.argv; + process.argv = [ + 'node', + 'stx', + '-l', // Use localnet flag + 'call_contract_func', + contractAddress, + contractName, + functionName, + String(fee), + String(nonce), + privateKey, + ]; + + const mockAbi = { ...TEST_ABI }; + mockAbi.functions[0].args = []; // Remove args from ABI, so we don't need to mock inquirer + const mockTxid = `0x${bytesToHex(randomBytes(32))}`; + + // Mock fetch calls: 1 for ABI, 1 for transaction broadcast + fetchMock.once(JSON.stringify(mockAbi)); + fetchMock.once(mockTxid); // Mock the broadcast response + + CLIMain(); // Run the main CLI entrypoint + await exit; + + process.argv = originalArgv; // Restore original argv + + // Verify fetch calls used the correct localnet URL + // Call 1: ABI fetch + expect(fetchMock.mock.calls[0][0]).toContain(localnetApiUrl); + expect(fetchMock.mock.calls[0][0]).toContain( + `/v2/contracts/interface/${contractAddress}/${contractName}` + ); + // Call 2: Transaction broadcast + expect(fetchMock.mock.calls[1][0]).toEqual(`${localnetApiUrl}/v2/transactions`); + + expect(exitSpy).toHaveBeenCalledWith(0); // Expect successful exit + }); + + test('Commands should use testnet API URL from -t flag', async () => { + const testnetApiUrl = 'https://api.testnet.hiro.so'; // From argparse.ts CONFIG_TESTNET_DEFAULTS + const contractAddress = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'; + const contractName = 'test-contract'; + const functionName = 'test-func-string-ascii-argument'; // Same as in the ABI + const fee = 100; + const nonce = 0; + const privateKey = randomPrivateKey(); + + // Store original argv and redefine + const originalArgv = process.argv; + process.argv = [ + 'node', + 'stx', + '-t', // Use testnet flag + 'call_contract_func', + contractAddress, + contractName, + functionName, + String(fee), + String(nonce), + privateKey, + ]; + + const mockAbi = { ...TEST_ABI }; + mockAbi.functions[0].args = []; // Remove args from ABI, so we don't need to mock inquirer + const mockTxid = `0x${bytesToHex(randomBytes(32))}`; + + // Mock fetch calls: 1 for ABI, 1 for transaction broadcast + fetchMock.once(JSON.stringify(mockAbi)); + fetchMock.once(mockTxid); // Mock the broadcast response + + CLIMain(); // Run the main CLI entrypoint + await exit; + + process.argv = originalArgv; // Restore original argv + + // Verify fetch calls used the correct testnet URL + // Call 1: ABI fetch + expect(fetchMock.mock.calls[0][0]).toContain(testnetApiUrl); + expect(fetchMock.mock.calls[0][0]).toContain( + `/v2/contracts/interface/${contractAddress}/${contractName}` + ); + // Call 2: Transaction broadcast + expect(fetchMock.mock.calls[1][0]).toEqual(`${testnetApiUrl}/v2/transactions`); + + expect(exitSpy).toHaveBeenCalledWith(0); // Expect successful exit + }); }); From 46a7c9b208a1a28c801e5f2e70dd30b4c6487e65 Mon Sep 17 00:00:00 2001 From: janniks Date: Fri, 9 May 2025 16:31:46 +0200 Subject: [PATCH 2/3] test: fix tests --- packages/cli/tests/cli.test.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index 2506f37ac..a8b4e00ee 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -461,11 +461,14 @@ test('can_stack', async () => { ); }); -describe('CLI Main', () => { +describe('CLIMain', () => { let exitSpy: jest.SpyInstance; let exit: Promise; + let argvBefore: string[]; beforeEach(() => { + fetchMock.resetMocks(); + argvBefore = [...process.argv]; exitSpy = jest.spyOn(process, 'exit'); exit = new Promise(resolve => { exitSpy.mockImplementation(() => resolve()); @@ -473,6 +476,7 @@ describe('CLI Main', () => { }); afterEach(() => { + process.argv = argvBefore; exitSpy.mockRestore(); }); @@ -491,8 +495,6 @@ describe('CLI Main', () => { const nonce = 0; const privateKey = randomPrivateKey(); - // Store original argv and redefine - const originalArgv = process.argv; process.argv = [ 'node', 'stx', @@ -518,8 +520,6 @@ describe('CLI Main', () => { CLIMain(); await exit; - process.argv = originalArgv; - // Call 1: ABI fetch expect(fetchMock.mock.calls[0][0]).toContain(customApiUrl); expect(fetchMock.mock.calls[0][0]).toContain( @@ -540,8 +540,6 @@ describe('CLI Main', () => { const nonce = 0; const privateKey = randomPrivateKey(); - // Store original argv and redefine - const originalArgv = process.argv; process.argv = [ 'node', 'stx', @@ -566,8 +564,6 @@ describe('CLI Main', () => { CLIMain(); // Run the main CLI entrypoint await exit; - process.argv = originalArgv; // Restore original argv - // Verify fetch calls used the correct localnet URL // Call 1: ABI fetch expect(fetchMock.mock.calls[0][0]).toContain(localnetApiUrl); @@ -589,8 +585,6 @@ describe('CLI Main', () => { const nonce = 0; const privateKey = randomPrivateKey(); - // Store original argv and redefine - const originalArgv = process.argv; process.argv = [ 'node', 'stx', @@ -615,8 +609,6 @@ describe('CLI Main', () => { CLIMain(); // Run the main CLI entrypoint await exit; - process.argv = originalArgv; // Restore original argv - // Verify fetch calls used the correct testnet URL // Call 1: ABI fetch expect(fetchMock.mock.calls[0][0]).toContain(testnetApiUrl); From de94b53076db984f53b279df3e6f6078aad36ffc Mon Sep 17 00:00:00 2001 From: janniks Date: Fri, 9 May 2025 17:09:14 +0200 Subject: [PATCH 3/3] test: add balance command test to increase test coverage --- packages/cli/tests/cli.test.ts | 38 ++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index a8b4e00ee..999515480 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -620,4 +620,42 @@ describe('CLIMain', () => { expect(exitSpy).toHaveBeenCalledWith(0); // Expect successful exit }); + + describe('"balance" command', () => { + test('should call the correct endpoint and exit successfully', async () => { + const testAddress = 'SP2ZNGJ85ENDY6QRHQ5P2D4FXKGZWCKTB2T0Z55KS'; + const mainnetApiUrl = 'https://api.hiro.so'; // From argparse.ts CONFIG_MAINNET_DEFAULTS + + process.argv = ['node', 'stx', 'balance', testAddress]; + + fetchMock.once( + `{"balance":"0x0000000000000000000000018d4e23ec","locked":"0x00000000000000000000000000000000","unlock_height":0,"nonce":13320}` + ); + + CLIMain(); + await exit; + + expect(fetchMock.mock.calls[0][0]).toEqual( + `${mainnetApiUrl}/v2/accounts/${testAddress}?proof=0` + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + test('should use testnet API URL from -t flag for balance', async () => { + const testAddress = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'; + const testnetApiUrl = 'https://api.testnet.hiro.so'; // From argparse.ts CONFIG_TESTNET_DEFAULTS + + process.argv = ['node', 'stx', '-t', 'balance', testAddress]; + + fetchMock.once( + `{"balance":"0x0000000000000000000000018d4e23ec","locked":"0x00000000000000000000000000000000","unlock_height":0,"nonce":13320}` + ); + + CLIMain(); + await exit; + + expect(fetchMock.mock.calls[0][0]).toContain(testnetApiUrl); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + }); });