diff --git a/Cargo.lock b/Cargo.lock index 975faef..c00a355 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4387,7 +4387,7 @@ dependencies = [ [[package]] name = "zkgov-check" -version = "0.0.1" +version = "0.0.2" dependencies = [ "clap", "colored", diff --git a/Cargo.toml b/Cargo.toml index 91b6c70..732a3bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zkgov-check" -version = "0.0.1" +version = "0.0.2" edition = "2021" authors = ["ubermensch3dot0"] description = "ZkSync Upgrade Verification Tool" diff --git a/README.md b/README.md index c2c5252..bcaa4a1 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ zkgov-check get-upgrades --rpc-url $ZKSYNC_RPC_URL --decode ``` **Output**: A detailed list of targets, values, and calldata, plus any Ethereum transactions if `sendToL1` is called. -### 3. Compute the Ethereum Proposal ID +### 3. Compute the Ethereum Proposal ID from a transaction hash: Generate the Ethereum-side proposal hash for verification: ```bash zkgov-check get-eth-id --rpc-url $ZKSYNC_RPC_URL @@ -79,6 +79,18 @@ zkgov-check get-eth-id --rpc-url $ZKSYNC_RPC_URL Ethereum proposal ID #1: 0x5ebd899d... ``` +### 4. Compute the Ethereum Proposal ID from a proposale file (json): +Generate the Ethereum-side proposal hash for verification: +```bash +zkgov-check get-eth-id --from-file +``` +**Output**: The Keccak-256 hash for the calls in the json file (see the file structure [here](/test-data/zip5.json)), e.g.: +``` +Ethereum Proposal ID +Proposal ID: 0x123456... +Encoded Proposal: 0x0000... +``` + ## Practical Examples Using the ZKsync Upgrade Verification Tool can significantly enhance the security of governance operations. Below is a step-by-step guide for verifying a proposal like [ZIP-7](https://www.tally.xyz/gov/zksync/proposal/53064417471903525695516096129021600825622830249245179379231067906906888383956): diff --git a/src/main.rs b/src/main.rs index a0bc862..a9dafdc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,9 +10,11 @@ use eyre::{eyre, Result}; use clap::{Parser, Subcommand}; use std::env; use std::str::FromStr; +use std::fs; +use serde::{Deserialize, Serialize}; use reqwest; -const VERSION: &str = "0.0.6"; +const VERSION: &str = "0.0.2"; const PROPOSAL_CREATED_TOPIC: &str = "0x7d84a6263ae0d98d3329bd7b46bb4e8d6f98cd35a7adb45c274c8b7fd5ebd5e0"; const DEFAULT_GOVERNOR: &str = "0x76705327e682F2d96943280D99464Ab61219e34f"; @@ -22,21 +24,49 @@ struct Cli { #[command(subcommand)] command: Commands, - #[arg(long, global = true)] + #[arg(long, global = true, help = "RPC URL for zkSync Era (can also be set via ZKSYNC_RPC_URL env var)")] rpc_url: Option, - #[arg(long, global = true, default_value = DEFAULT_GOVERNOR)] + #[arg(long, global = true, default_value = DEFAULT_GOVERNOR, help = "Governor contract address")] governor: String, - #[arg(long, global = true)] + #[arg(long, global = true, help = "Decode calldata for each transaction")] decode: bool, } #[derive(Subcommand)] enum Commands { - GetZkId { tx_hash: String }, - GetUpgrades { tx_hash: String }, - GetEthId { tx_hash: String }, + #[command(about = "Get the zkSync proposal ID from a transaction hash")] + GetZkId { + #[arg(help = "Transaction hash to analyze")] + tx_hash: String + }, + #[command(about = "List proposal actions and Ethereum transactions")] + GetUpgrades { + #[arg(help = "Transaction hash to analyze")] + tx_hash: String + }, + #[command(about = "Compute the Ethereum proposal ID from a transaction hash or JSON file")] + GetEthId { + #[arg(help = "Transaction hash to analyze (not required if --from-file is used)")] + tx_hash: Option, + #[arg(long, help = "Path to a JSON file containing proposal data")] + from_file: Option, + }, +} + +#[derive(Serialize, Deserialize, Debug)] +struct ProposalCall { + target: String, + value: String, + data: String, +} + +#[derive(Serialize, Deserialize, Debug)] +struct ProposalData { + executor: String, + salt: String, + calls: Vec, } async fn get_provider(rpc_url: &str) -> Result> { @@ -465,6 +495,48 @@ pub async fn get_eth_id(tx_hash: &str, rpc_url: &str, _governor: &str) -> Result Ok(()) } +pub async fn get_eth_id_from_file(file_path: &str) -> Result<()> { + // Read and parse the JSON file + let file_content = fs::read_to_string(file_path)?; + let proposal: ProposalData = serde_json::from_str(&file_content)?; + + // Convert the proposal data into the format needed for encoding + let mut call_tokens = Vec::new(); + for call in proposal.calls.iter() { + let target = Address::from_str(&call.target)?; + let value = U256::from_str(&call.value)?; + let data = hex::decode(call.data.trim_start_matches("0x"))?; + + call_tokens.push(Token::Tuple(vec![ + Token::Address(target), + Token::Uint(value), + Token::Bytes(data), + ])); + } + + let executor = Address::from_str(&proposal.executor)?; + let salt = hex::decode(proposal.salt.trim_start_matches("0x"))?; + if salt.len() != 32 { + return Err(eyre!("Salt must be 32 bytes")); + } + + let proposal_token = Token::Tuple(vec![ + Token::Array(call_tokens), + Token::Address(executor), + Token::FixedBytes(salt), + ]); + + // Encode and hash the proposal + let encoded_proposal = encode(&[proposal_token]); + let hash = keccak256(&encoded_proposal); + + print_header("Ethereum Proposal ID"); + print_field("Proposal ID", &format!("0x{}", hex::encode(hash))); + print_field("Encoded Proposal", &format!("0x{}", hex::encode(&encoded_proposal))); + + Ok(()) +} + fn print_header(header: &str) { println!("\n{}", header.underline().bold()); } @@ -494,8 +566,14 @@ async fn main() -> Result<()> { Commands::GetUpgrades { tx_hash } => { get_upgrades(&tx_hash, &rpc_url, cli.decode).await?; } - Commands::GetEthId { tx_hash } => { - get_eth_id(&tx_hash, &rpc_url, &cli.governor).await?; + Commands::GetEthId { tx_hash, from_file } => { + if let Some(file_path) = from_file { + get_eth_id_from_file(&file_path).await?; + } else if let Some(tx_hash) = tx_hash { + get_eth_id(&tx_hash, &rpc_url, &cli.governor).await?; + } else { + return Err(eyre!("{}", "Either --from-file or transaction hash must be provided".red().bold())); + } } } Ok(()) diff --git a/test-data/zip5.json b/test-data/zip5.json new file mode 100644 index 0000000..c698110 --- /dev/null +++ b/test-data/zip5.json @@ -0,0 +1,31 @@ +{ + "executor": "0xECE8e30bFc92c2A8e11e6cb2e17B70868572E3f6", + "salt": "0x0000000000000000000000000000000000000000000000000000000000000000", + "calls": [ + { + "target": "0x303a465b659cbb0ab36ee643ea362c509eeb5213", + "value": "0x00", + "data": "0x79ba5097" + }, + { + "target": "0xc2ee6b6af7d616f6e27ce7f4a451aedc2b0f5f5c", + "value": "0x00", + "data": "0x79ba5097" + }, + { + "target": "0xd7f9f54194c633f36ccd5f3da84ad4a1c38cb2cb", + "value": "0x00", + "data": "0x79ba5097" + }, + { + "target": "0x5d8ba173dc6c3c90c8f7c04c9288bef5fdbad06e", + "value": "0x00", + "data": "0x79ba5097" + }, + { + "target": "0xf553e6d903aa43420ed7e3bc2313be9286a8f987", + "value": "0x00", + "data": "0x79ba5097" + } + ] +} \ No newline at end of file