Skip to content

fix: add cli contract call arg parsing #1789

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion packages/cli/src/argparse.ts
Original file line number Diff line number Diff line change
@@ -335,9 +335,15 @@ export const CLI_ARGS = {
realtype: 'private_key',
pattern: `${PRIVATE_KEY_PATTERN_ANY}`,
},
{
name: 'function_args',
type: 'string',
realtype: 'string',
pattern: '.+',
},
],
minItems: 6,
maxItems: 6,
maxItems: 7,
help:
'Call a function in a deployed Clarity smart contract.\n' +
'\n' +
@@ -353,6 +359,12 @@ export const CLI_ARGS = {
" transaction: 'https://explorer.hiro.so/txid/0x2e33ad647a9cedacb718ce247967dc705bc0c878db899fdba5eae2437c6fa1e1'" +
' }\n' +
'```\n' +
'\n' +
'You can also provide function arguments directly instead of being prompted for them:\n' +
'```console\n' +
' $ stx call_contract_func SPBMRFRPPGCDE3F384WCJPK8PQJGZ8K9QKK7F59X contract_name' +
' contract_function 1 0 "$PAYMENT" "(u100), (true), (\\"some-string\\"")\n' +
'```\n' +
'\n',
group: 'Account Management',
},
142 changes: 85 additions & 57 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@ import {
ClarityAbi,
ClarityValue,
ContractCallPayload,
createContractCallPayload,
cvToJSON,
cvToString,
estimateTransactionByteLength,
@@ -789,6 +790,34 @@ async function contractDeploy(_network: CLINetworkAdapter, args: string[]): Prom
});
}

/** @internal */
function parseDirectFunctionArgs(functionArgsStr: string): ClarityValue[] {
return functionArgsStr
.split('')
.reduce(
(acc, char) => {
if (char === '(') acc.p++;
if (char === ')') acc.p--;
if (char === ',' && !acc.p) {
acc.segs.push('');
} else {
acc.segs[acc.segs.length - 1] += char;
}
return acc;
},
{ p: 0, segs: [''] }
)
.segs.filter(arg => arg.trim())
.map(arg => Cl.parse(arg.trim()));
}

// Get function arguments via interactive prompts
async function getInteractiveFunctionArgs(abiArgs: ClarityFunctionArg[]): Promise<ClarityValue[]> {
const prompts = makePromptsFromArgList(abiArgs);
const answers = await prompt(prompts);
return parseClarityFunctionArgAnswers(answers, abiArgs);
}

/*
* Call a Clarity smart contract function.
* args:
@@ -806,73 +835,71 @@ async function contractFunctionCall(_network: CLINetworkAdapter, args: string[])
const fee = BigInt(args[3]);
const nonce = BigInt(args[4]);
const privateKey = args[5];
const functionArgsStr = args.length > 6 ? args[6] : undefined;

// temporary hack to use network config from stacks-transactions lib
const network = _network.isMainnet() ? STACKS_MAINNET : STACKS_TESTNET;

let abi: ClarityAbi;
let abiArgs: ClarityFunctionArg[];
let functionArgs: ClarityValue[] = [];
const abi = await fetchAbi({ contractAddress, contractName, network });
const filteredFn = abi.functions.filter(fn => fn.name === functionName);

return fetchAbi({ contractAddress, contractName, network })
.then(responseAbi => {
abi = responseAbi;
const filtered = abi.functions.filter(fn => fn.name === functionName);
if (filtered.length === 1) {
abiArgs = filtered[0].args;
return makePromptsFromArgList(abiArgs);
} else {
return null;
}
})
.then(prompts => prompt(prompts!))
.then(answers => {
functionArgs = parseClarityFunctionArgAnswers(answers, abiArgs);
if (filteredFn.length !== 1) {
throw new Error(`Function ${functionName} not found in contract ${contractName}`);
}

const options: SignedContractCallOptions = {
contractAddress,
contractName,
functionName,
functionArgs,
senderKey: privateKey,
fee,
nonce,
network,
postConditionMode: PostConditionMode.Allow,
};
const abiArgs = filteredFn[0].args;
const functionArgs = functionArgsStr
? parseDirectFunctionArgs(functionArgsStr)
: await getInteractiveFunctionArgs(abiArgs);

return makeContractCall(options);
})
.then(tx => {
if (!validateContractCall(tx.payload as ContractCallPayload, abi)) {
throw new Error('Failed to validate function arguments against ABI');
}
const payload = createContractCallPayload(
contractAddress,
contractName,
functionName,
functionArgs
);
validateContractCall(payload, abi);

if (estimateOnly) {
return fetchFeeEstimateTransaction({
payload: serializePayload(tx.payload),
estimatedLength: estimateTransactionByteLength(tx),
}).then(costs => costs[1].fee.toString(10));
}
const options: SignedContractCallOptions = {
contractAddress,
contractName,
functionName,
functionArgs,
senderKey: privateKey,
fee,
nonce,
network,
postConditionMode: PostConditionMode.Allow,
};

if (txOnly) {
return Promise.resolve(tx.serialize());
}
const tx = await makeContractCall(options);

return broadcastTransaction({ transaction: tx, network })
.then(response => {
if (response.hasOwnProperty('error')) {
return response;
}
return {
txid: `0x${tx.txid()}`,
transaction: generateExplorerTxPageUrl(tx.txid(), network),
};
})
.catch(error => {
return error.toString();
});
if (!validateContractCall(tx.payload as ContractCallPayload, abi)) {
throw new Error('Failed to validate function arguments against ABI');
}

if (estimateOnly) {
const costs = await fetchFeeEstimateTransaction({
payload: serializePayload(tx.payload),
estimatedLength: estimateTransactionByteLength(tx),
});
return costs[1].fee.toString(10);
}

if (txOnly) return tx.serialize();

try {
const response = await broadcastTransaction({ transaction: tx, network });
if ('error' in response) {
return JSONStringify(response);
}
return JSONStringify({
txid: `0x${tx.txid()}`,
transaction: generateExplorerTxPageUrl(tx.txid(), network),
});
} catch (error) {
if (error instanceof Error) return error.message;
return 'Unknown error occurred';
}
}

/*
@@ -2144,5 +2171,6 @@ export const testables =
migrateSubdomains,
preorder,
register,
parseDirectFunctionArgs,
}
: undefined;
4 changes: 1 addition & 3 deletions packages/cli/tests/abi/test-abi.json
Original file line number Diff line number Diff line change
@@ -69,9 +69,7 @@
{
"name": "test-func-buffer-argument",
"access": "public",
"args": [
{ "name": "bufferArg", "type": { "buffer": { "length": 20 } } }
],
"args": [{ "name": "bufferArg", "type": { "buffer": { "length": 20 } } }],
"outputs": { "type": { "response": { "ok": "bool", "error": "none" } } }
}
],
135 changes: 135 additions & 0 deletions packages/cli/tests/direct-function-args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// packages/cli/tests/direct-function-args.test.ts

import { testables } from '../src/cli';
import { CLINetworkAdapter, CLI_NETWORK_OPTS, getNetwork } from '../src/network';
import { CLI_CONFIG_TYPE } from '../src/argparse';
import { ClarityAbi } from '@stacks/transactions';
import { readFileSync } from 'fs';
import inquirer from 'inquirer';
import fetchMock from 'jest-fetch-mock';
import path from 'path';
import {
makeContractCall,
uintCV,
falseCV,
standardPrincipalCV,
PostConditionMode,
} from '@stacks/transactions';
import { STACKS_TESTNET } from '@stacks/network';

const { contractFunctionCall } = testables as any;

// Import the real ABI file that contains all the test functions
const TEST_ABI: ClarityAbi = JSON.parse(
readFileSync(path.join(__dirname, './abi/test-abi.json')).toString()
);

// jest.mock('inquirer');
fetchMock.enableMocks();

const testnetNetwork = new CLINetworkAdapter(
getNetwork({} as CLI_CONFIG_TYPE, true),
{} as CLI_NETWORK_OPTS
);

describe('Contract Function Call with Direct Arguments', () => {
beforeEach(() => {
fetchMock.resetMocks();
});

test('Should fall back to interactive prompts when no direct arguments provided', async () => {
const contractAddress = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6';
const contractName = 'test-contract-name';
const functionName = 'test-func-primitive-argument';
const fee = '230';
const nonce = '3';
const privateKey = 'cb3df38053d132895220b9ce471f6b676db5b9bf0b4adefb55f2118ece2478df01';
const args = [contractAddress, contractName, functionName, fee, nonce, privateKey];

const contractInputArg = {
amount: '1000',
address: 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6',
exists: 'false',
};

// Mock the inquirer prompt to return our test values
// @ts-ignore
inquirer.prompt = jest.fn().mockResolvedValue(contractInputArg);

fetchMock.mockResponseOnce(JSON.stringify(TEST_ABI), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});

fetchMock.mockResponseOnce(
JSON.stringify({
txid: '0x94b1cfab79555b8c6725f19e4fcd6268934d905578a3e8ef7a1e542b931d3676',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);

// Create the expected transaction that should match what contractFunctionCall builds
const functionArgs = [
uintCV(1000),
standardPrincipalCV('STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6'),
falseCV(),
];

const expectedTx = await makeContractCall({
contractAddress,
contractName,
functionName,
functionArgs,
senderKey: privateKey,
fee: BigInt(fee),
nonce: BigInt(nonce),
network: STACKS_TESTNET,
postConditionMode: PostConditionMode.Allow,
});

const expectedTxHex = expectedTx.serialize();

await contractFunctionCall(testnetNetwork, args);

// Verify interactive prompts were used
expect(inquirer.prompt).toHaveBeenCalled();

// Verify fetch was called with the expected URLs
expect(fetchMock).toHaveBeenCalledTimes(2);

// First call should be to fetch the ABI
const abiCallUrl = fetchMock.mock.calls[0][0];
const abiCallUrlString = abiCallUrl instanceof Request ? abiCallUrl.url : String(abiCallUrl);
expect(abiCallUrlString).toContain('/v2/contracts/interface/');
expect(abiCallUrlString).toContain(contractAddress);
expect(abiCallUrlString).toContain(contractName);

// Second call should be to broadcast the transaction
const txCallUrl = fetchMock.mock.calls[1][0];
const txCallUrlString = txCallUrl instanceof Request ? txCallUrl.url : String(txCallUrl);
expect(txCallUrlString).toContain('/v2/transactions');

// Extract the transaction from the second fetch call
const broadcastRequest = fetchMock.mock.calls[1][1] as RequestInit;
// TypeScript safety: ensure body exists and convert to string if needed
const bodyString = broadcastRequest?.body
? typeof broadcastRequest.body === 'string'
? broadcastRequest.body
: String(broadcastRequest.body)
: '{}';

const requestBody = JSON.parse(bodyString);
const capturedTxHex = requestBody.tx;

// Validate the transaction
expect(capturedTxHex).toBeTruthy();
expect(typeof capturedTxHex).toBe('string');
expect(capturedTxHex.length).toBeGreaterThan(100); // Reasonable transaction length

// Compare the captured transaction hex with our expected transaction
expect(capturedTxHex).toEqual(expectedTxHex);
});
});