espresso_contract_deployer/proposals/
write.rs1use std::path::PathBuf;
4
5use alloy::{
6 primitives::{Address, B256, Bytes, U256},
7 providers::Provider,
8 sol,
9};
10use anyhow::{Context, Result};
11use chrono::Local;
12use url::Url;
13
14use crate::proposals::{
15 deployment_info::deployment_info,
16 proposal_toml::{PhaseToml, ProposalToml},
17 safe_hash::safe_tx_hashes,
18};
19
20sol! {
21 #[sol(rpc)]
22 interface ISafe {
23 function nonce() external view returns (uint256);
24 }
25}
26
27pub fn default_rpc_url(chain_id: u64) -> Option<Url> {
31 match chain_id {
32 1 => Some(
33 "https://ethereum-rpc.publicnode.com"
34 .parse()
35 .expect("static URL"),
36 ),
37 11155111 => Some(
38 "https://ethereum-sepolia-rpc.publicnode.com"
39 .parse()
40 .expect("static URL"),
41 ),
42 560048 => Some(
43 "https://ethereum-hoodi-rpc.publicnode.com"
44 .parse()
45 .expect("static URL"),
46 ),
47 _ => None,
48 }
49}
50
51pub fn validate_path_component(value: &str, label: &str) -> Result<()> {
55 if value.is_empty() {
56 anyhow::bail!("{label} must not be empty");
57 }
58 if value.contains('/') || value.contains('\\') {
59 anyhow::bail!("{label} must not contain '/' or '\\': {value:?}");
60 }
61 if value == ".." || value.starts_with("../") || value.ends_with("/..") {
62 anyhow::bail!("{label} must not be '..': {value:?}");
63 }
64 Ok(())
65}
66
67pub fn network_name(chain_id: u64) -> Option<String> {
69 match chain_id {
70 1 => Some("mainnet".to_owned()),
71 11155111 => Some("decaf".to_owned()),
72 560048 => Some("hoodi".to_owned()),
73 _ => None,
74 }
75}
76
77pub fn resolve_network(chain_id: u64, override_name: Option<String>) -> Result<String> {
79 if let Some(name) = override_name {
80 validate_path_component(&name, "--network")?;
81 return Ok(name);
82 }
83 network_name(chain_id)
84 .ok_or_else(|| anyhow::anyhow!("unknown chain id {chain_id}; pass --network"))
85}
86
87pub fn proposal_dir(proposals_root: PathBuf, network: &str, slug: &str) -> PathBuf {
90 let date = Local::now().format("%Y%m%d").to_string();
91 proposals_root.join(network).join(format!("{date}-{slug}"))
92}
93
94pub struct WriteProposalParams {
96 pub proposals_root: PathBuf,
98 pub network: String,
100 pub slug: String,
102 pub chain_id: u64,
103 pub proxy: Address,
104 pub new_impl: Address,
105 pub timelock: Address,
106 pub salt: B256,
107 pub delay: U256,
108 pub schedule_calldata: Bytes,
110 pub execute_calldata: Bytes,
112 pub safe_override: Option<Address>,
114}
115
116async fn safe_nonce(provider: &impl Provider, safe: Address) -> Result<u64> {
118 let n = ISafe::new(safe, provider)
119 .nonce()
120 .call()
121 .await
122 .with_context(|| format!("failed to query nonce() on Safe {safe:#x}"))?;
123 n.try_into().context("Safe nonce overflows u64")
124}
125
126async fn resolve_toml_phases(
131 provider: &impl Provider,
132 params: &WriteProposalParams,
133) -> Result<(PhaseToml, PhaseToml)> {
134 let (schedule_safe, execute_safe) = if let Some(safe) = params.safe_override {
135 (safe, safe)
136 } else {
137 let info = deployment_info(¶ms.network).with_context(|| {
138 format!(
139 "deployment-info unavailable for network {:?}",
140 params.network
141 )
142 })?;
143 let signers = &info.ops_timelock;
144 if signers.proposers.len() != 1 || signers.executors.len() != 1 {
145 anyhow::bail!(
146 "ambiguous signer set for network {:?}: {} proposer(s), {} executor(s); pass \
147 --safe to override",
148 params.network,
149 signers.proposers.len(),
150 signers.executors.len(),
151 );
152 }
153 (signers.proposers[0], signers.executors[0])
154 };
155
156 let schedule_nonce = safe_nonce(provider, schedule_safe).await?;
157 let execute_nonce = if execute_safe == schedule_safe {
158 schedule_nonce + 1
159 } else {
160 safe_nonce(provider, execute_safe).await?
161 };
162
163 let schedule_hashes = safe_tx_hashes(
164 schedule_safe,
165 params.chain_id,
166 params.timelock,
167 U256::ZERO,
168 ¶ms.schedule_calldata,
169 0,
170 schedule_nonce,
171 );
172 let execute_hashes = safe_tx_hashes(
173 execute_safe,
174 params.chain_id,
175 params.timelock,
176 U256::ZERO,
177 ¶ms.execute_calldata,
178 0,
179 execute_nonce,
180 );
181
182 let schedule_phase = PhaseToml {
183 safe: schedule_safe,
184 nonce: schedule_nonce,
185 domain: schedule_hashes.domain,
186 message: schedule_hashes.message,
187 safe_tx: schedule_hashes.safe_tx,
188 };
189 let execute_phase = PhaseToml {
190 safe: execute_safe,
191 nonce: execute_nonce,
192 domain: execute_hashes.domain,
193 message: execute_hashes.message,
194 safe_tx: execute_hashes.safe_tx,
195 };
196 Ok((schedule_phase, execute_phase))
197}
198
199pub async fn write_stake_table_v3_proposal_dir(
203 params: WriteProposalParams,
204 provider: &impl Provider,
205) -> Result<PathBuf> {
206 validate_path_component(¶ms.network, "--network")?;
207 validate_path_component(¶ms.slug, "--proposal-slug")?;
208
209 let dir = proposal_dir(params.proposals_root.clone(), ¶ms.network, ¶ms.slug);
210
211 if dir.join("schedule.json").exists() {
212 anyhow::bail!(
213 "proposal dir {} already exists; remove it or pass a different --proposal-slug",
214 dir.display()
215 );
216 }
217
218 std::fs::create_dir_all(&dir)
219 .with_context(|| format!("failed to create proposal dir {}", dir.display()))?;
220
221 let (schedule_phase, execute_phase) = resolve_toml_phases(provider, ¶ms).await?;
222
223 let delay_u64: u64 = params.delay.try_into().context("delay overflows u64")?;
224
225 let toml = ProposalToml {
226 contract: "stake-table-v3".to_owned(),
227 network: params.network.clone(),
228 chain_id: params.chain_id,
229 proxy: params.proxy,
230 new_impl: params.new_impl,
231 timelock: params.timelock,
232 salt: params.salt,
233 delay: delay_u64,
234 predecessor: B256::ZERO,
235 schedule: schedule_phase,
236 execute: execute_phase,
237 };
238 toml.write(&dir)?;
239
240 Ok(dir)
241}
242
243#[cfg(test)]
244mod tests {
245 use std::str::FromStr;
246
247 use alloy::{node_bindings::Anvil, providers::ProviderBuilder};
248
249 use super::*;
250 use crate::proposals::proposal_toml::ProposalToml;
251
252 #[test]
253 fn test_network_name_known() {
254 assert_eq!(network_name(1), Some("mainnet".to_owned()));
255 assert_eq!(network_name(11155111), Some("decaf".to_owned()));
256 assert_eq!(network_name(560048), Some("hoodi".to_owned()));
257 }
258
259 #[test]
260 fn test_network_name_unknown() {
261 assert_eq!(network_name(9999), None);
262 }
263
264 #[test]
265 fn test_resolve_network_override() {
266 let result = resolve_network(9999, Some("my-network".to_owned())).unwrap();
267 assert_eq!(result, "my-network");
268 }
269
270 #[test]
271 fn test_resolve_network_from_chain_id() {
272 assert_eq!(resolve_network(1, None).unwrap(), "mainnet");
273 assert_eq!(resolve_network(11155111, None).unwrap(), "decaf");
274 }
275
276 #[test]
277 fn test_resolve_network_unknown_errors() {
278 let err = resolve_network(9999, None).unwrap_err();
279 assert!(err.to_string().contains("unknown chain id 9999"));
280 assert!(err.to_string().contains("--network"));
281 }
282
283 #[test]
284 fn test_proposal_dir_format() {
285 let root = PathBuf::from("/tmp/proposals");
286 let dir = proposal_dir(root, "decaf", "stake-table-v3");
287 let s = dir.to_string_lossy();
288 assert!(s.contains("/tmp/proposals/decaf/"));
289 assert!(s.contains("-stake-table-v3"));
290 }
291
292 #[test]
293 fn test_validate_path_component_ok() {
294 assert!(validate_path_component("mainnet", "--network").is_ok());
295 assert!(validate_path_component("stake-table-v3", "--proposal-slug").is_ok());
296 }
297
298 #[test]
299 fn test_validate_path_component_rejects_slash() {
300 assert!(validate_path_component("foo/bar", "--network").is_err());
301 assert!(validate_path_component("foo\\bar", "--network").is_err());
302 }
303
304 #[test]
305 fn test_validate_path_component_rejects_dotdot() {
306 assert!(validate_path_component("..", "--network").is_err());
307 }
308
309 #[test]
310 fn test_validate_path_component_rejects_empty() {
311 assert!(validate_path_component("", "--network").is_err());
312 }
313
314 #[test]
315 fn test_resolve_network_rejects_traversal() {
316 let err = resolve_network(9999, Some("../evil".to_owned())).unwrap_err();
317 assert!(err.to_string().contains("--network"));
318 }
319
320 #[test]
322 fn test_proposal_toml_round_trip() {
323 let safe: Address = "0xb76834e371b666feee48e5d7d9a97ca08b5a0620"
324 .parse()
325 .unwrap();
326 let domain: alloy::primitives::B256 =
327 "0x8f560c9d209e6d9320305560aee98fa1dea01510aa5451a9c0911401893835c6"
328 .parse()
329 .unwrap();
330 let original = ProposalToml {
331 contract: "stake-table-v3".to_owned(),
332 network: "decaf".to_owned(),
333 chain_id: 11155111,
334 proxy: "0x40304FbE94D5E7D1492Dd90c53a2D63E8506a037"
335 .parse()
336 .unwrap(),
337 new_impl: "0x5a6250dd35d875c0529573d9d934629a1b2778db"
338 .parse()
339 .unwrap(),
340 timelock: "0x8e3b6563D683b87964104A2c3A4bf542bb70767F"
341 .parse()
342 .unwrap(),
343 salt: B256::repeat_byte(0x99),
344 delay: 300,
345 predecessor: B256::ZERO,
346 schedule: crate::proposals::proposal_toml::PhaseToml {
347 safe,
348 nonce: 24,
349 domain,
350 message: "0x9c5a62271d73b6accf3c8957a1e80b6434618d3bd4b8bd23e30817479c60d35b"
351 .parse()
352 .unwrap(),
353 safe_tx: "0xa3d4b5bfa93b559f34478b3988f1132c35ba67f953a87326c8a1c8250709c6b8"
354 .parse()
355 .unwrap(),
356 },
357 execute: crate::proposals::proposal_toml::PhaseToml {
358 safe,
359 nonce: 25,
360 domain,
361 message: "0xf7edebe09a94e770ddbccf107a5685d50d902adb08db5e2043c7b1f9c4ef648b"
362 .parse()
363 .unwrap(),
364 safe_tx: "0xbb7fd662e5b724a50e33f18ef737d6df9c1d92b8810def16fb190b7c27c16f45"
365 .parse()
366 .unwrap(),
367 },
368 };
369
370 let tmp = tempfile::tempdir().unwrap();
371 original.write(tmp.path()).unwrap();
372
373 let loaded = ProposalToml::load(tmp.path()).unwrap();
374 assert_eq!(original, loaded);
375 }
376
377 #[tokio::test]
379 async fn test_write_proposal_dir_rejects_existing() {
380 let anvil = Anvil::new().spawn();
381 let provider = ProviderBuilder::new().connect_http(anvil.endpoint_url());
382
383 let tmp = tempfile::tempdir().unwrap();
384 let root = tmp.path().to_path_buf();
385 let safe: Address =
386 Address::from_str("0xb76834e371b666feee48e5d7d9a97ca08b5a0620").unwrap();
387
388 let make_params = |root: PathBuf| WriteProposalParams {
389 proposals_root: root,
390 network: "decaf".to_owned(),
391 slug: "stake-table-v3".to_owned(),
392 chain_id: 11155111,
393 proxy: Address::from_str("0x40304FbE94D5E7D1492Dd90c53a2D63E8506a037").unwrap(),
394 new_impl: Address::from_str("0x5a6250dd35d875c0529573d9d934629a1b2778db").unwrap(),
395 timelock: Address::from_str("0x8e3b6563D683b87964104A2c3A4bf542bb70767F").unwrap(),
396 salt: B256::repeat_byte(0x11),
397 delay: U256::from(300u64),
398 schedule_calldata: Bytes::new(),
399 execute_calldata: Bytes::new(),
400 safe_override: Some(safe),
401 };
402
403 let dir = proposal_dir(root.clone(), "decaf", "stake-table-v3");
405 std::fs::create_dir_all(&dir).unwrap();
406 std::fs::write(dir.join("schedule.json"), "{}").unwrap();
407
408 let err = write_stake_table_v3_proposal_dir(make_params(root), &provider)
409 .await
410 .unwrap_err();
411 assert!(
412 err.to_string().contains("already exists"),
413 "unexpected error: {err}"
414 );
415 }
416}