import { Command, InvalidArgumentError } from '@commander-js/extra-typings' import process from 'process' import yaml from 'yaml' import { SolidityAPIService } from '@sentio/api' import { GetSimulationBundleInProjectRequestSchema, GetSimulationInProjectRequestSchema, GetSimulationsInProjectRequestSchema, SimulateTransactionBundleInProjectRequestSchema, SimulateTransactionInProjectRequestSchema } from '@sentio/api/gen/service/solidity/protos/solidity_api_service_pb.js' import { GetSimulationBundleResponseSchema, GetSimulationResponseSchema, GetSimulationsResponseSchema, SimulateTransactionBundleResponseSchema, SimulateTransactionResponseSchema } from '@sentio/api/gen/service/solidity/protos/solidity_service_pb.js' import { fromJson, toJson } from '@bufbuild/protobuf' import { CliError, createApiContext, fetchApiJson, getServiceClient, handleCommandError, loadJsonInput, resolveProjectRef } from '../api.js' interface SimulationOptions { host?: string apiKey?: string token?: string project?: string owner?: string name?: string projectId?: string json?: boolean yaml?: boolean } interface SimulationListOptions extends SimulationOptions { labelContains?: string page?: number pageSize?: number } interface SimulationRunOptions extends SimulationOptions { chainId?: string file?: string stdin?: boolean } interface SimulationTraceOptions extends SimulationOptions { chainId?: string withInternalCalls?: boolean disableOptimizer?: boolean ignoreGasCost?: boolean } export function createSimulationCommand() { const simulationCommand = new Command('simulation').description('Manage Sentio Solidity simulations') simulationCommand.addCommand(createSimulationListCommand()) simulationCommand.addCommand(createSimulationGetCommand()) simulationCommand.addCommand(createSimulationBundleCommand()) simulationCommand.addCommand(createSimulationRunCommand()) simulationCommand.addCommand(createSimulationRunBundleCommand()) simulationCommand.addCommand(createSimulationTraceCommand()) return simulationCommand } function createSimulationListCommand() { return withOutputOptions( withSharedProjectOptions(withAuthOptions(new Command('list').description('List simulations in a project'))) ) .showHelpAfterError() .option('--label-contains ', 'Filter by label substring') .option('--page ', 'Page number', parseInteger) .option('--page-size ', 'Page size', parseInteger) .action(async (options, command) => { try { await runSimulationList(options) } catch (error) { handleSimulationCommandError(error, command) } }) } function createSimulationGetCommand() { return withOutputOptions( withSharedProjectOptions( withAuthOptions(new Command('get').description('Get a simulation').argument('')) ) ) .showHelpAfterError() .action(async (simulationId, options, command) => { try { await runSimulationGet(simulationId, options) } catch (error) { handleSimulationCommandError(error, command) } }) } function createSimulationBundleCommand() { const bundleCommand = new Command('bundle').description('Get simulation bundles') bundleCommand.addCommand( withOutputOptions( withSharedProjectOptions( withAuthOptions(new Command('get').description('Get a simulation bundle').argument('')) ) ) .showHelpAfterError() .action(async (bundleId, options, command) => { try { await runSimulationBundleGet(bundleId, options) } catch (error) { handleSimulationCommandError(error, command) } }) ) return bundleCommand } function createSimulationRunCommand() { return withOutputOptions( withJsonInputOptions( withSharedProjectOptions(withAuthOptions(new Command('run').description('Run a transaction simulation'))) ) ) .showHelpAfterError() .option('--chain-id ', 'Chain id') .addHelpText( 'after', ` Examples: $ sentio simulation run --project sentio/coinbase --chain-id 1 --file simulation.yaml ` ) .action(async (options, command) => { try { await runSimulation(options) } catch (error) { handleSimulationCommandError(error, command) } }) } function createSimulationRunBundleCommand() { return withOutputOptions( withJsonInputOptions( withSharedProjectOptions(withAuthOptions(new Command('run-bundle').description('Run a bundle simulation'))) ) ) .showHelpAfterError() .option('--chain-id ', 'Chain id') .action(async (options, command) => { try { await runSimulationBundle(options) } catch (error) { handleSimulationCommandError(error, command) } }) } function createSimulationTraceCommand() { const traceCommand = new Command('trace').description('Fetch call traces') traceCommand.addCommand(createSimulationTraceBySimulationCommand()) traceCommand.addCommand(createSimulationTraceByBundleCommand()) traceCommand.addCommand(createSimulationTraceByTxCommand()) return traceCommand } function createSimulationTraceBySimulationCommand() { return withOutputOptions( withTraceOptions( withSharedProjectOptions( withAuthOptions(new Command('simulation').description('Get trace by simulation').argument('')) ) ) ) .showHelpAfterError() .action(async (simulationId, options, command) => { try { await runTraceBySimulation(simulationId, options) } catch (error) { handleSimulationCommandError(error, command) } }) } function createSimulationTraceByBundleCommand() { return withOutputOptions( withTraceOptions( withSharedProjectOptions( withAuthOptions(new Command('bundle').description('Get trace by bundle').argument('')) ) ) ) .showHelpAfterError() .action(async (bundleId, options, command) => { try { await runTraceByBundle(bundleId, options) } catch (error) { handleSimulationCommandError(error, command) } }) } function createSimulationTraceByTxCommand() { return withOutputOptions( withTraceOptions( withSharedProjectOptions( withAuthOptions(new Command('tx').description('Get trace by transaction').argument('')) ) ) ) .showHelpAfterError() .action(async (txHash, options, command) => { try { await runTraceByTransaction(txHash, options) } catch (error) { handleSimulationCommandError(error, command) } }) } async function runSimulationList(options: SimulationListOptions) { const context = createApiContext(options) const project = await resolveProjectRef(options, context, { ownerSlug: true }) const client = getServiceClient(SolidityAPIService, context) const res = await client.getSimulations( fromJson(GetSimulationsInProjectRequestSchema, { projectOwner: project.owner, projectSlug: project.slug, ...(options.labelContains !== undefined ? { labelContains: options.labelContains } : {}), ...(options.page !== undefined ? { page: options.page } : {}), ...(options.pageSize !== undefined ? { pageSize: options.pageSize } : {}) }) ) printOutput(options, toJson(GetSimulationsResponseSchema, res)) } async function runSimulationGet(simulationId: string, options: SimulationOptions) { const context = createApiContext(options) const project = await resolveProjectRef(options, context, { ownerSlug: true }) const client = getServiceClient(SolidityAPIService, context) const res = await client.getSimulation( fromJson(GetSimulationInProjectRequestSchema, { projectOwner: project.owner, projectSlug: project.slug, simulationId }) ) printOutput(options, toJson(GetSimulationResponseSchema, res)) } async function runSimulationBundleGet(bundleId: string, options: SimulationOptions) { const context = createApiContext(options) const project = await resolveProjectRef(options, context, { ownerSlug: true }) const client = getServiceClient(SolidityAPIService, context) const res = await client.getSimulationBundleInProject( fromJson(GetSimulationBundleInProjectRequestSchema, { projectOwner: project.owner, projectSlug: project.slug, bundleId }) ) printOutput(options, toJson(GetSimulationBundleResponseSchema, res)) } async function runSimulation(options: SimulationRunOptions) { const body = loadJsonInput(options) if (!body) { throw new CliError('Provide --file or --stdin for simulation run.') } if (!options.chainId) { throw new CliError('Chain id is required. Use --chain-id .') } const context = createApiContext(options) const project = await resolveProjectRef(options, context, { ownerSlug: true }) const client = getServiceClient(SolidityAPIService, context) // 1.x body was { simulation: Simulation } and chainId rode in the URL path; in // 2.x both live on the request message and chainId is a field of the simulation. const bodyJson = body as Record const simulation = { chainId: options.chainId, ...((bodyJson.simulation as Record | undefined) ?? {}) } const res = await client.simulateTransaction( fromJson(SimulateTransactionInProjectRequestSchema, { projectOwner: project.owner, projectSlug: project.slug, simulation }) ) printOutput(options, toJson(SimulateTransactionResponseSchema, res)) } async function runSimulationBundle(options: SimulationRunOptions) { const body = loadJsonInput(options) if (!body) { throw new CliError('Provide --file or --stdin for simulation run-bundle.') } if (!options.chainId) { throw new CliError('Chain id is required. Use --chain-id .') } const chainId = options.chainId const context = createApiContext(options) const project = await resolveProjectRef(options, context, { ownerSlug: true }) const client = getServiceClient(SolidityAPIService, context) // 1.x body was { simulations: Simulation[] } and chainId rode in the URL path; // in 2.x both live on the request message and chainId is a field of each simulation. const bodyJson = body as Record const inputSimulations = Array.isArray(bodyJson.simulations) ? (bodyJson.simulations as Array>) : [] const simulations = inputSimulations.map((simulation) => ({ chainId, ...simulation })) const res = await client.simulateTransactionBundle( fromJson(SimulateTransactionBundleInProjectRequestSchema, { projectOwner: project.owner, projectSlug: project.slug, simulations }) ) printOutput(options, toJson(SimulateTransactionBundleResponseSchema, res)) } async function runTraceBySimulation(simulationId: string, options: SimulationTraceOptions) { await runTrace('simulation', simulationId, options) } async function runTraceByBundle(bundleId: string, options: SimulationTraceOptions) { await runTrace('bundle', bundleId, options) } async function runTraceByTransaction(txHash: string, options: SimulationTraceOptions) { await runTrace('transaction', txHash, options) } // The call-trace-by-id endpoints are keyed by chainId + the simulation/bundle/tx // id, but the 2.x public proto (GetCallTraceInProjectRequest) does not carry those // fields. Call the REST gateway route directly — the same dialect the connect // client speaks — so the id and chainId can still be forwarded. async function runTrace( resource: 'simulation' | 'bundle' | 'transaction', id: string, options: SimulationTraceOptions ) { if (!options.chainId) { throw new CliError('Chain id is required. Use --chain-id .') } const context = createApiContext(options) const project = await resolveProjectRef(options, context, { ownerSlug: true }) const path = `/api/v1/solidity/${project.owner}/${project.slug}/${options.chainId}/${resource}/${encodeURIComponent(id)}/call_trace` const data = await fetchApiJson(path, context, buildTraceQuery(options)) printOutput(options, data) } function buildTraceQuery(options: SimulationTraceOptions) { return { ...(options.withInternalCalls !== undefined ? { withInternalCalls: options.withInternalCalls } : {}), ...(options.disableOptimizer !== undefined ? { disableOptimizer: options.disableOptimizer } : {}), ...(options.ignoreGasCost !== undefined ? { ignoreGasCost: options.ignoreGasCost } : {}) } } function withAuthOptions(command: Command) { return command .option('--host ', 'Override Sentio host') .option('--api-key ', 'Use an explicit API key instead of saved credentials') .option('--token ', 'Use an explicit bearer token instead of saved credentials') } function withSharedProjectOptions(command: Command) { return command .option('--project ', 'Sentio project in / format') .option('--owner ', 'Sentio project owner') .option('--name ', 'Sentio project name') .option('--project-id ', 'Sentio project id') } function withJsonInputOptions(command: Command) { return command .option('--file ', 'Read request JSON or YAML from file') .option('--stdin', 'Read request JSON or YAML from stdin') } function withOutputOptions(command: Command) { return command.option('--json', 'Print raw JSON response').option('--yaml', 'Print raw YAML response') } function withTraceOptions(command: Command) { return command .option('--chain-id ', 'Chain id') .option('--with-internal-calls', 'Fetch decoded trace with internal calls') .option('--disable-optimizer', 'Disable optimizer for more accurate internal calls') .option('--ignore-gas-cost', 'Ignore gas cost when optimizer is disabled') } function handleSimulationCommandError(error: unknown, command?: Command) { if ( error instanceof CliError && (error.message.startsWith('Project is required.') || error.message.startsWith('Invalid project ') || error.message.startsWith('Provide --file or --stdin for simulation run') || error.message.startsWith('Chain id is required. Use --chain-id .')) ) { console.error(error.message) if (command) { console.error() command.outputHelp() } process.exit(1) } handleCommandError(error) } function printOutput(options: SimulationOptions, data: unknown) { if (options.json && options.yaml) { throw new CliError('Choose only one structured output format: --json or --yaml.') } if (options.json) { console.log(JSON.stringify(data, null, 2)) return } if (options.yaml) { console.log(yaml.stringify(data).trimEnd()) return } console.log(formatOutput(data)) } function formatOutput(data: unknown) { if (data && typeof data === 'object' && Array.isArray((data as { simulations?: unknown[] }).simulations)) { const simulations = (data as { simulations: Array> }).simulations const lines = [`Simulations (${simulations.length})`] for (const simulation of simulations) { lines.push(formatSimulationLine(simulation)) } return lines.join('\n') } if (data && typeof data === 'object' && 'simulation' in (data as Record)) { const simulation = (data as { simulation?: Record }).simulation return simulation ? formatSimulationDetails(simulation) : JSON.stringify(data, null, 2) } if (data && typeof data === 'object' && 'bundleId' in (data as Record)) { const bundle = data as { bundleId?: string; simulations?: Array>; error?: string } const lines = [`Bundle: ${bundle.bundleId ?? ''}`] if (bundle.error) { lines.push(`Error: ${bundle.error}`) } const simulations = Array.isArray(bundle.simulations) ? bundle.simulations : [] if (simulations.length > 0) { lines.push(`Simulations (${simulations.length})`) for (const simulation of simulations) { lines.push(formatSimulationLine(simulation)) } } return lines.join('\n') } if ( data && typeof data === 'object' && ('contentType' in (data as Record) || 'data' in (data as Record)) ) { const body = data as { contentType?: string; data?: string } const lines = ['Trace'] if (body.contentType) { lines.push(`Content-Type: ${body.contentType}`) } if (body.data) { lines.push(body.data) } return lines.join('\n') } return JSON.stringify(data, null, 2) } function formatSimulationLine(simulation: Record) { const id = asString(simulation.id) ?? '' const label = asString(simulation.label) const chainId = asString(simulation.chainId) ?? asString((simulation.chainSpec as Record | undefined)?.chainId) ?? '?' const from = asString(simulation.from) ?? '?' const to = asString(simulation.to) ?? '?' return `- ${id}${label ? ` "${label}"` : ''} chain=${chainId} from=${from} to=${to}` } function formatSimulationDetails(simulation: Record) { const lines = [formatSimulationLine(simulation)] if (asString(simulation.bundleId)) { lines.push(`Bundle ID: ${asString(simulation.bundleId)}`) } if (asString(simulation.blockNumber)) { lines.push(`Block: ${asString(simulation.blockNumber)}`) } if (asString(simulation.transactionIndex)) { lines.push(`Transaction index: ${asString(simulation.transactionIndex)}`) } if (asString(simulation.value)) { lines.push(`Value: ${asString(simulation.value)}`) } return lines.join('\n') } function parseInteger(value: string) { const parsedValue = Number.parseInt(value, 10) if (Number.isNaN(parsedValue)) { throw new InvalidArgumentError('Not a number.') } return parsedValue } function asString(value: unknown) { return typeof value === 'string' ? value : undefined }