Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/celocli-governance-propose-simulate.md
Original file line number Diff line number Diff line change
@@ -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`
37 changes: 2 additions & 35 deletions packages/cli/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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()
}

Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/governance/build-proposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
5 changes: 3 additions & 2 deletions packages/cli/src/commands/governance/hashhotfix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/commands/governance/propose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
addExistingProposalIDToBuilder,
addExistingProposalJSONFileToBuilder,
checkProposal,
simulateProposalOnRpc,
} from '../../utils/governance'
import {
createSafeFromWeb3,
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/packages-to-be/public-client.ts
Original file line number Diff line number Diff line change
@@ -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<PublicCeloClient> {
// 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'],
})
}
80 changes: 77 additions & 3 deletions packages/cli/src/utils/governance.ts
Original file line number Diff line number Diff line change
@@ -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,
})
Comment on lines +45 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid requiring governance to pay gas in simulation

Sending each proposal step via walletClient.sendTransaction makes the governance address the top-level transaction sender, so simulation can fail with insufficient-funds/unlocked-account errors even when the proposal would execute on-chain. In production execution, an external executor pays gas and Governance only performs internal calls, so this introduces false negatives for governance:propose --simulate (especially on forks where Governance has low balance).

Useful? React with 👍 / 👎.

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(
Expand All @@ -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
}

Expand Down
Loading