diff --git a/Cargo.toml b/Cargo.toml index 2d9315d..3ca292c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["programs", "core", "acl", "evm", "runtime", "examples/*"] +members = ["programs", "core", "acl", "evm", "runtime", "examples/*", "substrate"] exclude = ["templates/*", "examples/risczero-zkvm-verification"] resolver = "2" diff --git a/examples/substrate/Cargo.toml b/examples/substrate/Cargo.toml new file mode 100644 index 0000000..80a227a --- /dev/null +++ b/examples/substrate/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "template-substrate" +version = "0.1.0" +authors = ["Entropy Cryptography "] +homepage = "https://entropy.xyz/" +license = "Unlicense" +repository = "https://github.com/entropyxyz/programs" +edition = "2021" + +# This is required to compile programs to a wasm module and for use in rust libs +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +entropy-programs = { workspace = true } +# TODO move hex parsing into the entropy-programs-evm crate +hex = { version = "0.4.3", default-features = false } +serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } +serde_json = { version = "1.0", default-features = false, features = ["alloc"]} +schemars = {version = "0.8.16", optional = true} +entropy-programs-substrate = { path = "../../substrate", default-features = false } + +[dev-dependencies] +subxt = { version = "0.35.3", default-features=false, features=["native"], target_arch = "wasm32" } +# These are used by `cargo component` +[package.metadata.component] +package = "entropy:template-basic-transaction" + +[package.metadata.component.target] +path = "../../wit" + +[package.metadata.component.dependencies] + +[features] +std = ["schemars"] \ No newline at end of file diff --git a/examples/substrate/README.md b/examples/substrate/README.md new file mode 100644 index 0000000..fc52595 --- /dev/null +++ b/examples/substrate/README.md @@ -0,0 +1,11 @@ +# 'Substrate' example template + +* uses the substrate helper functions to allow for signatures on substrate chains + +* Creates its AuxData and Config with mandatory fields then passes them to ``check_message_against_transaction`` +* Once done can now use AuxData fields to apply constraints + +### Not checked +* currently nonce and block mortality are not checked (will be addressed eventually) + + diff --git a/examples/substrate/src/lib.rs b/examples/substrate/src/lib.rs new file mode 100644 index 0000000..55b8509 --- /dev/null +++ b/examples/substrate/src/lib.rs @@ -0,0 +1,83 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +use alloc::{format, string::String, vec::Vec}; +use entropy_programs::core::{bindgen::*, export_program, prelude::*}; +use entropy_programs_substrate::{check_message_against_transaction, HasFieldsAux}; + +#[cfg(test)] +mod tests; + +use serde::{Deserialize, Serialize}; + +// TODO confirm this isn't an issue for audit +register_custom_getrandom!(always_fail); + +/// JSON-deserializable struct that will be used to derive the program-JSON interface. +#[cfg_attr(feature = "std", derive(schemars::JsonSchema))] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +pub struct UserConfig {} + +/// JSON representation of the auxiliary data +#[cfg_attr(feature = "std", derive(schemars::JsonSchema))] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +pub struct AuxData { + pub spec_version: u32, + pub transaction_version: u32, + pub values: String, + pub pallet: String, + pub function: String, + pub genesis_hash: String, +} + +impl HasFieldsAux for AuxData { + fn genesis_hash(&self) -> &String { + &self.genesis_hash + } + fn spec_version(&self) -> &u32 { + &self.spec_version + } + + fn transaction_version(&self) -> &u32 { + &self.transaction_version + } + fn values(&self) -> &String { + &self.values + } + fn pallet(&self) -> &String { + &self.pallet + } + fn function(&self) -> &String { + &self.function + } +} + +pub struct SubstrateProgram; + +impl Program for SubstrateProgram { + fn evaluate( + signature_request: SignatureRequest, + _config: Option>, + _oracle_data: Option>, + ) -> Result<(), Error> { + let (_aux_data, _api) = check_message_against_transaction::(signature_request) + .map_err(|e| { + Error::InvalidSignatureRequest(format!( + "Error comparing tx request and message: {}", + e + )) + })?; + + // can now use aux data and user config to apply constraints + // Ex: balance limit, genesis hash (locks it to a chain or specific chains) + Ok(()) + } + + /// Since we don't use a custom hash function, we can just return `None` here. + fn custom_hash(_data: Vec) -> Option> { + None + } +} + +export_program!(SubstrateProgram); diff --git a/examples/substrate/src/tests.rs b/examples/substrate/src/tests.rs new file mode 100644 index 0000000..cf90d9e --- /dev/null +++ b/examples/substrate/src/tests.rs @@ -0,0 +1,106 @@ +use super::*; +use alloc::{ + string::{ToString}, + vec, +}; +use entropy_programs_substrate::{get_offline_api, handle_encoding}; +use subxt::config::PolkadotExtrinsicParamsBuilder as Params; +use subxt::dynamic::tx; + +#[test] +fn test_should_sign() { + let aux_data = create_aux_data(); + + let api = get_offline_api( + aux_data.genesis_hash.clone(), + aux_data.spec_version, + aux_data.transaction_version, + ) + .unwrap(); + + let deserialized: Vec<(&str, &str)> = serde_json::from_str(&aux_data.values).unwrap(); + let encoding = handle_encoding(deserialized).unwrap(); + + let balance_transfer_tx = tx(aux_data.pallet.clone(), aux_data.function.clone(), encoding); + + let tx_params = Params::new().build(); + + let partial = api + .tx() + .create_partial_signed_offline(&balance_transfer_tx, tx_params) + .unwrap() + .signer_payload(); + + let signature_request = SignatureRequest { + message: partial.to_vec(), + auxilary_data: Some(serde_json::to_string(&aux_data).unwrap().into_bytes()), + }; + + assert!(SubstrateProgram::evaluate(signature_request, None, None).is_ok()); +} + +#[test] +fn test_should_fail() { + let aux_data = create_aux_data(); + + let api = get_offline_api( + aux_data.genesis_hash.clone(), + aux_data.spec_version, + aux_data.transaction_version, + ) + .unwrap(); + + let amount = 1000u128; + let binding = amount.to_string(); + let string_account_id = "5FA9nQDVg267DEd8m1ZypXLBnvN7SFxYwV7ndqSYGiN9TTpu"; + + let values: Vec<(&str, &str)> = vec![("account", string_account_id), ("amount", &binding)]; + + let encoding = handle_encoding(values.clone()).unwrap(); + let balance_transfer_tx = tx( + aux_data.pallet.clone(), + aux_data.function.clone(), + encoding.clone(), + ); + + let tx_params = Params::new().build(); + + let partial = api + .tx() + .create_partial_signed_offline(&balance_transfer_tx, tx_params) + .unwrap() + .signer_payload(); + + let signature_request = SignatureRequest { + message: partial.to_vec(), + auxilary_data: Some(serde_json::to_string(&aux_data).unwrap().into_bytes()), + }; + + assert_eq!( + SubstrateProgram::evaluate(signature_request, None, None) + .unwrap_err() + .to_string(), + "Error::InvalidSignatureRequest(\"Error comparing tx request and message: Error::Evaluation(\\\"Signatures don't match, message: \\\\\\\"07000088dc3417d5058ec4b4503e0c12ea1a0a89be200fe98922423d4334014fa6b0eea10f0000000a0000000a00000044670a68177821a6166b25f8d86b45e0f1c3b280ff576eea64057e4b0dd9ff4a44670a68177821a6166b25f8d86b45e0f1c3b280ff576eea64057e4b0dd9ff4a\\\\\\\", calldata: \\\\\\\"07000088dc3417d5058ec4b4503e0c12ea1a0a89be200fe98922423d4334014fa6b0ee9101\\\\\\\", genesis_hash: \\\\\\\"34343637306136383137373832316136313636623235663864383662343565306631633362323830666635373665656136343035376534623064643966663461\\\\\\\"\\\")\")" + ); +} + +pub fn create_aux_data() -> AuxData { + let genesis_hash = + "44670a68177821a6166b25f8d86b45e0f1c3b280ff576eea64057e4b0dd9ff4a".to_string(); + let spec_version = 10; + let transaction_version = 10; + let string_account_id = "5FA9nQDVg267DEd8m1ZypXLBnvN7SFxYwV7ndqSYGiN9TTpu"; + let amount = 100u128; + let binding = amount.to_string(); + let values: Vec<(&str, &str)> = vec![("account", string_account_id), ("amount", &binding)]; + + let aux_data = AuxData { + genesis_hash, + spec_version, + transaction_version, + pallet: "Balances".to_string(), + function: "transfer_allow_death".to_string(), + values: serde_json::to_string(&values).unwrap(), + }; + aux_data +} diff --git a/substrate/Cargo.toml b/substrate/Cargo.toml new file mode 100644 index 0000000..131e9d5 --- /dev/null +++ b/substrate/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "entropy-programs-substrate" +version = "0.1.0" +authors = ["Entropy Cryptography "] +homepage = "https://entropy.xyz/" +license = "AGPL-3.0-or-later" +repository = "https://github.com/entropyxyz/programs" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entropy-programs-core = { path = "../core" } +subxt = { version = "0.35.3", default-features=false, features=["native"], target_arch = "wasm32" } +codec = { package = "parity-scale-codec", version = "3.6.9", default-features = false } +hex ={ version="0.4.3" } +serde_json = "1.0.48" +serde ={ version="1.0", default-features=false, features=["derive"] } + + + +[features] +default = [] +std = [] \ No newline at end of file diff --git a/substrate/README.md b/substrate/README.md new file mode 100644 index 0000000..39f2b62 --- /dev/null +++ b/substrate/README.md @@ -0,0 +1,12 @@ +# `entropy-programs-substrate` + +Provides substrate helper functions to help with talking to substrate chains + +See examples/substrate for example of usage + +### Usage + +* Takes a Config and SignatureRequest + * Uses this data to build a transaction and use the data from the aux info to rebuild a transaction and compare it to what a user it trying to sign + * Errors if they don't match + * After this a program dev can use the details in the transaction to apply constraints, knowing that that is what is being signed diff --git a/substrate/build.rs b/substrate/build.rs new file mode 100644 index 0000000..917bbc7 --- /dev/null +++ b/substrate/build.rs @@ -0,0 +1,17 @@ +use std::env; +use std::fs; +use std::path::Path; + +fn main() { + let out_dir = env::var_os("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("metadata.rs"); + + let metadata = fs::read("substrate_metadata.scale").unwrap(); + fs::write( + dest_path, + format!("const METADATA: &[u8] = &{:?};\n", metadata), + ) + .unwrap(); + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=substrate_metadata.scale"); +} diff --git a/substrate/src/lib.rs b/substrate/src/lib.rs new file mode 100644 index 0000000..8e49aa7 --- /dev/null +++ b/substrate/src/lib.rs @@ -0,0 +1,179 @@ +use codec::Decode; +use core::str::FromStr; +use entropy_programs_core::{bindgen::SignatureRequest, Error}; +use serde::{de::DeserializeOwned, Deserialize}; +pub use subxt::{ + dynamic::tx, + ext::scale_value::{self, Composite, Value}, + utils::{AccountId32, H256}, + Metadata, OfflineClient, PolkadotConfig, +}; +#[cfg(test)] +mod tests; +#[cfg(test)] +use serde::Serialize; + +include!(concat!(env!("OUT_DIR"), "/metadata.rs")); + +pub trait HasFieldsAux { + fn genesis_hash(&self) -> &String; + fn spec_version(&self) -> &u32; + fn transaction_version(&self) -> &u32; + fn pallet(&self) -> &String; + fn function(&self) -> &String; + fn values(&self) -> &String; +} + +/// Info needed in AuxData to use these substrate helpers +#[cfg_attr(test, derive(Serialize, Debug, PartialEq))] +#[derive(Deserialize)] +pub struct AuxDataStruct { + /// Genesis hash of the chain you are trying to talk to + genesis_hash: String, + /// Spec version of the chain to call + spec_version: u32, + /// Transaction version of the chain to call + transaction_version: u32, + /// Pallet name to call (ex: Balances) + pallet: String, + /// Function name to call (ex: transfer_allow_death) + function: String, + /// Values string encoded in a tuple Vec<(type_string, value_string)> (ex: vec![("account", "5x5......"), ("amount", "100")];) + values: String, +} + +impl HasFieldsAux for AuxDataStruct { + fn spec_version(&self) -> &u32 { + &self.spec_version + } + fn transaction_version(&self) -> &u32 { + &self.transaction_version + } + fn pallet(&self) -> &String { + &self.pallet + } + fn function(&self) -> &String { + &self.function + } + fn values(&self) -> &String { + &self.values + } + fn genesis_hash(&self) -> &String { + &self.genesis_hash + } +} +/// Checks message request against passed info to make sure they match +pub fn check_message_against_transaction( + signature_request: SignatureRequest, +) -> Result<(AuxData, OfflineClient), Error> +where + AuxData: DeserializeOwned + HasFieldsAux, +{ + let SignatureRequest { + message, + auxilary_data, + } = signature_request; + + let aux_data_json = serde_json::from_slice::( + auxilary_data + .ok_or(Error::InvalidSignatureRequest( + "No auxilary_data provided".to_string(), + ))? + .as_slice(), + ) + .map_err(|e| Error::InvalidSignatureRequest(format!("Failed to parse auxilary_data: {}", e)))?; + + let api = get_offline_api( + aux_data_json.genesis_hash().clone().to_string(), + *aux_data_json.spec_version(), + *aux_data_json.transaction_version(), + )?; + + let deserialized: Vec<(&str, &str)> = serde_json::from_str(&aux_data_json.values()) + .map_err(|e| Error::Evaluation(format!("Failed to parse values: {}", e)))?; + let encoding = handle_encoding(deserialized.clone())?; + + let balance_transfer_tx = tx(aux_data_json.pallet(), aux_data_json.function(), encoding); + + let call_data = api + .tx() + .call_data(&balance_transfer_tx) + .map_err(|e| Error::Evaluation(format!("Failed to create transaction: {}", e)))?; + + let hex_message = hex::encode(message); + let hex_call_data = hex::encode(call_data); + let hex_genesis_hash = hex::encode(aux_data_json.genesis_hash()); + + if !&hex_message.contains(&hex_call_data) && !&hex_message.contains(&hex_genesis_hash) { + return Err(Error::Evaluation(format!( + "Signatures don't match, message: {:?}, calldata: {:?}, genesis_hash: {:?}", + hex_message, hex_call_data, hex_genesis_hash, + ))); + } + + Ok((aux_data_json, api)) +} + +/// Creates an offline api instance +/// Chain endpoint set on launch +pub fn get_offline_api( + hash: String, + spec_version: u32, + transaction_version: u32, +) -> Result, Error> { + let genesis_hash = { + let bytes = hex::decode(hash) + .map_err(|e| Error::InvalidSignatureRequest(format!("Failed to parse bytes: {}", e)))?; + H256::from_slice(&bytes) + }; + + // 2. A runtime version (system_version constant on a Substrate node has these): + let runtime_version = subxt::backend::RuntimeVersion { + spec_version, + transaction_version, + }; + + // Metadata comes from metadata.rs, which is a &[u8] representation of the metadata + // It takes a lot of space and is clunky.....I am very open to better ideas + let metadata = Metadata::decode(&mut &*METADATA) + .map_err(|e| Error::InvalidSignatureRequest(format!("Failed to parse metadata: {}", e)))?; + + // Create an offline client using the details obtained above: + Ok(OfflineClient::::new( + genesis_hash, + runtime_version, + metadata, + )) +} + +/// Hacky way to handle encoding takes a tuple of (string_type, string) see TODO: Make Issue +pub fn handle_encoding(encodings: Vec<(&str, &str)>) -> Result, Error> { + let mut values: Vec = vec![]; + for encoding in encodings { + let value = match encoding.0 { + "account" => { + let account_id = AccountId32::from_str(&encoding.1).map_err(|e| { + Error::InvalidSignatureRequest(format!("account id issue: {}", e)) + })?; + Ok(Value::unnamed_variant( + "Id", + vec![Value::from_bytes(account_id)], + )) + } + "true" => Ok(Value::bool(true)), + "false" => Ok(Value::bool(false)), + "string" => Ok(Value::string(encoding.1.to_string())), + "amount" => { + let number: u128 = encoding.1.parse().map_err(|e| { + Error::InvalidSignatureRequest(format!("parse number issue: {}", e)) + })?; + Ok(Value::u128(number)) + } + _ => Err(Error::InvalidSignatureRequest( + "Incorrect value heading".to_string(), + )), + }?; + values.push(value); + } + Ok(values) +} diff --git a/substrate/src/tests.rs b/substrate/src/tests.rs new file mode 100644 index 0000000..4c04d4b --- /dev/null +++ b/substrate/src/tests.rs @@ -0,0 +1,105 @@ +use crate::{ + check_message_against_transaction, get_offline_api, handle_encoding, tx, + AuxDataStruct, SignatureRequest, +}; +use subxt::config::PolkadotExtrinsicParamsBuilder as Params; + +#[test] +fn test_should_sign() { + let aux_data = create_aux_data(); + + let api = get_offline_api( + aux_data.genesis_hash.clone(), + aux_data.spec_version, + aux_data.transaction_version, + ) + .unwrap(); + + let deserialized: Vec<(&str, &str)> = serde_json::from_str(&aux_data.values).unwrap(); + let encoding = handle_encoding(deserialized).unwrap(); + + let balance_transfer_tx = tx(aux_data.pallet.clone(), aux_data.function.clone(), encoding); + + let tx_params = Params::new().build(); + + let partial = api + .tx() + .create_partial_signed_offline(&balance_transfer_tx, tx_params) + .unwrap() + .signer_payload(); + + let signature_request = SignatureRequest { + message: partial.to_vec(), + auxilary_data: Some(serde_json::to_string(&aux_data).unwrap().into_bytes()), + }; + let (aux_data_result, _api_result) = + check_message_against_transaction::(signature_request).unwrap(); + assert_eq!(aux_data_result, aux_data); +} + +#[test] +fn test_should_fail() { + let aux_data = create_aux_data(); + + let api = get_offline_api( + aux_data.genesis_hash.clone(), + aux_data.spec_version, + aux_data.transaction_version, + ) + .unwrap(); + + let amount = 1000u128; + let binding = amount.to_string(); + let string_account_id = "5FA9nQDVg267DEd8m1ZypXLBnvN7SFxYwV7ndqSYGiN9TTpu"; + + let values: Vec<(&str, &str)> = vec![("account", string_account_id), ("amount", &binding)]; + + let encoding = handle_encoding(values.clone()).unwrap(); + let balance_transfer_tx = tx( + aux_data.pallet.clone(), + aux_data.function.clone(), + encoding.clone(), + ); + + let tx_params = Params::new().build(); + + let partial = api + .tx() + .create_partial_signed_offline(&balance_transfer_tx, tx_params) + .unwrap() + .signer_payload(); + let signature_request = SignatureRequest { + message: partial.to_vec(), + auxilary_data: Some(serde_json::to_string(&aux_data).unwrap().into_bytes()), + }; + + assert_eq!( + check_message_against_transaction::( + signature_request, + ) + .unwrap_err() + .to_string(), + "Error::Evaluation(\"Signatures don't match, message: \\\"07000088dc3417d5058ec4b4503e0c12ea1a0a89be200fe98922423d4334014fa6b0eea10f0000000a0000000a00000044670a68177821a6166b25f8d86b45e0f1c3b280ff576eea64057e4b0dd9ff4a44670a68177821a6166b25f8d86b45e0f1c3b280ff576eea64057e4b0dd9ff4a\\\", calldata: \\\"07000088dc3417d5058ec4b4503e0c12ea1a0a89be200fe98922423d4334014fa6b0ee9101\\\", genesis_hash: \\\"34343637306136383137373832316136313636623235663864383662343565306631633362323830666635373665656136343035376534623064643966663461\\\"\")" + ); +} + +pub fn create_aux_data() -> AuxDataStruct { + let genesis_hash = + "44670a68177821a6166b25f8d86b45e0f1c3b280ff576eea64057e4b0dd9ff4a".to_string(); + let spec_version = 10; + let transaction_version = 10; + let string_account_id = "5FA9nQDVg267DEd8m1ZypXLBnvN7SFxYwV7ndqSYGiN9TTpu"; + let amount = 100u128; + let binding = amount.to_string(); + let values: Vec<(&str, &str)> = vec![("account", string_account_id), ("amount", &binding)]; + + let aux_data = AuxDataStruct { + genesis_hash, + spec_version, + transaction_version, + pallet: "Balances".to_string(), + function: "transfer_allow_death".to_string(), + values: serde_json::to_string(&values).unwrap(), + }; + aux_data +} diff --git a/substrate/substrate_metadata.scale b/substrate/substrate_metadata.scale new file mode 100644 index 0000000..f90a5f5 Binary files /dev/null and b/substrate/substrate_metadata.scale differ