nautilus_model/defi/data/
block.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Posei Systems Pty Ltd. All rights reserved.
3//  https://poseitrader.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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
31/// Represents an Ethereum-compatible blockchain block with essential metadata.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct Block {
35    /// The blockchain network this block is part of.
36    #[serde(skip)]
37    pub chain: Option<Blockchain>, // TODO: We should make this required eventually
38    /// The unique identifier hash of the block.
39    pub hash: String,
40    /// The block height/number in the blockchain.
41    #[serde(deserialize_with = "deserialize_hex_number")]
42    pub number: u64,
43    /// Hash of the parent block.
44    pub parent_hash: String,
45    /// Address of the miner or validator who produced this block.
46    pub miner: Ustr,
47    /// Maximum amount of gas allowed in this block.
48    #[serde(deserialize_with = "deserialize_hex_number")]
49    pub gas_limit: u64,
50    /// Total gas actually used by all transactions in this block.
51    #[serde(deserialize_with = "deserialize_hex_number")]
52    pub gas_used: u64,
53    /// EIP-1559 base fee per gas (wei); absent on pre-1559 or non-EIP chains.
54    #[serde(default, deserialize_with = "deserialize_opt_hex_u256")]
55    pub base_fee_per_gas: Option<U256>,
56    /// Blob gas used in this block (EIP-4844); absent on chains without blobs.
57    #[serde(default, deserialize_with = "deserialize_opt_hex_u256")]
58    pub blob_gas_used: Option<U256>,
59    /// Excess blob gas remaining after block execution (EIP-4844); None if not applicable.
60    #[serde(default, deserialize_with = "deserialize_opt_hex_u256")]
61    pub excess_blob_gas: Option<U256>,
62    /// L1 gas price used for posting this block's calldata (wei); Arbitrum only.
63    #[serde(default, deserialize_with = "deserialize_opt_hex_u256")]
64    pub l1_gas_price: Option<U256>,
65    /// L1 calldata gas units consumed when posting this block; Arbitrum only.
66    #[serde(default, deserialize_with = "deserialize_opt_hex_u64")]
67    pub l1_gas_used: Option<u64>,
68    /// Fixed-point (1e-6) scalar applied to the raw L1 fee; Arbitrum only.
69    #[serde(default, deserialize_with = "deserialize_opt_hex_u64")]
70    pub l1_fee_scalar: Option<u64>,
71    /// Unix timestamp when the block was created.
72    #[serde(deserialize_with = "deserialize_hex_timestamp")]
73    pub timestamp: UnixNanos,
74}
75
76impl Block {
77    /// Creates a new [`Block`] instance with the specified properties.
78    #[allow(clippy::too_many_arguments)]
79    pub fn new(
80        hash: String,
81        parent_hash: String,
82        number: u64,
83        miner: Ustr,
84        gas_limit: u64,
85        gas_used: u64,
86        timestamp: UnixNanos,
87        chain: Option<Blockchain>,
88    ) -> Self {
89        Self {
90            chain,
91            hash,
92            parent_hash,
93            number,
94            miner,
95            gas_used,
96            gas_limit,
97            timestamp,
98            base_fee_per_gas: None,
99            blob_gas_used: None,
100            excess_blob_gas: None,
101            l1_gas_price: None,
102            l1_gas_used: None,
103            l1_fee_scalar: None,
104        }
105    }
106
107    /// Returns the blockchain for this block.
108    ///
109    /// # Panics
110    ///
111    /// Panics if the `chain` has not been set.
112    pub fn chain(&self) -> Blockchain {
113        if let Some(chain) = self.chain {
114            chain
115        } else {
116            panic!("Must have the `chain` field set")
117        }
118    }
119
120    pub fn set_chain(&mut self, chain: Blockchain) {
121        self.chain = Some(chain)
122    }
123
124    /// Sets the EIP-1559 base fee and returns `self` for chaining.
125    #[must_use]
126    pub fn with_base_fee(mut self, fee: U256) -> Self {
127        self.base_fee_per_gas = Some(fee);
128        self
129    }
130
131    /// Sets blob-gas metrics (EIP-4844) and returns `self` for chaining.
132    #[must_use]
133    pub fn with_blob_gas(mut self, used: U256, excess: U256) -> Self {
134        self.blob_gas_used = Some(used);
135        self.excess_blob_gas = Some(excess);
136        self
137    }
138
139    /// Sets L1 fee components relevant for Arbitrum cost calculation and returns `self` for chaining.
140    #[must_use]
141    pub fn with_l1_fee_components(mut self, price: U256, gas_used: u64, scalar: u64) -> Self {
142        self.l1_gas_price = Some(price);
143        self.l1_gas_used = Some(gas_used);
144        self.l1_fee_scalar = Some(scalar);
145        self
146    }
147}
148
149impl PartialEq for Block {
150    fn eq(&self, other: &Self) -> bool {
151        self.hash == other.hash
152    }
153}
154
155impl Eq for Block {}
156
157impl Display for Block {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        write!(
160            f,
161            "Block(chain={}, number={}, timestamp={}, hash={})",
162            self.chain(),
163            self.number,
164            self.timestamp.to_rfc3339(),
165            self.hash
166        )
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use alloy_primitives::U256;
173    use chrono::{TimeZone, Utc};
174    use nautilus_core::UnixNanos;
175    use rstest::{fixture, rstest};
176    use ustr::Ustr;
177
178    use super::Block;
179    use crate::defi::{Blockchain, chain::chains, rpc::RpcNodeWssResponse};
180
181    #[fixture]
182    fn eth_rpc_block_response() -> String {
183        // https://etherscan.io/block/22294175
184        r#"{
185        "jsonrpc":"2.0",
186        "method":"eth_subscription",
187        "params":{
188            "subscription":"0xe06a2375238a4daa8ec823f585a0ef1e",
189            "result":{
190                "baseFeePerGas":"0x1862a795",
191                "blobGasUsed":"0xc0000",
192                "difficulty":"0x0",
193                "excessBlobGas":"0x4840000",
194                "extraData":"0x546974616e2028746974616e6275696c6465722e78797a29",
195                "gasLimit":"0x223b4a1",
196                "gasUsed":"0xde3909",
197                "hash":"0x71ece187051700b814592f62774e6ebd8ebdf5efbb54c90859a7d1522ce38e0a",
198                "miner":"0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97",
199                "mixHash":"0x43adbd4692459c8820b0913b0bc70e8a87bed2d40c395cc41059aa108a7cbe84",
200                "nonce":"0x0000000000000000",
201                "number":"0x1542e9f",
202                "parentBeaconBlockRoot":"0x58673bf001b31af805fb7634fbf3257dde41fbb6ae05c71799b09632d126b5c7",
203                "parentHash":"0x2abcce1ac985ebea2a2d6878a78387158f46de8d6db2cefca00ea36df4030a40",
204                "receiptsRoot":"0x35fead0b79338d4acbbc361014521d227874a1e02d24342ed3e84460df91f271",
205                "sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347",
206                "stateRoot":"0x99f29ee8ed6622c6a1520dca86e361029605f76d2e09aa7d3b1f9fc8b0268b13",
207                "timestamp":"0x6801f4bb",
208                "transactionsRoot":"0x9484b18d38886f25a44b465ad0136c792ef67dd5863b102cab2ab7a76bfb707d",
209                "withdrawalsRoot":"0x152f0040f4328639397494ef0d9c02d36c38b73f09588f304084e9f29662e9cb"
210            }
211         }
212      }"#.to_string()
213    }
214
215    #[fixture]
216    fn polygon_rpc_block_response() -> String {
217        // https://polygonscan.com/block/70453741
218        r#"{
219        "jsonrpc": "2.0",
220        "method": "eth_subscription",
221        "params": {
222            "subscription": "0x20f7c54c468149ed99648fd09268c903",
223            "result": {
224                "baseFeePerGas": "0x19e",
225                "difficulty": "0x18",
226                "gasLimit": "0x1c9c380",
227                "gasUsed": "0x1270f14",
228                "hash": "0x38ca655a2009e1748097f5559a0c20de7966243b804efeb53183614e4bebe199",
229                "miner": "0x0000000000000000000000000000000000000000",
230                "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
231                "nonce": "0x0000000000000000",
232                "number": "0x43309ed",
233                "parentHash": "0xf25e108267e3d6e1e4aaf4e329872273f2b1ad6186a4a22e370623aa8d021c50",
234                "receiptsRoot": "0xfffb93a991d15b9689536e59f20564cc49c254ec41a222d988abe58d2869968c",
235                "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347",
236                "stateRoot": "0xe66a9bc516bde8fc7b8c1ba0b95bfea0f4574fc6cfe95c68b7f8ab3d3158278d",
237                "timestamp": "0x680250d5",
238                "totalDifficulty": "0x505bd180",
239                "transactionsRoot": "0xd9ebc2fd5c7ce6f69ab2e427da495b0b0dff14386723b8c07b347449fd6293a6"
240            }
241          }
242      }"#.to_string()
243    }
244
245    #[fixture]
246    fn base_rpc_block_response() -> String {
247        r#"{
248        "jsonrpc":"2.0",
249        "method":"eth_subscription",
250        "params":{
251            "subscription":"0xeb7d715d93964e22b2d99192791ca984",
252            "result":{
253                "baseFeePerGas":"0xaae54",
254                "blobGasUsed":"0x0",
255                "difficulty":"0x0",
256                "excessBlobGas":"0x0",
257                "extraData":"0x00000000fa00000002",
258                "gasLimit":"0x7270e00",
259                "gasUsed":"0x56fce26",
260                "hash":"0x14575c65070d455e6d20d5ee17be124917a33ce4437dd8615a56d29e8279b7ad",
261                "logsBloom":"0x02bcf67d7b87f2d884b8d56bbe3965f6becc9ed8f9637ffc67efdffcef446cf435ffec7e7ce8e4544fe782bb06ef37afc97687cbf3c7ee7e26dd12a8f1fd836bc17dd2fd64fce3ef03bc74d8faedb07dddafe6f2cedff3e6f5d8683cc2ef26f763dee76e7b6fdeeade8c8a7cec7a5fdca237be97be2efe67dc908df7ce3f94a3ce150b2a9f07776fa577d5c52dbffe5bfc38bbdfeefc305f0efaf37fba3a4cdabf366b17fcb3b881badbe571dfb2fd652e879fbf37e88dbedb6a6f9f4bb7aef528e81c1f3cda38f777cb0a2d6f0ddb8abcb3dda5d976541fa062dba6255a7b328b5fdf47e8d6fac2fc43d8bee5936e6e8f2bff33526fdf6637f3f2216d950fef",
262                "miner":"0x4200000000000000000000000000000000000011",
263                "mixHash":"0xeacd829463c5d21df523005d55f25a0ca20474f1310c5c7eb29ff2c479789e98",
264                "nonce":"0x0000000000000000",
265                "number":"0x1bca2ac",
266                "parentBeaconBlockRoot":"0xfe4c48425a274a6716c569dfa9c238551330fc39d295123b12bc2461e6f41834",
267                "parentHash":"0x9a6ad4ffb258faa47ecd5eea9e7a9d8fa1772aa6232bc7cb4bbad5bc30786258",
268                "receiptsRoot":"0x5fc932dd358c33f9327a704585c83aafbe0d25d12b62c1cd8282df8b328aac16",
269                "sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347",
270                "stateRoot":"0xd2d3a6a219fb155bfc5afbde11f3161f1051d931432ccf32c33affe54176bb18",
271                "timestamp":"0x6803a23b",
272                "transactionsRoot":"0x59726fb9afc101cd49199c70bbdbc28385f4defa02949cb6e20493e16035a59d",
273                "withdrawalsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421"
274            }
275        }
276      }"#.to_string()
277    }
278
279    #[fixture]
280    fn arbitrum_rpc_block_response() -> String {
281        // https://arbiscan.io/block/328014516
282        r#"{
283        "jsonrpc":"2.0",
284        "method":"eth_subscription",
285        "params":{
286            "subscription":"0x0c5a0b38096440ef9a30a84837cf2012",
287            "result":{
288                "baseFeePerGas":"0x989680",
289                "difficulty":"0x1",
290                "extraData":"0xc66cd959dcdc1baf028efb61140d4461629c53c9643296cbda1c40723e97283b",
291                "gasLimit":"0x4000000000000",
292                "gasUsed":"0x17af4",
293                "hash":"0x724a0af4720fd7624976f71b16163de25f8532e87d0e7058eb0c1d3f6da3c1f8",
294                "miner":"0xa4b000000000000000000073657175656e636572",
295                "mixHash":"0x0000000000023106000000000154528900000000000000200000000000000000",
296                "nonce":"0x00000000001daa7c",
297                "number":"0x138d1ab4",
298                "parentHash":"0xe7176e201c2db109be479770074ad11b979de90ac850432ed38ed335803861b6",
299                "receiptsRoot":"0xefb382e3a4e3169e57920fa2367fc81c98bbfbd13611f57767dee07d3b3f96d4",
300                "sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347",
301                "stateRoot":"0x57e5475675abf1ec4c763369342e327a04321d17eeaa730a4ca20a9cafeee380",
302                "timestamp":"0x6803a606",
303                "totalDifficulty":"0x123a3d6c",
304                "transactionsRoot":"0x710b520177ecb31fa9092d16ee593b692070912b99ddd9fcf73eb4e9dd15193d"
305            }
306        }
307      }"#.to_string()
308    }
309
310    #[rstest]
311    fn test_block_set_chain() {
312        let mut block = Block::new(
313            "0x1234567890abcdef".to_string(),
314            "0xabcdef1234567890".to_string(),
315            12345,
316            Ustr::from("0x742E4422b21FB8B4dF463F28689AC98bD56c39e0"),
317            21000,
318            20000,
319            UnixNanos::from(1_640_995_200_000_000_000u64),
320            None,
321        );
322
323        assert!(block.chain.is_none());
324
325        let chain = Blockchain::Ethereum;
326        block.set_chain(chain);
327
328        assert_eq!(block.chain, Some(chain));
329    }
330
331    #[rstest]
332    fn test_ethereum_block_parsing(eth_rpc_block_response: String) {
333        let mut block =
334            match serde_json::from_str::<RpcNodeWssResponse<Block>>(&eth_rpc_block_response) {
335                Ok(rpc_response) => rpc_response.params.result,
336                Err(e) => panic!("Failed to deserialize block response with error {}", e),
337            };
338        block.set_chain(Blockchain::Ethereum);
339
340        assert_eq!(
341            block.to_string(),
342            "Block(chain=Ethereum, number=22294175, timestamp=2025-04-18T06:44:11+00:00, hash=0x71ece187051700b814592f62774e6ebd8ebdf5efbb54c90859a7d1522ce38e0a)".to_string(),
343        );
344        assert_eq!(
345            block.hash,
346            "0x71ece187051700b814592f62774e6ebd8ebdf5efbb54c90859a7d1522ce38e0a"
347        );
348        assert_eq!(
349            block.parent_hash,
350            "0x2abcce1ac985ebea2a2d6878a78387158f46de8d6db2cefca00ea36df4030a40"
351        );
352        assert_eq!(block.number, 22294175);
353        assert_eq!(block.miner, "0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97");
354        // Timestamp of block is on Apr-18-2025 06:44:11 AM +UTC
355        assert_eq!(
356            block.timestamp,
357            UnixNanos::from(Utc.with_ymd_and_hms(2025, 4, 18, 6, 44, 11).unwrap())
358        );
359        assert_eq!(block.gas_used, 14563593);
360        assert_eq!(block.gas_limit, 35894433);
361
362        assert_eq!(block.base_fee_per_gas, Some(U256::from(0x1862a795u64)));
363        assert_eq!(block.blob_gas_used, Some(U256::from(0xc0000u64)));
364        assert_eq!(block.excess_blob_gas, Some(U256::from(0x4840000u64)));
365    }
366
367    #[rstest]
368    fn test_polygon_block_parsing(polygon_rpc_block_response: String) {
369        let mut block =
370            match serde_json::from_str::<RpcNodeWssResponse<Block>>(&polygon_rpc_block_response) {
371                Ok(rpc_response) => rpc_response.params.result,
372                Err(e) => panic!("Failed to deserialize block response with error {}", e),
373            };
374        block.set_chain(Blockchain::Polygon);
375
376        assert_eq!(
377            block.to_string(),
378            "Block(chain=Polygon, number=70453741, timestamp=2025-04-18T13:17:09+00:00, hash=0x38ca655a2009e1748097f5559a0c20de7966243b804efeb53183614e4bebe199)".to_string(),
379        );
380        assert_eq!(
381            block.hash,
382            "0x38ca655a2009e1748097f5559a0c20de7966243b804efeb53183614e4bebe199"
383        );
384        assert_eq!(
385            block.parent_hash,
386            "0xf25e108267e3d6e1e4aaf4e329872273f2b1ad6186a4a22e370623aa8d021c50"
387        );
388        assert_eq!(block.number, 70453741);
389        assert_eq!(block.miner, "0x0000000000000000000000000000000000000000");
390        // Timestamp of block is on Apr-18-2025 01:17:09 PM +UTC
391        assert_eq!(
392            block.timestamp,
393            UnixNanos::from(Utc.with_ymd_and_hms(2025, 4, 18, 13, 17, 9).unwrap())
394        );
395        assert_eq!(block.gas_used, 19336980);
396        assert_eq!(block.gas_limit, 30000000);
397        assert_eq!(block.base_fee_per_gas, Some(U256::from(0x19eu64)));
398        assert!(block.blob_gas_used.is_none()); // Not applicable on Polygon
399        assert!(block.excess_blob_gas.is_none()); // Not applicable on Polygon
400    }
401
402    #[rstest]
403    fn test_base_block_parsing(base_rpc_block_response: String) {
404        let mut block =
405            match serde_json::from_str::<RpcNodeWssResponse<Block>>(&base_rpc_block_response) {
406                Ok(rpc_response) => rpc_response.params.result,
407                Err(e) => panic!("Failed to deserialize block response with error {}", e),
408            };
409        block.set_chain(Blockchain::Base);
410
411        assert_eq!(
412            block.to_string(),
413            "Block(chain=Base, number=29139628, timestamp=2025-04-19T13:16:43+00:00, hash=0x14575c65070d455e6d20d5ee17be124917a33ce4437dd8615a56d29e8279b7ad)".to_string(),
414        );
415        assert_eq!(
416            block.hash,
417            "0x14575c65070d455e6d20d5ee17be124917a33ce4437dd8615a56d29e8279b7ad"
418        );
419        assert_eq!(
420            block.parent_hash,
421            "0x9a6ad4ffb258faa47ecd5eea9e7a9d8fa1772aa6232bc7cb4bbad5bc30786258"
422        );
423        assert_eq!(block.number, 29139628);
424        assert_eq!(block.miner, "0x4200000000000000000000000000000000000011");
425        // Timestamp of block is on Apr 19 2025 13:16:43 PM +UTC
426        assert_eq!(
427            block.timestamp,
428            UnixNanos::from(Utc.with_ymd_and_hms(2025, 4, 19, 13, 16, 43).unwrap())
429        );
430        assert_eq!(block.gas_used, 91213350);
431        assert_eq!(block.gas_limit, 120000000);
432
433        assert_eq!(block.base_fee_per_gas, Some(U256::from(0xaae54u64)));
434        assert_eq!(block.blob_gas_used, Some(U256::ZERO));
435        assert_eq!(block.excess_blob_gas, Some(U256::ZERO));
436    }
437
438    #[rstest]
439    fn test_arbitrum_block_parsing(arbitrum_rpc_block_response: String) {
440        let mut block =
441            match serde_json::from_str::<RpcNodeWssResponse<Block>>(&arbitrum_rpc_block_response) {
442                Ok(rpc_response) => rpc_response.params.result,
443                Err(e) => panic!("Failed to deserialize block response with error {}", e),
444            };
445        block.set_chain(Blockchain::Arbitrum);
446
447        assert_eq!(
448            block.to_string(),
449            "Block(chain=Arbitrum, number=328014516, timestamp=2025-04-19T13:32:54+00:00, hash=0x724a0af4720fd7624976f71b16163de25f8532e87d0e7058eb0c1d3f6da3c1f8)".to_string(),
450        );
451        assert_eq!(
452            block.hash,
453            "0x724a0af4720fd7624976f71b16163de25f8532e87d0e7058eb0c1d3f6da3c1f8"
454        );
455        assert_eq!(
456            block.parent_hash,
457            "0xe7176e201c2db109be479770074ad11b979de90ac850432ed38ed335803861b6"
458        );
459        assert_eq!(block.number, 328014516);
460        assert_eq!(block.miner, "0xa4b000000000000000000073657175656e636572");
461        // Timestamp of block is on Apr-19-2025 13:32:54 PM +UTC
462        assert_eq!(
463            block.timestamp,
464            UnixNanos::from(Utc.with_ymd_and_hms(2025, 4, 19, 13, 32, 54).unwrap())
465        );
466        assert_eq!(block.gas_used, 97012);
467        assert_eq!(block.gas_limit, 1125899906842624);
468
469        assert_eq!(block.base_fee_per_gas, Some(U256::from(0x989680u64)));
470        assert!(block.blob_gas_used.is_none());
471        assert!(block.excess_blob_gas.is_none());
472    }
473
474    #[rstest]
475    fn test_block_builder_helpers() {
476        let block = Block::new(
477            "0xabc".into(),
478            "0xdef".into(),
479            1,
480            Ustr::from("0x0000000000000000000000000000000000000000"),
481            100_000,
482            50_000,
483            UnixNanos::from(1_700_000_000u64),
484            Some(Blockchain::Arbitrum),
485        );
486
487        let block = block
488            .with_base_fee(U256::from(1_000u64))
489            .with_blob_gas(U256::from(0x10u8), U256::from(0x20u8))
490            .with_l1_fee_components(U256::from(30_000u64), 1_234, 1_000_000);
491
492        assert_eq!(block.chain, Some(chains::ARBITRUM.name));
493        assert_eq!(block.base_fee_per_gas, Some(U256::from(1_000u64)));
494        assert_eq!(block.blob_gas_used, Some(U256::from(0x10u8)));
495        assert_eq!(block.excess_blob_gas, Some(U256::from(0x20u8)));
496        assert_eq!(block.l1_gas_price, Some(U256::from(30_000u64)));
497        assert_eq!(block.l1_gas_used, Some(1_234));
498        assert_eq!(block.l1_fee_scalar, Some(1_000_000));
499    }
500}