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