diff --git a/packages/cli/src/argparse.ts b/packages/cli/src/argparse.ts index 3c32385cb..28c3f1c1e 100644 --- a/packages/cli/src/argparse.ts +++ b/packages/cli/src/argparse.ts @@ -395,6 +395,37 @@ export const CLI_ARGS = { '\n', group: 'Account Management', }, + decode_cv: { + type: 'array', + items: [ + { + name: 'clarity_value', + type: 'string', + realtype: 'string', + pattern: '-|^(0x|0X)?[a-fA-F0-9]+$', + }, + { + name: 'format', + type: 'string', + realtype: 'format', + pattern: '^(repr|pretty|json)$', + }, + ], + minItems: 1, + maxItems: 4, + help: + 'Decode a serialized Clarity value.\n' + + '\n' + + 'Example:\n' + + '\n' + + ' $ stx decode_cv 0x050011deadbeef11ababffff11deadbeef11ababffff\n' + + ' S08XXBDYXW8TQAZZZW8XXBDYXW8TQAZZZZ88551S\n' + + ' $ stx decode_cv --format json SPA2MZWV9N67TBYVWTE0PSSKMJ2F6YXW7CBE6YPW\n' + + ' {"type":"principal","value":"S08XXBDYXW8TQAZZZW8XXBDYXW8TQAZZZZ88551S"}\n' + + ' $ echo 0x050011deadbeef11ababffff11deadbeef11ababffff | stx decode_cv -\n' + + ' S08XXBDYXW8TQAZZZW8XXBDYXW8TQAZZZZ88551S\n', + group: 'Utilities', + }, convert_address: { type: 'array', items: [ diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b6cdb9db9..c92a6c4c4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -41,6 +41,8 @@ import { TransactionVersion, TxBroadcastResult, validateContractCall, + Cl, + cvToJSON, } from '@stacks/transactions'; import express from 'express'; import { prompt } from 'inquirer'; @@ -952,6 +954,37 @@ async function readOnlyContractFunctionCall( }); } +/* + * Decode a serialized Clarity value + * args: + * @value (string) the hex string of the serialized value, or '-' to read from stdin + * @format (string) the format to output the value in; one of 'pretty', 'json', or 'repr' + */ +function decodeCV(_network: CLINetworkAdapter, args: string[]): Promise { + const inputArg = args[0]; + const format = args[1]; + + let inputValue: string; + if (inputArg === '-') { + inputValue = fs.readFileSync(process.stdin.fd, 'utf-8').trim(); + } else { + inputValue = inputArg; + } + + const cv = Cl.deserialize(inputValue); + let cvString: string; + if (format === 'pretty') { + cvString = Cl.prettyPrint(cv, 2); + } else if (format === 'json') { + cvString = JSON.stringify(cvToJSON(cv)); + } else if (format === 'repr' || !format) { + cvString = cvToString(cv); + } else { + throw new Error('Invalid format option'); + } + return Promise.resolve(cvString); +} + // /* // * Get the number of confirmations of a txid. // * args: @@ -1959,6 +1992,7 @@ const COMMANDS: Record = { can_stack: canStack, call_contract_func: contractFunctionCall, call_read_only_contract_func: readOnlyContractFunctionCall, + decode_cv: decodeCV, convert_address: addressConvert, decrypt_keychain: decryptMnemonic, deploy_contract: contractDeploy, @@ -2159,6 +2193,7 @@ export const testables = process.env.NODE_ENV === 'test' ? { addressConvert, + decodeCV, canStack, contractFunctionCall, getStacksWalletKey, diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index 038bbf5e8..4c73dba1a 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -3,6 +3,7 @@ import { CLIMain, testables } from '../src/cli'; import { CLINetworkAdapter, CLI_NETWORK_OPTS, getNetwork } from '../src/network'; import { + Cl, ClarityAbi, createStacksPrivateKey, publicKeyFromSignatureVrs, @@ -23,6 +24,7 @@ import { WalletKeyInfoResult, } from './derivation-path/keychain'; import * as fixtures from './fixtures/cli.fixture'; +import { bytesToHex } from '@stacks/common'; const TEST_ABI: ClarityAbi = JSON.parse( readFileSync(path.join(__dirname, './abi/test-abi.json')).toString() @@ -34,6 +36,7 @@ jest.mock('inquirer'); const { addressConvert, + decodeCV, canStack, contractFunctionCall, getStacksWalletKey, @@ -53,6 +56,39 @@ const testnetNetwork = new CLINetworkAdapter( {} as CLI_NETWORK_OPTS ); +describe('decode_cv', () => { + test('Should decode from hex arg', async () => { + const result = await decodeCV(mainnetNetwork, [ + '0x050011deadbeef11ababffff11deadbeef11ababffff', + ]); + expect(result).toEqual('S08XXBDYXW8TQAZZZW8XXBDYXW8TQAZZZZ88551S'); + }); + + test('Should decode from hex to json', async () => { + const result = await decodeCV(mainnetNetwork, [ + '0x050011deadbeef11ababffff11deadbeef11ababffff', + 'json', + ]); + expect(result).toEqual( + '{"type":"principal","value":"S08XXBDYXW8TQAZZZW8XXBDYXW8TQAZZZZ88551S"}' + ); + }); + + test('Should decode from hex to repr', async () => { + const list = Cl.list([1, 2, 3].map(Cl.int)); + const serialized = bytesToHex(Cl.serialize(list)); + const result = await decodeCV(mainnetNetwork, [serialized, 'repr']); + expect(result).toEqual('(list 1 2 3)'); + }); + + test('Should decode from hex to pretty print', async () => { + const list = Cl.list([1, 2, 3].map(Cl.int)); + const serialized = bytesToHex(Cl.serialize(list)); + const result = await decodeCV(mainnetNetwork, [serialized, 'pretty']); + expect(result).toEqual('(list\n 1\n 2\n 3\n)'); + }); +}); + describe('convert_address', () => { test.each(fixtures.convertAddress)('%p - testnet: %p', async (input, testnet, expectedResult) => { const network = testnet ? testnetNetwork : mainnetNetwork;