diff --git a/CHANGELOG.md b/CHANGELOG.md index 2841fc3fb..a2db6620e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add a user-friendly view of contract storage data in the form of a table - [#1414](https://github.com/paritytech/cargo-contract/pull/1414) +- Add `rpc` command - [#1458](https://github.com/paritytech/cargo-contract/pull/1458) ### Changed - Mandatory dylint-based lints - [#1412](https://github.com/paritytech/cargo-contract/pull/1412) diff --git a/README.md b/README.md index 3e8e75220..b03cd3549 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,10 @@ Verify a metadata file or a contract bundle containing metadata against the sche Fetch and display the storage of a contract on chain. +##### `cargo contract rpc` + +Invoke an RPC call to the node. See [rpc](docs/rpc.md). + ## Publishing diff --git a/crates/cargo-contract/src/cmd/mod.rs b/crates/cargo-contract/src/cmd/mod.rs index 160e05996..5d4a20c09 100644 --- a/crates/cargo-contract/src/cmd/mod.rs +++ b/crates/cargo-contract/src/cmd/mod.rs @@ -21,6 +21,7 @@ pub mod encode; pub mod info; pub mod instantiate; pub mod remove; +pub mod rpc; pub mod schema; pub mod storage; pub mod upload; @@ -39,6 +40,7 @@ pub(crate) use self::{ }, instantiate::InstantiateCommand, remove::RemoveCommand, + rpc::RpcCommand, schema::{ GenerateSchemaCommand, VerifySchemaCommand, diff --git a/crates/cargo-contract/src/cmd/rpc.rs b/crates/cargo-contract/src/cmd/rpc.rs new file mode 100644 index 000000000..cf9c1af6d --- /dev/null +++ b/crates/cargo-contract/src/cmd/rpc.rs @@ -0,0 +1,74 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// This file is part of cargo-contract. +// +// cargo-contract is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// cargo-contract is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with cargo-contract. If not, see . + +use contract_build::name_value_println; +use contract_extrinsics::{ + ErrorVariant, + RawParams, + RpcRequest, +}; +use subxt::ext::scale_value; + +use super::MAX_KEY_COL_WIDTH; + +#[derive(Debug, clap::Args)] +#[clap(name = "rpc", about = "Make a raw RPC call")] +pub struct RpcCommand { + /// The name of the method to call. + method: String, + /// The arguments of the method to call. + #[clap(num_args = 0..)] + params: Vec, + /// Websockets url of a substrate node. + #[clap( + name = "url", + long, + value_parser, + default_value = "ws://localhost:9944" + )] + url: url::Url, + /// Export the call output in JSON format. + #[clap(long)] + output_json: bool, +} + +impl RpcCommand { + pub async fn run(&self) -> Result<(), ErrorVariant> { + let request = RpcRequest::new(&self.url).await?; + let params = RawParams::new(&self.params)?; + + let result = request.raw_call(&self.method, params).await; + + match (result, self.output_json) { + (Err(err), false) => Err(anyhow::anyhow!("Method call failed: {}", err))?, + (Err(err), true) => { + Err(anyhow::anyhow!(serde_json::to_string_pretty( + &ErrorVariant::from(err) + )?))? + } + (Ok(res), false) => { + let output: scale_value::Value = serde_json::from_str(res.get())?; + name_value_println!("Result", output, MAX_KEY_COL_WIDTH); + Ok(()) + } + (Ok(res), true) => { + let json: serde_json::Value = serde_json::from_str(res.get())?; + println!("{}", serde_json::to_string_pretty(&json)?); + Ok(()) + } + } + } +} diff --git a/crates/cargo-contract/src/main.rs b/crates/cargo-contract/src/main.rs index 7da9e55c6..916dd9d41 100644 --- a/crates/cargo-contract/src/main.rs +++ b/crates/cargo-contract/src/main.rs @@ -28,6 +28,7 @@ use self::cmd::{ InfoCommand, InstantiateCommand, RemoveCommand, + RpcCommand, StorageCommand, UploadCommand, VerifyCommand, @@ -154,6 +155,9 @@ enum Command { /// Verify schema from the current metadata specification. #[clap(name = "verify-schema")] VerifySchema(VerifySchemaCommand), + /// Make a raw RPC call. + #[clap(name = "rpc")] + Rpc(RpcCommand), } fn main() { @@ -260,6 +264,9 @@ fn exec(cmd: Command) -> Result<()> { } Ok(()) } + Command::Rpc(rpc) => { + runtime.block_on(async { rpc.run().await.map_err(format_err) }) + } } } diff --git a/crates/extrinsics/src/integration_tests.rs b/crates/extrinsics/src/integration_tests.rs index 5700e3433..0de4c0491 100644 --- a/crates/extrinsics/src/integration_tests.rs +++ b/crates/extrinsics/src/integration_tests.rs @@ -619,6 +619,51 @@ async fn api_build_upload_remove() { let _ = node_process; } +/// Sanity test the RPC API +#[tokio::test] +async fn api_rpc_call() { + init_tracing_subscriber(); + + let tmp_dir = tempfile::Builder::new() + .prefix("cargo-contract.cli.test.") + .tempdir() + .expect("temporary directory creation failed"); + + let node_process = ContractsNodeProcess::spawn(CONTRACTS_NODE) + .await + .expect("Error spawning contracts node"); + + cargo_contract(tmp_dir.path()) + .arg("rpc") + .arg("author_insertKey") + .arg("\"sr25\"") + .arg("\"//ALICE\"") + .arg("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY") + .assert() + .success(); + + let output = cargo_contract(tmp_dir.path()) + .arg("rpc") + .arg("author_hasKey") + .arg("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY") + .arg("\"sr25\"") + .arg("--output-json") + .output() + .expect("failed to execute process"); + + let stdout = str::from_utf8(&output.stdout).unwrap(); + let stderr = str::from_utf8(&output.stderr).unwrap(); + assert!( + output.status.success(), + "rpc method execution failed: {stderr}" + ); + + assert_eq!(stdout.trim_end(), "true", "{stdout:?}"); + + // prevent the node_process from being dropped and killed + let _ = node_process; +} + /// Sanity test the whole lifecycle of: /// new -> build -> upload -> instantiate -> storage /// diff --git a/crates/extrinsics/src/lib.rs b/crates/extrinsics/src/lib.rs index 6f536a61b..a1d91dd19 100644 --- a/crates/extrinsics/src/lib.rs +++ b/crates/extrinsics/src/lib.rs @@ -27,6 +27,7 @@ mod extrinsic_opts; mod instantiate; pub mod pallet_contracts_primitives; mod remove; +mod rpc; mod upload; #[cfg(test)] @@ -112,6 +113,11 @@ pub use upload::{ UploadResult, }; +pub use rpc::{ + RawParams, + RpcRequest, +}; + pub type Client = OnlineClient; pub type Balance = u128; pub type CodeHash = ::Hash; diff --git a/crates/extrinsics/src/rpc.rs b/crates/extrinsics/src/rpc.rs new file mode 100644 index 000000000..48643cecb --- /dev/null +++ b/crates/extrinsics/src/rpc.rs @@ -0,0 +1,208 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// This file is part of cargo-contract. +// +// cargo-contract is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// cargo-contract is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with cargo-contract. If not, see . + +use std::str::FromStr; + +use contract_transcode::AccountId32; +use subxt::{ + backend::rpc::{ + RawValue, + RpcClient, + RpcParams, + }, + ext::scale_value::{ + stringify::{ + from_str_custom, + ParseError, + }, + Value, + }, +}; + +use crate::url_to_string; +use anyhow::{ + anyhow, + bail, + Result, +}; + +pub struct RawParams(Option>); + +impl RawParams { + /// Creates a new `RawParams` instance from a slice of string parameters. + /// Returns a `Result` containing the parsed `RawParams` or an error if parsing fails. + pub fn new(params: &[String]) -> Result { + let mut str_parser = from_str_custom(); + str_parser = str_parser.add_custom_parser(custom_hex_parse); + str_parser = str_parser.add_custom_parser(custom_ss58_parse); + + let value_params = params + .iter() + .map(|e| str_parser.parse(e).0) + .collect::, ParseError>>() + .map_err(|e| anyhow::anyhow!("Method parameters parsing failed: {e}"))?; + + let params = match value_params.is_empty() { + true => None, + false => { + value_params + .iter() + .try_fold(RpcParams::new(), |mut v, e| { + v.push(e)?; + Ok(v) + }) + .map_err(|e: subxt::Error| { + anyhow::anyhow!("Building method parameters failed: {e}") + })? + .build() + } + }; + + Ok(Self(params)) + } +} + +pub struct RpcRequest(RpcClient); + +impl RpcRequest { + /// Creates a new `RpcRequest` instance. + pub async fn new(url: &url::Url) -> Result { + let rpc = RpcClient::from_url(url_to_string(url)).await?; + Ok(Self(rpc)) + } + + /// Performs a raw RPC call with the specified method and parameters. + /// Returns a `Result` containing the raw RPC call result or an error if the call + /// fails. + pub async fn raw_call<'a>( + &'a self, + method: &'a str, + params: RawParams, + ) -> Result> { + let methods = self.get_supported_methods().await?; + if !methods.iter().any(|e| e == method) { + bail!( + "Method not found, supported methods: {}", + methods.join(", ") + ); + } + self.0 + .request_raw(method, params.0) + .await + .map_err(|e| anyhow!("Raw RPC call failed: {e}")) + } + + /// Retrieves the supported RPC methods. + /// Returns a `Result` containing a vector of supported RPC methods or an error if the + /// call fails. + async fn get_supported_methods(&self) -> Result> { + let result = self + .0 + .request_raw("rpc_methods", None) + .await + .map_err(|e| anyhow!("Rpc call 'rpc_methods' failed: {e}"))?; + + let result_value: serde_json::Value = serde_json::from_str(result.get())?; + + let methods = result_value + .get("methods") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow!("Methods field parsing failed!"))?; + + // Exclude unupported methods using pattern matching + let patterns = ["watch", "unstable", "subscribe"]; + let filtered_methods: Vec = methods + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .filter(|s| { + patterns + .iter() + .all(|&pattern| !s.to_lowercase().contains(pattern)) + }) + .collect(); + + Ok(filtered_methods) + } +} + +/// Parse hex to string +fn custom_hex_parse(s: &mut &str) -> Option, ParseError>> { + if !s.starts_with("0x") { + return None + } + + let end_idx = s + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(s.len()); + let hex = &s[..end_idx]; + *s = &s[end_idx..]; + Some(Ok(Value::string(hex.to_string()))) +} + +/// Parse ss58 address to string +fn custom_ss58_parse(s: &mut &str) -> Option, ParseError>> { + let end_idx = s + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(s.len()); + let account = AccountId32::from_str(&s[..end_idx]).ok()?; + + *s = &s[end_idx..]; + Some(Ok(Value::string(format!("0x{}", hex::encode(account.0))))) +} + +#[cfg(test)] +mod tests { + use super::*; + fn assert_raw_params_value(input: &[&str], expected: &str) { + let input = input.iter().map(|e| e.to_string()).collect::>(); + let raw_params = RawParams::new(&input).expect("Raw param shall be created"); + let expected = expected + .chars() + .filter(|&c| !c.is_whitespace()) + .collect::(); + assert_eq!(raw_params.0.unwrap().get(), expected); + } + + #[test] + fn parse_ss58_works() { + let expected = r#"["0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d","sr25"]"#; + let input = &[ + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "\"sr25\"", + ]; + assert_raw_params_value(input, expected); + } + + #[test] + fn parse_seq_works() { + let expected = r#"[[1,"0x1234",true]]"#; + let input = &["(1, 0x1234, true)"]; + assert_raw_params_value(input, expected); + } + + #[test] + fn parse_map_works() { + let expected = r#"[{ + "hello": true, + "a": 4, + "b": "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", + "c": "test" + }]"#; + let input = &["{hello: true, a: 4, b: \ + 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY, c: \"test\"}"]; + assert_raw_params_value(input, expected); + } +} diff --git a/docs/rpc.md b/docs/rpc.md new file mode 100644 index 000000000..dfe6e77dd --- /dev/null +++ b/docs/rpc.md @@ -0,0 +1,38 @@ +### `rpc` + +Invoke an RPC call to the node in the format: +`cargo contract rpc [Options] METHOD [PARAMS]` + +e.g. + +```bash +cargo contract rpc author_insertKey '"sr25"' '"//ALICE"' \ + 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY + + cargo contract rpc author_hasKey + 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY '"sr25"' \ +``` + +account can be provided as ss58 address: +`5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY` +or in hex: +`0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d` + +Some of the commands require SS58 address in the string format: + + ```bash +cargo contract rpc system_accountNextIndex \ + '"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"' +``` + +Command using a sequence as a parameter: + +```bash +cargo contract rpc state_getReadProof \ + '(0x4b9cce91a924c0f4d469b3d62e02f9682079560c6cfc45c1a9498812dfff4b3a)' +``` + +*Optional* + +- `--url` the url of the rpc endpoint you want to specify - by default `ws://localhost:9944`. +- `--output-json` to export the output as JSON.