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.