1use std::fmt::Display;
17
18use alloy_primitives::U256;
19use nautilus_core::UnixNanos;
20use serde::{Deserialize, Serialize};
21use ustr::Ustr;
22
23use crate::defi::{
24 Blockchain,
25 hex::{
26 deserialize_hex_number, deserialize_hex_timestamp, deserialize_opt_hex_u64,
27 deserialize_opt_hex_u256,
28 },
29};
30
31pub const BLOCK_SCOPED_SNAPSHOT_INDEX: u32 = i32::MAX as u32;
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub struct BlockPosition {
37 pub number: u64,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub block_hash: Option<String>,
42 pub transaction_hash: String,
44 pub transaction_index: u32,
46 pub log_index: u32,
48}
49
50impl BlockPosition {
51 #[must_use]
53 pub fn new(number: u64, transaction_hash: String, index: u32, log_index: u32) -> Self {
54 Self {
55 number,
56 block_hash: None,
57 transaction_hash,
58 transaction_index: index,
59 log_index,
60 }
61 }
62
63 #[must_use]
65 pub fn with_block_hash(mut self, block_hash: Option<String>) -> Self {
66 self.block_hash = block_hash;
67 self
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74#[cfg_attr(
75 feature = "python",
76 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
77)]
78#[cfg_attr(
79 feature = "python",
80 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
81)]
82pub struct Block {
83 #[serde(skip)]
85 pub chain: Option<Blockchain>, pub hash: String,
88 #[serde(deserialize_with = "deserialize_hex_number")]
90 pub number: u64,
91 pub parent_hash: String,
93 pub miner: Ustr,
95 #[serde(deserialize_with = "deserialize_hex_number")]
97 pub gas_limit: u64,
98 #[serde(deserialize_with = "deserialize_hex_number")]
100 pub gas_used: u64,
101 #[serde(default, deserialize_with = "deserialize_opt_hex_u256")]
103 pub base_fee_per_gas: Option<U256>,
104 #[serde(default, deserialize_with = "deserialize_opt_hex_u256")]
106 pub blob_gas_used: Option<U256>,
107 #[serde(default, deserialize_with = "deserialize_opt_hex_u256")]
109 pub excess_blob_gas: Option<U256>,
110 #[serde(default, deserialize_with = "deserialize_opt_hex_u256")]
112 pub l1_gas_price: Option<U256>,
113 #[serde(default, deserialize_with = "deserialize_opt_hex_u64")]
115 pub l1_gas_used: Option<u64>,
116 #[serde(default, deserialize_with = "deserialize_opt_hex_u64")]
118 pub l1_fee_scalar: Option<u64>,
119 #[serde(deserialize_with = "deserialize_hex_timestamp")]
121 pub timestamp: UnixNanos,
122}
123
124impl Block {
125 #[expect(clippy::too_many_arguments)]
127 #[must_use]
128 pub fn new(
129 hash: String,
130 parent_hash: String,
131 number: u64,
132 miner: Ustr,
133 gas_limit: u64,
134 gas_used: u64,
135 timestamp: UnixNanos,
136 chain: Option<Blockchain>,
137 ) -> Self {
138 Self {
139 chain,
140 hash,
141 parent_hash,
142 number,
143 miner,
144 gas_used,
145 gas_limit,
146 timestamp,
147 base_fee_per_gas: None,
148 blob_gas_used: None,
149 excess_blob_gas: None,
150 l1_gas_price: None,
151 l1_gas_used: None,
152 l1_fee_scalar: None,
153 }
154 }
155
156 #[must_use]
162 pub fn chain(&self) -> Blockchain {
163 if let Some(chain) = self.chain {
164 chain
165 } else {
166 panic!("Must have the `chain` field set")
167 }
168 }
169
170 pub fn set_chain(&mut self, chain: Blockchain) {
171 self.chain = Some(chain);
172 }
173
174 #[must_use]
176 pub fn with_base_fee(mut self, fee: U256) -> Self {
177 self.base_fee_per_gas = Some(fee);
178 self
179 }
180
181 #[must_use]
183 pub fn with_blob_gas(mut self, used: U256, excess: U256) -> Self {
184 self.blob_gas_used = Some(used);
185 self.excess_blob_gas = Some(excess);
186 self
187 }
188
189 #[must_use]
191 pub fn with_l1_fee_components(mut self, price: U256, gas_used: u64, scalar: u64) -> Self {
192 self.l1_gas_price = Some(price);
193 self.l1_gas_used = Some(gas_used);
194 self.l1_fee_scalar = Some(scalar);
195 self
196 }
197}
198
199impl PartialEq for Block {
200 fn eq(&self, other: &Self) -> bool {
201 self.hash == other.hash
202 }
203}
204
205impl Eq for Block {}
206
207impl Display for Block {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 write!(
210 f,
211 "Block(chain={}, number={}, timestamp={}, hash={})",
212 self.chain(),
213 self.number,
214 self.timestamp.to_rfc3339(),
215 self.hash
216 )
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use alloy_primitives::U256;
223 use jiff::{Timestamp, civil::Date, tz::Offset};
224 use nautilus_core::UnixNanos;
225 use rstest::{fixture, rstest};
226 use ustr::Ustr;
227
228 use super::{Block, BlockPosition};
229 use crate::defi::{Blockchain, chain::chains, rpc::RpcNodeWssResponse};
230
231 fn utc_timestamp(year: i16, month: i8, day: i8, hour: i8, minute: i8, second: i8) -> Timestamp {
232 Offset::UTC
233 .to_timestamp(
234 Date::new(year, month, day)
235 .unwrap()
236 .at(hour, minute, second, 0),
237 )
238 .unwrap()
239 }
240
241 #[rstest]
242 fn test_block_position_deserializes_legacy_shape_without_block_hash() {
243 let position: BlockPosition = serde_json::from_value(serde_json::json!({
244 "number": 42,
245 "transaction_hash": "0xabc",
246 "transaction_index": 3,
247 "log_index": 7
248 }))
249 .unwrap();
250
251 assert_eq!(position, BlockPosition::new(42, "0xabc".to_string(), 3, 7));
252 assert_eq!(
253 serde_json::to_value(position).unwrap(),
254 serde_json::json!({
255 "number": 42,
256 "transaction_hash": "0xabc",
257 "transaction_index": 3,
258 "log_index": 7
259 })
260 );
261 }
262
263 #[fixture]
264 fn eth_rpc_block_response() -> String {
265 r#"{
267 "jsonrpc":"2.0",
268 "method":"eth_subscription",
269 "params":{
270 "subscription":"0xe06a2375238a4daa8ec823f585a0ef1e",
271 "result":{
272 "baseFeePerGas":"0x1862a795",
273 "blobGasUsed":"0xc0000",
274 "difficulty":"0x0",
275 "excessBlobGas":"0x4840000",
276 "extraData":"0x546974616e2028746974616e6275696c6465722e78797a29",
277 "gasLimit":"0x223b4a1",
278 "gasUsed":"0xde3909",
279 "hash":"0x71ece187051700b814592f62774e6ebd8ebdf5efbb54c90859a7d1522ce38e0a",
280 "miner":"0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97",
281 "mixHash":"0x43adbd4692459c8820b0913b0bc70e8a87bed2d40c395cc41059aa108a7cbe84",
282 "nonce":"0x0000000000000000",
283 "number":"0x1542e9f",
284 "parentBeaconBlockRoot":"0x58673bf001b31af805fb7634fbf3257dde41fbb6ae05c71799b09632d126b5c7",
285 "parentHash":"0x2abcce1ac985ebea2a2d6878a78387158f46de8d6db2cefca00ea36df4030a40",
286 "receiptsRoot":"0x35fead0b79338d4acbbc361014521d227874a1e02d24342ed3e84460df91f271",
287 "sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347",
288 "stateRoot":"0x99f29ee8ed6622c6a1520dca86e361029605f76d2e09aa7d3b1f9fc8b0268b13",
289 "timestamp":"0x6801f4bb",
290 "transactionsRoot":"0x9484b18d38886f25a44b465ad0136c792ef67dd5863b102cab2ab7a76bfb707d",
291 "withdrawalsRoot":"0x152f0040f4328639397494ef0d9c02d36c38b73f09588f304084e9f29662e9cb"
292 }
293 }
294 }"#.to_string()
295 }
296
297 #[fixture]
298 fn polygon_rpc_block_response() -> String {
299 r#"{
301 "jsonrpc": "2.0",
302 "method": "eth_subscription",
303 "params": {
304 "subscription": "0x20f7c54c468149ed99648fd09268c903",
305 "result": {
306 "baseFeePerGas": "0x19e",
307 "difficulty": "0x18",
308 "gasLimit": "0x1c9c380",
309 "gasUsed": "0x1270f14",
310 "hash": "0x38ca655a2009e1748097f5559a0c20de7966243b804efeb53183614e4bebe199",
311 "miner": "0x0000000000000000000000000000000000000000",
312 "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
313 "nonce": "0x0000000000000000",
314 "number": "0x43309ed",
315 "parentHash": "0xf25e108267e3d6e1e4aaf4e329872273f2b1ad6186a4a22e370623aa8d021c50",
316 "receiptsRoot": "0xfffb93a991d15b9689536e59f20564cc49c254ec41a222d988abe58d2869968c",
317 "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347",
318 "stateRoot": "0xe66a9bc516bde8fc7b8c1ba0b95bfea0f4574fc6cfe95c68b7f8ab3d3158278d",
319 "timestamp": "0x680250d5",
320 "totalDifficulty": "0x505bd180",
321 "transactionsRoot": "0xd9ebc2fd5c7ce6f69ab2e427da495b0b0dff14386723b8c07b347449fd6293a6"
322 }
323 }
324 }"#.to_string()
325 }
326
327 #[fixture]
328 fn base_rpc_block_response() -> String {
329 r#"{
330 "jsonrpc":"2.0",
331 "method":"eth_subscription",
332 "params":{
333 "subscription":"0xeb7d715d93964e22b2d99192791ca984",
334 "result":{
335 "baseFeePerGas":"0xaae54",
336 "blobGasUsed":"0x0",
337 "difficulty":"0x0",
338 "excessBlobGas":"0x0",
339 "extraData":"0x00000000fa00000002",
340 "gasLimit":"0x7270e00",
341 "gasUsed":"0x56fce26",
342 "hash":"0x14575c65070d455e6d20d5ee17be124917a33ce4437dd8615a56d29e8279b7ad",
343 "logsBloom":"0x02bcf67d7b87f2d884b8d56bbe3965f6becc9ed8f9637ffc67efdffcef446cf435ffec7e7ce8e4544fe782bb06ef37afc97687cbf3c7ee7e26dd12a8f1fd836bc17dd2fd64fce3ef03bc74d8faedb07dddafe6f2cedff3e6f5d8683cc2ef26f763dee76e7b6fdeeade8c8a7cec7a5fdca237be97be2efe67dc908df7ce3f94a3ce150b2a9f07776fa577d5c52dbffe5bfc38bbdfeefc305f0efaf37fba3a4cdabf366b17fcb3b881badbe571dfb2fd652e879fbf37e88dbedb6a6f9f4bb7aef528e81c1f3cda38f777cb0a2d6f0ddb8abcb3dda5d976541fa062dba6255a7b328b5fdf47e8d6fac2fc43d8bee5936e6e8f2bff33526fdf6637f3f2216d950fef",
344 "miner":"0x4200000000000000000000000000000000000011",
345 "mixHash":"0xeacd829463c5d21df523005d55f25a0ca20474f1310c5c7eb29ff2c479789e98",
346 "nonce":"0x0000000000000000",
347 "number":"0x1bca2ac",
348 "parentBeaconBlockRoot":"0xfe4c48425a274a6716c569dfa9c238551330fc39d295123b12bc2461e6f41834",
349 "parentHash":"0x9a6ad4ffb258faa47ecd5eea9e7a9d8fa1772aa6232bc7cb4bbad5bc30786258",
350 "receiptsRoot":"0x5fc932dd358c33f9327a704585c83aafbe0d25d12b62c1cd8282df8b328aac16",
351 "sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347",
352 "stateRoot":"0xd2d3a6a219fb155bfc5afbde11f3161f1051d931432ccf32c33affe54176bb18",
353 "timestamp":"0x6803a23b",
354 "transactionsRoot":"0x59726fb9afc101cd49199c70bbdbc28385f4defa02949cb6e20493e16035a59d",
355 "withdrawalsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421"
356 }
357 }
358 }"#.to_string()
359 }
360
361 #[fixture]
362 fn arbitrum_rpc_block_response() -> String {
363 r#"{
365 "jsonrpc":"2.0",
366 "method":"eth_subscription",
367 "params":{
368 "subscription":"0x0c5a0b38096440ef9a30a84837cf2012",
369 "result":{
370 "baseFeePerGas":"0x989680",
371 "difficulty":"0x1",
372 "extraData":"0xc66cd959dcdc1baf028efb61140d4461629c53c9643296cbda1c40723e97283b",
373 "gasLimit":"0x4000000000000",
374 "gasUsed":"0x17af4",
375 "hash":"0x724a0af4720fd7624976f71b16163de25f8532e87d0e7058eb0c1d3f6da3c1f8",
376 "miner":"0xa4b000000000000000000073657175656e636572",
377 "mixHash":"0x0000000000023106000000000154528900000000000000200000000000000000",
378 "nonce":"0x00000000001daa7c",
379 "number":"0x138d1ab4",
380 "parentHash":"0xe7176e201c2db109be479770074ad11b979de90ac850432ed38ed335803861b6",
381 "receiptsRoot":"0xefb382e3a4e3169e57920fa2367fc81c98bbfbd13611f57767dee07d3b3f96d4",
382 "sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347",
383 "stateRoot":"0x57e5475675abf1ec4c763369342e327a04321d17eeaa730a4ca20a9cafeee380",
384 "timestamp":"0x6803a606",
385 "totalDifficulty":"0x123a3d6c",
386 "transactionsRoot":"0x710b520177ecb31fa9092d16ee593b692070912b99ddd9fcf73eb4e9dd15193d"
387 }
388 }
389 }"#.to_string()
390 }
391
392 #[rstest]
393 fn test_block_set_chain() {
394 let mut block = Block::new(
395 "0x1234567890abcdef".to_string(),
396 "0xabcdef1234567890".to_string(),
397 12345,
398 Ustr::from("0x742E4422b21FB8B4dF463F28689AC98bD56c39e0"),
399 21000,
400 20000,
401 UnixNanos::from(1_640_995_200_000_000_000u64),
402 None,
403 );
404
405 assert!(block.chain.is_none());
406
407 let chain = Blockchain::Ethereum;
408 block.set_chain(chain);
409
410 assert_eq!(block.chain, Some(chain));
411 }
412
413 #[rstest]
414 fn test_ethereum_block_parsing(eth_rpc_block_response: String) {
415 let mut block =
416 match serde_json::from_str::<RpcNodeWssResponse<Block>>(ð_rpc_block_response) {
417 Ok(rpc_response) => rpc_response.params.result,
418 Err(e) => panic!("Failed to deserialize block response with error {e}"),
419 };
420 block.set_chain(Blockchain::Ethereum);
421
422 assert_eq!(
423 block.to_string(),
424 "Block(chain=Ethereum, number=22294175, timestamp=2025-04-18T06:44:11+00:00, hash=0x71ece187051700b814592f62774e6ebd8ebdf5efbb54c90859a7d1522ce38e0a)".to_string(),
425 );
426 assert_eq!(
427 block.hash,
428 "0x71ece187051700b814592f62774e6ebd8ebdf5efbb54c90859a7d1522ce38e0a"
429 );
430 assert_eq!(
431 block.parent_hash,
432 "0x2abcce1ac985ebea2a2d6878a78387158f46de8d6db2cefca00ea36df4030a40"
433 );
434 assert_eq!(block.number, 22_294_175);
435 assert_eq!(block.miner, "0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97");
436 assert_eq!(
438 block.timestamp,
439 UnixNanos::from(utc_timestamp(2025, 4, 18, 6, 44, 11))
440 );
441 assert_eq!(block.gas_used, 14_563_593);
442 assert_eq!(block.gas_limit, 35_894_433);
443
444 assert_eq!(block.base_fee_per_gas, Some(U256::from(0x1862_a795_u64)));
445 assert_eq!(block.blob_gas_used, Some(U256::from(0xc0000u64)));
446 assert_eq!(block.excess_blob_gas, Some(U256::from(0x0484_0000_u64)));
447 }
448
449 #[rstest]
450 fn test_polygon_block_parsing(polygon_rpc_block_response: String) {
451 let mut block =
452 match serde_json::from_str::<RpcNodeWssResponse<Block>>(&polygon_rpc_block_response) {
453 Ok(rpc_response) => rpc_response.params.result,
454 Err(e) => panic!("Failed to deserialize block response with error {e}"),
455 };
456 block.set_chain(Blockchain::Polygon);
457
458 assert_eq!(
459 block.to_string(),
460 "Block(chain=Polygon, number=70453741, timestamp=2025-04-18T13:17:09+00:00, hash=0x38ca655a2009e1748097f5559a0c20de7966243b804efeb53183614e4bebe199)".to_string(),
461 );
462 assert_eq!(
463 block.hash,
464 "0x38ca655a2009e1748097f5559a0c20de7966243b804efeb53183614e4bebe199"
465 );
466 assert_eq!(
467 block.parent_hash,
468 "0xf25e108267e3d6e1e4aaf4e329872273f2b1ad6186a4a22e370623aa8d021c50"
469 );
470 assert_eq!(block.number, 70_453_741);
471 assert_eq!(block.miner, "0x0000000000000000000000000000000000000000");
472 assert_eq!(
474 block.timestamp,
475 UnixNanos::from(utc_timestamp(2025, 4, 18, 13, 17, 9))
476 );
477 assert_eq!(block.gas_used, 19_336_980);
478 assert_eq!(block.gas_limit, 30_000_000);
479 assert_eq!(block.base_fee_per_gas, Some(U256::from(0x19eu64)));
480 assert!(block.blob_gas_used.is_none()); assert!(block.excess_blob_gas.is_none()); }
483
484 #[rstest]
485 fn test_base_block_parsing(base_rpc_block_response: String) {
486 let mut block =
487 match serde_json::from_str::<RpcNodeWssResponse<Block>>(&base_rpc_block_response) {
488 Ok(rpc_response) => rpc_response.params.result,
489 Err(e) => panic!("Failed to deserialize block response with error {e}"),
490 };
491 block.set_chain(Blockchain::Base);
492
493 assert_eq!(
494 block.to_string(),
495 "Block(chain=Base, number=29139628, timestamp=2025-04-19T13:16:43+00:00, hash=0x14575c65070d455e6d20d5ee17be124917a33ce4437dd8615a56d29e8279b7ad)".to_string(),
496 );
497 assert_eq!(
498 block.hash,
499 "0x14575c65070d455e6d20d5ee17be124917a33ce4437dd8615a56d29e8279b7ad"
500 );
501 assert_eq!(
502 block.parent_hash,
503 "0x9a6ad4ffb258faa47ecd5eea9e7a9d8fa1772aa6232bc7cb4bbad5bc30786258"
504 );
505 assert_eq!(block.number, 29_139_628);
506 assert_eq!(block.miner, "0x4200000000000000000000000000000000000011");
507 assert_eq!(
509 block.timestamp,
510 UnixNanos::from(utc_timestamp(2025, 4, 19, 13, 16, 43))
511 );
512 assert_eq!(block.gas_used, 91_213_350);
513 assert_eq!(block.gas_limit, 120_000_000);
514
515 assert_eq!(block.base_fee_per_gas, Some(U256::from(0xaae54u64)));
516 assert_eq!(block.blob_gas_used, Some(U256::ZERO));
517 assert_eq!(block.excess_blob_gas, Some(U256::ZERO));
518 }
519
520 #[rstest]
521 fn test_arbitrum_block_parsing(arbitrum_rpc_block_response: String) {
522 let mut block =
523 match serde_json::from_str::<RpcNodeWssResponse<Block>>(&arbitrum_rpc_block_response) {
524 Ok(rpc_response) => rpc_response.params.result,
525 Err(e) => panic!("Failed to deserialize block response with error {e}"),
526 };
527 block.set_chain(Blockchain::Arbitrum);
528
529 assert_eq!(
530 block.to_string(),
531 "Block(chain=Arbitrum, number=328014516, timestamp=2025-04-19T13:32:54+00:00, hash=0x724a0af4720fd7624976f71b16163de25f8532e87d0e7058eb0c1d3f6da3c1f8)".to_string(),
532 );
533 assert_eq!(
534 block.hash,
535 "0x724a0af4720fd7624976f71b16163de25f8532e87d0e7058eb0c1d3f6da3c1f8"
536 );
537 assert_eq!(
538 block.parent_hash,
539 "0xe7176e201c2db109be479770074ad11b979de90ac850432ed38ed335803861b6"
540 );
541 assert_eq!(block.number, 328_014_516);
542 assert_eq!(block.miner, "0xa4b000000000000000000073657175656e636572");
543 assert_eq!(
545 block.timestamp,
546 UnixNanos::from(utc_timestamp(2025, 4, 19, 13, 32, 54))
547 );
548 assert_eq!(block.gas_used, 97012);
549 assert_eq!(block.gas_limit, 1_125_899_906_842_624);
550
551 assert_eq!(block.base_fee_per_gas, Some(U256::from(0x0098_9680_u64)));
552 assert!(block.blob_gas_used.is_none());
553 assert!(block.excess_blob_gas.is_none());
554 }
555
556 #[rstest]
557 fn test_block_builder_helpers() {
558 let block = Block::new(
559 "0xabc".into(),
560 "0xdef".into(),
561 1,
562 Ustr::from("0x0000000000000000000000000000000000000000"),
563 100_000,
564 50_000,
565 UnixNanos::from(1_700_000_000u64),
566 Some(Blockchain::Arbitrum),
567 );
568
569 let block = block
570 .with_base_fee(U256::from(1_000u64))
571 .with_blob_gas(U256::from(0x10u8), U256::from(0x20u8))
572 .with_l1_fee_components(U256::from(30_000u64), 1_234, 1_000_000);
573
574 assert_eq!(block.chain, Some(chains::ARBITRUM.name));
575 assert_eq!(block.base_fee_per_gas, Some(U256::from(1_000u64)));
576 assert_eq!(block.blob_gas_used, Some(U256::from(0x10u8)));
577 assert_eq!(block.excess_blob_gas, Some(U256::from(0x20u8)));
578 assert_eq!(block.l1_gas_price, Some(U256::from(30_000u64)));
579 assert_eq!(block.l1_gas_used, Some(1_234));
580 assert_eq!(block.l1_fee_scalar, Some(1_000_000));
581 }
582}