Skip to main content

foundry_evm_core/fork/
resolved.rs

1use crate::opts::ForkContext;
2use alloy_eips::{BlockId, BlockNumHash};
3use alloy_primitives::{B256, BlockNumber, keccak256};
4use std::fmt;
5
6/// An exact fork snapshot resolved from a fully configured RPC source.
7///
8/// The snapshot binds three layers that must travel together: the source URL, request headers, and
9/// JWT; the configured selector (`latest` or a block number); and the observed exact block (number
10/// and hash) plus endpoint context. `latest` is retained as the configured selector, while `block`
11/// is always exact. Reusing this value keeps preflight reads, environment reconstruction, cache
12/// identity, and backend construction on the same remote state.
13#[derive(Clone, PartialEq, Eq, Hash)]
14pub struct ResolvedFork {
15    source: ForkSource,
16    selector: Option<BlockNumber>,
17    block: BlockNumHash,
18    context: ForkContext,
19}
20
21#[derive(Clone, PartialEq, Eq, Hash)]
22struct ForkSource {
23    url: String,
24    headers: Vec<String>,
25    jwt: Option<String>,
26}
27
28impl ResolvedFork {
29    pub(crate) fn new(
30        url: &str,
31        headers: Option<&[String]>,
32        jwt: Option<&str>,
33        selector: Option<BlockNumber>,
34        block: BlockNumHash,
35        context: ForkContext,
36    ) -> Self {
37        debug_assert_eq!(block.number, context.block_number);
38        Self {
39            source: ForkSource {
40                url: url.to_string(),
41                headers: headers.unwrap_or_default().to_vec(),
42                jwt: jwt.map(str::to_string),
43            },
44            selector,
45            block,
46            context,
47        }
48    }
49
50    pub(crate) fn matches(
51        &self,
52        url: &str,
53        headers: Option<&[String]>,
54        jwt: Option<&str>,
55        selector: Option<BlockNumber>,
56    ) -> bool {
57        self.matches_source(url, headers, jwt) && self.selector == selector
58    }
59
60    /// Returns whether the configured RPC source still matches this resolved fork.
61    pub(crate) fn matches_source(
62        &self,
63        url: &str,
64        headers: Option<&[String]>,
65        jwt: Option<&str>,
66    ) -> bool {
67        self.source.url == url
68            && self.source.headers.as_slice() == headers.unwrap_or_default()
69            && self.source.jwt.as_deref() == jwt
70    }
71
72    /// Returns the resolved block number.
73    pub const fn number(&self) -> BlockNumber {
74        self.block.number
75    }
76
77    /// Returns the resolved block hash.
78    pub const fn hash(&self) -> B256 {
79        self.block.hash
80    }
81
82    /// Returns the endpoint and network identity resolved with this block.
83    pub const fn context(&self) -> ForkContext {
84        self.context
85    }
86
87    /// Returns an EIP-1898 selector for the exact resolved block.
88    ///
89    /// The block is not required to remain canonical so callers can still query the resolved
90    /// state after a reorganization.
91    pub fn exact_block_id(&self) -> BlockId {
92        BlockId::from((self.hash(), Some(false)))
93    }
94
95    /// Returns the resolved block number and hash.
96    pub(crate) const fn block(&self) -> BlockNumHash {
97        self.block
98    }
99
100    /// Returns this resolution advanced to another exact block on the same RPC source.
101    pub(crate) fn at_block(&self, block: BlockNumHash) -> Self {
102        let mut resolved = self.clone();
103        resolved.selector = Some(block.number);
104        resolved.block = block;
105        resolved.context.block_number = block.number;
106        resolved
107    }
108
109    /// Returns an opaque identity for the complete configured RPC source.
110    pub(crate) fn source_id(&self) -> B256 {
111        let mut encoded = Vec::new();
112        encoded.extend_from_slice(b"foundry-resolved-fork-source-v1");
113        encode_source_part(&mut encoded, self.source.url.as_bytes());
114        encoded.extend_from_slice(
115            &u64::try_from(self.source.headers.len())
116                .expect("fork header count exceeds u64")
117                .to_be_bytes(),
118        );
119        for header in &self.source.headers {
120            encode_source_part(&mut encoded, header.as_bytes());
121        }
122        if let Some(jwt) = &self.source.jwt {
123            encoded.push(1);
124            encode_source_part(&mut encoded, jwt.as_bytes());
125        } else {
126            encoded.push(0);
127        }
128        keccak256(encoded)
129    }
130
131    /// Returns a redacted, opaque fingerprint of the complete resolved fork identity.
132    pub fn fingerprint(&self) -> B256 {
133        let encoded = serde_json::to_vec(&(
134            "foundry-resolved-fork-v1",
135            self.source_id(),
136            self.block,
137            self.context,
138        ))
139        .expect("resolved fork identity is serializable");
140        keccak256(encoded)
141    }
142}
143
144fn encode_source_part(encoded: &mut Vec<u8>, part: &[u8]) {
145    let len = u64::try_from(part.len()).expect("source identity part length exceeds u64");
146    encoded.extend_from_slice(&len.to_be_bytes());
147    encoded.extend_from_slice(part);
148}
149
150impl fmt::Debug for ResolvedFork {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        let mut debug = f.debug_struct("ResolvedFork");
153        debug.field("source", &"<redacted>");
154        if let Some(number) = self.selector {
155            debug.field("selector", &number);
156        } else {
157            debug.field("selector", &"latest");
158        }
159        debug.field("number", &self.number()).field("hash", &self.hash()).finish()
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use foundry_evm_networks::{NetworkConfigs, NetworkVariant};
167    use serde_json::json;
168    use std::collections::HashSet;
169
170    fn context(block_number: BlockNumber) -> ForkContext {
171        ForkContext {
172            execution_chain_id: 1,
173            source_chain_id: 1,
174            network: NetworkVariant::Ethereum,
175            network_profile: NetworkConfigs::default(),
176            block_number,
177            hardfork: None,
178            instance_id: None,
179            source_fork_block_number: None,
180            source_fork_block_hash: None,
181        }
182    }
183
184    #[test]
185    fn exact_block_id_serializes_as_eip_1898_object() {
186        let hash = B256::with_last_byte(1);
187        let fork = ResolvedFork::new(
188            "http://localhost:8545",
189            None,
190            None,
191            None,
192            BlockNumHash::new(1, hash),
193            context(1),
194        );
195
196        assert_eq!(
197            serde_json::to_value(fork.exact_block_id()).unwrap(),
198            json!({
199                "blockHash": hash,
200                "requireCanonical": false,
201            })
202        );
203    }
204
205    #[test]
206    fn endpoint_identity_participates_in_equality_and_hashing() {
207        let block = BlockNumHash::new(1, B256::with_last_byte(1));
208        let first = ResolvedFork::new("http://localhost:8545", None, None, None, block, context(1));
209        let mut changed_context = context(1);
210        changed_context.instance_id = Some(B256::with_last_byte(2));
211        let second =
212            ResolvedFork::new("http://localhost:8545", None, None, None, block, changed_context);
213
214        assert_ne!(first, second);
215        assert_eq!(HashSet::from([first, second]).len(), 2);
216    }
217
218    #[test]
219    fn configured_source_identity_is_unambiguous() {
220        let block = BlockNumHash::new(1, B256::with_last_byte(1));
221        let context = context(1);
222        let plain = ResolvedFork::new("http://localhost:8545", None, None, None, block, context);
223        let header = ResolvedFork::new(
224            "http://localhost:8545",
225            Some(&["secret".to_string()]),
226            None,
227            None,
228            block,
229            context,
230        );
231        let jwt =
232            ResolvedFork::new("http://localhost:8545", None, Some("secret"), None, block, context);
233
234        assert_ne!(plain.source_id(), header.source_id());
235        assert_ne!(plain.source_id(), jwt.source_id());
236        assert_ne!(header.source_id(), jwt.source_id());
237        assert_ne!(plain.fingerprint(), header.fingerprint());
238        assert_ne!(plain.fingerprint(), jwt.fingerprint());
239    }
240}