Skip to main content

cuprate_test_utils/rpc/
client.rs

1//! HTTP RPC client.
2
3//---------------------------------------------------------------------------------------------------- Use
4use monero_daemon_rpc::{prelude::ProvidesTransactions, MoneroDaemon};
5use monero_oxide::block::Block;
6use monero_simple_request_rpc::SimpleRequestTransport;
7use serde::Deserialize;
8use serde_json::json;
9use tokio::task::spawn_blocking;
10
11use cuprate_helper::tx::tx_fee;
12use cuprate_types::{VerifiedBlockInformation, VerifiedTransactionInformation};
13
14//---------------------------------------------------------------------------------------------------- Constants
15/// The default URL used for Monero RPC connections.
16pub const LOCALHOST_RPC_URL: &str = "http://127.0.0.1:18081";
17
18//---------------------------------------------------------------------------------------------------- HttpRpcClient
19/// An HTTP RPC client for Monero.
20pub struct HttpRpcClient {
21    address: String,
22    rpc: MoneroDaemon<SimpleRequestTransport>,
23}
24
25impl HttpRpcClient {
26    /// Create an [`HttpRpcClient`].
27    ///
28    /// `address` should be an HTTP URL pointing to a `monerod`.
29    ///
30    /// If `None` is provided the default is used: [`LOCALHOST_RPC_URL`].
31    ///
32    /// Note that for [`Self::get_verified_block_information`] to work, the `monerod`
33    /// must be in unrestricted mode such that some fields (e.g. `pow_hash`) appear
34    /// in the JSON response.
35    ///
36    /// # Panics
37    /// This panics if the `address` is invalid or a connection could not be made.
38    pub async fn new(address: Option<String>) -> Self {
39        let address = address.unwrap_or_else(|| LOCALHOST_RPC_URL.to_string());
40
41        Self {
42            rpc: SimpleRequestTransport::new(address.clone()).await.unwrap(),
43            address,
44        }
45    }
46
47    /// The address used for this [`HttpRpcClient`].
48    #[allow(clippy::allow_attributes, dead_code, reason = "expect doesn't work")]
49    const fn address(&self) -> &String {
50        &self.address
51    }
52
53    /// Access to the inner RPC client for other usage.
54    #[expect(dead_code)]
55    const fn rpc(&self) -> &MoneroDaemon<SimpleRequestTransport> {
56        &self.rpc
57    }
58
59    /// Request data and map the response to a [`VerifiedBlockInformation`].
60    ///
61    /// # Panics
62    /// This function will panic at any error point, e.g.,
63    /// if the node cannot be connected to, if deserialization fails, etc.
64    pub async fn get_verified_block_information(&self, height: usize) -> VerifiedBlockInformation {
65        #[derive(Debug, Deserialize)]
66        struct Result {
67            blob: String,
68            block_header: BlockHeader,
69        }
70
71        #[derive(Debug, Deserialize)]
72        struct BlockHeader {
73            block_weight: usize,
74            long_term_weight: usize,
75            cumulative_difficulty: u128,
76            hash: String,
77            height: usize,
78            pow_hash: String,
79            reward: u64, // generated_coins + total_tx_fees
80        }
81
82        let result = self
83            .rpc
84            .json_rpc_call(
85                "get_block",
86                Some(
87                    json!(
88                        {
89                            "height": height,
90                            "fill_pow_hash": true
91                        }
92                    )
93                    .to_string(),
94                ),
95                usize::MAX,
96            )
97            .await
98            .unwrap();
99
100        let result: Result = serde_json::from_str(&result).unwrap();
101
102        // Make sure this is a trusted, `pow_hash` only works there.
103        assert!(
104            !result.block_header.pow_hash.is_empty(),
105            "untrusted node detected, `pow_hash` will not show on these nodes - use a trusted node!"
106        );
107
108        let reward = result.block_header.reward;
109
110        let (block_hash, block_blob, block) = spawn_blocking(|| {
111            let block_blob = hex::decode(result.blob).unwrap();
112            let block = Block::read(&mut block_blob.as_slice()).unwrap();
113            (block.hash(), block_blob, block)
114        })
115        .await
116        .unwrap();
117
118        let txs: Vec<VerifiedTransactionInformation> = self
119            .get_transaction_verification_data(&block.transactions)
120            .await
121            .collect();
122
123        let block_header = result.block_header;
124        let block_hash_2 = <[u8; 32]>::try_from(hex::decode(&block_header.hash).unwrap()).unwrap();
125        let pow_hash = <[u8; 32]>::try_from(hex::decode(&block_header.pow_hash).unwrap()).unwrap();
126
127        // Assert the block hash matches.
128        assert_eq!(block_hash, block_hash_2);
129
130        let total_tx_fees = txs.iter().map(|tx| tx.fee).sum::<u64>();
131        let generated_coins = block
132            .miner_transaction()
133            .prefix()
134            .outputs
135            .iter()
136            .map(|output| output.amount.expect("miner_tx amount was None"))
137            .sum::<u64>()
138            - total_tx_fees;
139        assert_eq!(
140            reward,
141            generated_coins + total_tx_fees,
142            "generated_coins ({generated_coins}) + total_tx_fees ({total_tx_fees}) != reward ({reward})"
143        );
144
145        VerifiedBlockInformation {
146            block,
147            block_blob,
148            txs,
149            block_hash,
150            pow_hash,
151            generated_coins,
152            height: block_header.height,
153            weight: block_header.block_weight,
154            long_term_weight: block_header.long_term_weight,
155            cumulative_difficulty: block_header.cumulative_difficulty,
156        }
157    }
158
159    /// Request data and map the response to a [`VerifiedTransactionInformation`].
160    ///
161    /// # Panics
162    /// This function will panic at any error point, e.g.,
163    /// if the node cannot be connected to, if deserialization fails, etc.
164    pub async fn get_transaction_verification_data<'a>(
165        &self,
166        tx_hashes: &'a [[u8; 32]],
167    ) -> impl Iterator<Item = VerifiedTransactionInformation> + 'a {
168        self.rpc
169            .transactions(tx_hashes)
170            .await
171            .unwrap()
172            .into_iter()
173            .enumerate()
174            .map(|(i, tx)| {
175                let tx_hash = tx.hash();
176                assert_eq!(tx_hash, tx_hashes[i]);
177                let tx_weight = tx.weight();
178                let fee = tx_fee(&tx);
179                let (tx_pruned, prunable) = tx.pruned_with_prunable();
180
181                VerifiedTransactionInformation {
182                    tx_weight,
183                    tx_pruned: tx_pruned.serialize(),
184                    tx_prunable_blob: prunable,
185                    tx_hash,
186                    fee,
187                    tx: tx_pruned,
188                }
189            })
190    }
191}
192
193//---------------------------------------------------------------------------------------------------- TESTS
194#[cfg(test)]
195mod tests {
196    use hex_literal::hex;
197
198    use super::*;
199
200    /// Assert the default address is localhost.
201    #[ignore] // FIXME: doesn't work in CI, we need a real unrestricted node
202    #[tokio::test]
203    async fn localhost() {
204        assert_eq!(HttpRpcClient::new(None).await.address(), LOCALHOST_RPC_URL);
205    }
206
207    /// Assert blocks are correctly received/calculated.
208    #[ignore] // FIXME: doesn't work in CI, we need a real unrestricted node
209    #[tokio::test]
210    async fn get() {
211        #[expect(clippy::too_many_arguments)]
212        async fn assert_eq(
213            rpc: &HttpRpcClient,
214            height: usize,
215            block_hash: [u8; 32],
216            pow_hash: [u8; 32],
217            generated_coins: u64,
218            weight: usize,
219            long_term_weight: usize,
220            cumulative_difficulty: u128,
221            tx_count: usize,
222        ) {
223            let block = rpc.get_verified_block_information(height).await;
224
225            println!("block height: {height}");
226            assert_eq!(block.txs.len(), tx_count);
227            println!("{block:#?}");
228
229            assert_eq!(block.block_hash, block_hash);
230            assert_eq!(block.pow_hash, pow_hash);
231            assert_eq!(block.height, height);
232            assert_eq!(block.generated_coins, generated_coins);
233            assert_eq!(block.weight, weight);
234            assert_eq!(block.long_term_weight, long_term_weight);
235            assert_eq!(block.cumulative_difficulty, cumulative_difficulty);
236        }
237
238        let rpc = HttpRpcClient::new(None).await;
239
240        assert_eq(
241            &rpc,
242            0,                                                                        // height
243            hex!("418015bb9ae982a1975da7d79277c2705727a56894ba0fb246adaabb1f4632e3"), // block_hash
244            hex!("8a7b1a780e99eec31a9425b7d89c283421b2042a337d5700dfd4a7d6eb7bd774"), // pow_hash
245            17592186044415, // generated_coins
246            80,             // weight
247            80,             // long_term_weight
248            1,              // cumulative_difficulty
249            0,              // tx_count (miner_tx excluded)
250        )
251        .await;
252
253        assert_eq(
254            &rpc,
255            1,
256            hex!("771fbcd656ec1464d3a02ead5e18644030007a0fc664c0a964d30922821a8148"),
257            hex!("5aeebb3de73859d92f3f82fdb97286d81264ecb72a42e4b9f1e6d62eb682d7c0"),
258            17592169267200,
259            383,
260            383,
261            2,
262            0,
263        )
264        .await;
265
266        assert_eq(
267            &rpc,
268            202612,
269            hex!("bbd604d2ba11ba27935e006ed39c9bfdd99b76bf4a50654bc1e1e61217962698"),
270            hex!("84f64766475d51837ac9efbef1926486e58563c95a19fef4aec3254f03000000"),
271            13138270467918,
272            55503,
273            55503,
274            126654460829362,
275            513,
276        )
277        .await;
278
279        assert_eq(
280            &rpc,
281            1731606,
282            hex!("f910435a5477ca27be1986c080d5476aeab52d0c07cf3d9c72513213350d25d4"),
283            hex!("7c78b5b67a112a66ea69ea51477492057dba9cfeaa2942ee7372c61800000000"),
284            3403774022163,
285            6597,
286            6597,
287            23558910234058343,
288            3,
289        )
290        .await;
291
292        assert_eq(
293            &rpc,
294            2751506,
295            hex!("43bd1f2b6556dcafa413d8372974af59e4e8f37dbf74dc6b2a9b7212d0577428"),
296            hex!("10b473b5d097d6bfa0656616951840724dfe38c6fb9c4adf8158800300000000"),
297            600000000000,
298            106,
299            176470,
300            236046001376524168,
301            0,
302        )
303        .await;
304
305        assert_eq(
306            &rpc,
307            3132285,
308            hex!("a999c6ba4d2993541ba9d81561bb8293baa83b122f8aa9ab65b3c463224397d8"),
309            hex!("4eaa3b3d4dc888644bc14dc4895ca0b008586e30b186fbaa009d330100000000"),
310            600000000000,
311            133498,
312            176470,
313            348189741564698577,
314            57,
315        )
316        .await;
317    }
318}