1use 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}