From c97bfd3f648f9255f1174fa1b0299ec36d908523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Volpe?= Date: Wed, 29 Apr 2026 17:05:18 +0200 Subject: [PATCH 1/7] Add --simulate flag to governance:propose for forked-node simulation --- packages/cli/src/base.ts | 37 +---------- .../cli/src/commands/governance/propose.ts | 18 +++++- .../cli/src/packages-to-be/public-client.ts | 40 ++++++++++++ packages/cli/src/utils/governance.ts | 64 +++++++++++++++++++ 4 files changed, 122 insertions(+), 37 deletions(-) create mode 100644 packages/cli/src/packages-to-be/public-client.ts 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/propose.ts b/packages/cli/src/commands/governance/propose.ts index be7d426528..424ee0dd6b 100644 --- a/packages/cli/src/commands/governance/propose.ts +++ b/packages/cli/src/commands/governance/propose.ts @@ -2,6 +2,7 @@ import { ProposalBuilder, proposalToJSON, ProposalTransactionJSON } from '@celo/ import { Flags } from '@oclif/core' import { BigNumber } from 'bignumber.js' import { readFileSync } from 'fs' +import { type Hex } from 'viem' import { BaseCommand } from '../../base' import { newCheckBuilder } from '../../utils/checks' import { displaySendTx, printValueMapRecursive } from '../../utils/cli' @@ -11,6 +12,7 @@ import { addExistingProposalIDToBuilder, addExistingProposalJSONFileToBuilder, checkProposal, + simulateProposalOnRpc, } from '../../utils/governance' import { createSafeFromWeb3, @@ -33,7 +35,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.', + exclusive: ['force'], + }), noInfo: Flags.boolean({ description: 'Skip printing the proposal info', default: false }), descriptionURL: CustomFlags.proposalDescriptionURL({ required: true, @@ -114,7 +126,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 as Hex) + : await checkProposal(proposal, kit) 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..7f4534e32c --- /dev/null +++ b/packages/cli/src/packages-to-be/public-client.ts @@ -0,0 +1,40 @@ +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 { + 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, + }) + } + + 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..678cd2c7d7 100644 --- a/packages/cli/src/utils/governance.ts +++ b/packages/cli/src/utils/governance.ts @@ -4,12 +4,76 @@ 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 simulateProposalOnRpc( + proposal: ProposalTransaction[], + rpcUrl: string, + governanceAddress: Hex +) { + 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) { + continue + } + try { + const hash = await walletClient.sendTransaction({ + to: tx.to as Hex, + 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 Hex, + 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( proposal: ProposalTransaction[], kit: ContractKit, From 131b4c8c094e13c1ef9b279cc322b51a51cf32dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Volpe?= Date: Wed, 29 Apr 2026 17:08:06 +0200 Subject: [PATCH 2/7] Note --simulate use case in flag description --- packages/cli/src/commands/governance/propose.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/governance/propose.ts b/packages/cli/src/commands/governance/propose.ts index 424ee0dd6b..fb1797c662 100644 --- a/packages/cli/src/commands/governance/propose.ts +++ b/packages/cli/src/commands/governance/propose.ts @@ -43,7 +43,7 @@ export default class Propose extends BaseCommand { 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.', + '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 }), From 9493dbc9bd2f00cc4fced43725fd39191e7dc0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Volpe?= Date: Wed, 29 Apr 2026 17:14:12 +0200 Subject: [PATCH 3/7] Use StrongAddress for governance address (repo convention) --- packages/cli/src/commands/governance/propose.ts | 3 +-- packages/cli/src/utils/governance.ts | 11 +++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/governance/propose.ts b/packages/cli/src/commands/governance/propose.ts index fb1797c662..1984db880b 100644 --- a/packages/cli/src/commands/governance/propose.ts +++ b/packages/cli/src/commands/governance/propose.ts @@ -2,7 +2,6 @@ import { ProposalBuilder, proposalToJSON, ProposalTransactionJSON } from '@celo/ import { Flags } from '@oclif/core' import { BigNumber } from 'bignumber.js' import { readFileSync } from 'fs' -import { type Hex } from 'viem' import { BaseCommand } from '../../base' import { newCheckBuilder } from '../../utils/checks' import { displaySendTx, printValueMapRecursive } from '../../utils/cli' @@ -127,7 +126,7 @@ export default class Propose extends BaseCommand { if (!res.flags.force) { const ok = res.flags.simulate - ? await simulateProposalOnRpc(proposal, res.flags.simulate, governance.address as Hex) + ? await simulateProposalOnRpc(proposal, res.flags.simulate, governance.address) : await checkProposal(proposal, kit) if (!ok) { return diff --git a/packages/cli/src/utils/governance.ts b/packages/cli/src/utils/governance.ts index 678cd2c7d7..208ac079fa 100644 --- a/packages/cli/src/utils/governance.ts +++ b/packages/cli/src/utils/governance.ts @@ -1,3 +1,4 @@ +import { type StrongAddress } from '@celo/base' import { toTxResult } from '@celo/connect' import { ContractKit } from '@celo/contractkit' import { ProposalTransaction } from '@celo/contractkit/lib/wrappers/Governance' @@ -15,7 +16,7 @@ export async function checkProposal(proposal: ProposalTransaction[], kit: Contra export async function simulateProposalOnRpc( proposal: ProposalTransaction[], rpcUrl: string, - governanceAddress: Hex + governanceAddress: StrongAddress ) { const transport = http(rpcUrl) const publicClient = await createCeloPublicClient({ transport, nodeUrl: rpcUrl }) @@ -26,9 +27,7 @@ export async function simulateProposalOnRpc( account: governanceAddress, }) - console.log( - `Simulating proposal execution against ${rpcUrl} as Governance ${governanceAddress}` - ) + console.log(`Simulating proposal execution against ${rpcUrl} as Governance ${governanceAddress}`) let ok = true for (const [i, tx] of proposal.entries()) { @@ -37,7 +36,7 @@ export async function simulateProposalOnRpc( } try { const hash = await walletClient.sendTransaction({ - to: tx.to as Hex, + to: tx.to as StrongAddress, value: BigInt(tx.value ?? 0), data: (tx.input ?? '0x') as Hex, }) @@ -46,7 +45,7 @@ export async function simulateProposalOnRpc( let reason = '' try { await publicClient.call({ - to: tx.to as Hex, + to: tx.to as StrongAddress, data: (tx.input ?? '0x') as Hex, value: BigInt(tx.value ?? 0), account: governanceAddress, From 88c5b67d61ce42a0207178df3e9d763b46b95007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Volpe?= Date: Wed, 29 Apr 2026 17:18:12 +0200 Subject: [PATCH 4/7] Pass governance address into checkProposal instead of refetching --- packages/cli/src/commands/governance/build-proposal.ts | 3 ++- packages/cli/src/commands/governance/hashhotfix.ts | 5 +++-- packages/cli/src/commands/governance/propose.ts | 2 +- packages/cli/src/utils/governance.ts | 9 ++++++--- 4 files changed, 12 insertions(+), 7 deletions(-) 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 1984db880b..f60e1789bb 100644 --- a/packages/cli/src/commands/governance/propose.ts +++ b/packages/cli/src/commands/governance/propose.ts @@ -127,7 +127,7 @@ export default class Propose extends BaseCommand { if (!res.flags.force) { const ok = res.flags.simulate ? await simulateProposalOnRpc(proposal, res.flags.simulate, governance.address) - : await checkProposal(proposal, kit) + : await checkProposal(proposal, kit, governance.address) if (!ok) { return } diff --git a/packages/cli/src/utils/governance.ts b/packages/cli/src/utils/governance.ts index 208ac079fa..edac6c1d80 100644 --- a/packages/cli/src/utils/governance.ts +++ b/packages/cli/src/utils/governance.ts @@ -8,9 +8,12 @@ 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( From 45d958eee5fbc4550da8e0287b116767c1a888bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Volpe?= Date: Wed, 29 Apr 2026 17:22:14 +0200 Subject: [PATCH 5/7] Restore inline comments in createCeloPublicClient --- packages/cli/src/packages-to-be/public-client.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/packages-to-be/public-client.ts b/packages/cli/src/packages-to-be/public-client.ts index 7f4534e32c..e5d2e4055f 100644 --- a/packages/cli/src/packages-to-be/public-client.ts +++ b/packages/cli/src/packages-to-be/public-client.ts @@ -9,6 +9,7 @@ export default async function createCeloPublicClient({ 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({ @@ -24,6 +25,7 @@ export default async function createCeloPublicClient({ }) } + // we might be connecting to a dev chain or anvil fork or another testnet return createPublicClient({ transport, chain: { From 31b20734a15f590ae32f6fcbb62817564e2737fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Volpe?= Date: Wed, 29 Apr 2026 17:28:25 +0200 Subject: [PATCH 6/7] Fail simulation when proposal tx has no 'to' address --- packages/cli/src/utils/governance.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/cli/src/utils/governance.ts b/packages/cli/src/utils/governance.ts index edac6c1d80..7a795aa22d 100644 --- a/packages/cli/src/utils/governance.ts +++ b/packages/cli/src/utils/governance.ts @@ -35,6 +35,10 @@ export async function simulateProposalOnRpc( 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 { @@ -94,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 } From 059b19f011a73082c87cb44d503e1d35b962070f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Volpe?= Date: Wed, 29 Apr 2026 17:31:55 +0200 Subject: [PATCH 7/7] Add changeset for --simulate flag --- .changeset/celocli-governance-propose-simulate.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/celocli-governance-propose-simulate.md 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`