Skip to main content

espresso_contract_deployer/proposals/
verify.rs

1//! Trust-minimizing verifier for Safe-tx-builder upgrade proposals.
2//!
3//! Checks that the impl address in a proposal holds exactly the bytecode the
4//! deployer ships for the supplied contract kind, and that governance wiring
5//! (owner, timelock, delay, init call) is correct. No trust in Etherscan or
6//! the JSON description. Validates all fields against the committed proposal.toml.
7
8use std::{
9    collections::BTreeMap,
10    fmt,
11    path::{Path, PathBuf},
12};
13
14use alloy::{
15    primitives::{Address, B256, Bytes, U256},
16    providers::{Provider, ProviderBuilder},
17    sol_types::SolCall,
18};
19use anyhow::{Result, anyhow, bail};
20use clap::ValueEnum;
21use hotshot_contract_adapter::sol_types::{
22    EspTokenV2, FeeContract, OpsTimelock, RewardClaim, StakeTableV2, StakeTableV3,
23};
24use serde::Deserialize;
25use url::Url;
26
27use crate::proposals::{
28    deployment_info::deployment_info,
29    proposal_toml::ProposalToml,
30    safe_hash::{SafeTxHashes, safe_tx_hashes},
31    write::{ISafe, default_rpc_url},
32};
33
34// ── JSON deserialization ─────────────────────────────────────────────────────
35
36#[derive(Debug, Deserialize)]
37pub struct SafeBatch {
38    pub meta: SafeMeta,
39    pub transactions: Vec<SafeTx>,
40}
41
42#[derive(Debug, Deserialize)]
43pub struct SafeMeta {
44    pub description: String,
45}
46
47#[derive(Debug, Clone, Deserialize)]
48pub struct SafeTx {
49    pub to: Address,
50    pub value: String,
51    pub data: Option<String>,
52    #[serde(rename = "contractMethod")]
53    pub contract_method: Option<SafeContractMethod>,
54    #[serde(rename = "contractInputsValues")]
55    pub contract_inputs_values: Option<BTreeMap<String, String>>,
56}
57
58#[derive(Debug, Clone, Deserialize)]
59pub struct SafeContractMethod {
60    pub name: String,
61}
62
63// ── Proposal batch classification ────────────────────────────────────────────
64
65/// Phase of a single-transaction batch, identified from `contractMethod.name`
66/// or the 4-byte ABI selector.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Phase {
69    Schedule,
70    Execute,
71}
72
73/// A classified timelock proposal pair.
74/// Produced by `classify_batches`; input to `decode_proposal`.
75#[derive(Debug)]
76pub struct TimelockBatches {
77    pub schedule: SafeBatch,
78    pub execute: SafeBatch,
79}
80
81// ── Decoded upgrade ──────────────────────────────────────────────────────────
82
83/// Reconstructed outer calldata for schedule and execute phases.
84#[derive(Debug, Clone)]
85pub struct OuterCalldatas {
86    pub schedule: Bytes,
87    pub execute: Bytes,
88}
89
90#[derive(Debug, Clone)]
91pub struct DecodedUpgrade {
92    /// Timelock address.
93    pub outer_to: Address,
94    pub proxy: Address,
95    pub new_impl: Address,
96    pub init_data: Bytes,
97    pub value: U256,
98    pub predecessor: B256,
99    pub salt: B256,
100    pub delay: U256,
101    pub description: String,
102    pub outer_calldatas: OuterCalldatas,
103}
104
105// ── Normalization ────────────────────────────────────────────────────────────
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct SolcVersion(pub [u8; 3]);
109
110impl fmt::Display for SolcVersion {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(f, "{}.{}.{}", self.0[0], self.0[1], self.0[2])
113    }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum MatchClass {
118    /// Cores and solc version are byte-equal.
119    FullMatch,
120    /// Cores match but solc versions differ; PASS with warning.
121    CodeMatchMetaDiffers,
122    /// Core bytecodes differ; FAIL.
123    Mismatch,
124}
125
126#[derive(Debug, Clone)]
127pub struct BytecodeCheck {
128    pub class: MatchClass,
129    pub onchain_solc: Option<SolcVersion>,
130    pub reference_solc: Option<SolcVersion>,
131    /// On `Mismatch`, describes the first unexplained difference (offset and
132    /// both values).
133    pub mismatch: Option<String>,
134}
135
136/// Strip the trailing solc CBOR metadata tail.
137///
138/// The tail is `<cbor body> <u16-BE body length>`. Solc emits the
139/// `"solc": <3-byte version>` map entry last, so a genuine body ends with
140/// `64 "solc" 43 <ver>`. The body length is bounded (51 bytes with the default
141/// ipfs entry); a larger claimed length is treated as code, not metadata, so an
142/// attacker cannot hide appended bytes under an oversized tail.
143///
144/// Without a valid tail the input is returned unchanged with `None`.
145pub fn strip_cbor_metadata(code: &[u8]) -> (Vec<u8>, Option<SolcVersion>) {
146    // `64 "solc" 43`: CBOR key "solc" followed by the bytes3 version prefix.
147    const SOLC_ENTRY: &[u8] = &[0x64, 0x73, 0x6f, 0x6c, 0x63, 0x43];
148    const MAX_CBOR_LEN: usize = 64;
149
150    if code.len() < 2 {
151        return (code.to_vec(), None);
152    }
153    let tail_len = u16::from_be_bytes([code[code.len() - 2], code[code.len() - 1]]) as usize;
154    let total_drop = tail_len + 2;
155    if tail_len > MAX_CBOR_LEN || total_drop > code.len() {
156        return (code.to_vec(), None);
157    }
158    let cbor = &code[code.len() - total_drop..code.len() - 2];
159    if cbor.len() < 9 || cbor[cbor.len() - 9..cbor.len() - 3] != *SOLC_ENTRY {
160        return (code.to_vec(), None);
161    }
162    let ver = SolcVersion(cbor[cbor.len() - 3..].try_into().expect("3 bytes"));
163    (code[..code.len() - total_drop].to_vec(), Some(ver))
164}
165
166/// Compare normalized on-chain bytecode against the binding reference.
167///
168/// Strips the CBOR metadata tail from both, then requires every byte
169/// difference to be explained: a difference is only accepted inside a 20-byte
170/// window where the on-chain code holds `impl_addr` and the reference holds
171/// zeros. Those windows are the UUPS `__self` immutable slots (the impl's own
172/// address, baked in at deploy; the binding is compiled with
173/// `address(this) = 0`). Any other difference is a `Mismatch`, reported with
174/// the offset and both values.
175///
176/// Exactly `expected_self_windows` explained windows are required. This pins
177/// the impl address to the immutable slots: substituting it into any other
178/// zero region of the reference (e.g. a `PUSH32 0` constant) changes the count
179/// and fails. Residual: a window misaligned within a 32-byte slot still
180/// passes, but a wrong `__self` value only makes `upgradeToAndCall` revert
181/// (`proxiableUUID` is `notDelegated`); it cannot substitute code.
182///
183/// LightClient verification is deferred; bails if the reference contains
184/// `0xff*20` library placeholders.
185pub fn compare_normalized(
186    onchain: &[u8],
187    reference: &[u8],
188    impl_addr: Address,
189    expected_self_windows: usize,
190) -> Result<BytecodeCheck> {
191    let placeholder = [0xffu8; 20];
192    if reference.windows(20).any(|w| w == placeholder.as_slice()) {
193        bail!(
194            "reference bytecode contains library placeholder (0xff*20); LightClient verification \
195             is deferred"
196        );
197    }
198    if impl_addr == Address::ZERO {
199        bail!("impl address is zero");
200    }
201
202    let (onchain_core, onchain_solc) = strip_cbor_metadata(onchain);
203    let (ref_core, ref_solc) = strip_cbor_metadata(reference);
204
205    if onchain_core.len() != ref_core.len() {
206        return Ok(BytecodeCheck {
207            class: MatchClass::Mismatch,
208            onchain_solc,
209            reference_solc: ref_solc,
210            mismatch: Some(format!(
211                "core length differs: onchain={} reference={}",
212                onchain_core.len(),
213                ref_core.len()
214            )),
215        });
216    }
217
218    // Zero every __self window (impl_addr on-chain over zeros in the reference)
219    // in a copy; matching against the pristine core keeps the scan independent
220    // of window order and overlap.
221    let mut normalized = onchain_core.clone();
222    let mut self_windows = 0usize;
223    for w in 0..onchain_core.len().saturating_sub(19) {
224        if onchain_core[w..w + 20] == *impl_addr.as_slice() && ref_core[w..w + 20] == [0u8; 20] {
225            normalized[w..w + 20].fill(0);
226            self_windows += 1;
227        }
228    }
229
230    if let Some(first) = (0..normalized.len()).find(|&i| normalized[i] != ref_core[i]) {
231        let end = (first + 20).min(normalized.len());
232        return Ok(BytecodeCheck {
233            class: MatchClass::Mismatch,
234            onchain_solc,
235            reference_solc: ref_solc,
236            mismatch: Some(format!(
237                "unexplained difference at core offset {first}: onchain=0x{} reference=0x{}",
238                alloy::hex::encode(&onchain_core[first..end]),
239                alloy::hex::encode(&ref_core[first..end])
240            )),
241        });
242    }
243
244    if self_windows != expected_self_windows {
245        bail!("found {self_windows} __self immutable windows; expected {expected_self_windows}");
246    }
247
248    let class = if onchain_solc == ref_solc {
249        MatchClass::FullMatch
250    } else {
251        MatchClass::CodeMatchMetaDiffers
252    };
253    Ok(BytecodeCheck {
254        class,
255        onchain_solc,
256        reference_solc: ref_solc,
257        mismatch: None,
258    })
259}
260
261// ── Contract kind ────────────────────────────────────────────────────────────
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
264pub enum ContractKindArg {
265    #[clap(name = "stake-table-v2")]
266    StakeTableV2,
267    #[clap(name = "stake-table-v3")]
268    StakeTableV3,
269    #[clap(name = "esp-token-v2")]
270    EspTokenV2,
271    #[clap(name = "fee-contract")]
272    FeeContract,
273    #[clap(name = "reward-claim")]
274    RewardClaim,
275}
276
277impl ContractKindArg {
278    /// Kebab-case string representation, matching the toml `contract` field.
279    pub fn as_str(self) -> &'static str {
280        match self {
281            Self::StakeTableV2 => "stake-table-v2",
282            Self::StakeTableV3 => "stake-table-v3",
283            Self::EspTokenV2 => "esp-token-v2",
284            Self::FeeContract => "fee-contract",
285            Self::RewardClaim => "reward-claim",
286        }
287    }
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum OwnerAccessor {
292    Owner,
293    CurrentAdmin,
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum TimelockKind {
298    Ops,
299    SafeExit,
300}
301
302/// Expected reinitializer call and the major version it brings the proxy to.
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub struct ExpectedInit {
305    pub selector: [u8; 4],
306    pub target_major: u8,
307}
308
309#[derive(Debug, Clone)]
310pub struct ContractKind {
311    pub name: &'static str,
312    pub deployed_bytecode: &'static [u8],
313    pub expected_init: Option<ExpectedInit>,
314    pub owner_accessor: OwnerAccessor,
315    pub timelock_kind: TimelockKind,
316    pub expected_prev_major: Option<u8>,
317    /// Number of UUPS `__self` immutable slots in the deployed bytecode.
318    /// Pinned against the binding's zero runs in `test_verify_kind_by_bytecode_ok`.
319    pub self_windows: usize,
320}
321
322pub fn contract_kind(arg: ContractKindArg) -> ContractKind {
323    match arg {
324        ContractKindArg::StakeTableV2 => ContractKind {
325            name: "StakeTableV2",
326            deployed_bytecode: &StakeTableV2::DEPLOYED_BYTECODE,
327            expected_init: Some(ExpectedInit {
328                selector: StakeTableV2::initializeV2Call::SELECTOR,
329                target_major: 2,
330            }),
331            owner_accessor: OwnerAccessor::Owner,
332            timelock_kind: TimelockKind::Ops,
333            expected_prev_major: Some(1),
334            self_windows: 3,
335        },
336        ContractKindArg::StakeTableV3 => ContractKind {
337            name: "StakeTableV3",
338            deployed_bytecode: &StakeTableV3::DEPLOYED_BYTECODE,
339            expected_init: Some(ExpectedInit {
340                selector: StakeTableV3::initializeV3Call::SELECTOR,
341                target_major: 3,
342            }),
343            owner_accessor: OwnerAccessor::Owner,
344            timelock_kind: TimelockKind::Ops,
345            expected_prev_major: Some(2),
346            self_windows: 3,
347        },
348        ContractKindArg::EspTokenV2 => ContractKind {
349            name: "EspTokenV2",
350            deployed_bytecode: &EspTokenV2::DEPLOYED_BYTECODE,
351            expected_init: Some(ExpectedInit {
352                selector: EspTokenV2::initializeV2Call::SELECTOR,
353                target_major: 2,
354            }),
355            owner_accessor: OwnerAccessor::Owner,
356            timelock_kind: TimelockKind::SafeExit,
357            expected_prev_major: Some(1),
358            self_windows: 3,
359        },
360        ContractKindArg::FeeContract => ContractKind {
361            name: "FeeContract",
362            deployed_bytecode: &FeeContract::DEPLOYED_BYTECODE,
363            expected_init: None,
364            owner_accessor: OwnerAccessor::Owner,
365            timelock_kind: TimelockKind::Ops,
366            expected_prev_major: Some(1),
367            self_windows: 3,
368        },
369        ContractKindArg::RewardClaim => ContractKind {
370            name: "RewardClaim",
371            deployed_bytecode: &RewardClaim::DEPLOYED_BYTECODE,
372            expected_init: None,
373            owner_accessor: OwnerAccessor::CurrentAdmin,
374            timelock_kind: TimelockKind::SafeExit,
375            expected_prev_major: None,
376            self_windows: 3,
377        },
378    }
379}
380
381// ── calldata reconstruction ──────────────────────────────────────────────────
382
383pub fn tx_calldata(tx: &SafeTx) -> Result<(Address, Bytes)> {
384    if let (Some(method), Some(inputs)) = (&tx.contract_method, &tx.contract_inputs_values) {
385        let calldata = reconstruct_timelock_calldata(&method.name, inputs)?;
386        Ok((tx.to, calldata))
387    } else if let Some(hex_data) = &tx.data {
388        Ok((tx.to, parse_hex_bytes(hex_data)?))
389    } else {
390        bail!("SafeTx has neither contractMethod nor data")
391    }
392}
393
394fn parse_hex_bytes(s: &str) -> Result<Bytes> {
395    let s = s.strip_prefix("0x").unwrap_or(s);
396    Ok(Bytes::from(alloy::hex::decode(s)?))
397}
398
399fn reconstruct_timelock_calldata(name: &str, inputs: &BTreeMap<String, String>) -> Result<Bytes> {
400    match name {
401        "schedule" => {
402            let target: Address = inputs
403                .get("target")
404                .ok_or_else(|| anyhow!("missing 'target' in schedule inputs"))?
405                .parse()?;
406            let value = U256::from_str_radix(
407                inputs
408                    .get("value")
409                    .ok_or_else(|| anyhow!("missing 'value' in schedule inputs"))?,
410                10,
411            )?;
412            let data = parse_hex_bytes(
413                inputs
414                    .get("data")
415                    .ok_or_else(|| anyhow!("missing 'data' in schedule inputs"))?,
416            )?;
417            let predecessor: B256 = inputs
418                .get("predecessor")
419                .ok_or_else(|| anyhow!("missing 'predecessor' in schedule inputs"))?
420                .parse()?;
421            let salt: B256 = inputs
422                .get("salt")
423                .ok_or_else(|| anyhow!("missing 'salt' in schedule inputs"))?
424                .parse()?;
425            let delay = U256::from_str_radix(
426                inputs
427                    .get("delay")
428                    .ok_or_else(|| anyhow!("missing 'delay' in schedule inputs"))?,
429                10,
430            )?;
431            Ok(Bytes::from(
432                OpsTimelock::scheduleCall {
433                    target,
434                    value,
435                    data,
436                    predecessor,
437                    salt,
438                    delay,
439                }
440                .abi_encode(),
441            ))
442        },
443        "execute" => {
444            let target: Address = inputs
445                .get("target")
446                .ok_or_else(|| anyhow!("missing 'target' in execute inputs"))?
447                .parse()?;
448            let value = U256::from_str_radix(
449                inputs
450                    .get("value")
451                    .ok_or_else(|| anyhow!("missing 'value' in execute inputs"))?,
452                10,
453            )?;
454            let payload = parse_hex_bytes(
455                inputs
456                    .get("payload")
457                    .or_else(|| inputs.get("data"))
458                    .ok_or_else(|| anyhow!("missing 'payload'/'data' in execute inputs"))?,
459            )?;
460            let predecessor: B256 = inputs
461                .get("predecessor")
462                .ok_or_else(|| anyhow!("missing 'predecessor' in execute inputs"))?
463                .parse()?;
464            let salt: B256 = inputs
465                .get("salt")
466                .ok_or_else(|| anyhow!("missing 'salt' in execute inputs"))?
467                .parse()?;
468            Ok(Bytes::from(
469                OpsTimelock::executeCall {
470                    target,
471                    value,
472                    payload,
473                    predecessor,
474                    salt,
475                }
476                .abi_encode(),
477            ))
478        },
479        other => bail!("unsupported contractMethod name: {other}"),
480    }
481}
482
483pub fn decode_inner_upgrade(inner: &Bytes) -> Result<(Address, Bytes)> {
484    if inner.len() < 4 {
485        bail!("inner calldata too short");
486    }
487    let call = StakeTableV3::upgradeToAndCallCall::abi_decode(inner)
488        .map_err(|e| anyhow!("failed to decode upgradeToAndCall: {e}"))?;
489    Ok((call.newImplementation, call.data))
490}
491
492/// Identify the phase of a single-transaction batch.
493///
494/// Fails unless `batch.transactions.len() == 1`.
495fn batch_phase(batch: &SafeBatch) -> Result<Phase> {
496    if batch.transactions.len() != 1 {
497        bail!(
498            "batch must contain exactly 1 transaction; got {}",
499            batch.transactions.len()
500        );
501    }
502    let tx = &batch.transactions[0];
503
504    if let Some(method) = &tx.contract_method {
505        return match method.name.as_str() {
506            "schedule" => Ok(Phase::Schedule),
507            "execute" => Ok(Phase::Execute),
508            other => bail!("unrecognised contractMethod name: {other}"),
509        };
510    }
511
512    if let Some(hex) = &tx.data {
513        let bytes = parse_hex_bytes(hex)?;
514        if bytes.len() < 4 {
515            bail!("raw data too short to contain a selector");
516        }
517        let sel: [u8; 4] = bytes[..4].try_into().expect("len checked");
518        return match sel {
519            s if s == OpsTimelock::scheduleCall::SELECTOR => Ok(Phase::Schedule),
520            s if s == OpsTimelock::executeCall::SELECTOR => Ok(Phase::Execute),
521            _ => bail!("unrecognised 4-byte selector 0x{}", alloy::hex::encode(sel)),
522        };
523    }
524
525    bail!("batch has neither contractMethod nor data");
526}
527
528/// Load `schedule.json` and `execute.json` from a proposal directory.
529///
530/// Both files must be present and parse as `SafeBatch`. Each batch must contain
531/// exactly one transaction. Phase is validated via `batch_phase`.
532pub fn load_proposal_dir(dir: &Path) -> Result<TimelockBatches> {
533    let load = |name: &str| -> Result<SafeBatch> {
534        let p = dir.join(name);
535        let text =
536            std::fs::read_to_string(&p).map_err(|e| anyhow!("cannot read {}: {e}", p.display()))?;
537        serde_json::from_str::<SafeBatch>(&text)
538            .map_err(|e| anyhow!("failed to parse {}: {e}", p.display()))
539    };
540
541    let schedule = load("schedule.json")?;
542    let execute = load("execute.json")?;
543
544    let sched_phase = batch_phase(&schedule)?;
545    if sched_phase != Phase::Schedule {
546        bail!(
547            "{}/schedule.json has phase {:?}; expected Schedule",
548            dir.display(),
549            sched_phase
550        );
551    }
552    let exec_phase = batch_phase(&execute)?;
553    if exec_phase != Phase::Execute {
554        bail!(
555            "{}/execute.json has phase {:?}; expected Execute",
556            dir.display(),
557            exec_phase
558        );
559    }
560
561    Ok(TimelockBatches { schedule, execute })
562}
563
564/// Decode a `TimelockBatches` into a `DecodedUpgrade`.
565///
566/// Each batch must have exactly one transaction; extra transactions would
567/// escape verification.
568pub fn decode_proposal(batches: TimelockBatches) -> Result<DecodedUpgrade> {
569    if batches.schedule.transactions.len() != 1 {
570        bail!(
571            "schedule batch must contain exactly 1 transaction; got {}",
572            batches.schedule.transactions.len()
573        );
574    }
575    if batches.execute.transactions.len() != 1 {
576        bail!(
577            "execute batch must contain exactly 1 transaction; got {}",
578            batches.execute.transactions.len()
579        );
580    }
581
582    let sched_tx = batches
583        .schedule
584        .transactions
585        .into_iter()
586        .next()
587        .expect("len checked");
588    let exec_tx = batches
589        .execute
590        .transactions
591        .into_iter()
592        .next()
593        .expect("len checked");
594
595    let (sched_to, sched_calldata) = tx_calldata(&sched_tx)?;
596    let (exec_to, exec_calldata) = tx_calldata(&exec_tx)?;
597
598    let sched = OpsTimelock::scheduleCall::abi_decode(&sched_calldata)
599        .map_err(|e| anyhow!("failed to decode schedule calldata: {e}"))?;
600    let exec = OpsTimelock::executeCall::abi_decode(&exec_calldata)
601        .map_err(|e| anyhow!("failed to decode execute calldata: {e}"))?;
602
603    if sched_to != exec_to {
604        bail!(
605            "schedule and execute target different addresses: {} vs {}",
606            sched_to,
607            exec_to
608        );
609    }
610    if sched.data != exec.payload {
611        bail!("schedule.data != execute.payload: inner payloads are not identical");
612    }
613    if sched.salt != exec.salt {
614        bail!("schedule.salt != execute.salt");
615    }
616    if sched.predecessor != exec.predecessor {
617        bail!("schedule.predecessor != execute.predecessor");
618    }
619    if sched.target != exec.target {
620        bail!("schedule.target != exec.target");
621    }
622    if sched.value != exec.value {
623        bail!(
624            "schedule.value != execute.value: {} vs {}",
625            sched.value,
626            exec.value
627        );
628    }
629
630    let (new_impl, init_data) = decode_inner_upgrade(&sched.data)?;
631
632    Ok(DecodedUpgrade {
633        outer_to: sched_to,
634        proxy: sched.target,
635        new_impl,
636        init_data,
637        value: sched.value,
638        predecessor: sched.predecessor,
639        salt: sched.salt,
640        delay: sched.delay,
641        description: batches.schedule.meta.description,
642        outer_calldatas: OuterCalldatas {
643            schedule: sched_calldata,
644            execute: exec_calldata,
645        },
646    })
647}
648
649// ── Report ───────────────────────────────────────────────────────────────────
650
651#[derive(Debug, Clone)]
652pub struct CheckRow {
653    pub name: String,
654    pub pass: bool,
655    pub detail: String,
656}
657
658/// Safe-tx hashes for schedule and execute phases.
659#[derive(Debug, Clone)]
660pub struct PhaseHashes {
661    pub schedule_nonce: u64,
662    pub schedule: SafeTxHashes,
663    pub execute_nonce: u64,
664    pub execute: SafeTxHashes,
665}
666
667#[derive(Debug)]
668pub struct VerifyReport {
669    pub rows: Vec<CheckRow>,
670    pub header: ReportHeader,
671    pub phase_hashes: PhaseHashes,
672}
673
674#[derive(Debug, Clone)]
675pub struct ReportHeader {
676    pub proxy: Address,
677    pub new_impl: Address,
678    pub contract_name: &'static str,
679    pub network: String,
680    pub description: String,
681    pub onchain_solc: Option<SolcVersion>,
682    pub reference_solc: Option<SolcVersion>,
683}
684
685impl VerifyReport {
686    pub fn print(&self) {
687        println!("=== Upgrade Proposal Verification ===");
688        println!("  contract:    {}", self.header.contract_name);
689        println!("  network:     {}", self.header.network);
690        println!("  proxy:       {}", self.header.proxy);
691        println!("  new_impl:    {}", self.header.new_impl);
692        println!("  route:       timelock two-phase");
693        println!("  description: {}", self.header.description);
694        if let Some(ref s) = self.header.onchain_solc {
695            println!("  onchain_solc: {s}");
696        }
697        if let Some(ref s) = self.header.reference_solc {
698            println!("  ref_solc:     {s}");
699        }
700        println!();
701        println!("{:<40} {:<6} DETAIL", "CHECK", "RESULT");
702        println!("{}", "-".repeat(80));
703        for row in &self.rows {
704            let status = if row.pass { "PASS" } else { "FAIL" };
705            println!("{:<40} {:<6} {}", row.name, status, row.detail);
706        }
707        println!();
708        println!("--- Safe tx hashes (operation=0, single-tx; confirm against Safe UI) ---");
709        println!("  schedule (nonce={}):", self.phase_hashes.schedule_nonce);
710        print_hashes(&self.phase_hashes.schedule);
711        println!("  execute (nonce={}):", self.phase_hashes.execute_nonce);
712        print_hashes(&self.phase_hashes.execute);
713        println!();
714        let all_pass = self.rows.iter().all(|r| r.pass);
715        println!("Result: {}", if all_pass { "ALL PASS" } else { "FAIL" });
716    }
717
718    pub fn exit_code(&self) -> i32 {
719        if self.rows.iter().all(|r| r.pass) {
720            0
721        } else {
722            1
723        }
724    }
725}
726
727fn print_hashes(h: &SafeTxHashes) {
728    println!("    domain:   {}", h.domain);
729    println!("    message:  {}", h.message);
730    println!("    safe_tx:  {}", h.safe_tx);
731}
732
733fn pass(name: impl Into<String>, detail: impl Into<String>) -> CheckRow {
734    CheckRow {
735        name: name.into(),
736        pass: true,
737        detail: detail.into(),
738    }
739}
740
741fn fail(name: impl Into<String>, detail: impl Into<String>) -> CheckRow {
742    CheckRow {
743        name: name.into(),
744        pass: false,
745        detail: detail.into(),
746    }
747}
748
749fn solc_str(v: Option<&SolcVersion>) -> String {
750    v.map(|s| s.to_string()).unwrap_or_else(|| "?".to_owned())
751}
752
753// ── Pure row classifiers ─────────────────────────────────────────────────────
754
755pub fn value_zero_row(value: U256) -> CheckRow {
756    if value == U256::ZERO {
757        pass("value==0", "ok")
758    } else {
759        fail("value==0", format!("value={value}"))
760    }
761}
762
763pub fn predecessor_zero_row(predecessor: B256) -> CheckRow {
764    if predecessor == B256::ZERO {
765        pass("predecessor==0", "ok")
766    } else {
767        fail("predecessor==0", format!("{predecessor}"))
768    }
769}
770
771pub fn owner_timelock_row(owner: Address, outer_to: Address) -> CheckRow {
772    if owner == outer_to {
773        pass("owner==timelock", format!("owner={owner}"))
774    } else {
775        fail(
776            "owner==timelock",
777            format!("proxy owner={owner} != outer_to={outer_to}"),
778        )
779    }
780}
781
782pub fn delay_row(delay: U256, min_delay: U256) -> CheckRow {
783    if delay >= min_delay {
784        pass(
785            "delay>=minDelay",
786            format!("delay={delay} minDelay={min_delay}"),
787        )
788    } else {
789        fail(
790            "delay>=minDelay",
791            format!("delay={delay} < minDelay={min_delay}"),
792        )
793    }
794}
795
796/// Row asserting a decoded field equals the proposal.toml value.
797fn toml_field_row<T: PartialEq + fmt::Display>(name: &str, decoded: T, recorded: T) -> CheckRow {
798    if decoded == recorded {
799        pass(format!("toml:{name}"), format!("{decoded}"))
800    } else {
801        fail(
802            format!("toml:{name}"),
803            format!("decoded={decoded} toml={recorded}"),
804        )
805    }
806}
807
808/// Row asserting a hash field equals the toml value (uses hex display).
809fn toml_hash_row(name: &str, computed: B256, recorded: B256) -> CheckRow {
810    if computed == recorded {
811        pass(format!("toml:{name}"), format!("{computed}"))
812    } else {
813        fail(
814            format!("toml:{name}"),
815            format!("computed={computed} toml={recorded}"),
816        )
817    }
818}
819
820// ── Static network/chain-id mapping ──────────────────────────────────────────
821
822/// Map a network name to its canonical chain id.
823fn network_chain_id(network: &str) -> Option<u64> {
824    match network {
825        "mainnet" => Some(1),
826        "decaf" => Some(11155111),
827        "hoodi" => Some(560048),
828        _ => None,
829    }
830}
831
832// ── CLI args ─────────────────────────────────────────────────────────────────
833
834#[derive(Debug, Clone, clap::Args)]
835pub struct VerifyProposalArgs {
836    /// Proposal directory containing schedule.json, execute.json, and proposal.toml.
837    pub dir: PathBuf,
838
839    /// Override the contract kind; defaults to proposal.toml `contract` field.
840    #[clap(long)]
841    pub contract: Option<ContractKindArg>,
842
843    /// RPC URL; defaults to the network's public node from proposal.toml.
844    ///
845    /// Note: this flag must follow the `verify-proposal` subcommand; the top-level
846    /// `deploy --rpc-url` does not apply here.
847    #[clap(long, env = "ESPRESSO_L1_PROVIDER")]
848    pub rpc_url: Option<Url>,
849}
850
851// ── Orchestrator ─────────────────────────────────────────────────────────────
852
853/// Run verification without a wallet provider.
854///
855/// Reads `chain_id` from `<args.dir>/proposal.toml`, resolves the RPC from
856/// `args.rpc_url` (or `ESPRESSO_L1_PROVIDER`) or the built-in public-node map,
857/// then delegates to `run_verify`.
858pub async fn run_verify_standalone(args: &VerifyProposalArgs) -> Result<VerifyReport> {
859    let toml = ProposalToml::load(&args.dir)?;
860    let chain_id = toml.chain_id;
861
862    let rpc = args
863        .rpc_url
864        .clone()
865        .or_else(|| default_rpc_url(chain_id))
866        .ok_or_else(|| anyhow!("unknown chain id {chain_id}; pass --rpc-url"))?;
867
868    let provider = ProviderBuilder::new().connect_http(rpc);
869
870    let provider_chain_id = provider.get_chain_id().await?;
871    run_verify(args, &provider, provider_chain_id).await
872}
873
874pub async fn run_verify(
875    args: &VerifyProposalArgs,
876    provider: &impl Provider,
877    chain_id: u64,
878) -> Result<VerifyReport> {
879    let toml = ProposalToml::load(&args.dir)?;
880
881    // Resolve contract kind: flag overrides toml; if both present, assert equal.
882    let kind_arg = match args.contract {
883        Some(flag_kind) => {
884            if flag_kind.as_str() != toml.contract {
885                bail!(
886                    "--contract {:?} conflicts with proposal.toml contract {:?} in {}",
887                    flag_kind.as_str(),
888                    toml.contract,
889                    args.dir.display()
890                );
891            }
892            flag_kind
893        },
894        None => ContractKindArg::from_str(&toml.contract, true).map_err(|e| {
895            anyhow!(
896                "proposal.toml has unknown contract {:?}: {e}",
897                toml.contract
898            )
899        })?,
900    };
901    let kind = contract_kind(kind_arg);
902    let mut rows: Vec<CheckRow> = vec![];
903
904    // Network/chain_id consistency: static mapping, never circular (item F).
905    rows.push(network_chain_id_row(&toml.network, toml.chain_id));
906
907    // Provider chain_id vs toml.chain_id.
908    rows.push(if chain_id == toml.chain_id {
909        pass("toml:chain_id", format!("{chain_id}"))
910    } else {
911        fail(
912            "toml:chain_id",
913            format!("provider chain_id={chain_id} toml={}", toml.chain_id),
914        )
915    });
916
917    let batches = load_proposal_dir(&args.dir)?;
918    let upgrade = match decode_proposal(batches) {
919        Ok(u) => {
920            rows.push(pass("decode", format!("outer_to={}", u.outer_to)));
921            u
922        },
923        Err(e) => {
924            rows.push(fail("decode", e.to_string()));
925            let phase_hashes = build_phase_hashes_from_toml(&toml);
926            return Ok(VerifyReport {
927                rows,
928                header: ReportHeader {
929                    proxy: Address::ZERO,
930                    new_impl: Address::ZERO,
931                    contract_name: kind.name,
932                    network: toml.network.clone(),
933                    description: String::new(),
934                    onchain_solc: None,
935                    reference_solc: None,
936                },
937                phase_hashes,
938            });
939        },
940    };
941
942    // Validate decoded fields against toml.
943    rows.push(toml_field_row("proxy", upgrade.proxy, toml.proxy));
944    rows.push(toml_field_row("impl", upgrade.new_impl, toml.new_impl));
945    rows.push(toml_field_row("timelock", upgrade.outer_to, toml.timelock));
946    rows.push(toml_field_row("salt", upgrade.salt, toml.salt));
947    rows.push(toml_field_row(
948        "predecessor",
949        upgrade.predecessor,
950        toml.predecessor,
951    ));
952    rows.push(toml_field_row("delay", upgrade.delay, toml.delay_u256()));
953
954    rows.push(value_zero_row(upgrade.value));
955    rows.push(predecessor_zero_row(upgrade.predecessor));
956
957    // Deployment-info address checks (no provider needed).
958    let info_rows = deployment_info_rows(&toml, &upgrade, &kind);
959    rows.extend(info_rows);
960
961    let onchain_code = provider.get_code_at(upgrade.new_impl).await?;
962
963    let bytecode_check = match compare_normalized(
964        &onchain_code,
965        kind.deployed_bytecode,
966        upgrade.new_impl,
967        kind.self_windows,
968    ) {
969        Ok(check) => check,
970        Err(e) => {
971            rows.push(fail("bytecode-match", e.to_string()));
972            let phase_hashes = build_phase_hashes_from_toml(&toml);
973            return Ok(VerifyReport {
974                rows,
975                header: ReportHeader {
976                    proxy: upgrade.proxy,
977                    new_impl: upgrade.new_impl,
978                    contract_name: kind.name,
979                    network: toml.network.clone(),
980                    description: upgrade.description,
981                    onchain_solc: None,
982                    reference_solc: None,
983                },
984                phase_hashes,
985            });
986        },
987    };
988
989    rows.push(match bytecode_check.class {
990        MatchClass::FullMatch => pass(
991            "bytecode-match",
992            format!(
993                "FullMatch solc={}",
994                solc_str(bytecode_check.onchain_solc.as_ref())
995            ),
996        ),
997        MatchClass::CodeMatchMetaDiffers => pass(
998            "bytecode-match",
999            format!(
1000                "WARN CodeMatchMetaDiffers: onchain_solc={} ref_solc={}",
1001                solc_str(bytecode_check.onchain_solc.as_ref()),
1002                solc_str(bytecode_check.reference_solc.as_ref())
1003            ),
1004        ),
1005        MatchClass::Mismatch => fail(
1006            "bytecode-match",
1007            format!(
1008                "on-chain impl at {} does not match {} binding: {}",
1009                upgrade.new_impl,
1010                kind.name,
1011                bytecode_check.mismatch.as_deref().unwrap_or("")
1012            ),
1013        ),
1014    });
1015
1016    let proxy_major = fetch_proxy_major_version(provider, upgrade.proxy).await;
1017    rows.push(check_init_selector(
1018        &kind,
1019        &upgrade.init_data,
1020        proxy_major.as_ref().ok().copied(),
1021    ));
1022
1023    // Safe validation: assert toml Safes match deployment-info.
1024    let safe_rows = safe_address_rows(&toml, &kind);
1025    rows.extend(safe_rows);
1026
1027    // Recompute Safe hashes for both phases and assert against toml.
1028    let phase_hashes =
1029        compute_and_validate_phase_hashes(&toml, chain_id, &upgrade.outer_calldatas, &mut rows);
1030
1031    // Nonce drift check (WARN, not FAIL).
1032    nonce_drift_rows(provider, &toml, &mut rows).await;
1033
1034    let gov_rows = governance_checks(provider, &upgrade, &kind, proxy_major).await;
1035    rows.extend(gov_rows);
1036
1037    Ok(VerifyReport {
1038        rows,
1039        header: ReportHeader {
1040            proxy: upgrade.proxy,
1041            new_impl: upgrade.new_impl,
1042            contract_name: kind.name,
1043            network: toml.network.clone(),
1044            description: upgrade.description,
1045            onchain_solc: bytecode_check.onchain_solc,
1046            reference_solc: bytecode_check.reference_solc,
1047        },
1048        phase_hashes,
1049    })
1050}
1051
1052/// Build PhaseHashes directly from toml (used when decoding fails).
1053fn build_phase_hashes_from_toml(toml: &ProposalToml) -> PhaseHashes {
1054    PhaseHashes {
1055        schedule_nonce: toml.schedule.nonce,
1056        schedule: SafeTxHashes {
1057            domain: toml.schedule.domain,
1058            message: toml.schedule.message,
1059            safe_tx: toml.schedule.safe_tx,
1060        },
1061        execute_nonce: toml.execute.nonce,
1062        execute: SafeTxHashes {
1063            domain: toml.execute.domain,
1064            message: toml.execute.message,
1065            safe_tx: toml.execute.safe_tx,
1066        },
1067    }
1068}
1069
1070/// Emit `toml:network` row (static chain-id mapping, breaks circular check).
1071fn network_chain_id_row(network: &str, chain_id: u64) -> CheckRow {
1072    match network_chain_id(network) {
1073        None => fail(
1074            "toml:network",
1075            format!(
1076                "unknown network {:?}; known: mainnet, decaf, hoodi",
1077                network
1078            ),
1079        ),
1080        Some(expected) if expected != chain_id => fail(
1081            "toml:network",
1082            format!("network={network:?} maps to chain_id={expected} but toml.chain_id={chain_id}"),
1083        ),
1084        Some(_) => pass("toml:network", format!("{network} chain_id={chain_id}")),
1085    }
1086}
1087
1088/// Timelock and proxy address checks against embedded deployment-info.
1089///
1090/// These rows are always emitted; FAIL when deployment-info is unavailable.
1091fn deployment_info_rows(
1092    toml: &ProposalToml,
1093    upgrade: &DecodedUpgrade,
1094    kind: &ContractKind,
1095) -> Vec<CheckRow> {
1096    let info = match deployment_info(&toml.network) {
1097        Ok(i) => i,
1098        Err(e) => {
1099            return vec![
1100                fail(
1101                    "timelock-addr-match",
1102                    format!("deployment-info unavailable: {e}"),
1103                ),
1104                fail(
1105                    "proxy-addr-match",
1106                    format!("deployment-info unavailable: {e}"),
1107                ),
1108            ];
1109        },
1110    };
1111
1112    let expected_timelock = match kind.timelock_kind {
1113        TimelockKind::Ops => info.ops_timelock.address,
1114        TimelockKind::SafeExit => info.safe_exit_timelock.address,
1115    };
1116
1117    let expected_proxy = match kind.name {
1118        "StakeTableV2" | "StakeTableV3" => info.stake_table,
1119        "EspTokenV2" => info.esp_token,
1120        "FeeContract" => info.fee_contract,
1121        "RewardClaim" => info.reward_claim,
1122        other => {
1123            return vec![
1124                fail(
1125                    "timelock-addr-match",
1126                    format!("no proxy mapping for contract kind {other:?}"),
1127                ),
1128                fail(
1129                    "proxy-addr-match",
1130                    format!("no proxy mapping for contract kind {other:?}"),
1131                ),
1132            ];
1133        },
1134    };
1135
1136    let timelock_row = if upgrade.outer_to == expected_timelock {
1137        pass(
1138            "timelock-addr-match",
1139            format!("outer_to={} matches deployment-info", upgrade.outer_to),
1140        )
1141    } else {
1142        fail(
1143            "timelock-addr-match",
1144            format!(
1145                "outer_to={} != deployment-info timelock={}",
1146                upgrade.outer_to, expected_timelock
1147            ),
1148        )
1149    };
1150
1151    let proxy_row = if upgrade.proxy == expected_proxy {
1152        pass(
1153            "proxy-addr-match",
1154            format!("proxy={} matches deployment-info", upgrade.proxy),
1155        )
1156    } else {
1157        fail(
1158            "proxy-addr-match",
1159            format!(
1160                "proxy={} != deployment-info proxy={}",
1161                upgrade.proxy, expected_proxy
1162            ),
1163        )
1164    };
1165
1166    vec![timelock_row, proxy_row]
1167}
1168
1169/// Validate toml Safe addresses against embedded deployment-info (hard fail on unavailable).
1170fn safe_address_rows(toml: &ProposalToml, kind: &ContractKind) -> Vec<CheckRow> {
1171    let mut rows = vec![];
1172
1173    let info = match deployment_info(&toml.network) {
1174        Ok(i) => i,
1175        Err(e) => {
1176            rows.push(fail(
1177                "toml:schedule.safe",
1178                format!("deployment-info unavailable: {e}"),
1179            ));
1180            rows.push(fail(
1181                "toml:execute.safe",
1182                format!("deployment-info unavailable: {e}"),
1183            ));
1184            return rows;
1185        },
1186    };
1187
1188    let signers = match kind.timelock_kind {
1189        TimelockKind::Ops => &info.ops_timelock,
1190        TimelockKind::SafeExit => &info.safe_exit_timelock,
1191    };
1192
1193    let proposer_match = signers.proposers.contains(&toml.schedule.safe);
1194    rows.push(if proposer_match {
1195        pass(
1196            "toml:schedule.safe",
1197            format!("{} is a known proposer", toml.schedule.safe),
1198        )
1199    } else {
1200        fail(
1201            "toml:schedule.safe",
1202            format!(
1203                "{} not in proposers {:?}",
1204                toml.schedule.safe, signers.proposers
1205            ),
1206        )
1207    });
1208
1209    let executor_match = signers.executors.contains(&toml.execute.safe);
1210    rows.push(if executor_match {
1211        pass(
1212            "toml:execute.safe",
1213            format!("{} is a known executor", toml.execute.safe),
1214        )
1215    } else {
1216        fail(
1217            "toml:execute.safe",
1218            format!(
1219                "{} not in executors {:?}",
1220                toml.execute.safe, signers.executors
1221            ),
1222        )
1223    });
1224
1225    rows
1226}
1227
1228/// Recompute Safe hashes from the JSONs and assert each equals the toml value.
1229///
1230/// Returns the PhaseHashes (from the toml, now validated).
1231fn compute_and_validate_phase_hashes(
1232    toml: &ProposalToml,
1233    chain_id: u64,
1234    calldatas: &OuterCalldatas,
1235    rows: &mut Vec<CheckRow>,
1236) -> PhaseHashes {
1237    let sched = safe_tx_hashes(
1238        toml.schedule.safe,
1239        chain_id,
1240        toml.timelock,
1241        U256::ZERO,
1242        &calldatas.schedule,
1243        0,
1244        toml.schedule.nonce,
1245    );
1246    let exec = safe_tx_hashes(
1247        toml.execute.safe,
1248        chain_id,
1249        toml.timelock,
1250        U256::ZERO,
1251        &calldatas.execute,
1252        0,
1253        toml.execute.nonce,
1254    );
1255
1256    rows.push(toml_hash_row(
1257        "schedule.domain",
1258        sched.domain,
1259        toml.schedule.domain,
1260    ));
1261    rows.push(toml_hash_row(
1262        "schedule.message",
1263        sched.message,
1264        toml.schedule.message,
1265    ));
1266    rows.push(toml_hash_row(
1267        "schedule.safe_tx",
1268        sched.safe_tx,
1269        toml.schedule.safe_tx,
1270    ));
1271    rows.push(toml_hash_row(
1272        "execute.domain",
1273        exec.domain,
1274        toml.execute.domain,
1275    ));
1276    rows.push(toml_hash_row(
1277        "execute.message",
1278        exec.message,
1279        toml.execute.message,
1280    ));
1281    rows.push(toml_hash_row(
1282        "execute.safe_tx",
1283        exec.safe_tx,
1284        toml.execute.safe_tx,
1285    ));
1286
1287    PhaseHashes {
1288        schedule_nonce: toml.schedule.nonce,
1289        schedule: SafeTxHashes {
1290            domain: toml.schedule.domain,
1291            message: toml.schedule.message,
1292            safe_tx: toml.schedule.safe_tx,
1293        },
1294        execute_nonce: toml.execute.nonce,
1295        execute: SafeTxHashes {
1296            domain: toml.execute.domain,
1297            message: toml.execute.message,
1298            safe_tx: toml.execute.safe_tx,
1299        },
1300    }
1301}
1302
1303/// Query on-chain nonces and emit WARN rows (not hard fails) when they differ from toml.
1304///
1305/// Nonce drift is expected if other transactions were queued since generation.
1306async fn nonce_drift_rows(provider: &impl Provider, toml: &ProposalToml, rows: &mut Vec<CheckRow>) {
1307    for (label, safe, recorded_nonce) in [
1308        ("schedule.nonce", toml.schedule.safe, toml.schedule.nonce),
1309        ("execute.nonce", toml.execute.safe, toml.execute.nonce),
1310    ] {
1311        match ISafe::new(safe, provider).nonce().call().await {
1312            Err(e) => {
1313                rows.push(pass(
1314                    format!("toml:{label}"),
1315                    format!("WARN: nonce query failed ({e}); signer must reconfirm"),
1316                ));
1317            },
1318            Ok(onchain) => {
1319                let onchain_u64: u64 = match onchain.try_into() {
1320                    Ok(n) => n,
1321                    Err(_) => {
1322                        rows.push(pass(
1323                            format!("toml:{label}"),
1324                            "WARN: onchain nonce overflows u64".to_owned(),
1325                        ));
1326                        continue;
1327                    },
1328                };
1329                if onchain_u64 == recorded_nonce {
1330                    rows.push(pass(
1331                        format!("toml:{label}"),
1332                        format!("nonce={onchain_u64}"),
1333                    ));
1334                } else {
1335                    rows.push(pass(
1336                        format!("toml:{label}"),
1337                        format!(
1338                            "WARN: onchain nonce={onchain_u64} != toml={recorded_nonce}; hashes \
1339                             in toml use recorded nonce; signer must reconfirm",
1340                        ),
1341                    ));
1342                }
1343            },
1344        }
1345    }
1346}
1347
1348/// Validate init calldata against the kind's expected reinitializer.
1349///
1350/// Empty init data is only accepted when the reinitializer is genuinely
1351/// unnecessary: either the kind has none, or the proxy's on-chain major
1352/// version (`proxy_major`, `None` if the query failed) already reached the
1353/// target. Otherwise the upgrade would silently skip e.g. `initializeV3()`.
1354fn check_init_selector(
1355    kind: &ContractKind,
1356    init_data: &Bytes,
1357    proxy_major: Option<u8>,
1358) -> CheckRow {
1359    let Some(init) = kind.expected_init else {
1360        return if init_data.is_empty() {
1361            pass(
1362                "init-selector",
1363                "empty (expected for patch/no-reinitializer)",
1364            )
1365        } else {
1366            fail(
1367                "init-selector",
1368                format!(
1369                    "non-empty init data with no expected selector; selector=0x{} (arbitrary \
1370                     delegated call through proxy)",
1371                    alloy::hex::encode(&init_data[..4.min(init_data.len())])
1372                ),
1373            )
1374        };
1375    };
1376
1377    if init_data.is_empty() {
1378        return match proxy_major {
1379            Some(major) if major >= init.target_major => pass(
1380                "init-selector",
1381                format!(
1382                    "empty ok: proxy_major={major} >= target={}",
1383                    init.target_major
1384                ),
1385            ),
1386            Some(major) => fail(
1387                "init-selector",
1388                format!(
1389                    "empty init data but proxy_major={major} < target={}; reinitializer 0x{} \
1390                     would never run",
1391                    init.target_major,
1392                    alloy::hex::encode(init.selector)
1393                ),
1394            ),
1395            None => fail(
1396                "init-selector",
1397                "empty init data and proxy version query failed; cannot confirm the reinitializer \
1398                 is unnecessary",
1399            ),
1400        };
1401    }
1402
1403    // The correct selector passes regardless of proxy_major: re-running a
1404    // reinitializer on an already-upgraded proxy reverts at execution
1405    // (InvalidInitialization), so this cannot be exploited, only wasted.
1406    if init_data.len() >= 4 && init_data[..4] == init.selector {
1407        pass(
1408            "init-selector",
1409            format!("ok selector=0x{}", alloy::hex::encode(init.selector)),
1410        )
1411    } else {
1412        fail(
1413            "init-selector",
1414            format!(
1415                "expected 0x{} got 0x{}",
1416                alloy::hex::encode(init.selector),
1417                alloy::hex::encode(&init_data[..4.min(init_data.len())])
1418            ),
1419        )
1420    }
1421}
1422
1423async fn governance_checks(
1424    provider: &impl Provider,
1425    upgrade: &DecodedUpgrade,
1426    kind: &ContractKind,
1427    proxy_major: Result<u8>,
1428) -> Vec<CheckRow> {
1429    let mut rows = vec![];
1430
1431    match fetch_proxy_owner(provider, upgrade.proxy, kind.owner_accessor).await {
1432        Err(e) => rows.push(fail("owner-query", e.to_string())),
1433        Ok(owner) => {
1434            rows.push(owner_timelock_row(owner, upgrade.outer_to));
1435        },
1436    }
1437
1438    match fetch_min_delay(provider, upgrade.outer_to).await {
1439        Err(e) => rows.push(fail("delay>=minDelay", e.to_string())),
1440        Ok(min_delay) => rows.push(delay_row(upgrade.delay, min_delay)),
1441    }
1442
1443    match (proxy_major, kind.expected_prev_major) {
1444        (Err(e), _) => rows.push(fail("version-prereq", e.to_string())),
1445        (Ok(_), None) => rows.push(pass("version-prereq", "no prereq (RewardClaim)")),
1446        (Ok(major), Some(expected_prev)) => rows.push(if major >= expected_prev {
1447            pass(
1448                "version-prereq",
1449                format!("proxy_major={major} >= required={expected_prev}"),
1450            )
1451        } else {
1452            fail(
1453                "version-prereq",
1454                format!("proxy_major={major} < required={expected_prev}; upgrade path invalid"),
1455            )
1456        }),
1457    }
1458
1459    rows
1460}
1461
1462async fn fetch_proxy_owner(
1463    provider: &impl Provider,
1464    proxy: Address,
1465    accessor: OwnerAccessor,
1466) -> Result<Address> {
1467    match accessor {
1468        OwnerAccessor::Owner => Ok(StakeTableV3::new(proxy, provider).owner().call().await?),
1469        OwnerAccessor::CurrentAdmin => Ok(RewardClaim::new(proxy, provider)
1470            .currentAdmin()
1471            .call()
1472            .await?),
1473    }
1474}
1475
1476async fn fetch_min_delay(provider: &impl Provider, timelock: Address) -> Result<U256> {
1477    Ok(OpsTimelock::new(timelock, provider)
1478        .getMinDelay()
1479        .call()
1480        .await?)
1481}
1482
1483async fn fetch_proxy_major_version(provider: &impl Provider, proxy: Address) -> Result<u8> {
1484    Ok(StakeTableV3::new(proxy, provider)
1485        .getVersion()
1486        .call()
1487        .await?
1488        .majorVersion)
1489}
1490
1491// ── Tests ────────────────────────────────────────────────────────────────────
1492
1493#[cfg(test)]
1494mod tests {
1495    use alloy::{
1496        primitives::{Address, B256, Bytes, U256},
1497        sol_types::SolCall,
1498    };
1499
1500    use super::*;
1501    use crate::proposals::proposal_toml::{PhaseToml, ProposalToml};
1502
1503    const SCHEDULE_JSON: &str = include_str!("fixtures/decaf_stake_table_v3_schedule.json");
1504    const EXECUTE_JSON: &str = include_str!("fixtures/decaf_stake_table_v3_execute.json");
1505
1506    fn load_fixture_proposal() -> TimelockBatches {
1507        let s: SafeBatch = serde_json::from_str(SCHEDULE_JSON).unwrap();
1508        let e: SafeBatch = serde_json::from_str(EXECUTE_JSON).unwrap();
1509        TimelockBatches {
1510            schedule: s,
1511            execute: e,
1512        }
1513    }
1514
1515    /// Build a ProposalToml matching the decaf fixture (known-vector hashes from safe_hash test).
1516    fn fixture_toml() -> ProposalToml {
1517        let safe: Address = "0xb76834e371b666feee48e5d7d9a97ca08b5a0620"
1518            .parse()
1519            .unwrap();
1520        ProposalToml {
1521            contract: "stake-table-v3".to_owned(),
1522            network: "decaf".to_owned(),
1523            chain_id: 11155111,
1524            proxy: "0x40304FbE94D5E7D1492Dd90c53a2D63E8506a037"
1525                .parse()
1526                .unwrap(),
1527            new_impl: "0x5a6250dd35d875c0529573d9d934629a1b2778db"
1528                .parse()
1529                .unwrap(),
1530            timelock: "0x8e3b6563D683b87964104A2c3A4bf542bb70767F"
1531                .parse()
1532                .unwrap(),
1533            salt: "0x99f200000000000000000000000000000000000000000000000000000000000f"
1534                .parse()
1535                .unwrap(),
1536            delay: 300,
1537            predecessor: B256::ZERO,
1538            schedule: PhaseToml {
1539                safe,
1540                nonce: 24,
1541                domain: "0x8f560c9d209e6d9320305560aee98fa1dea01510aa5451a9c0911401893835c6"
1542                    .parse()
1543                    .unwrap(),
1544                message: "0x9c5a62271d73b6accf3c8957a1e80b6434618d3bd4b8bd23e30817479c60d35b"
1545                    .parse()
1546                    .unwrap(),
1547                safe_tx: "0xa3d4b5bfa93b559f34478b3988f1132c35ba67f953a87326c8a1c8250709c6b8"
1548                    .parse()
1549                    .unwrap(),
1550            },
1551            execute: PhaseToml {
1552                safe,
1553                nonce: 25,
1554                domain: "0x8f560c9d209e6d9320305560aee98fa1dea01510aa5451a9c0911401893835c6"
1555                    .parse()
1556                    .unwrap(),
1557                message: "0xf7edebe09a94e770ddbccf107a5685d50d902adb08db5e2043c7b1f9c4ef648b"
1558                    .parse()
1559                    .unwrap(),
1560                safe_tx: "0xbb7fd662e5b724a50e33f18ef737d6df9c1d92b8810def16fb190b7c27c16f45"
1561                    .parse()
1562                    .unwrap(),
1563            },
1564        }
1565    }
1566
1567    // ── TEST:verify-decode-timelock-ok ─────────────────────────────────────
1568
1569    #[test]
1570    fn test_verify_decode_timelock_ok() {
1571        let upgrade = decode_proposal(load_fixture_proposal()).expect("decode");
1572
1573        assert_eq!(
1574            upgrade.proxy,
1575            "0x40304FbE94D5E7D1492Dd90c53a2D63E8506a037"
1576                .parse::<Address>()
1577                .unwrap()
1578        );
1579        assert_eq!(
1580            upgrade.new_impl,
1581            "0x5a6250dd35d875c0529573d9d934629a1b2778db"
1582                .parse::<Address>()
1583                .unwrap()
1584        );
1585        assert_eq!(
1586            upgrade.outer_to,
1587            "0x8e3b6563D683b87964104A2c3A4bf542bb70767F"
1588                .parse::<Address>()
1589                .unwrap()
1590        );
1591        assert_eq!(upgrade.value, U256::ZERO);
1592        assert_eq!(upgrade.predecessor, B256::ZERO);
1593        assert_eq!(upgrade.delay, U256::from(300));
1594        assert_eq!(
1595            &upgrade.init_data[..4],
1596            &StakeTableV3::initializeV3Call::SELECTOR
1597        );
1598    }
1599
1600    // ── TEST:verify-inner-identical-ok ────────────────────────────────────
1601
1602    #[test]
1603    fn test_verify_inner_identical_ok() {
1604        decode_proposal(load_fixture_proposal()).expect("inner payloads must be identical");
1605    }
1606
1607    // ── TEST:verify-inner-mismatch-fails ──────────────────────────────────
1608
1609    #[test]
1610    fn test_verify_inner_mismatch_fails() {
1611        let s: SafeBatch = serde_json::from_str(SCHEDULE_JSON).unwrap();
1612        let e_json = EXECUTE_JSON.replace("0x4f1ef286", "0xdeadbeef");
1613        let e: SafeBatch = serde_json::from_str(&e_json).unwrap();
1614        let err = decode_proposal(TimelockBatches {
1615            schedule: s,
1616            execute: e,
1617        })
1618        .unwrap_err();
1619        assert!(
1620            err.to_string().contains("not identical") || err.to_string().contains("payload"),
1621            "unexpected error: {err}"
1622        );
1623    }
1624
1625    // ── TEST:verify-batch-extra-tx-errors ────────────────────────────────
1626
1627    #[test]
1628    fn test_verify_batch_extra_tx_errors() {
1629        let mut s: SafeBatch = serde_json::from_str(SCHEDULE_JSON).unwrap();
1630        // Duplicate the transaction to simulate a multi-tx batch.
1631        let dup = s.transactions[0].clone();
1632        s.transactions.push(dup);
1633        assert_eq!(s.transactions.len(), 2);
1634
1635        let err = batch_phase(&s).unwrap_err();
1636        assert!(
1637            err.to_string().contains("exactly 1"),
1638            "expected single-tx error, got: {err}"
1639        );
1640    }
1641
1642    // ── TEST:verify-decode-extra-tx-errors ───────────────────────────────
1643
1644    #[test]
1645    fn test_verify_decode_extra_tx_errors() {
1646        let mut s: SafeBatch = serde_json::from_str(SCHEDULE_JSON).unwrap();
1647        let dup_tx = s.transactions[0].clone();
1648        s.transactions.push(dup_tx);
1649        let e: SafeBatch = serde_json::from_str(EXECUTE_JSON).unwrap();
1650
1651        let err = decode_proposal(TimelockBatches {
1652            schedule: s,
1653            execute: e,
1654        })
1655        .unwrap_err();
1656        assert!(
1657            err.to_string().contains("exactly 1"),
1658            "expected single-tx error, got: {err}"
1659        );
1660    }
1661
1662    // ── TEST:verify-load-proposal-dir-ok ─────────────────────────────────
1663
1664    #[test]
1665    fn test_verify_load_proposal_dir_ok() {
1666        let tmp = tempfile::tempdir().unwrap();
1667        let dir = tmp.path();
1668        std::fs::write(dir.join("schedule.json"), SCHEDULE_JSON).unwrap();
1669        std::fs::write(dir.join("execute.json"), EXECUTE_JSON).unwrap();
1670
1671        let batches = load_proposal_dir(dir).expect("load_proposal_dir");
1672        assert_eq!(
1673            batches.schedule.transactions[0]
1674                .contract_method
1675                .as_ref()
1676                .unwrap()
1677                .name,
1678            "schedule"
1679        );
1680        assert_eq!(
1681            batches.execute.transactions[0]
1682                .contract_method
1683                .as_ref()
1684                .unwrap()
1685                .name,
1686            "execute"
1687        );
1688    }
1689
1690    // ── TEST:verify-load-proposal-dir-missing-execute-errors ─────────────
1691
1692    #[test]
1693    fn test_verify_load_proposal_dir_missing_execute_errors() {
1694        let tmp = tempfile::tempdir().unwrap();
1695        let dir = tmp.path();
1696        std::fs::write(dir.join("schedule.json"), SCHEDULE_JSON).unwrap();
1697        // No execute.json.
1698        let err = load_proposal_dir(dir).unwrap_err();
1699        assert!(
1700            err.to_string().contains("execute.json"),
1701            "unexpected error: {err}"
1702        );
1703    }
1704
1705    // ── TEST:verify-load-proposal-dir-phase-mismatch-errors ──────────────
1706
1707    #[test]
1708    fn test_verify_load_proposal_dir_phase_mismatch_errors() {
1709        // schedule.json contains an execute call; phase mismatch must error.
1710        let tmp = tempfile::tempdir().unwrap();
1711        let dir = tmp.path();
1712        // Write the execute JSON as schedule.json so phase validation fails.
1713        std::fs::write(dir.join("schedule.json"), EXECUTE_JSON).unwrap();
1714        std::fs::write(dir.join("execute.json"), EXECUTE_JSON).unwrap();
1715        let err = load_proposal_dir(dir).unwrap_err();
1716        assert!(
1717            err.to_string().contains("schedule.json") && err.to_string().contains("Execute"),
1718            "unexpected error: {err}"
1719        );
1720    }
1721
1722    // ── TEST:verify-contract-flag-vs-toml-mismatch-errors ────────────────
1723
1724    #[test]
1725    fn test_verify_contract_flag_vs_toml_mismatch_errors() {
1726        let tmp = tempfile::tempdir().unwrap();
1727        let dir = tmp.path().to_path_buf();
1728        let mut toml = fixture_toml();
1729        toml.contract = "stake-table-v3".to_owned();
1730        toml.write(&dir).unwrap();
1731
1732        let args = VerifyProposalArgs {
1733            dir: dir.clone(),
1734            contract: Some(ContractKindArg::FeeContract),
1735            rpc_url: None,
1736        };
1737        // Simulate the mismatch check from run_verify.
1738        let loaded = ProposalToml::load(&args.dir).unwrap();
1739        assert_ne!(args.contract.unwrap().as_str(), loaded.contract);
1740    }
1741
1742    // ── TEST:verify-strip-cbor-828-ok ─────────────────────────────────────
1743
1744    #[test]
1745    fn test_verify_strip_cbor_828_ok() {
1746        let mut code: Vec<u8> = vec![0xde, 0xad, 0xbe, 0xef];
1747        code.extend_from_slice(&make_cbor_tail([0x00, 0x08, 0x1c]));
1748        let (stripped, ver) = strip_cbor_metadata(&code);
1749        assert_eq!(stripped, vec![0xde, 0xad, 0xbe, 0xef]);
1750        assert_eq!(ver, Some(SolcVersion([0x00, 0x08, 0x1c])));
1751    }
1752
1753    // ── TEST:verify-strip-cbor-835-ok ─────────────────────────────────────
1754
1755    #[test]
1756    fn test_verify_strip_cbor_835_ok() {
1757        let mut code: Vec<u8> = vec![0xca, 0xfe];
1758        code.extend_from_slice(&make_cbor_tail([0x00, 0x08, 0x23]));
1759        let (stripped, ver) = strip_cbor_metadata(&code);
1760        assert_eq!(stripped, vec![0xca, 0xfe]);
1761        assert_eq!(ver, Some(SolcVersion([0x00, 0x08, 0x23])));
1762    }
1763
1764    // ── TEST:verify-strip-cbor-ipfs-ok ────────────────────────────────────
1765    //
1766    // Default solc metadata: a2 map with an ipfs hash entry before the solc entry.
1767
1768    #[test]
1769    fn test_verify_strip_cbor_ipfs_ok() {
1770        let mut cbor: Vec<u8> = vec![0xa2, 0x64, 0x69, 0x70, 0x66, 0x73, 0x58, 0x22];
1771        cbor.extend_from_slice(&[0x12; 34]);
1772        cbor.extend_from_slice(&[0x64, 0x73, 0x6f, 0x6c, 0x63, 0x43, 0x00, 0x08, 0x23]);
1773        let mut code: Vec<u8> = vec![0xde, 0xad];
1774        code.extend_from_slice(&cbor);
1775        code.extend_from_slice(&(cbor.len() as u16).to_be_bytes());
1776        let (stripped, ver) = strip_cbor_metadata(&code);
1777        assert_eq!(stripped, vec![0xde, 0xad]);
1778        assert_eq!(ver, Some(SolcVersion([0x00, 0x08, 0x23])));
1779    }
1780
1781    // ── TEST:verify-strip-cbor-oversized-tail-rejected ────────────────────
1782    //
1783    // A claimed CBOR length above the metadata bound must not strip, even if
1784    // the body ends with a valid solc entry (hides appended code otherwise).
1785
1786    #[test]
1787    fn test_verify_strip_cbor_oversized_tail_rejected() {
1788        let mut code: Vec<u8> = vec![0xde, 0xad];
1789        code.extend_from_slice(&[0x66; 60]); // attacker-appended bytes
1790        code.extend_from_slice(&[0x64, 0x73, 0x6f, 0x6c, 0x63, 0x43, 0x00, 0x08, 0x23]);
1791        code.extend_from_slice(&69u16.to_be_bytes()); // claims 60 + 9 bytes of "metadata"
1792        let (stripped, ver) = strip_cbor_metadata(&code);
1793        assert_eq!(stripped, code);
1794        assert!(ver.is_none());
1795    }
1796
1797    // ── TEST:verify-strip-cbor-marker-not-at-end-rejected ─────────────────
1798
1799    #[test]
1800    fn test_verify_strip_cbor_marker_not_at_end_rejected() {
1801        let mut cbor = make_cbor_tail([0x00, 0x08, 0x23]);
1802        cbor.truncate(cbor.len() - 2); // drop the length, keep the body
1803        cbor.extend_from_slice(&[0xba, 0xad]); // bytes after the solc entry
1804        let mut code: Vec<u8> = vec![0xde, 0xad];
1805        code.extend_from_slice(&cbor);
1806        code.extend_from_slice(&(cbor.len() as u16).to_be_bytes());
1807        let (stripped, ver) = strip_cbor_metadata(&code);
1808        assert_eq!(stripped, code);
1809        assert!(ver.is_none());
1810    }
1811
1812    // ── TEST:verify-no-metadata-tail-ok ───────────────────────────────────
1813    //
1814    // Code without a valid solc CBOR marker must be returned unchanged.
1815
1816    #[test]
1817    fn test_verify_no_metadata_tail_ok() {
1818        let code: Vec<u8> = vec![0xde, 0xad, 0xbe, 0xef, 0x00, 0x04];
1819        let (stripped, ver) = strip_cbor_metadata(&code);
1820        assert!(ver.is_none());
1821        // Code must not be truncated when no solc marker found.
1822        assert_eq!(
1823            stripped, code,
1824            "code without solc marker must be returned unchanged"
1825        );
1826    }
1827
1828    // ── TEST:verify-appended-garbage-mismatch ────────────────────────────
1829    //
1830    // Garbage appended to on-chain bytecode (no solc marker) must not be stripped,
1831    // causing a core-length mismatch and Mismatch classification.
1832
1833    #[test]
1834    fn test_verify_appended_garbage_mismatch() {
1835        let impl_addr = Address::repeat_byte(0x01);
1836        let cbor = make_cbor_tail([0x00, 0x08, 0x23]);
1837
1838        // Build reference: core bytes + impl zeros + cbor.
1839        let mut reference = vec![0xaa, 0xbb, 0xcc, 0xdd, 0xee];
1840        reference.extend_from_slice(&[0u8; 20]); // immutable window
1841        reference.extend_from_slice(&cbor);
1842
1843        // Build on-chain: same but impl address injected, then garbage bytes appended
1844        // with no valid solc CBOR marker at the new tail.
1845        let mut onchain = vec![0xaa, 0xbb, 0xcc, 0xdd, 0xee];
1846        onchain.extend_from_slice(impl_addr.as_slice());
1847        onchain.extend_from_slice(&cbor);
1848        // Append garbage that happens to look like a length field pointing into cbor,
1849        // but whose cbor body has no solc marker.
1850        onchain.extend_from_slice(&[0xba, 0xad, 0xf0, 0x0d]);
1851        // The last 2 bytes encode a plausible length but the body won't have the solc prefix.
1852        let garbage_len: u16 = 6;
1853        onchain.extend_from_slice(&garbage_len.to_be_bytes());
1854
1855        let check = compare_normalized(&onchain, &reference, impl_addr, 1).unwrap();
1856        assert_eq!(
1857            check.class,
1858            MatchClass::Mismatch,
1859            "appended garbage must not be silently stripped"
1860        );
1861    }
1862
1863    // ── TEST:verify-bytecode-fullmatch-ok ─────────────────────────────────
1864
1865    #[test]
1866    fn test_verify_bytecode_fullmatch_ok() {
1867        let impl_addr = Address::repeat_byte(0x01);
1868        let cbor = make_cbor_tail([0x00, 0x08, 0x23]);
1869
1870        // impl_addr at offset 5; reference has zeros there.
1871        let mut onchain = vec![0xaa, 0xbb, 0xcc, 0xdd, 0xee];
1872        onchain.extend_from_slice(impl_addr.as_slice());
1873        onchain.extend_from_slice(&[0x11, 0x22, 0x33]);
1874        onchain.extend_from_slice(&cbor);
1875
1876        let mut reference = vec![0xaa, 0xbb, 0xcc, 0xdd, 0xee];
1877        reference.extend_from_slice(&[0u8; 20]);
1878        reference.extend_from_slice(&[0x11, 0x22, 0x33]);
1879        reference.extend_from_slice(&cbor);
1880
1881        let check = compare_normalized(&onchain, &reference, impl_addr, 1).unwrap();
1882        assert_eq!(check.class, MatchClass::FullMatch);
1883    }
1884
1885    // ── TEST:verify-bytecode-metadiff-ok ──────────────────────────────────
1886
1887    #[test]
1888    fn test_verify_bytecode_metadiff_ok() {
1889        let impl_addr = Address::repeat_byte(0x01);
1890        let cbor_828 = make_cbor_tail([0x00, 0x08, 0x1c]);
1891        let cbor_835 = make_cbor_tail([0x00, 0x08, 0x23]);
1892
1893        let mut onchain = vec![0xaa, 0xbb, 0xcc, 0xdd, 0xee];
1894        onchain.extend_from_slice(impl_addr.as_slice());
1895        onchain.extend_from_slice(&cbor_828);
1896
1897        let mut reference = vec![0xaa, 0xbb, 0xcc, 0xdd, 0xee];
1898        reference.extend_from_slice(&[0u8; 20]);
1899        reference.extend_from_slice(&cbor_835);
1900
1901        let check = compare_normalized(&onchain, &reference, impl_addr, 1).unwrap();
1902        assert_eq!(check.class, MatchClass::CodeMatchMetaDiffers);
1903        assert_eq!(check.onchain_solc, Some(SolcVersion([0x00, 0x08, 0x1c])));
1904        assert_eq!(check.reference_solc, Some(SolcVersion([0x00, 0x08, 0x23])));
1905    }
1906
1907    // ── TEST:verify-bytecode-core-flip-fails ──────────────────────────────
1908
1909    #[test]
1910    fn test_verify_bytecode_core_flip_fails() {
1911        let impl_addr = Address::repeat_byte(0x01);
1912        let cbor = make_cbor_tail([0x00, 0x08, 0x23]);
1913
1914        let mut onchain = vec![0xaa, 0xbb, 0xcc, 0xdd, 0xee];
1915        onchain.extend_from_slice(impl_addr.as_slice());
1916        onchain.extend_from_slice(&cbor);
1917
1918        let mut reference = vec![0xaa, 0xbb, 0xcc, 0xdd, 0xff]; // flipped byte
1919        reference.extend_from_slice(&[0u8; 20]);
1920        reference.extend_from_slice(&cbor);
1921
1922        let check = compare_normalized(&onchain, &reference, impl_addr, 1).unwrap();
1923        assert_eq!(check.class, MatchClass::Mismatch);
1924        let detail = check.mismatch.unwrap();
1925        assert!(detail.contains("offset 4"), "{detail}");
1926    }
1927
1928    // ── TEST:verify-kind-by-bytecode-ok ───────────────────────────────────
1929    //
1930    // For every contract kind: locate the __self immutable slots in the
1931    // DEPLOYED_BYTECODE core (the only 20+-byte zero runs), inject a fake impl
1932    // there, and assert compare_normalized passes. Pinning the run count to
1933    // kind.self_windows guards the "difference over zeros must be __self"
1934    // assumption: a rebuilt binding that grows a large zero run outside the
1935    // immutable slots (e.g. a PUSH32 0 constant) fails here and forces review,
1936    // because such a run would be a substitution surface at verify time.
1937
1938    /// Maximal runs of zero bytes of length >= 20, as (start, len).
1939    fn zero_runs(core: &[u8]) -> Vec<(usize, usize)> {
1940        let mut runs = vec![];
1941        let mut i = 0;
1942        while i < core.len() {
1943            if core[i] == 0 {
1944                let start = i;
1945                while i < core.len() && core[i] == 0 {
1946                    i += 1;
1947                }
1948                if i - start >= 20 {
1949                    runs.push((start, i - start));
1950                }
1951            } else {
1952                i += 1;
1953            }
1954        }
1955        runs
1956    }
1957
1958    #[test]
1959    fn test_verify_kind_by_bytecode_ok() {
1960        for arg in [
1961            ContractKindArg::StakeTableV2,
1962            ContractKindArg::StakeTableV3,
1963            ContractKindArg::EspTokenV2,
1964            ContractKindArg::FeeContract,
1965            ContractKindArg::RewardClaim,
1966        ] {
1967            let kind = contract_kind(arg);
1968            let name = kind.name;
1969            let (ref_core, _) = strip_cbor_metadata(kind.deployed_bytecode);
1970            let runs = zero_runs(&ref_core);
1971            // Each __self slot is a 32-byte zero run (12-byte pad + 20-byte address).
1972            assert_eq!(runs.len(), kind.self_windows, "{name}: {runs:?}");
1973            assert!(
1974                runs.iter().all(|&(_, len)| len == 32),
1975                "{name}: unexpected zero runs {runs:?}"
1976            );
1977
1978            // Inject a fake impl at the address position of each slot.
1979            let fake_impl = Address::repeat_byte(0x42);
1980            let mut onchain = ref_core.clone();
1981            for &(start, _) in &runs {
1982                onchain[start + 12..start + 32].copy_from_slice(fake_impl.as_slice());
1983            }
1984
1985            // Re-attach a consistent cbor tail to both so strip works symmetrically.
1986            let cbor = make_cbor_tail([0x00, 0x08, 0x23]);
1987            let mut onchain_full = onchain;
1988            onchain_full.extend_from_slice(&cbor);
1989            let mut ref_full = ref_core;
1990            ref_full.extend_from_slice(&cbor);
1991
1992            let check = compare_normalized(&onchain_full, &ref_full, fake_impl, kind.self_windows)
1993                .unwrap_or_else(|e| panic!("{name}: compare_normalized failed: {e}"));
1994            assert_eq!(check.class, MatchClass::FullMatch, "{name}");
1995        }
1996    }
1997
1998    // ── TEST:verify-extra-self-window-bails ───────────────────────────────
1999    //
2000    // Impl address substituted into an additional zero region of the reference
2001    // (beyond the expected __self slots) must error, not pass.
2002
2003    #[test]
2004    fn test_verify_extra_self_window_bails() {
2005        let impl_addr = Address::repeat_byte(0x42);
2006        let cbor = make_cbor_tail([0x00, 0x08, 0x23]);
2007
2008        let mut ref_full = vec![0x11u8; 10];
2009        ref_full.extend_from_slice(&[0u8; 20]); // __self slot
2010        ref_full.extend_from_slice(&[0x22u8; 10]);
2011        ref_full.extend_from_slice(&[0u8; 20]); // zero constant, not a __self slot
2012        ref_full.extend_from_slice(&cbor);
2013
2014        let mut onchain_full = vec![0x11u8; 10];
2015        onchain_full.extend_from_slice(impl_addr.as_slice());
2016        onchain_full.extend_from_slice(&[0x22u8; 10]);
2017        onchain_full.extend_from_slice(impl_addr.as_slice()); // substituted constant
2018        onchain_full.extend_from_slice(&cbor);
2019
2020        let err = compare_normalized(&onchain_full, &ref_full, impl_addr, 1).unwrap_err();
2021        assert!(err.to_string().contains("found 2"), "{err}");
2022    }
2023
2024    // ── TEST:verify-missing-self-window-bails ─────────────────────────────
2025    //
2026    // Only one of two expected __self slots filled (the other byte-equal to
2027    // the reference zeros) must error.
2028
2029    #[test]
2030    fn test_verify_missing_self_window_bails() {
2031        let impl_addr = Address::repeat_byte(0x42);
2032        let cbor = make_cbor_tail([0x00, 0x08, 0x23]);
2033
2034        let mut ref_full = vec![0x11u8; 10];
2035        ref_full.extend_from_slice(&[0u8; 20]);
2036        ref_full.extend_from_slice(&[0x22u8; 10]);
2037        ref_full.extend_from_slice(&[0u8; 20]);
2038        ref_full.extend_from_slice(&cbor);
2039
2040        let mut onchain_full = vec![0x11u8; 10];
2041        onchain_full.extend_from_slice(impl_addr.as_slice());
2042        onchain_full.extend_from_slice(&[0x22u8; 10]);
2043        onchain_full.extend_from_slice(&[0u8; 20]); // slot left unfilled
2044        onchain_full.extend_from_slice(&cbor);
2045
2046        let err = compare_normalized(&onchain_full, &ref_full, impl_addr, 2).unwrap_err();
2047        assert!(err.to_string().contains("found 1"), "{err}");
2048    }
2049
2050    // ── TEST:verify-impl-over-nonzero-reference-mismatch ─────────────────
2051    //
2052    // Impl address written where the reference has non-zero bytes is not an
2053    // explainable difference and must produce Mismatch.
2054
2055    #[test]
2056    fn test_verify_impl_over_nonzero_reference_mismatch() {
2057        let impl_addr = Address::repeat_byte(0x42);
2058        let cbor = make_cbor_tail([0x00, 0x08, 0x23]);
2059
2060        let mut ref_full = vec![0x11u8; 30];
2061        ref_full.extend_from_slice(&[0u8; 20]); // one genuine __self slot
2062        ref_full.extend_from_slice(&cbor);
2063
2064        let mut onchain_core = vec![0x11u8; 30];
2065        onchain_core.extend_from_slice(impl_addr.as_slice());
2066        // Overwrite non-zero reference bytes with the impl address.
2067        onchain_core[5..25].copy_from_slice(impl_addr.as_slice());
2068        let mut onchain_full = onchain_core;
2069        onchain_full.extend_from_slice(&cbor);
2070
2071        let check = compare_normalized(&onchain_full, &ref_full, impl_addr, 1).unwrap();
2072        assert_eq!(check.class, MatchClass::Mismatch);
2073    }
2074
2075    // ── TEST:verify-corrupted-self-window-mismatch ────────────────────────
2076    //
2077    // A __self slot holding anything other than exactly the impl address
2078    // (one byte flipped) must produce Mismatch.
2079
2080    #[test]
2081    fn test_verify_corrupted_self_window_mismatch() {
2082        let impl_addr = Address::repeat_byte(0x42);
2083        let cbor = make_cbor_tail([0x00, 0x08, 0x23]);
2084
2085        let mut ref_full = vec![0x11u8; 10];
2086        ref_full.extend_from_slice(&[0u8; 20]);
2087        ref_full.extend_from_slice(&cbor);
2088
2089        let mut window: [u8; 20] = impl_addr.into();
2090        window[7] ^= 0x01;
2091        let mut onchain_full = vec![0x11u8; 10];
2092        onchain_full.extend_from_slice(&window);
2093        onchain_full.extend_from_slice(&cbor);
2094
2095        let check = compare_normalized(&onchain_full, &ref_full, impl_addr, 1).unwrap();
2096        assert_eq!(check.class, MatchClass::Mismatch);
2097    }
2098
2099    // ── TEST:verify-no-self-window-bails ──────────────────────────────────
2100    //
2101    // On-chain code byte-identical to the binding has no baked-in __self
2102    // address; that is impossible for a real UUPS deploy and must error.
2103
2104    #[test]
2105    fn test_verify_no_self_window_bails() {
2106        let impl_addr = Address::repeat_byte(0x42);
2107        let cbor = make_cbor_tail([0x00, 0x08, 0x23]);
2108        let mut code = vec![0x11u8; 10];
2109        code.extend_from_slice(&[0u8; 20]);
2110        code.extend_from_slice(&cbor);
2111
2112        let err = compare_normalized(&code, &code, impl_addr, 1).unwrap_err();
2113        assert!(err.to_string().contains("__self"), "{err}");
2114    }
2115
2116    // ── TEST:verify-zero-impl-addr-bails ──────────────────────────────────
2117
2118    #[test]
2119    fn test_verify_zero_impl_addr_bails() {
2120        let code = vec![0x11u8; 10];
2121        assert!(compare_normalized(&code, &code, Address::ZERO, 1).is_err());
2122    }
2123
2124    // ── TEST:verify-contract-kind-args ────────────────────────────────────
2125
2126    #[test]
2127    fn test_verify_contract_kind_args() {
2128        let k = contract_kind(ContractKindArg::StakeTableV3);
2129        assert_eq!(k.name, "StakeTableV3");
2130        assert_eq!(k.owner_accessor, OwnerAccessor::Owner);
2131        assert_eq!(k.timelock_kind, TimelockKind::Ops);
2132
2133        let k = contract_kind(ContractKindArg::RewardClaim);
2134        assert_eq!(k.owner_accessor, OwnerAccessor::CurrentAdmin);
2135        assert_eq!(k.timelock_kind, TimelockKind::SafeExit);
2136    }
2137
2138    // ── TEST:verify-governance-owner-ok ───────────────────────────────────
2139
2140    #[test]
2141    fn test_verify_governance_owner_ok() {
2142        let timelock = Address::repeat_byte(0x11);
2143        assert!(owner_timelock_row(timelock, timelock).pass);
2144        assert!(!owner_timelock_row(Address::repeat_byte(0x22), timelock).pass);
2145    }
2146
2147    // ── TEST:verify-governance-delay-short-fails ──────────────────────────
2148
2149    #[test]
2150    fn test_verify_governance_delay_short_fails() {
2151        assert!(!delay_row(U256::from(60u64), U256::from(300u64)).pass);
2152        assert!(delay_row(U256::from(300u64), U256::from(300u64)).pass);
2153        assert!(delay_row(U256::from(400u64), U256::from(300u64)).pass);
2154    }
2155
2156    // ── TEST:verify-safe-tx-hash-ok ────────────────────────────────────────
2157
2158    #[test]
2159    fn test_verify_safe_tx_hash_ok() {
2160        let safe: Address = "0xb76834e371b666feee48e5d7d9a97ca08b5a0620"
2161            .parse()
2162            .unwrap();
2163        let chain_id: u64 = 11155111;
2164        let timelock: Address = "0x8e3b6563d683b87964104a2c3a4bf542bb70767f"
2165            .parse()
2166            .unwrap();
2167
2168        let s: SafeBatch = serde_json::from_str(SCHEDULE_JSON).unwrap();
2169        let e: SafeBatch = serde_json::from_str(EXECUTE_JSON).unwrap();
2170        let (_, sched_calldata) = tx_calldata(&s.transactions[0]).unwrap();
2171        let (_, exec_calldata) = tx_calldata(&e.transactions[0]).unwrap();
2172
2173        let sched_hashes =
2174            safe_tx_hashes(safe, chain_id, timelock, U256::ZERO, &sched_calldata, 0, 24);
2175        let expected_domain: B256 =
2176            "0x8f560c9d209e6d9320305560aee98fa1dea01510aa5451a9c0911401893835c6"
2177                .parse()
2178                .unwrap();
2179        assert_eq!(sched_hashes.domain, expected_domain);
2180        assert_eq!(
2181            sched_hashes.message,
2182            "0x9c5a62271d73b6accf3c8957a1e80b6434618d3bd4b8bd23e30817479c60d35b"
2183                .parse::<B256>()
2184                .unwrap()
2185        );
2186        assert_eq!(
2187            sched_hashes.safe_tx,
2188            "0xa3d4b5bfa93b559f34478b3988f1132c35ba67f953a87326c8a1c8250709c6b8"
2189                .parse::<B256>()
2190                .unwrap()
2191        );
2192
2193        let exec_hashes =
2194            safe_tx_hashes(safe, chain_id, timelock, U256::ZERO, &exec_calldata, 0, 25);
2195        assert_eq!(exec_hashes.domain, expected_domain);
2196        assert_eq!(
2197            exec_hashes.message,
2198            "0xf7edebe09a94e770ddbccf107a5685d50d902adb08db5e2043c7b1f9c4ef648b"
2199                .parse::<B256>()
2200                .unwrap()
2201        );
2202        assert_eq!(
2203            exec_hashes.safe_tx,
2204            "0xbb7fd662e5b724a50e33f18ef737d6df9c1d92b8810def16fb190b7c27c16f45"
2205                .parse::<B256>()
2206                .unwrap()
2207        );
2208    }
2209
2210    // ── TEST:verify-exit-code-fail-nonzero ────────────────────────────────
2211
2212    #[test]
2213    fn test_verify_exit_code_fail_nonzero() {
2214        let domain: B256 = B256::ZERO;
2215        let report = VerifyReport {
2216            rows: vec![pass("check-a", "ok"), fail("check-b", "something wrong")],
2217            header: ReportHeader {
2218                proxy: Address::ZERO,
2219                new_impl: Address::ZERO,
2220                contract_name: "StakeTableV3",
2221                network: "decaf".to_owned(),
2222                description: String::new(),
2223                onchain_solc: None,
2224                reference_solc: None,
2225            },
2226            phase_hashes: PhaseHashes {
2227                schedule_nonce: 24,
2228                schedule: SafeTxHashes {
2229                    domain,
2230                    message: domain,
2231                    safe_tx: domain,
2232                },
2233                execute_nonce: 25,
2234                execute: SafeTxHashes {
2235                    domain,
2236                    message: domain,
2237                    safe_tx: domain,
2238                },
2239            },
2240        };
2241        assert_eq!(report.exit_code(), 1);
2242    }
2243
2244    // ── TEST:verify-empty-init-at-target-ok ───────────────────────────────
2245    //
2246    // Empty init data is fine when the proxy already reached the target major
2247    // (re-verify after upgrade, or a patch within the same major).
2248
2249    #[test]
2250    fn test_verify_empty_init_at_target_ok() {
2251        let kind = contract_kind(ContractKindArg::StakeTableV3);
2252        let row = check_init_selector(&kind, &Bytes::new(), Some(3));
2253        assert!(row.pass, "{}", row.detail);
2254    }
2255
2256    // ── TEST:verify-empty-init-below-target-fails ─────────────────────────
2257    //
2258    // A V2→V3 proposal with empty init data would skip initializeV3(); must FAIL.
2259
2260    #[test]
2261    fn test_verify_empty_init_below_target_fails() {
2262        let kind = contract_kind(ContractKindArg::StakeTableV3);
2263        let row = check_init_selector(&kind, &Bytes::new(), Some(2));
2264        assert!(!row.pass, "{}", row.detail);
2265        assert!(row.detail.contains("would never run"), "{}", row.detail);
2266    }
2267
2268    // ── TEST:verify-empty-init-unknown-version-fails ──────────────────────
2269
2270    #[test]
2271    fn test_verify_empty_init_unknown_version_fails() {
2272        let kind = contract_kind(ContractKindArg::StakeTableV3);
2273        let row = check_init_selector(&kind, &Bytes::new(), None);
2274        assert!(!row.pass, "{}", row.detail);
2275    }
2276
2277    // ── TEST:verify-feecontract-no-init-ok ────────────────────────────────
2278
2279    #[test]
2280    fn test_verify_feecontract_no_init_ok() {
2281        let kind = contract_kind(ContractKindArg::FeeContract);
2282        let row = check_init_selector(&kind, &Bytes::new(), None);
2283        assert!(row.pass, "FeeContract patch should accept empty init");
2284    }
2285
2286    // ── TEST:verify-feecontract-nonempty-init-fails ───────────────────────
2287    //
2288    // Non-empty init data with expected_init=None must FAIL.
2289
2290    #[test]
2291    fn test_verify_feecontract_nonempty_init_fails() {
2292        let kind = contract_kind(ContractKindArg::FeeContract);
2293        let data = Bytes::from(vec![0xde, 0xad, 0xbe, 0xef, 0x00]);
2294        let row = check_init_selector(&kind, &data, Some(1));
2295        assert!(
2296            !row.pass,
2297            "non-empty init with no expected selector must FAIL: {}",
2298            row.detail
2299        );
2300        assert!(
2301            row.detail.contains("arbitrary delegated call"),
2302            "detail should explain risk: {}",
2303            row.detail
2304        );
2305    }
2306
2307    // ── TEST:verify-rewardclaim-nonempty-init-fails ───────────────────────
2308
2309    #[test]
2310    fn test_verify_rewardclaim_nonempty_init_fails() {
2311        let kind = contract_kind(ContractKindArg::RewardClaim);
2312        let data = Bytes::from(vec![0xca, 0xfe, 0xba, 0xbe]);
2313        let row = check_init_selector(&kind, &data, Some(1));
2314        assert!(
2315            !row.pass,
2316            "non-empty init on RewardClaim with no expected selector must FAIL: {}",
2317            row.detail
2318        );
2319    }
2320
2321    // ── TEST:verify-value-nonzero-fails ───────────────────────────────────
2322
2323    #[test]
2324    fn test_verify_value_nonzero_fails() {
2325        assert!(!value_zero_row(U256::from(1u64)).pass);
2326        assert!(value_zero_row(U256::ZERO).pass);
2327    }
2328
2329    // ── TEST:verify-predecessor-nonzero-fails ─────────────────────────────
2330
2331    #[test]
2332    fn test_verify_predecessor_nonzero_fails() {
2333        assert!(!predecessor_zero_row(B256::repeat_byte(0x01)).pass);
2334        assert!(predecessor_zero_row(B256::ZERO).pass);
2335    }
2336
2337    // ── TEST:verify-toml-tampered-impl-fails ──────────────────────────────
2338
2339    #[test]
2340    fn test_verify_toml_tampered_impl_fails() {
2341        let upgrade_impl: Address = "0x5a6250dd35d875c0529573d9d934629a1b2778db"
2342            .parse()
2343            .unwrap();
2344        let tampered_impl = Address::repeat_byte(0xde);
2345
2346        // toml_field_row returns fail when decoded != recorded.
2347        let row = toml_field_row("impl", upgrade_impl, tampered_impl);
2348        assert!(!row.pass, "tampered impl must fail: {}", row.detail);
2349        assert!(row.detail.contains("decoded="), "detail: {}", row.detail);
2350    }
2351
2352    // ── TEST:verify-toml-tampered-safe-tx-fails ───────────────────────────
2353
2354    #[test]
2355    fn test_verify_toml_tampered_safe_tx_fails() {
2356        let s: SafeBatch = serde_json::from_str(SCHEDULE_JSON).unwrap();
2357        let e: SafeBatch = serde_json::from_str(EXECUTE_JSON).unwrap();
2358        let (_, sched_calldata) = tx_calldata(&s.transactions[0]).unwrap();
2359        let (_, exec_calldata) = tx_calldata(&e.transactions[0]).unwrap();
2360
2361        let mut toml = fixture_toml();
2362        // Tamper the schedule safe_tx hash.
2363        toml.schedule.safe_tx = B256::repeat_byte(0xba);
2364
2365        let calldatas = OuterCalldatas {
2366            schedule: sched_calldata,
2367            execute: exec_calldata,
2368        };
2369        let mut rows = vec![];
2370        compute_and_validate_phase_hashes(&toml, 11155111, &calldatas, &mut rows);
2371
2372        let safe_tx_row = rows
2373            .iter()
2374            .find(|r| r.name == "toml:schedule.safe_tx")
2375            .expect("must have schedule.safe_tx row");
2376        assert!(
2377            !safe_tx_row.pass,
2378            "tampered safe_tx must fail: {}",
2379            safe_tx_row.detail
2380        );
2381    }
2382
2383    // ── TEST:verify-toml-round-trip-ok ────────────────────────────────────
2384
2385    #[test]
2386    fn test_verify_toml_round_trip_ok() {
2387        let original = fixture_toml();
2388        let tmp = tempfile::tempdir().unwrap();
2389        original.write(tmp.path()).unwrap();
2390        let loaded = ProposalToml::load(tmp.path()).unwrap();
2391        assert_eq!(original, loaded);
2392    }
2393
2394    // ── TEST:verify-network-chain-id-match-ok ─────────────────────────────
2395
2396    #[test]
2397    fn test_verify_network_chain_id_match_ok() {
2398        assert!(network_chain_id_row("mainnet", 1).pass);
2399        assert!(network_chain_id_row("decaf", 11155111).pass);
2400        assert!(network_chain_id_row("hoodi", 560048).pass);
2401    }
2402
2403    // ── TEST:verify-network-chain-id-mismatch-fails ───────────────────────
2404
2405    #[test]
2406    fn test_verify_network_chain_id_mismatch_fails() {
2407        let row = network_chain_id_row("mainnet", 11155111);
2408        assert!(!row.pass, "mainnet with sepolia chain_id must fail");
2409        assert!(
2410            row.detail.contains("chain_id=1"),
2411            "detail must show expected: {}",
2412            row.detail
2413        );
2414    }
2415
2416    // ── TEST:verify-network-unknown-fails ─────────────────────────────────
2417
2418    #[test]
2419    fn test_verify_network_unknown_fails() {
2420        let row = network_chain_id_row("bogusnet", 999);
2421        assert!(!row.pass, "unknown network must fail");
2422        assert!(
2423            row.detail.contains("unknown network"),
2424            "detail: {}",
2425            row.detail
2426        );
2427    }
2428
2429    // ── TEST:verify-safe-address-unknown-network-fail ─────────────────────
2430
2431    #[test]
2432    fn test_verify_safe_address_unknown_network_fail() {
2433        let mut toml = fixture_toml();
2434        toml.network = "bogusnet".to_owned();
2435        let kind = contract_kind(ContractKindArg::StakeTableV3);
2436        let rows = safe_address_rows(&toml, &kind);
2437        assert_eq!(rows.len(), 2);
2438        assert!(!rows[0].pass, "unknown network must FAIL schedule.safe");
2439        assert!(!rows[1].pass, "unknown network must FAIL execute.safe");
2440    }
2441
2442    // ── TEST:verify-safe-address-safe-exit-kind ───────────────────────────
2443
2444    #[test]
2445    fn test_verify_safe_address_safe_exit_kind() {
2446        // RewardClaim is SafeExit; decaf safe_exit_timelock has espresso_labs as proposer/executor.
2447        let espresso_labs: Address = "0xb76834e371b666feee48e5d7d9a97ca08b5a0620"
2448            .parse()
2449            .unwrap();
2450        let mut toml = fixture_toml();
2451        toml.schedule.safe = espresso_labs;
2452        toml.execute.safe = espresso_labs;
2453        let kind = contract_kind(ContractKindArg::RewardClaim);
2454        let rows = safe_address_rows(&toml, &kind);
2455        assert_eq!(rows.len(), 2);
2456        assert!(
2457            rows[0].pass,
2458            "espresso_labs is safe_exit proposer: {}",
2459            rows[0].detail
2460        );
2461        assert!(
2462            rows[1].pass,
2463            "espresso_labs is safe_exit executor: {}",
2464            rows[1].detail
2465        );
2466    }
2467
2468    // ── Helpers ───────────────────────────────────────────────────────────
2469
2470    /// Real solc tail with `bytecode_hash = "none"`: `a1 64 "solc" 43 <ver>` + u16 length.
2471    fn make_cbor_tail(ver: [u8; 3]) -> Vec<u8> {
2472        let mut cbor = vec![
2473            0xa1, 0x64, 0x73, 0x6f, 0x6c, 0x63, 0x43, ver[0], ver[1], ver[2],
2474        ];
2475        cbor.extend_from_slice(&(cbor.len() as u16).to_be_bytes());
2476        cbor
2477    }
2478}