Skip to main content

espresso_contract_deployer/proposals/
write.rs

1//! Helpers for writing a proposal directory under the `contracts/deployments/proposals/` convention.
2
3use 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
27/// Map well-known chain IDs to their public RPC endpoints.
28///
29/// Returns `None` for unknown chain IDs; callers must require `--rpc-url` in that case.
30pub 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
51/// Reject path components that could escape the proposals root.
52///
53/// Disallows empty strings, `/`, `\`, and the `..` component.
54pub 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
67/// Map well-known chain IDs to their network names.
68pub 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
77/// Resolve network name: explicit override, then chain-id map, else error.
78pub 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
87/// Compute the convention directory:
88///   `<proposals_root>/<network>/<YYYYMMDD>-<slug>/`
89pub 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
94/// Parameters for writing the full StakeTableV3 proposal directory.
95pub struct WriteProposalParams {
96    /// Root of the `contracts/deployments/proposals/` tree (defaults to CWD-relative).
97    pub proposals_root: PathBuf,
98    /// Resolved network name (e.g. "decaf", "mainnet").
99    pub network: String,
100    /// Slug used in the directory name (e.g. "stake-table-v3").
101    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    /// Outer schedule calldata (timelock.schedule(...)) for hash computation.
109    pub schedule_calldata: Bytes,
110    /// Outer execute calldata (timelock.execute(...)) for hash computation.
111    pub execute_calldata: Bytes,
112    /// Optional Safe address override (bypasses deployment-info resolution).
113    pub safe_override: Option<Address>,
114}
115
116/// Query the Safe nonce on-chain.
117async 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
126/// Resolve schedule/execute Safe addresses and nonces, then compute all hashes.
127///
128/// Fails loudly if the signer set is ambiguous or the nonce query fails; a
129/// partial proposal.toml would be unverifiable and must not be written.
130async 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(&params.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        &params.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        &params.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
199/// Create the proposal directory, write `schedule.json`, `execute.json`, and `proposal.toml`.
200///
201/// Fails if the Safe set is ambiguous or any nonce query fails; no partial output.
202pub async fn write_stake_table_v3_proposal_dir(
203    params: WriteProposalParams,
204    provider: &impl Provider,
205) -> Result<PathBuf> {
206    validate_path_component(&params.network, "--network")?;
207    validate_path_component(&params.slug, "--proposal-slug")?;
208
209    let dir = proposal_dir(params.proposals_root.clone(), &params.network, &params.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, &params).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    /// proposal.toml round-trip: serialize then deserialize recovers identical struct.
321    #[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    /// The "already exists" guard fires before any on-chain call.
378    #[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        // Pre-create the dir and plant a schedule.json so the guard fires immediately.
404        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}