nautilus_model/defi/
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 serde::{Deserialize, Deserializer};
17
18use crate::defi::{chain::Chain, hex::deserialize_hex_number};
19
20/// Represents a transaction on an EVM based blockchain.
21#[derive(Debug, Clone, Deserialize)]
22pub struct Transaction {
23    /// The blockchain network identifier where this transaction occurred.
24    #[serde(rename = "chainId", deserialize_with = "deserialize_chain")]
25    pub chain: Chain,
26    /// The unique identifier (hash) of the transaction.
27    pub hash: String,
28    /// The hash of the block containing this transaction.
29    #[serde(rename = "blockHash")]
30    pub block_hash: String,
31    /// The block number in which this transaction was included.
32    #[serde(rename = "blockNumber", deserialize_with = "deserialize_hex_number")]
33    pub block_number: u64,
34    /// The address of the sender (transaction originator).
35    pub from: String,
36    /// The address of the recipient.
37    pub to: String,
38    /// The amount of Ether transferred in the transaction, in wei.
39    #[serde(deserialize_with = "deserialize_hex_number")]
40    pub value: u64,
41    /// The index of the transaction within its containing block.
42    #[serde(
43        rename = "transactionIndex",
44        deserialize_with = "deserialize_hex_number"
45    )]
46    pub transaction_index: u64,
47    /// The amount of gas allocated for transaction execution.
48    #[serde(deserialize_with = "deserialize_hex_number")]
49    pub gas: u64,
50    /// The price of gas in wei.
51    #[serde(rename = "gasPrice", deserialize_with = "deserialize_hex_number")]
52    pub gas_price: u64,
53}
54
55impl Transaction {
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: String,
63        to: String,
64        gas: u64,
65        gas_price: u64,
66        transaction_index: u64,
67        value: u64,
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,
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!(tx.from, "0xd6a8749e224ecdfcc79d473d3355b1b0eb51d423");
188        assert_eq!(tx.to, "0x3c9af20c7b7809a825373881f61b5a69ef8bc6bd");
189        assert_eq!(tx.gas, 21000);
190        assert_eq!(tx.gas_price, 762999156);
191        assert_eq!(tx.transaction_index, 153);
192        assert_eq!(tx.value, 100000000);
193    }
194
195    #[rstest]
196    fn test_smart_contract_interaction_tx(eth_rpc_response_smart_contract_interaction_tx: String) {
197        let tx = match serde_json::from_str::<RpcNodeHttpResponse<Transaction>>(
198            &eth_rpc_response_smart_contract_interaction_tx,
199        ) {
200            Ok(rpc_response) => rpc_response.result,
201            Err(e) => panic!("Failed to deserialize transaction RPC response: {}", e),
202        };
203        assert_eq!(tx.chain.name, Blockchain::Ethereum);
204        assert_eq!(
205            tx.hash,
206            "0x6ba6dd4a82101d8a0387f4cb4ce57a2eb64a1e1bd0679a9d4ea8448a27004a57"
207        );
208        assert_eq!(
209            tx.block_hash,
210            "0xfdba50e306d1b0ebd1971ec0440799b324229841637d8c56afbd1d6950bb09f0"
211        );
212        assert_eq!(tx.from, "0x2b711ee00b50d67667c4439c28aeaf7b75cb6e0d");
213        assert_eq!(tx.to, "0x8c0bfc04ada21fd496c55b8c50331f904306f564");
214        assert_eq!(tx.gas, 15000000);
215        assert_eq!(tx.gas_price, 1399572700);
216        assert_eq!(tx.transaction_index, 74);
217        assert_eq!(tx.value, 0);
218    }
219}