import type { BaseContract } from "ethers"; import { getAddress, isAddress } from "ethers"; import { ethers } from "hardhat"; import { assertSelectedSimulationSignerAuthorized, decideAuthority, } from "./authority"; import { encodeCall } from "./calldata"; import { findExecutionCandidateAddress } from "./contractInfoCandidates"; import { getDeploymentAddress } from "./deployments"; import { ExternalCall, ExternalExecutionRequired } from "./external"; import { resolveValue, WorkflowStep } from "./parameters"; import type { WorkflowContext } from "../run"; import { impersonateWorkflowSimulationSigner } from "./signer"; export interface ContractCall { method: string; args?: unknown[]; value?: string | number; expect?: unknown; } export interface ResolvedContract { contractName: string; abiName: string; address: string; contract: BaseContract; } type ContractMethod = (...args: unknown[]) => Promise; const resultKey = (call: ContractCall, index: number, calls: ContractCall[]): string => { return calls.length === 1 ? call.method : `${index + 1}.${call.method}`; }; const calldataStepId = (step: WorkflowStep, call: ContractCall, index: number, calls: ContractCall[]): string => { return calls.length === 1 ? step.id : `${step.id}-${index + 1}-${call.method}`; }; const methodOf = (contract: BaseContract, method: string): ContractMethod => { const callable = contract as unknown as Record; const contractMethod = callable[method]; if (typeof contractMethod !== "function") { throw new Error(`Contract does not expose method "${method}"`); } return contractMethod as ContractMethod; }; const findStateAddress = ( context: WorkflowContext, name: string, ): string | undefined => { for (const pathExpression of [`${name}.address`, `${name}.proxyAddress`]) { try { const value = context.state.get(pathExpression); if (typeof value === "string" && value) { return value; } } catch { // State references are opportunistic here; persisted contractInfo remains the fallback. } } return undefined; }; const stringStepField = ( step: WorkflowStep, key: string, ): string | undefined => { const value = step[key]; return typeof value === "string" && value.trim() ? value.trim() : undefined; }; export const resolveContract = async ( context: WorkflowContext, step: WorkflowStep, ): Promise => { const contractName = String(step.contract); const abiName = String(step.abi || contractName); const deploymentName = typeof step.deployment === "string" && step.deployment.trim() ? step.deployment.trim() : undefined; const lookupName = deploymentName || stringStepField(step, "saveAs") || contractName; const address = String( step.address ? resolveValue(step.address, { targetId: context.targetId, target: context.target, params: context.targetParams, state: context.state, }) : findStateAddress(context, lookupName) || findExecutionCandidateAddress({ root: context.root, deploymentId: context.taskId, executionId: context.executionId, network: context.targetId, name: lookupName, }) || getDeploymentAddress(context.root, context.config, context.targetId, lookupName), ); const contract = await ethers.getContractAt(abiName, address, context.signer); return { contractName, abiName, address, contract, }; }; export const resolveStepCalls = (step: WorkflowStep): ContractCall[] => { const calls = step.calls as ContractCall[] | undefined; if (!calls || calls.length === 0) { throw new Error(`Step ${step.id} requires calls`); } return calls; }; export const resolveCallArgs = ( context: WorkflowContext, call: ContractCall, ): unknown[] => { const args = resolveValue(call.args || [], { targetId: context.targetId, target: context.target, params: context.targetParams, state: context.state, }); if (!Array.isArray(args)) { throw new Error(`Call ${call.method} args must resolve to an array`); } return args; }; export const stringifyCallResult = (value: unknown): string => { return value?.toString?.() ?? String(value); }; const comparableCallResults = ( actual: string, expected: string, ): { actual: string; expected: string } => { if (isAddress(actual) && isAddress(expected)) { return { actual: getAddress(actual), expected: getAddress(expected), }; } return { actual, expected }; }; export const normalizeCallValue = (value: unknown): string => { if (value === undefined) return "0"; if (typeof value === "string") { const candidate = value.trim(); if (candidate.length === 0) return "0"; if (!/^(?:[0-9]+|0[xX][0-9a-fA-F]+)$/.test(candidate)) { throw new Error( "Call value must be a nonnegative decimal/hex string or a safe nonnegative integer", ); } return BigInt(candidate).toString(10); } if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { return value.toString(10); } throw new Error( "Call value must be undefined, empty, a nonnegative decimal/hex string, or a safe nonnegative integer", ); }; export const runContractWriteCalls = async ( context: WorkflowContext, step: WorkflowStep, ): Promise => { const resolved = await resolveContract(context, step); const calls = resolveStepCalls(step); const callValues = calls.map((call) => normalizeCallValue(call.value)); if (!context.signerAddress) { throw new Error(`Call step ${step.id} requires a signer address`); } const authority = await decideAuthority(resolved.contract, context.target, context.signerAddress); if ( context.mode === "simulate" && !context.executionPlanner && !context.configuredSimulationCaller ) { assertSelectedSimulationSignerAuthorized(authority, step.id); } if ( authority.mode === "calldata" && context.mode === "execute" && !context.executionPlanner ) { if (!authority.executor) { throw new Error(`Call step ${step.id} cannot resolve an external executor`); } const externalExecutor = authority.executor; const externalCalls: ExternalCall[] = calls.map((call, index) => { const args = resolveCallArgs(context, call); const stepId = calldataStepId(step, call, index, calls); return { transactionId: stepId, stepId, to: resolved.address, value: callValues[index], data: encodeCall(resolved.contract.interface, call.method, args), operation: 0, method: call.method, args, executor: externalExecutor, reason: authority.reason, }; }); throw new ExternalExecutionRequired(step.id, externalCalls); } for (const [index, call] of calls.entries()) { const args = resolveCallArgs(context, call); const value = callValues[index]; if (context.executionPlanner) { const stepId = calldataStepId(step, call, index, calls); const executor = authority.mode === "execute" ? context.signerAddress : authority.executor; if (!executor) { throw new Error(`Call step ${step.id} cannot resolve its required executor`); } const data = encodeCall(resolved.contract.interface, call.method, args); const planned = context.executionPlanner.add({ stepId, executor, to: resolved.address, value, data, operation: 0, }); if (planned.executionAction === "replay") { context.reporter.result("execution", { stepId: planned.stepId, kind: step.kind, mode: "replay", transactionId: planned.transactionId, transactionHash: planned.transactionHash, executor: planned.executor, }); continue; } if (planned.route === "external" && context.mode !== "simulate") { context.externalCalls.push({ transactionId: planned.transactionId, stepId: planned.stepId, to: planned.to, value: planned.value, data: planned.data, operation: planned.operation, method: call.method, args, executor: planned.executor, reason: authority.reason, }); context.reporter.result("execution", { stepId: planned.stepId, kind: step.kind, mode: "external", transactionId: planned.transactionId, executor: planned.executor, }); continue; } const selectedContract = planned.route === "external" ? await ethers.getContractAt( resolved.abiName, resolved.address, await impersonateWorkflowSimulationSigner(planned.executor), ) : resolved.contract; const txArgs = value !== "0" ? [...args, { value }] : args; const tx = await methodOf(selectedContract, call.method)(...txArgs) as { hash: string; wait: () => Promise; }; await tx.wait(); if (context.mode === "execute" && planned.route === "operator") { context.operatorReceipts.push({ transactionId: planned.transactionId, transactionHash: tx.hash, }); } context.reporter.result("execution", { stepId: planned.stepId, kind: step.kind, mode: context.mode === "simulate" ? `simulate-${planned.route}` : planned.route, transactionId: planned.transactionId, executor: planned.executor, contract: resolved.contractName, address: resolved.address, method: call.method, args, value, txHash: tx.hash, }); continue; } const txArgs = value !== "0" ? [...args, { value }] : args; const tx = await methodOf(resolved.contract, call.method)(...txArgs) as { hash: string; wait: () => Promise; }; await tx.wait(); context.reporter.result("execution", { stepId: step.id, kind: step.kind, mode: "execute", contract: resolved.contractName, address: resolved.address, method: call.method, args, value, txHash: tx.hash, }); } }; export const runContractViewCalls = async ( context: WorkflowContext, step: WorkflowStep, ): Promise => { const resolved = await resolveContract(context, step); const calls = resolveStepCalls(step); const results: Record = {}; for (const [index, call] of calls.entries()) { const args = resolveCallArgs(context, call); const value = await methodOf(resolved.contract, call.method)(...args); results[resultKey(call, index, calls)] = stringifyCallResult(value); } context.reporter.result("view", { stepId: step.id, kind: step.kind, contract: resolved.contractName, address: resolved.address, results, }); }; export const runContractCheckCalls = async ( context: WorkflowContext, step: WorkflowStep, ): Promise => { const resolved = await resolveContract(context, step); const calls = resolveStepCalls(step); const results: Record = {}; for (const [index, call] of calls.entries()) { const args = resolveCallArgs(context, call); const actualValue = await methodOf(resolved.contract, call.method)(...args); const expectedValue = resolveValue(call.expect, { targetId: context.targetId, target: context.target, params: context.targetParams, state: context.state, }); const actual = stringifyCallResult(actualValue); const expected = stringifyCallResult(expectedValue); const comparable = comparableCallResults(actual, expected); if (comparable.actual !== comparable.expected) { throw new Error( `Check failed for ${step.id}.${call.method}: expected ${comparable.expected}, got ${comparable.actual}`, ); } results[resultKey(call, index, calls)] = comparable; } context.reporter.result("verification", { stepId: step.id, kind: step.kind, contract: resolved.contractName, address: resolved.address, results, }); };