1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Phase {
69 Schedule,
70 Execute,
71}
72
73#[derive(Debug)]
76pub struct TimelockBatches {
77 pub schedule: SafeBatch,
78 pub execute: SafeBatch,
79}
80
81#[derive(Debug, Clone)]
85pub struct OuterCalldatas {
86 pub schedule: Bytes,
87 pub execute: Bytes,
88}
89
90#[derive(Debug, Clone)]
91pub struct DecodedUpgrade {
92 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#[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 FullMatch,
120 CodeMatchMetaDiffers,
122 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 pub mismatch: Option<String>,
134}
135
136pub fn strip_cbor_metadata(code: &[u8]) -> (Vec<u8>, Option<SolcVersion>) {
146 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
166pub 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 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#[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 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#[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 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
381pub 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
492fn 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
528pub 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
564pub 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#[derive(Debug, Clone)]
652pub struct CheckRow {
653 pub name: String,
654 pub pass: bool,
655 pub detail: String,
656}
657
658#[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
753pub 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
796fn 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
808fn 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
820fn 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#[derive(Debug, Clone, clap::Args)]
835pub struct VerifyProposalArgs {
836 pub dir: PathBuf,
838
839 #[clap(long)]
841 pub contract: Option<ContractKindArg>,
842
843 #[clap(long, env = "ESPRESSO_L1_PROVIDER")]
848 pub rpc_url: Option<Url>,
849}
850
851pub 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 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 rows.push(network_chain_id_row(&toml.network, toml.chain_id));
906
907 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 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 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 let safe_rows = safe_address_rows(&toml, &kind);
1025 rows.extend(safe_rows);
1026
1027 let phase_hashes =
1029 compute_and_validate_phase_hashes(&toml, chain_id, &upgrade.outer_calldatas, &mut rows);
1030
1031 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
1052fn 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
1070fn 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
1088fn 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
1169fn 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
1228fn 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
1303async 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
1348fn 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 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#[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 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]
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]
1603 fn test_verify_inner_identical_ok() {
1604 decode_proposal(load_fixture_proposal()).expect("inner payloads must be identical");
1605 }
1606
1607 #[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]
1628 fn test_verify_batch_extra_tx_errors() {
1629 let mut s: SafeBatch = serde_json::from_str(SCHEDULE_JSON).unwrap();
1630 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]
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]
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]
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 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]
1708 fn test_verify_load_proposal_dir_phase_mismatch_errors() {
1709 let tmp = tempfile::tempdir().unwrap();
1711 let dir = tmp.path();
1712 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]
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 let loaded = ProposalToml::load(&args.dir).unwrap();
1739 assert_ne!(args.contract.unwrap().as_str(), loaded.contract);
1740 }
1741
1742 #[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]
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]
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]
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]); code.extend_from_slice(&[0x64, 0x73, 0x6f, 0x6c, 0x63, 0x43, 0x00, 0x08, 0x23]);
1791 code.extend_from_slice(&69u16.to_be_bytes()); let (stripped, ver) = strip_cbor_metadata(&code);
1793 assert_eq!(stripped, code);
1794 assert!(ver.is_none());
1795 }
1796
1797 #[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); cbor.extend_from_slice(&[0xba, 0xad]); 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]
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 assert_eq!(
1823 stripped, code,
1824 "code without solc marker must be returned unchanged"
1825 );
1826 }
1827
1828 #[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 let mut reference = vec![0xaa, 0xbb, 0xcc, 0xdd, 0xee];
1840 reference.extend_from_slice(&[0u8; 20]); reference.extend_from_slice(&cbor);
1842
1843 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 onchain.extend_from_slice(&[0xba, 0xad, 0xf0, 0x0d]);
1851 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]
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 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]
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]
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]; 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 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 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 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 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]
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]); ref_full.extend_from_slice(&[0x22u8; 10]);
2011 ref_full.extend_from_slice(&[0u8; 20]); 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()); 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]
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]); 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]
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]); 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 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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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 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]
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 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]
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]
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]
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]
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]
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]
2445 fn test_verify_safe_address_safe_exit_kind() {
2446 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 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}