nautilus_model/defi/data/
transaction.rs

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