import _ from 'lodash'; import * as viem from 'viem'; import prompts, { Choice } from 'prompts'; import { red, bold, gray, green, yellow, cyan } from 'chalk'; import { CannonSigner, ChainArtifacts, Contract, ContractMap, traceActions } from '@usecannon/builder'; import { log, logSpinner, errorSpinner, logSpinnerStart, logSpinnerEnd } from '../util/console'; import { formatAbiFunction } from '../helpers'; import { PackageSpecification } from '../types'; import { getChainById } from '../chains'; const PROMPT_BACK_OPTION = { title: '↩ BACK' }; type InteractTaskArgs = { packages: PackageSpecification[]; packagesArtifacts?: ChainArtifacts[]; contracts: { [name: string]: Contract }[]; provider: viem.PublicClient; signer?: CannonSigner; blockTag?: number; }; export async function interact(ctx: InteractTaskArgs) { // ----------------- // Start interaction // ----------------- await printHeader(ctx); let pickedPackage = -1; let pickedContract: string | null = null; let pickedFunction: viem.AbiFunction | null = null; let currentArgs: any[] | null = null; let txnValue = BigInt(0); // eslint-disable-next-line no-constant-condition while (true) { if (ctx.packages.length === 1) { pickedPackage = 0; } if (pickedPackage === -1) { pickedPackage = await pickPackage(ctx.packages); if (pickedPackage === -1) { return null; } } else if (!pickedContract) { pickedContract = await pickContract({ contractNames: Object.keys(ctx.contracts[pickedPackage]), contractArtifacts: ctx.packagesArtifacts?.[pickedPackage]?.contracts, }); if (!pickedContract) { if (ctx.packages.length === 1) { return null; } pickedPackage = -1; } } else if (!pickedFunction) { await printHelpfulInfo(ctx, pickedPackage, pickedContract); pickedFunction = await pickFunction({ contract: ctx.contracts[pickedPackage][pickedContract], }); if (!pickedFunction) { pickedContract = null; } } else if (!currentArgs) { const argData = await pickFunctionArgs({ func: pickedFunction, }); if (!argData) { pickedFunction = null; } else { currentArgs = argData.args; txnValue = argData.value; } } else { const contract = ctx.contracts[pickedPackage][pickedContract!]; if (ctx.packagesArtifacts) { ctx.provider = ctx.provider.extend(traceActions(ctx.packagesArtifacts[pickedPackage]) as any); } if (pickedFunction.stateMutability === 'view' || pickedFunction.stateMutability === 'pure') { await query({ provider: ctx.provider, contract, functionAbi: pickedFunction, args: currentArgs, blockTag: ctx.blockTag, }); } else if (!ctx.signer) { logSpinner(); logSpinner(red(' Signer is not supplied. cannot invoke writable function.')); logSpinner(); } else { const receipt = await execTxn({ provider: ctx.provider, signer: ctx.signer, contract, functionAbi: pickedFunction, args: currentArgs, value: txnValue, }); if (receipt?.status === 'success') { await logTxSucceed(ctx, receipt); } else { logTxFail('tx did not succeed'); } } // return to function select pickedFunction = null; currentArgs = null; } } } async function printHeader(ctx: InteractTaskArgs) { // retrieve balance of the signer address // this isnt always necessary but it serves as a nice test that the provider is working // and prevents the UI from lurching later if its queried later const signerBalance = ctx.signer ? await ctx.provider.getBalance({ address: ctx.signer.address }) : BigInt(0); logSpinner('\n'); logSpinner(gray('================================================================================')); logSpinner(gray('> Gas price: provider default')); logSpinner(gray(`> Block tag: ${ctx.blockTag || 'latest'}`)); if (ctx.signer) { logSpinner(yellow(`> Read/Write: ${ctx.signer.address}`)); if (signerBalance > viem.parseEther('0.01')) { logSpinner(green(`> Signer Balance: ${viem.formatEther(signerBalance)}`)); } else { logSpinner(red(`> WARNING! Low signer balance: ${viem.formatEther(signerBalance)}`)); } } else { logSpinner(gray('> Read Only')); } logSpinner(gray('================================================================================')); logSpinner('\n'); } async function printHelpfulInfo(ctx: InteractTaskArgs, pickedPackage: number, pickedContract: string | null) { if (pickedContract) { logSpinner(gray.inverse(`${pickedContract} => ${ctx.contracts[pickedPackage][pickedContract].address}`)); } logSpinner(gray(` * Signer: ${ctx.signer ? ctx.signer.address : 'None'}`)); logSpinner('\n'); } interface PackageChoice extends Choice { value: number; } async function pickPackage(packages: PackageSpecification[]) { const choices: PackageChoice[] = packages.map((p, i) => ({ title: `${p.name}:${p.version}`, value: i, description: Object.entries(p.settings) .map(([k, v]) => `${k}=${v}`) .join(' | '), })); choices.unshift({ ...PROMPT_BACK_OPTION, value: -1 }); logSpinnerEnd(); const { pickedPackage } = await prompts.prompt([ { type: 'select', name: 'pickedPackage', message: 'Pick a PACKAGE:', choices, }, ]); logSpinnerStart(); return typeof pickedPackage === 'number' ? pickedPackage : -1; } async function pickContract({ contractNames, contractArtifacts, }: { contractNames: string[]; contractArtifacts?: ContractMap; }) { const isHighlighted = (n: string) => !!contractArtifacts?.[n]?.highlight; const choices: Choice[] = _.sortBy(contractNames, [ (contractName) => !isHighlighted(contractName), (contractName) => contractName, ]).map((contractName) => ({ title: isHighlighted(contractName) ? bold(contractName) : contractName, value: contractName, })); choices.unshift(PROMPT_BACK_OPTION); logSpinnerEnd(); const { pickedContract } = await prompts.prompt([ { type: 'autocomplete', name: 'pickedContract', message: 'Pick a CONTRACT:', choices, suggest: suggestBySubtring, }, ]); logSpinnerStart(); return pickedContract === PROMPT_BACK_OPTION.title ? null : pickedContract; } function assembleFunctionSignatures(abi: viem.Abi): [viem.AbiFunction[], string[]] { const abiFunctions = abi.filter((v) => v.type === 'function') as viem.AbiFunction[]; const prettyNames = abiFunctions.map(formatAbiFunction); return [abiFunctions, prettyNames]; } async function pickFunction({ contract }: { contract: Contract }) { const [abiFunctions, functionSignatures] = assembleFunctionSignatures(contract.abi); const choices = _.sortBy(functionSignatures).map((s) => ({ title: s })); choices.unshift(PROMPT_BACK_OPTION); logSpinnerEnd(); const { pickedFunction } = await prompts.prompt([ { type: 'autocomplete', name: 'pickedFunction', message: 'Pick a FUNCTION:', choices, suggest: suggestBySubtring, }, ]); logSpinnerStart(); return pickedFunction == PROMPT_BACK_OPTION.title ? null : abiFunctions[functionSignatures.indexOf(pickedFunction)]; } async function pickFunctionArgs({ func }: { func: viem.AbiFunction }) { const args: any[] = []; let value = BigInt(0); if (func.stateMutability === 'payable') { logSpinnerEnd(); const { txnValue } = await prompts.prompt([ { type: 'text', name: 'txnValue', message: 'Function is payable. ETH AMOUNT (in eth units):', }, ]); logSpinnerStart(); value = viem.parseEther(txnValue); } for (const input of func.inputs) { const rawValue = await promptInputValue(input); if (_.isNil(rawValue)) { return null; } args.push(rawValue); } return { args, value }; } async function query({ provider, contract, functionAbi, args, blockTag, }: { provider: viem.PublicClient; contract: Contract; functionAbi: viem.AbiFunction; args: any[]; blockTag?: number; }) { const callData = viem.encodeFunctionData({ abi: [functionAbi], functionName: functionAbi.name, args, }); logSpinner(gray(` > calldata: ${callData}`)); let result = []; const callArgs = { address: contract.address, abi: [functionAbi], functionName: functionAbi.name, args, blockTag: blockTag as any, }; try { logSpinner(gray(` > estimated gas required: ${await provider.estimateContractGas(callArgs)}`)); const simulation = await provider.simulateContract(callArgs); result = simulation.result; } catch (err: any) { errorSpinner('failed query:', err?.message && process.env.TRACE !== 'true' ? err?.message : err); return null; } for (let i = 0; i < functionAbi.outputs.length; i++) { const output = functionAbi.outputs[i]; logSpinner( cyan(` ↪ ${output.name || ''}(${output.type}):`), renderArgs(output, functionAbi.outputs.length > 1 ? result[i] : result) ); } return result; } async function execTxn({ contract, functionAbi, args, value, provider, signer, }: { contract: Contract; functionAbi: viem.AbiFunction; args: any[]; value: any; provider: viem.PublicClient; signer: CannonSigner; }) { const callData = viem.encodeFunctionData({ abi: [functionAbi], functionName: functionAbi.name, args, }); let txn: viem.TransactionRequest = { to: contract.address, data: callData, value: value || 0, } as any; // estimate gas try { const chain = getChainById(await provider.getChainId()); txn = (await provider.prepareTransactionRequest( _.assign(txn, { account: signer.wallet.account || signer.address, chain, }) )) as any; logSpinner(gray(` > calldata: ${txn!.data}`)); logSpinner(gray(` > estimated gas required: ${txn!.gas}`)); logSpinner( gray( ` > gas: ${JSON.stringify( _.mapValues(_.pick(txn, 'gasPrice', 'maxFeePerGas', 'maxPriorityFeePerGas'), viem.formatGwei) )}` ) ); logSpinner(green(bold(' ✅ txn will succeed'))); } catch (err) { errorSpinner(red(`❌ txn will most likely fail: ${(err as Error).toString()}`)); errorSpinner('Txn gas limit has been set to 1,000,000 due to simulation failure'); errorSpinner('txn data', txn); txn.gas = BigInt(1000000); } if (signer != null) { logSpinnerEnd(); const { confirmation } = await prompts.prompt([ { type: 'confirm', name: 'confirmation', message: 'Send transaction?', }, ]); logSpinnerStart(); if (!confirmation) { return null; } let txHash; try { txHash = await signer.wallet.sendTransaction({ ...(txn as any)!, }); logSpinner('> hash: ', txHash); logSpinner('confirming...'); const receipt = await provider.waitForTransactionReceipt({ hash: txHash }); return receipt; } catch (err) { logTxFail(err); return null; } } else { logSpinner('not submitting transaction because in read-only mode'); } } async function promptInputValue(input: viem.AbiParameter): Promise { const name = input.name || input.type; const message = input.name ? `${input.name} (${input.type})` : input.type; for (let i = 0; i < 5; i++) { try { logSpinnerEnd(); const answer = await prompts.prompt([ { type: 'text', message, name, }, ]); logSpinnerStart(); // if there is a problem this will throw and user will be forced to re-enter data return parseInput(input, answer[name]); } catch (err) { errorSpinner('invalid input: ', err); } } } function parseInput(input: viem.AbiParameter, rawValue: string): any { const isTuple = input.type.includes('tuple'); const isBytes32 = input.type.includes('bytes32'); const isArray = input.type.includes('[]'); const isNumber = input.type.includes('int'); const isBoolean = input.type.includes('bool'); let processed = isArray || isTuple ? JSON.parse(rawValue) : rawValue; if (isBytes32 && !viem.isHex(processed)) { if (isArray) { processed = processed.map((item: string) => viem.stringToHex(item, { size: 32 })); } else { processed = viem.stringToHex(processed, { size: 32 }); } } if (isNumber) { if (isArray) { processed = processed.map((item: string) => parseWeiValue(item)); } else { processed = parseWeiValue(processed); } } if (isArray) { processed = processed.map((value: string) => boolify(value)); } if (isBoolean) { switch (processed.toLowerCase()) { case 'false': case '0': case 'no': processed = false; break; case 'true': case '1': case 'yes': processed = true; break; default: processed = null; } } //const processed = preprocessInput(input, type, hre); if (processed !== rawValue) { logSpinner(gray(` > processed inputs (${isArray ? processed.length : '1'}):`, processed)); } // Encode user's input to validate it viem.encodeAbiParameters([input], [processed]); return processed; } function parseWeiValue(v: string): bigint { if (v.includes('.')) { return viem.parseEther(v); } else { return BigInt(v); } } /** * Checks if a given ABI parameter is a tuple with components. * * This function acts as a type guard, allowing TypeScript to understand * that the parameter passed to it, if the function returns true, * is not just any AbiParameter, but specifically one that includes * the 'components' property. * * @param {viem.AbiParameter} parameter - The ABI parameter to check. * @returns {boolean} - True if the parameter is a tuple with components, false otherwise. */ function _isTupleParameter( parameter: viem.AbiParameter ): parameter is viem.AbiParameter & { components: readonly viem.AbiParameter[] } { return 'components' in parameter && parameter.type.startsWith('tuple'); } /** * Converts an object to a prettified JSON string with a specified indentation and offset. * * @param {any} obj - The object to be converted into a JSON string. * @param {number} offsetSpaces - The number of spaces to offset the entire JSON string. Default is 4. * @param {number} indentSpaces - The number of spaces used for indentation in the JSON string. Default is 2. * @returns {string} - The prettified and offset JSON string. */ export function stringifyWithOffset(obj: any, offsetSpaces = 4, indentSpaces = 2) { const jsonString = JSON.stringify(obj, null, indentSpaces); const offset = ' '.repeat(offsetSpaces); return jsonString .split('\n') .map((line) => offset + line) .join('\n'); } /** * Renders the arguments for a given ABI parameter and its associated value. * This function handles different data structures (tuples, arrays, uint, int, etc). * * @param {viem.AbiParameter} input - The ABI parameter describing the type and structure of the value. * @param {any} value - The value associated with the ABI parameter. * @param {string} offset - A string used for initial indentation, facilitating readable output formatting. * @returns {string} - A string representation of the argument, formatted for readability. */ export function renderArgs(input: viem.AbiParameter, value: any, offset = ' '): string { const lines: string[] = []; switch (true) { case _isTupleParameter(input): lines.push('', stringifyWithOffset(value)); break; case input.type.endsWith('[]') && Array.isArray(value): lines.push('', stringifyWithOffset(value)); break; default: lines.push(`${offset}${value.toString()}`); } return lines.join('\n'); } // Avoid 'false' and '0' being interpreted as bool = true function boolify(value: any) { if (value === 'false' || value === '0') return 0; return value; } async function logTxSucceed(ctx: InteractTaskArgs, receipt: viem.TransactionReceipt) { logSpinner(green(' ✅ Success')); // logSpinner('receipt', JSON.stringify(receipt, null, 2)); // Print tx hash logSpinner(gray(` tx hash: ${receipt.transactionHash}`)); // Print gas used logSpinner(gray(` gas used: ${receipt.gasUsed.toString()}`)); // Print emitted events if (receipt.logs && receipt.logs.length > 0) { const contractsByAddress = _.mapKeys( _.groupBy(_.flatten(ctx.contracts.map((contract) => _.toPairs(contract))), '1.address'), (v, k) => k.toLowerCase() ); for (let i = 0; i < receipt.logs.length; i++) { const logItem = receipt.logs[i]; let foundLog = false; for (const [n, logContract] of contractsByAddress[logItem.address.toLowerCase()] || []) { try { // find contract matching address of the log const parsedLog = viem.decodeEventLog({ ...logContract, ...logItem }); foundLog = true; logSpinner(gray(`\n log ${i}:`), cyan(parsedLog.eventName), gray(`\t${n}`)); //logContract.interface.getEvent(parsedLog.name).inputs[i] // TODO: for some reason viem does not export `AbiEvent` type (even though they export other types like AbiFunction) const eventAbiDef = viem.getAbiItem({ abi: logContract.abi, name: parsedLog.eventName as any }) as any; for (const [a, arg] of ((eventAbiDef.inputs || []) as viem.AbiParameter[]).entries()) { const output = parsedLog.args![arg.name || (`${a}` as any)]; logSpinner(cyan(` ↪ ${arg.name || ''}(${arg.type}):`), renderArgs(arg, output)); } break; } catch (err) { // nothing } } if (!foundLog) { logSpinner(gray(`\n log ${i}: unable to decode log - ${JSON.stringify(log)}`)); } } } } function logTxFail(error: any) { logSpinner(red(' ❌ Error')); function findReason(error: any): string { if (typeof error === 'string') { return error; } else { if (_.get(error, 'reason')) { return error.reason; } else if (_.get(error, 'error')) { return findReason(error.error); } } return error.toString(); } const reason = findReason(error); if (reason) logSpinner(red(` Reason: ${reason}`)); logSpinner(gray(JSON.stringify(error, null, 2))); } // filters choices by subtrings that don't have to be continuous e.g. 'ybtc' will match 'SynthsBTC' const suggestBySubtring = (input: string, choices: [{ title: string }]) => Promise.resolve( choices.filter((choice) => { const titleStr = choice.title.toLowerCase(); let index = 0; for (const c of input.toLowerCase()) { index = titleStr.indexOf(c, index); if (index === -1) { return false; // not found } else { index += 1; // start from next index } } return true; }) );