1use crate::opts::ForkContext;
2use alloy_eips::{BlockId, BlockNumHash};
3use alloy_primitives::{B256, BlockNumber, keccak256};
4use std::fmt;
5
6#[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 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 pub const fn number(&self) -> BlockNumber {
74 self.block.number
75 }
76
77 pub const fn hash(&self) -> B256 {
79 self.block.hash
80 }
81
82 pub const fn context(&self) -> ForkContext {
84 self.context
85 }
86
87 pub fn exact_block_id(&self) -> BlockId {
92 BlockId::from((self.hash(), Some(false)))
93 }
94
95 pub(crate) const fn block(&self) -> BlockNumHash {
97 self.block
98 }
99
100 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 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 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}