Skip to main content

nautilus_model/defi/data/
transaction.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.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 alloy_primitives::{Address, U256};
17use serde::{Deserialize, Deserializer};
18
19use crate::defi::{chain::Chain, hex::deserialize_hex_number};
20
21/// Represents a transaction on an EVM based blockchain.
22#[derive(Debug, Clone, Deserialize)]
23#[serde(rename_all = "camelCase")]
24#[cfg_attr(
25    feature = "python",
26    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
27)]
28#[cfg_attr(
29    feature = "python",
30    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
31)]
32pub struct Transaction {
33    /// The blockchain network identifier where this transaction occurred.
34    #[serde(rename = "chainId", deserialize_with = "deserialize_chain")]
35    pub chain: Chain,
36    /// The unique identifier (hash) of the transaction.
37    pub hash: String,
38    /// The hash of the block containing this transaction.
39    pub block_hash: String,
40    /// The block number in which this transaction was included.
41    #[serde(deserialize_with = "deserialize_hex_number")]
42    pub block_number: u64,
43    /// The address of the sender (transaction originator).
44    pub from: Address,
45    /// The address of the recipient.
46    pub to: Address,
47    /// The amount of Ether transferred in the transaction, in wei.
48    pub value: U256,
49    /// The index of the transaction within its containing block.
50    #[serde(deserialize_with = "deserialize_hex_number")]
51    pub transaction_index: u64,
52    /// The amount of gas allocated for transaction execution.
53    pub gas: U256,
54    /// The price of gas in wei per gas unit.
55    pub gas_price: U256,
56}
57
58impl Transaction {
59    /// Creates a new [`Transaction`] instance with the specified properties.
60    #[expect(clippy::too_many_arguments)]
61    #[must_use]
62    pub const fn new(
63        chain: Chain,
64        hash: String,
65        block_hash: String,
66        block_number: u64,
67        from: Address,
68        to: Address,
69        gas: U256,
70        gas_price: U256,
71        transaction_index: u64,
72        value: U256,
73    ) -> Self {
74        Self {
75            chain,
76            hash,
77            block_hash,
78            block_number,
79            from,
80            to,
81            value,
82            transaction_index,
83            gas,
84            gas_price,
85        }
86    }
87}
88
89/// Custom deserializer function to convert a hex chain ID string to a Chain.
90///
91/// # Errors
92///
93/// Returns an error if parsing the hex string fails or the chain ID is unknown.
94pub fn deserialize_chain<'de, D>(deserializer: D) -> Result<Chain, D::Error>
95where
96    D: Deserializer<'de>,
97{
98    let hex_string = String::deserialize(deserializer)?;
99    let without_prefix = hex_string.trim_start_matches("0x");
100    let chain_id = u32::from_str_radix(without_prefix, 16).map_err(serde::de::Error::custom)?;
101
102    Chain::from_chain_id(chain_id)
103        .cloned()
104        .ok_or_else(|| serde::de::Error::custom(format!("Unknown chain ID: {chain_id}")))
105}
106
107#[cfg(test)]
108mod tests {
109    use rstest::{fixture, rstest};
110
111    use super::*;
112    use crate::defi::{chain::Blockchain, rpc::RpcNodeHttpResponse};
113
114    #[fixture]
115    fn eth_rpc_response_eth_transfer_tx() -> String {
116        // https://etherscan.io/tx/0x6d0b33a68953fdfa280a3a3d7a21e9513aed38d8587682f03728bc178b52b824
117        r#"{
118            "jsonrpc": "2.0",
119            "id": 1,
120            "result": {
121                "blockHash": "0xfdba50e306d1b0ebd1971ec0440799b324229841637d8c56afbd1d6950bb09f0",
122                "blockNumber": "0x154a1d6",
123                "chainId": "0x1",
124                "from": "0xd6a8749e224ecdfcc79d473d3355b1b0eb51d423",
125                "gas": "0x5208",
126                "gasPrice": "0x2d7a7174",
127                "hash": "0x6d0b33a68953fdfa280a3a3d7a21e9513aed38d8587682f03728bc178b52b824",
128                "input": "0x",
129                "nonce": "0x0",
130                "r": "0x6de16d6254956674d5075951a0a814e2333c6d430e9ab21113fd0c8a11ea8435",
131                "s": "0x14c67075d1371f22936ee173d9fbd7e0284c37dd93e482df334be3a3dbd93fe9",
132                "to": "0x3c9af20c7b7809a825373881f61b5a69ef8bc6bd",
133                "transactionIndex": "0x99",
134                "type": "0x0",
135                "v": "0x25",
136                "value": "0x5f5e100"
137            }
138        }"#
139        .to_string()
140    }
141
142    #[fixture]
143    fn eth_rpc_response_smart_contract_interaction_tx() -> String {
144        // input field was omitted as it was too long and we don't need to parse it
145        // https://etherscan.io/tx/0x6ba6dd4a82101d8a0387f4cb4ce57a2eb64a1e1bd0679a9d4ea8448a27004a57
146        r#"{
147            "jsonrpc": "2.0",
148            "id": 1,
149            "result": {
150                "accessList": [],
151                "blockHash": "0xfdba50e306d1b0ebd1971ec0440799b324229841637d8c56afbd1d6950bb09f0",
152                "blockNumber": "0x154a1d6",
153                "chainId": "0x1",
154                "from": "0x2b711ee00b50d67667c4439c28aeaf7b75cb6e0d",
155                "gas": "0xe4e1c0",
156                "gasPrice": "0x536bc8dc",
157                "hash": "0x6ba6dd4a82101d8a0387f4cb4ce57a2eb64a1e1bd0679a9d4ea8448a27004a57",
158                "maxFeePerGas": "0x559d2c91",
159                "maxPriorityFeePerGas": "0x3b9aca00",
160                "nonce": "0x4c5",
161                "r": "0x65f9cf4bb1e53b0a9c04e75f8ffb3d62872d872944d660056a5ebb92a2620e0c",
162                "s": "0x3dbab5a679327019488237def822f38566cad066ea50be5f53bc06d741a9404e",
163                "to": "0x8c0bfc04ada21fd496c55b8c50331f904306f564",
164                "transactionIndex": "0x4a",
165                "type": "0x2",
166                "v": "0x1",
167                "value": "0x0",
168                "yParity": "0x1"
169            }
170        }"#
171        .to_string()
172    }
173
174    #[rstest]
175    fn test_eth_transfer_tx(eth_rpc_response_eth_transfer_tx: String) {
176        let tx = match serde_json::from_str::<RpcNodeHttpResponse<Transaction>>(
177            &eth_rpc_response_eth_transfer_tx,
178        ) {
179            Ok(rpc_response) => rpc_response.result.unwrap(),
180            Err(e) => panic!("Failed to deserialize transaction RPC response: {e}"),
181        };
182        assert_eq!(tx.chain.name, Blockchain::Ethereum);
183        assert_eq!(
184            tx.hash,
185            "0x6d0b33a68953fdfa280a3a3d7a21e9513aed38d8587682f03728bc178b52b824"
186        );
187        assert_eq!(
188            tx.block_hash,
189            "0xfdba50e306d1b0ebd1971ec0440799b324229841637d8c56afbd1d6950bb09f0"
190        );
191        assert_eq!(tx.block_number, 22_323_670);
192        assert_eq!(
193            tx.from,
194            "0xd6a8749e224ecdfcc79d473d3355b1b0eb51d423"
195                .parse::<Address>()
196                .unwrap()
197        );
198        assert_eq!(
199            tx.to,
200            "0x3c9af20c7b7809a825373881f61b5a69ef8bc6bd"
201                .parse::<Address>()
202                .unwrap()
203        );
204        assert_eq!(tx.gas, U256::from(21000));
205        assert_eq!(tx.gas_price, U256::from(762_999_156));
206        assert_eq!(tx.transaction_index, 153);
207        assert_eq!(tx.value, U256::from(100_000_000));
208    }
209
210    #[rstest]
211    fn test_smart_contract_interaction_tx(eth_rpc_response_smart_contract_interaction_tx: String) {
212        let tx = match serde_json::from_str::<RpcNodeHttpResponse<Transaction>>(
213            &eth_rpc_response_smart_contract_interaction_tx,
214        ) {
215            Ok(rpc_response) => rpc_response.result.unwrap(),
216            Err(e) => panic!("Failed to deserialize transaction RPC response: {e}"),
217        };
218        assert_eq!(tx.chain.name, Blockchain::Ethereum);
219        assert_eq!(
220            tx.hash,
221            "0x6ba6dd4a82101d8a0387f4cb4ce57a2eb64a1e1bd0679a9d4ea8448a27004a57"
222        );
223        assert_eq!(
224            tx.block_hash,
225            "0xfdba50e306d1b0ebd1971ec0440799b324229841637d8c56afbd1d6950bb09f0"
226        );
227        assert_eq!(
228            tx.from,
229            "0x2b711ee00b50d67667c4439c28aeaf7b75cb6e0d"
230                .parse::<Address>()
231                .unwrap()
232        );
233        assert_eq!(
234            tx.to,
235            "0x8c0bfc04ada21fd496c55b8c50331f904306f564"
236                .parse::<Address>()
237                .unwrap()
238        );
239        assert_eq!(tx.gas, U256::from(15_000_000));
240        assert_eq!(tx.gas_price, U256::from(1_399_572_700));
241        assert_eq!(tx.transaction_index, 74);
242        assert_eq!(tx.value, U256::ZERO);
243    }
244
245    #[rstest]
246    fn test_transaction_with_large_values() {
247        // Test with transaction that has very large gas and value amounts
248        let large_value_tx = r#"{
249            "jsonrpc": "2.0",
250            "id": 1,
251            "result": {
252                "blockHash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
253                "blockNumber": "0x1000000",
254                "chainId": "0x1",
255                "from": "0x0000000000000000000000000000000000000001",
256                "gas": "0xffffffffffffffff",
257                "gasPrice": "0xde0b6b3a7640000",
258                "hash": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
259                "to": "0x0000000000000000000000000000000000000002",
260                "transactionIndex": "0x0",
261                "value": "0xde0b6b3a7640000"
262            }
263        }"#;
264
265        let tx = serde_json::from_str::<RpcNodeHttpResponse<Transaction>>(large_value_tx)
266            .expect("Should parse large value transaction")
267            .result
268            .unwrap();
269
270        // Test that large values are handled correctly with U256
271        assert_eq!(tx.gas, U256::from(u64::MAX));
272        assert_eq!(tx.gas_price, U256::from(1_000_000_000_000_000_000u64)); // 1 ETH in wei
273        assert_eq!(tx.value, U256::from(1_000_000_000_000_000_000u64)); // 1 ETH in wei
274        assert_eq!(tx.block_number, 16_777_216); // 0x1000000
275    }
276
277    #[rstest]
278    fn test_transaction_parsing_with_invalid_address_should_fail() {
279        let invalid_address_tx = r#"{
280            "jsonrpc": "2.0",
281            "id": 1,
282            "result": {
283                "blockHash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
284                "blockNumber": "0x1",
285                "chainId": "0x1",
286                "from": "0xinvalid_address",
287                "gas": "0x5208",
288                "gasPrice": "0x2d7a7174",
289                "hash": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
290                "to": "0x0000000000000000000000000000000000000002",
291                "transactionIndex": "0x0",
292                "value": "0x0"
293            }
294        }"#;
295
296        let result = serde_json::from_str::<RpcNodeHttpResponse<Transaction>>(invalid_address_tx);
297        assert!(result.is_err(), "Should fail to parse invalid address");
298    }
299
300    #[rstest]
301    fn test_transaction_parsing_with_unknown_chain_should_fail() {
302        let unknown_chain_tx = r#"{
303            "jsonrpc": "2.0",
304            "id": 1,
305            "result": {
306                "blockHash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
307                "blockNumber": "0x1",
308                "chainId": "0x999999",
309                "from": "0x0000000000000000000000000000000000000001",
310                "gas": "0x5208",
311                "gasPrice": "0x2d7a7174",
312                "hash": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
313                "to": "0x0000000000000000000000000000000000000002",
314                "transactionIndex": "0x0",
315                "value": "0x0"
316            }
317        }"#;
318
319        let result = serde_json::from_str::<RpcNodeHttpResponse<Transaction>>(unknown_chain_tx);
320        assert!(result.is_err(), "Should fail to parse unknown chain ID");
321    }
322
323    #[rstest]
324    fn test_transaction_creation_with_constructor() {
325        use crate::defi::chain::chains;
326
327        let chain = chains::ETHEREUM.clone();
328        let from_addr = "0x0000000000000000000000000000000000000001"
329            .parse::<Address>()
330            .unwrap();
331        let to_addr = "0x0000000000000000000000000000000000000002"
332            .parse::<Address>()
333            .unwrap();
334
335        let tx = Transaction::new(
336            chain,
337            "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(),
338            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
339            123_456,
340            from_addr,
341            to_addr,
342            U256::from(21_000),
343            U256::from(20_000_000_000u64), // 20 gwei
344            0,
345            U256::from(1_000_000_000_000_000_000u64), // 1 ETH
346        );
347
348        assert_eq!(tx.from, from_addr);
349        assert_eq!(tx.to, to_addr);
350        assert_eq!(tx.gas, U256::from(21_000));
351        assert_eq!(tx.gas_price, U256::from(20_000_000_000u64));
352        assert_eq!(tx.value, U256::from(1_000_000_000_000_000_000u64));
353    }
354}