conflux/command/
rpc.rs

1// Copyright 2019 Conflux Foundation. All rights reserved.
2// Conflux is free software and distributed under GNU General Public License.
3// See http://www.gnu.org/licenses/
4
5use crate::command::helpers::{input_password, password_prompt};
6use clap::ArgMatches;
7use jsonrpsee::{core::client::ClientT, http_client::HttpClientBuilder};
8use serde_json::{Map, Value};
9
10pub struct RpcCommand {
11    pub url: String,
12    pub method: String,
13    pub args: Vec<Value>,
14}
15
16impl RpcCommand {
17    pub fn parse(matches: &ArgMatches) -> Result<Option<RpcCommand>, String> {
18        let method = match matches.get_one::<String>("rpc-method") {
19            Some(method) => method,
20            None => return Ok(None),
21        };
22
23        let url = match matches.get_one::<String>("url") {
24            Some(url) => url,
25            None => return Err(String::from("RPC URL not specified")),
26        };
27
28        let args: Vec<Value> = match matches.try_get_many::<String>("rpc-args")
29        {
30            Ok(Some(args)) => {
31                let mut params = Vec::new();
32
33                for arg in args {
34                    match ArgSchema::parse(arg).value(matches)? {
35                        Some(val) => params.push(val),
36                        None => break,
37                    }
38                }
39                params
40            }
41            Ok(None) => Vec::new(),
42            Err(_e) => Vec::new(),
43        };
44
45        Ok(Some(RpcCommand {
46            url: url.into(),
47            method: method.into(),
48            args,
49        }))
50    }
51
52    pub async fn execute(self) -> Result<String, String> {
53        let client = HttpClientBuilder::default()
54            .build(&self.url)
55            .map_err(|e| e.to_string())?;
56        let result: Value = client
57            .request(&self.method, self.args)
58            .await
59            .map_err(|e| e.to_string())?;
60        Ok(format!("{:#}", result))
61    }
62}
63
64struct ArgSchema<'a> {
65    arg_name: &'a str,
66    arg_type: &'a str,
67}
68
69impl<'a> ArgSchema<'a> {
70    fn parse(arg: &'a str) -> Self {
71        let schema: Vec<&str> = arg.splitn(2, ':').collect();
72        ArgSchema {
73            arg_name: schema[0],
74            arg_type: schema.get(1).cloned().unwrap_or("string"),
75        }
76    }
77
78    fn value(&self, matches: &ArgMatches) -> Result<Option<Value>, String> {
79        match self.arg_type {
80            "string" => match matches.get_one::<String>(self.arg_name) {
81                Some(val) => Ok(Some(Value::String(val.into()))),
82                None => Ok(None),
83            },
84            "bool" => Ok(Some(Value::Bool(matches.get_flag(self.arg_name)))),
85            "u64" => self.u64(matches),
86            "password" => Ok(Some(self.password()?)),
87            "password2" => Ok(Some(self.password2()?)),
88            _ => {
89                if self.arg_type.starts_with("map(")
90                    && self.arg_type.ends_with(')')
91                {
92                    return Ok(Some(self.object(matches)?));
93                }
94
95                panic!("unsupported RPC argument type: {}", self.arg_type);
96            }
97        }
98    }
99
100    fn u64(&self, matches: &ArgMatches) -> Result<Option<Value>, String> {
101        let val = match matches.get_one::<u64>(self.arg_name) {
102            Some(val) => val,
103            None => return Ok(None),
104        };
105
106        Ok(Some(Value::String(format!("{:#x}", val))))
107    }
108
109    fn object(&self, matches: &ArgMatches) -> Result<Value, String> {
110        let fields: Vec<&str> = self
111            .arg_type
112            .trim_start_matches("map(")
113            .trim_end_matches(')')
114            .split(';')
115            .collect();
116
117        let mut object = Map::new();
118
119        for field in fields {
120            let schema = ArgSchema::parse(field);
121            if let Some(val) = schema.value(matches)? {
122                object.insert(schema.arg_name.into(), val);
123            }
124        }
125
126        Ok(Value::Object(object))
127    }
128
129    fn password(&self) -> Result<Value, String> {
130        input_password().map(|pwd| Value::String(pwd.as_str().to_string()))
131    }
132
133    fn password2(&self) -> Result<Value, String> {
134        password_prompt().map(|pwd| Value::String(pwd.as_str().to_string()))
135    }
136}
137
138#[cfg(test)]
139
140mod tests {
141    use std::vec;
142
143    use crate::cli::Cli;
144
145    use super::*;
146    use clap::CommandFactory;
147    use mockito::{Matcher, Server};
148    use serde_json::json;
149    use tokio;
150
151    async fn run_rpc_test(
152        method: &str, args: Vec<Value>, expected_result_value: Value,
153    ) {
154        let mut server = Server::new_async().await;
155        let url = server.url();
156
157        let expected_request_body = json!({
158            "jsonrpc": "2.0",
159            "method": method,
160            "params": args.clone(),
161            "id": 0
162        });
163
164        let mock_response_body = json!({
165          "jsonrpc": "2.0",
166          "id": 0,
167          "result": expected_result_value.clone()
168        });
169
170        let mock = server
171            .mock("POST", "/")
172            .match_header("content-type", "application/json")
173            .match_body(Matcher::Json(expected_request_body.clone()))
174            .with_status(200)
175            .with_body(mock_response_body.to_string())
176            .create_async()
177            .await;
178
179        let command = RpcCommand {
180            url,
181            method: method.to_string(),
182            args,
183        };
184
185        let result = command.execute().await;
186
187        mock.assert_async().await;
188        assert!(result.is_ok());
189        let result_str = result.unwrap();
190        assert_eq!(result_str, format!("{:#}", expected_result_value));
191    }
192
193    #[tokio::test]
194    async fn test_rpc_execute_without_args() {
195        let method = "cfx_getStatus";
196        let args: Vec<Value> = vec![];
197        let expected_result = json!({
198            "bestHash": "0x64c936773e434069ede6bec161419b37ab6110409095a1d91d2bb91c344b523f",
199            "chainId": "0x1",
200            "ethereumSpaceChainId": "0x47",
201            "networkId": "0x1",
202            "epochNumber": "0xcdee1fd",
203            "blockNumber": "0x10be2f9b",
204            "pendingTxNumber": "0x8cf",
205            "latestCheckpoint": "0xcdd7500",
206            "latestConfirmed": "0xcdee1c3",
207            "latestState": "0xcdee1f9",
208            "latestFinalized": "0xcdee0ac"
209        });
210
211        run_rpc_test(method, args, expected_result).await;
212    }
213
214    #[tokio::test]
215    async fn test_rpc_execute_cfx_epoch_number_with_param() {
216        let method = "cfx_epochNumber";
217        let args: Vec<Value> = vec![json!("0x4350b21")];
218        let expected_result = json!("0x4350b21");
219
220        run_rpc_test(method, args, expected_result).await;
221    }
222
223    #[test]
224    fn test_rpc_command_parse() {
225        #[derive(Debug)]
226        struct TestCase {
227            name: &'static str,
228            args: Vec<&'static str>,
229            expected_method: &'static str,
230            expected_url: &'static str,
231            expected_params: Vec<Value>,
232        }
233
234        let test_cases = vec![
235            TestCase {
236                name: "estimate-gas with many arguments",
237                args: vec![
238                    "conflux",
239                    "rpc",
240                    "estimate-gas",
241                    "--from",
242                    "addr_from",
243                    "--to",
244                    "addr_to",
245                    "--gas-price",
246                    "gp_val",
247                    "--type",
248                    "type_val",
249                    "--max-fee-per-gas",
250                    "mfpg_val",
251                    "--max-priority-fee-per-gas",
252                    "mpfpg_val",
253                    "--gas",
254                    "gas_val",
255                    "--value",
256                    "value_val",
257                    "--data",
258                    "data_val",
259                    "--nonce",
260                    "nonce_val",
261                    "--epoch",
262                    "epoch_val",
263                ],
264                expected_method: "cfx_estimateGas",
265                expected_url: "http://localhost:12539",
266                expected_params: vec![
267                    json!({
268                        "data": "data_val",
269                        "from": "addr_from",
270                        "gas": "gas_val",
271                        "gas-price": "gp_val",
272                        "max-fee-per-gas": "mfpg_val",
273                        "max-priority-fee-per-gas": "mpfpg_val",
274                        "nonce": "nonce_val",
275                        "to": "addr_to",
276                        "type": "type_val",
277                        "value": "value_val"
278                    }),
279                    json!("epoch_val"),
280                ],
281            },
282            TestCase {
283                name: "balance with custom URL",
284                args: vec![
285                    "conflux",
286                    "rpc",
287                    "balance",
288                    "--url",
289                    "http://0.0.0.0:8080",
290                    "--address",
291                    "test_address_001",
292                    "--epoch",
293                    "latest_state",
294                ],
295                expected_method: "cfx_getBalance",
296                expected_url: "http://0.0.0.0:8080",
297                expected_params: vec![
298                    json!("test_address_001"),
299                    json!("latest_state"),
300                ],
301            },
302            TestCase {
303                name: "block-by-hash",
304                args: vec![
305                    "conflux",
306                    "rpc",
307                    "block-by-hash",
308                    "--hash",
309                    "0x654321fedcba",
310                ],
311                expected_method: "cfx_getBlockByHash",
312                expected_url: "http://localhost:12539",
313                expected_params: vec![json!("0x654321fedcba"), json!(false)],
314            },
315            TestCase {
316                name: "voting_status",
317                args: vec!["conflux", "rpc", "local", "pos", "voting_status"],
318                expected_method: "test_posVotingStatus",
319                expected_params: vec![],
320                expected_url: "http://localhost:12539",
321            },
322            TestCase {
323                name: "voting_status_with_custom_url",
324                args: vec![
325                    "conflux",
326                    "rpc",
327                    "local",
328                    "pos",
329                    "voting_status",
330                    "--url",
331                    "http://localhost:9999",
332                ],
333                expected_method: "test_posVotingStatus",
334                expected_params: vec![],
335                expected_url: "http://localhost:9999",
336            },
337        ];
338
339        for test_case in test_cases {
340            let cli = Cli::command().get_matches_from(test_case.args);
341            let mut subcmd_matches = &cli;
342            while let Some(m) = subcmd_matches.subcommand() {
343                subcmd_matches = m.1;
344            }
345
346            let rpc_command = match RpcCommand::parse(subcmd_matches) {
347                Ok(Some(cmd)) => cmd,
348                Ok(None) => panic!(
349                    "Test case '{}': Expected RpcCommand but got None",
350                    test_case.name
351                ),
352                Err(e) => panic!(
353                    "Test case '{}': Error parsing RpcCommand: {}",
354                    test_case.name, e
355                ),
356            };
357
358            assert_eq!(
359                rpc_command.method, test_case.expected_method,
360                "Test case '{}': Method mismatch",
361                test_case.name
362            );
363
364            assert_eq!(
365                rpc_command.url, test_case.expected_url,
366                "Test case '{}': URL mismatch",
367                test_case.name
368            );
369
370            assert_eq!(
371                rpc_command.args, test_case.expected_params,
372                "Test case '{}': Parameters mismatch",
373                test_case.name
374            );
375        }
376    }
377}