Skip to main content

nautilus_blockchain/contracts/
base.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 std::sync::Arc;
17
18use alloy::{primitives::Address, sol, sol_types::SolCall};
19use nautilus_core::hex;
20use nautilus_model::defi::validation::validate_address;
21
22use crate::rpc::{error::BlockchainRpcClientError, http::BlockchainHttpRpcClient};
23
24sol! {
25    #[sol(rpc)]
26    contract Multicall3 {
27        struct Call {
28            address target;
29            bytes callData;
30        }
31
32        struct Call3 {
33            address target;
34            bool allowFailure;
35            bytes callData;
36        }
37
38        struct Result {
39            bool success;
40            bytes returnData;
41        }
42
43        function aggregate3(Call3[] calldata calls) external payable returns (Result[] memory returnData);
44        function tryAggregate(bool requireSuccess, Call[] calldata calls) external payable returns (Result[] memory returnData);
45    }
46}
47
48/// Standard Multicall3 address (same on all EVM chains).
49pub const MULTICALL3_ADDRESS: &str = "0xcA11bde05977b3631167028862bE2a173976CA11";
50pub(crate) const DEFAULT_MULTICALL_CALLS_PER_RPC_REQUEST: u32 = 200;
51
52/// Base contract functionality for interacting with blockchain contracts.
53///
54/// This struct provides common RPC execution patterns that can be reused
55/// by specific contract implementations like ERC20, ERC721, etc.
56#[derive(Debug)]
57pub struct BaseContract {
58    /// The HTTP RPC client used to communicate with the blockchain node.
59    client: Arc<BlockchainHttpRpcClient>,
60    /// The Multicall3 contract address.
61    multicall_address: Address,
62    /// Maximum number of contract calls encoded into one Multicall RPC request.
63    multicall_calls_per_rpc_request: usize,
64    /// Per-request RPC timeout in seconds, when bounded.
65    rpc_timeout_secs: Option<u64>,
66}
67
68/// Represents a single contract call for batching in multicall.
69#[derive(Debug)]
70pub struct ContractCall {
71    /// The target contract address
72    pub target: Address,
73    /// Whether this call can fail without reverting the entire multicall.
74    pub allow_failure: bool,
75    /// The encoded call data.
76    pub call_data: Vec<u8>,
77}
78
79impl BaseContract {
80    /// Creates a new base contract interface with the specified RPC client.
81    ///
82    /// # Panics
83    ///
84    /// Panics if the multicall address is invalid (which should never happen with the hardcoded address).
85    #[must_use]
86    pub fn new(client: Arc<BlockchainHttpRpcClient>) -> Self {
87        Self::new_with_multicall_limit(client, DEFAULT_MULTICALL_CALLS_PER_RPC_REQUEST)
88    }
89
90    /// Creates a new base contract interface with an explicit Multicall request size.
91    ///
92    /// # Panics
93    ///
94    /// Panics if the multicall address is invalid (which should never happen with the hardcoded address).
95    #[must_use]
96    pub fn new_with_multicall_limit(
97        client: Arc<BlockchainHttpRpcClient>,
98        multicall_calls_per_rpc_request: u32,
99    ) -> Self {
100        Self::new_with_multicall_limit_and_timeout(client, multicall_calls_per_rpc_request, None)
101    }
102
103    /// Creates a new base contract interface with an explicit Multicall request size and
104    /// per-request RPC timeout.
105    ///
106    /// # Panics
107    ///
108    /// Panics if the multicall address is invalid (which should never happen with the hardcoded address).
109    #[must_use]
110    pub fn new_with_multicall_limit_and_timeout(
111        client: Arc<BlockchainHttpRpcClient>,
112        multicall_calls_per_rpc_request: u32,
113        rpc_timeout_secs: Option<u64>,
114    ) -> Self {
115        let multicall_address =
116            validate_address(MULTICALL3_ADDRESS).expect("Invalid multicall address");
117        let multicall_calls_per_rpc_request = (multicall_calls_per_rpc_request as usize).max(1);
118
119        Self {
120            client,
121            multicall_address,
122            multicall_calls_per_rpc_request,
123            rpc_timeout_secs,
124        }
125    }
126
127    /// Gets a reference to the RPC client.
128    #[must_use]
129    pub const fn client(&self) -> &Arc<BlockchainHttpRpcClient> {
130        &self.client
131    }
132
133    /// Executes a single contract call and returns the raw response bytes.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if the RPC call fails or response decoding fails.
138    pub async fn execute_call(
139        &self,
140        contract_address: &Address,
141        call_data: &[u8],
142        block: Option<u64>,
143    ) -> Result<Vec<u8>, BlockchainRpcClientError> {
144        let rpc_request =
145            self.client
146                .construct_eth_call(&contract_address.to_string(), call_data, block);
147
148        self.execute_call_request(rpc_request).await
149    }
150
151    async fn execute_call_request(
152        &self,
153        rpc_request: serde_json::Value,
154    ) -> Result<Vec<u8>, BlockchainRpcClientError> {
155        let encoded_response = self
156            .client
157            .execute_rpc_call_with_timeout::<String>(rpc_request, self.rpc_timeout_secs)
158            .await
159            .map_err(|e| BlockchainRpcClientError::ClientError(format!("RPC call failed: {e}")))?;
160
161        decode_hex_response(&encoded_response)
162    }
163
164    /// Executes multiple contract calls in a single multicall transaction.
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if the multicall fails or decoding fails.
169    pub async fn execute_multicall(
170        &self,
171        calls: Vec<ContractCall>,
172        block: Option<u64>,
173    ) -> Result<Vec<Multicall3::Result>, BlockchainRpcClientError> {
174        if calls.is_empty() {
175            return Ok(Vec::new());
176        }
177
178        let mut results = Vec::with_capacity(calls.len());
179        for chunk in contract_call_chunks(&calls, self.multicall_calls_per_rpc_request) {
180            results.extend(self.execute_multicall_request(chunk, block).await?);
181        }
182        Ok(results)
183    }
184
185    async fn execute_multicall_request(
186        &self,
187        calls: &[ContractCall],
188        block: Option<u64>,
189    ) -> Result<Vec<Multicall3::Result>, BlockchainRpcClientError> {
190        // Convert to Multicall3 format.
191        let multicall_calls: Vec<Multicall3::Call> = calls
192            .iter()
193            .map(|call| Multicall3::Call {
194                target: call.target,
195                callData: call.call_data.clone().into(),
196            })
197            .collect();
198
199        let multicall_data = Multicall3::tryAggregateCall {
200            requireSuccess: false,
201            calls: multicall_calls,
202        }
203        .abi_encode();
204        let rpc_request = self.client.construct_eth_call(
205            &self.multicall_address.to_string(),
206            multicall_data.as_slice(),
207            block,
208        );
209
210        let encoded_response = self
211            .client
212            .execute_rpc_call_with_timeout::<String>(rpc_request, self.rpc_timeout_secs)
213            .await
214            .map_err(|e| BlockchainRpcClientError::ClientError(format!("Multicall failed: {e}")))?;
215
216        let bytes = decode_hex_response(&encoded_response)?;
217        let results = Multicall3::tryAggregateCall::abi_decode_returns(&bytes).map_err(|e| {
218            BlockchainRpcClientError::AbiDecodingError(format!(
219                "Failed to decode multicall results: {e}"
220            ))
221        })?;
222
223        Ok(results)
224    }
225}
226
227/// Decodes a hexadecimal string response from a blockchain RPC call.
228///
229/// # Errors
230///
231/// Returns an `BlockchainRpcClientError::AbiDecodingError` if the hex decoding fails.
232pub fn decode_hex_response(encoded_response: &str) -> Result<Vec<u8>, BlockchainRpcClientError> {
233    // Remove the "0x" prefix if present
234    let encoded_str = encoded_response
235        .strip_prefix("0x")
236        .unwrap_or(encoded_response);
237    hex::decode(encoded_str).map_err(|e| {
238        BlockchainRpcClientError::AbiDecodingError(format!("Error decoding hex response: {e}"))
239    })
240}
241
242fn contract_call_chunks(
243    calls: &[ContractCall],
244    multicall_calls_per_rpc_request: usize,
245) -> std::slice::Chunks<'_, ContractCall> {
246    calls.chunks(multicall_calls_per_rpc_request.max(1))
247}
248
249#[cfg(test)]
250mod tests {
251    use alloy::primitives::address;
252    use rstest::rstest;
253
254    use super::*;
255
256    #[rstest]
257    fn contract_call_chunks_preserves_order_across_chunk_boundary() {
258        let target = address!("25b76A90E389bD644a29db919b136Dc63B174Ec7");
259        let calls: Vec<ContractCall> = (0u8..5)
260            .map(|value| ContractCall {
261                target,
262                allow_failure: true,
263                call_data: vec![value],
264            })
265            .collect();
266
267        let chunks: Vec<Vec<u8>> = contract_call_chunks(&calls, 2)
268            .map(|chunk| chunk.iter().map(|call| call.call_data[0]).collect())
269            .collect();
270
271        assert_eq!(chunks, vec![vec![0, 1], vec![2, 3], vec![4]]);
272    }
273
274    #[rstest]
275    fn contract_call_chunks_treats_zero_limit_as_one() {
276        let target = address!("25b76A90E389bD644a29db919b136Dc63B174Ec7");
277        let calls: Vec<ContractCall> = (0u8..3)
278            .map(|value| ContractCall {
279                target,
280                allow_failure: true,
281                call_data: vec![value],
282            })
283            .collect();
284
285        let chunks: Vec<Vec<u8>> = contract_call_chunks(&calls, 0)
286            .map(|chunk| chunk.iter().map(|call| call.call_data[0]).collect())
287            .collect();
288
289        assert_eq!(chunks, vec![vec![0], vec![1], vec![2]]);
290    }
291}