diff --git a/.changeset/celocli-governance-propose-simulate.md b/.changeset/celocli-governance-propose-simulate.md new file mode 100644 index 0000000000..0f475b293a --- /dev/null +++ b/.changeset/celocli-governance-propose-simulate.md @@ -0,0 +1,9 @@ +--- +'@celo/celocli': minor +--- + +Add `--simulate` flag to `governance:propose` command to simulate proposal execution against a forked node (e.g. anvil) before submitting on-chain. + +Unlike the default `eth_call`-based simulation, `--simulate` actually sends each proposal transaction sequentially from the Governance contract address (which the node must have unlocked, e.g. `anvil --auto-impersonate`). This means transactions execute against cumulative state changes — useful for proposals where the success of one transaction depends on a previous one succeeding. If any transaction fails, the proposal is not submitted and the decoded revert reason is printed. + +Example: `celocli governance:propose --jsonTransactions ./transactions.json --deposit 100e18 --from 0x... --descriptionURL https://... --simulate http://127.0.0.1:8545` diff --git a/packages/cli/src/base.ts b/packages/cli/src/base.ts index 3856b79271..c8d3bfec35 100644 --- a/packages/cli/src/base.ts +++ b/packages/cli/src/base.ts @@ -18,9 +18,7 @@ import { ArgOutput, FlagOutput, Input, ParserOutput } from '@oclif/core/lib/inte import chalk from 'chalk' import net from 'net' import { - createPublicClient, createWalletClient, - extractChain, http, isAddressEqual, MethodNotFoundRpcError, @@ -31,6 +29,7 @@ import { privateKeyToAccount } from 'viem/accounts' import { celo, celoSepolia } from 'viem/chains' import { ipc } from 'viem/node' import Web3 from 'web3' +import createCeloPublicClient from './packages-to-be/public-client' import createRpcWalletClient from './packages-to-be/rpc-client' import { failWith } from './utils/cli' import { CustomFlags } from './utils/command' @@ -227,39 +226,7 @@ export abstract class BaseCommand extends Command { const nodeUrl = await this.getNodeUrl() ux.action.start(`Connecting to Node ${nodeUrl}`) const transport = await this.getTransport() - - // Create an intermediate client to get the chain id - const intermediateClient = createPublicClient({ - transport, - }) - const chainId = await intermediateClient.getChainId() - const extractedChain = extractChain({ - chains: [celo, celoSepolia], - id: chainId as typeof celo.id | typeof celoSepolia.id, - }) - - if (extractedChain) { - this.publicClient = createPublicClient({ - transport, - batch: { multicall: true }, - chain: extractedChain, - }) - } else { - // we might be connecting to a dev chain or anvil fork or another testnet - this.publicClient = createPublicClient({ - transport, - chain: { - name: 'Custom Celo Chain', - id: chainId, - nativeCurrency: celo.nativeCurrency, - formatters: celo.formatters, - serializers: celo.serializers, - rpcUrls: { - default: { http: [nodeUrl] }, - }, - } as unknown as PublicCeloClient['chain'], - }) - } + this.publicClient = await createCeloPublicClient({ transport, nodeUrl }) ux.action.stop() } diff --git a/packages/cli/src/commands/governance/build-proposal.ts b/packages/cli/src/commands/governance/build-proposal.ts index 07c281239f..46540b3278 100644 --- a/packages/cli/src/commands/governance/build-proposal.ts +++ b/packages/cli/src/commands/governance/build-proposal.ts @@ -55,6 +55,7 @@ export default class BuildProposal extends BaseCommand { output.forEach((tx) => builder.addJsonTx(tx)) const proposal = await builder.build() - await checkProposal(proposal, kit) + const governance = await kit.contracts.getGovernance() + await checkProposal(proposal, kit, governance.address) } } diff --git a/packages/cli/src/commands/governance/hashhotfix.ts b/packages/cli/src/commands/governance/hashhotfix.ts index 0d123aaec2..22d5126b24 100644 --- a/packages/cli/src/commands/governance/hashhotfix.ts +++ b/packages/cli/src/commands/governance/hashhotfix.ts @@ -34,15 +34,16 @@ export default class HashHotfix extends BaseCommand { jsonTransactions.forEach((tx) => builder.addJsonTx(tx)) const hotfix = await builder.build() + const governance = await kit.contracts.getGovernance() + if (!res.flags.force) { - const ok = await checkProposal(hotfix, kit) + const ok = await checkProposal(hotfix, kit, governance.address) if (!ok) { return } } // Combine with the salt and hash the proposal. - const governance = await kit.contracts.getGovernance() const saltBuff = Buffer.from(trimLeading0x(res.flags.salt), 'hex') console.log(`salt: ${res.flags.salt}, buf: ${saltBuff.toString('hex')}`) const hash = await governance.getHotfixHash(hotfix, saltBuff) diff --git a/packages/cli/src/commands/governance/propose.ts b/packages/cli/src/commands/governance/propose.ts index be7d426528..f60e1789bb 100644 --- a/packages/cli/src/commands/governance/propose.ts +++ b/packages/cli/src/commands/governance/propose.ts @@ -11,6 +11,7 @@ import { addExistingProposalIDToBuilder, addExistingProposalJSONFileToBuilder, checkProposal, + simulateProposalOnRpc, } from '../../utils/governance' import { createSafeFromWeb3, @@ -33,7 +34,17 @@ export default class Propose extends BaseCommand { description: 'Amount of Celo to attach to proposal', }), from: CustomFlags.address({ required: true, description: "Proposer's address" }), - force: Flags.boolean({ description: 'Skip execution check', default: false }), + force: Flags.boolean({ + description: 'Skip execution check', + default: false, + exclusive: ['simulate'], + }), + simulate: Flags.string({ + required: false, + description: + 'RPC URL of a forked node (e.g. anvil) to simulate the proposal against. Each proposal transaction is actually sent (not eth_call) from the Governance contract address, which the node must have unlocked (e.g. anvil --auto-impersonate). Replaces the default eth_call simulation. Useful for proposals where the success of one tx depends on a previous one succeeding.', + exclusive: ['force'], + }), noInfo: Flags.boolean({ description: 'Skip printing the proposal info', default: false }), descriptionURL: CustomFlags.proposalDescriptionURL({ required: true, @@ -114,7 +125,9 @@ export default class Propose extends BaseCommand { } if (!res.flags.force) { - const ok = await checkProposal(proposal, kit) + const ok = res.flags.simulate + ? await simulateProposalOnRpc(proposal, res.flags.simulate, governance.address) + : await checkProposal(proposal, kit, governance.address) if (!ok) { return } diff --git a/packages/cli/src/packages-to-be/public-client.ts b/packages/cli/src/packages-to-be/public-client.ts new file mode 100644 index 0000000000..e5d2e4055f --- /dev/null +++ b/packages/cli/src/packages-to-be/public-client.ts @@ -0,0 +1,42 @@ +import { type PublicCeloClient } from '@celo/actions' +import { createPublicClient, extractChain, type Transport } from 'viem' +import { celo, celoSepolia } from 'viem/chains' + +export default async function createCeloPublicClient({ + transport, + nodeUrl, +}: { + transport: Transport + nodeUrl: string +}): Promise { + // Create an intermediate client to get the chain id + const intermediateClient = createPublicClient({ transport }) + const chainId = await intermediateClient.getChainId() + const extractedChain = extractChain({ + chains: [celo, celoSepolia], + id: chainId as typeof celo.id | typeof celoSepolia.id, + }) + + if (extractedChain) { + return createPublicClient({ + transport, + batch: { multicall: true }, + chain: extractedChain, + }) + } + + // we might be connecting to a dev chain or anvil fork or another testnet + return createPublicClient({ + transport, + chain: { + name: 'Custom Celo Chain', + id: chainId, + nativeCurrency: celo.nativeCurrency, + formatters: celo.formatters, + serializers: celo.serializers, + rpcUrls: { + default: { http: [nodeUrl] }, + }, + } as unknown as PublicCeloClient['chain'], + }) +} diff --git a/packages/cli/src/utils/governance.ts b/packages/cli/src/utils/governance.ts index c1dd228c05..7a795aa22d 100644 --- a/packages/cli/src/utils/governance.ts +++ b/packages/cli/src/utils/governance.ts @@ -1,13 +1,83 @@ +import { type StrongAddress } from '@celo/base' import { toTxResult } from '@celo/connect' import { ContractKit } from '@celo/contractkit' import { ProposalTransaction } from '@celo/contractkit/lib/wrappers/Governance' import { ProposalBuilder, proposalToJSON, ProposalTransactionJSON } from '@celo/governance' import chalk from 'chalk' import { readJsonSync } from 'fs-extra' +import { createWalletClient, http, type Hex } from 'viem' +import createCeloPublicClient from '../packages-to-be/public-client' -export async function checkProposal(proposal: ProposalTransaction[], kit: ContractKit) { - const governance = await kit.contracts.getGovernance() - return tryProposal(proposal, kit, governance.address, true) +export async function checkProposal( + proposal: ProposalTransaction[], + kit: ContractKit, + governanceAddress: StrongAddress +) { + return tryProposal(proposal, kit, governanceAddress, true) +} + +export async function simulateProposalOnRpc( + proposal: ProposalTransaction[], + rpcUrl: string, + governanceAddress: StrongAddress +) { + const transport = http(rpcUrl) + const publicClient = await createCeloPublicClient({ transport, nodeUrl: rpcUrl }) + + const walletClient = createWalletClient({ + transport, + chain: publicClient.chain, + account: governanceAddress, + }) + + console.log(`Simulating proposal execution against ${rpcUrl} as Governance ${governanceAddress}`) + + let ok = true + for (const [i, tx] of proposal.entries()) { + if (!tx.to) { + console.log( + chalk.red(` ${chalk.bold('✘')} Transaction ${i} has no 'to' address; skipping`) + ) + ok = false + continue + } + try { + const hash = await walletClient.sendTransaction({ + to: tx.to as StrongAddress, + value: BigInt(tx.value ?? 0), + data: (tx.input ?? '0x') as Hex, + }) + const receipt = await publicClient.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') { + let reason = '' + try { + await publicClient.call({ + to: tx.to as StrongAddress, + data: (tx.input ?? '0x') as Hex, + value: BigInt(tx.value ?? 0), + account: governanceAddress, + blockNumber: receipt.blockNumber, + }) + } catch (callErr: any) { + reason = callErr.shortMessage || callErr.message || String(callErr) + } + console.log( + chalk.red( + ` ${chalk.bold('✘')} Transaction ${i} reverted on-chain (${hash})${ + reason ? `: ${reason}` : '' + }` + ) + ) + ok = false + } else { + console.log(chalk.green(` ${chalk.bold('✔')} Transaction ${i} success! (${hash})`)) + } + } catch (err: any) { + console.log(chalk.red(` ${chalk.bold('✘')} Transaction ${i} failure: ${err.toString()}`)) + ok = false + } + } + return ok } export async function executeProposal( @@ -28,6 +98,10 @@ async function tryProposal( let ok = true for (const [i, tx] of proposal.entries()) { if (!tx.to) { + console.log( + chalk.red(` ${chalk.bold('✘')} Transaction ${i} has no 'to' address; skipping`) + ) + ok = false continue }