From 4ef15f75dd8a65213087be770e215d78a8324c67 Mon Sep 17 00:00:00 2001 From: Jesse Abramowitz Date: Mon, 27 May 2024 18:41:28 -0400 Subject: [PATCH 1/8] turn cli into lib --- crates/test-cli/src/lib.rs | 529 ++++++++++++++++++++++++++++++++++++ crates/test-cli/src/main.rs | 506 +--------------------------------- 2 files changed, 531 insertions(+), 504 deletions(-) create mode 100644 crates/test-cli/src/lib.rs diff --git a/crates/test-cli/src/lib.rs b/crates/test-cli/src/lib.rs new file mode 100644 index 000000000..6297b9ae1 --- /dev/null +++ b/crates/test-cli/src/lib.rs @@ -0,0 +1,529 @@ +// Copyright (C) 2023 Entropy Cryptography Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Simple CLI to test registering, updating programs and signing +use std::{ + fmt::{self, Display}, + fs, + path::PathBuf, +}; + +use anyhow::{anyhow, ensure}; +use clap::{Parser, Subcommand}; +use colored::Colorize; +use entropy_client::{ + chain_api::{ + entropy::runtime_types::{ + bounded_collections::bounded_vec::BoundedVec, pallet_registry::pallet::ProgramInstance, + }, + EntropyConfig, + }, + client::{ + get_accounts, get_api, get_programs, get_rpc, register, sign, store_program, + update_programs, KeyParams, KeyShare, KeyVisibility, VERIFYING_KEY_LENGTH, + }, +}; +use entropy_testing_utils::constants::TEST_PROGRAM_WASM_BYTECODE; +use sp_core::{sr25519, DeriveJunction, Hasher, Pair}; +use sp_runtime::traits::BlakeTwo256; +use subxt::{ + backend::legacy::LegacyRpcMethods, + utils::{AccountId32 as SubxtAccountId32, H256}, + OnlineClient, +}; +use x25519_dalek::StaticSecret; + +#[derive(Parser, Debug, Clone)] +#[clap( + version, + about = "CLI tool for testing Entropy", + long_about = "This is a CLI test client.\nIt requires a running deployment of Entropy with at least two chain nodes and two TSS servers." +)] +struct Cli { + #[clap(subcommand)] + command: CliCommand, + /// The chain endpoint to use. + /// + /// The format should be in the form of `scheme://hostname:port`. + /// + /// Default to `ws://localhost:9944`. If a value exists for `ENTROPY_DEVNET`, that takes + /// priority. + #[arg(short, long)] + chain_endpoint: Option, +} + +#[derive(Subcommand, Debug, Clone)] +enum CliCommand { + /// Register with Entropy and create keyshares + Register { + /// The access mode of the Entropy account + #[arg(value_enum, default_value_t = Default::default())] + key_visibility: Visibility, + /// Either hex-encoded hashes of existing programs, or paths to wasm files to store. + /// + /// Specifying program configurations + /// + /// If there exists a file in the current directory of the same name or hex hash and + /// a '.json' extension, it will be read and used as the configuration for that program. + /// + /// If the path to a wasm file is given, and there is a file with the same name with a + /// '.interface-description' extension, it will be stored as that program's configuration + /// interface. If no such file exists, it is assumed the program has no configuration + /// interface. + programs: Vec, + /// A name from which to generate a program modification keypair, eg: "Bob" + /// This is used to send the register extrinsic and so it must be funded + /// + /// Optionally may be preceeded with "//" eg: "//Bob" + #[arg(short, long)] + mnemonic_option: Option, + }, + /// Ask the network to sign a given message + Sign { + /// The verifying key of the account to sign with, given as hex + signature_verifying_key: String, + /// The message to be signed + message: String, + /// Optional auxiliary data passed to the program, given as hex + auxilary_data: Option, + /// A name from which to generate a keypair, eg: "Alice" + /// This is only needed when using private mode. + /// + /// Optionally may be preceeded with "//", eg: "//Alice" + #[arg(short, long)] + mnemonic_option: Option, + }, + /// Update the program for a particular account + UpdatePrograms { + /// The verifying key of the account to update their programs, given as hex + signature_verifying_key: String, + /// Either hex-encoded program hashes, or paths to wasm files to store. + /// + /// Specifying program configurations + /// + /// If there exists a file in the current directory of the same name or hex hash and + /// a '.json' extension, it will be read and used as the configuration for that program. + /// + /// If the path to a wasm file is given, and there is a file with the same name with a + /// '.interface-description' extension, it will be stored as that program's configuration + /// interface. If no such file exists, it is assumed the program has no configuration + /// interface. + programs: Vec, + /// A name from which to generate a program modification keypair, eg: "Bob" + /// + /// Optionally may be preceeded with "//", eg: "//Bob" + #[arg(short, long)] + mnemonic_option: Option, + }, + /// Store a given program on chain + StoreProgram { + /// The path to a .wasm file containing the program (defaults to a test program) + program_file: Option, + /// The path to a file containing the program config interface (defaults to empty) + config_interface_file: Option, + /// The path to a file containing the program aux interface (defaults to empty) + aux_data_interface_file: Option, + /// A name from which to generate a keypair, eg: "Alice" + /// + /// Optionally may be preceeded with "//", eg: "//Alice" + #[arg(short, long)] + mnemonic_option: Option, + }, + /// Display a list of registered Entropy accounts + Status, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum, Default)] +enum Visibility { + /// User holds keyshare + Private, + /// User does not hold a keyshare + #[default] + Public, +} + +impl From for Visibility { + fn from(key_visibility: KeyVisibility) -> Self { + match key_visibility { + KeyVisibility::Private(_) => Visibility::Private, + KeyVisibility::Public => Visibility::Public, + } + } +} + +impl Display for Visibility { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +pub async fn run_command() -> anyhow::Result { + let cli = Cli::parse(); + + let endpoint_addr = cli.chain_endpoint.unwrap_or_else(|| { + std::env::var("ENTROPY_DEVNET").unwrap_or("ws://localhost:9944".to_string()) + }); + + let passed_mnemonic = std::env::var("DEPLOYER_MNEMONIC").unwrap_or("//Alice".to_string()); + + let api = get_api(&endpoint_addr).await?; + let rpc = get_rpc(&endpoint_addr).await?; + + match cli.command { + CliCommand::Register { mnemonic_option, key_visibility, programs } => { + let mnemonic = if let Some(mnemonic_option) = mnemonic_option { + mnemonic_option + } else { + passed_mnemonic + }; + + let program_keypair = ::from_string(&mnemonic, None)?; + let program_account = SubxtAccountId32(program_keypair.public().0); + println!("Program account: {}", program_keypair.public()); + + let (key_visibility_converted, x25519_secret) = match key_visibility { + Visibility::Private => { + let x25519_secret = derive_x25519_static_secret(&program_keypair); + let x25519_public = x25519_dalek::PublicKey::from(&x25519_secret); + (KeyVisibility::Private(x25519_public.to_bytes()), Some(x25519_secret)) + }, + Visibility::Public => (KeyVisibility::Public, None), + }; + let mut programs_info = vec![]; + + for program in programs { + programs_info.push( + Program::from_hash_or_filename(&api, &rpc, &program_keypair, program).await?.0, + ); + } + + let (verifying_key, registered_info, keyshare_option) = register( + &api, + &rpc, + program_keypair.clone(), + program_account, + key_visibility_converted, + BoundedVec(programs_info), + x25519_secret, + ) + .await?; + + // If we got a keyshare, write it to a file + if let Some(keyshare) = keyshare_option { + let verifying_key = + keyshare.verifying_key().to_encoded_point(true).as_bytes().to_vec(); + KeyShareFile::new(&verifying_key).write(keyshare)?; + } + + Ok(format!("Verfiying key: {},\n{:?}", hex::encode(verifying_key), registered_info)) + }, + CliCommand::Sign { signature_verifying_key, message, auxilary_data, mnemonic_option } => { + let mnemonic = if let Some(mnemonic_option) = mnemonic_option { + mnemonic_option + } else { + passed_mnemonic + }; + // If an account name is not provided, use the Alice key + let user_keypair = ::from_string(&mnemonic, None)?; + + println!("User account: {}", user_keypair.public()); + + let auxilary_data = + if let Some(data) = auxilary_data { Some(hex::decode(data)?) } else { None }; + + let signature_verifying_key: [u8; VERIFYING_KEY_LENGTH] = + hex::decode(signature_verifying_key)? + .try_into() + .map_err(|_| anyhow!("Verifying key must be 33 bytes"))?; + + // If we have a keyshare file for this account, get it + let private_keyshare = KeyShareFile::new(&signature_verifying_key.to_vec()).read().ok(); + + let private_details = private_keyshare.map(|keyshare| { + let x25519_secret = derive_x25519_static_secret(&user_keypair); + (keyshare, x25519_secret) + }); + + let recoverable_signature = sign( + &api, + &rpc, + user_keypair, + signature_verifying_key, + message.as_bytes().to_vec(), + private_details, + auxilary_data, + ) + .await?; + Ok(format!("Message signed: {:?}", recoverable_signature)) + }, + CliCommand::StoreProgram { + mnemonic_option, + program_file, + config_interface_file, + aux_data_interface_file, + } => { + let mnemonic = if let Some(mnemonic_option) = mnemonic_option { + mnemonic_option + } else { + passed_mnemonic + }; + let keypair = ::from_string(&mnemonic, None)?; + println!("Storing program using account: {}", keypair.public()); + + let program = match program_file { + Some(file_name) => fs::read(file_name)?, + None => TEST_PROGRAM_WASM_BYTECODE.to_owned(), + }; + + let config_interface = match config_interface_file { + Some(file_name) => fs::read(file_name)?, + None => vec![], + }; + + let aux_data_interface = match aux_data_interface_file { + Some(file_name) => fs::read(file_name)?, + None => vec![], + }; + + let hash = store_program( + &api, + &rpc, + &keypair, + program, + config_interface, + aux_data_interface, + vec![], + ) + .await?; + Ok(format!("Program stored {hash}")) + }, + CliCommand::UpdatePrograms { signature_verifying_key, mnemonic_option, programs } => { + let mnemonic = if let Some(mnemonic_option) = mnemonic_option { + mnemonic_option + } else { + passed_mnemonic + }; + let program_keypair = ::from_string(&mnemonic, None)?; + println!("Program account: {}", program_keypair.public()); + + let mut programs_info = Vec::new(); + for program in programs { + programs_info.push( + Program::from_hash_or_filename(&api, &rpc, &program_keypair, program).await?.0, + ); + } + + let verifying_key: [u8; VERIFYING_KEY_LENGTH] = hex::decode(signature_verifying_key)? + .try_into() + .map_err(|_| anyhow!("Verifying key must be 33 bytes"))?; + + update_programs(&api, &rpc, verifying_key, &program_keypair, BoundedVec(programs_info)) + .await?; + + Ok("Programs updated".to_string()) + }, + CliCommand::Status => { + let accounts = get_accounts(&api, &rpc).await?; + println!( + "There are {} registered Entropy accounts.\n", + accounts.len().to_string().green() + ); + if !accounts.is_empty() { + println!( + "{:<64} {:<12} Programs:", + "Verifying key:".green(), + "Visibility:".purple(), + ); + for (account_id, info) in accounts { + let visibility: Visibility = info.key_visibility.0.into(); + println!( + "{} {:<12} {}", + hex::encode(account_id).green(), + format!("{}", visibility).purple(), + format!( + "{:?}", + info.programs_data + .0 + .iter() + .map(|program_instance| format!( + "{}", + program_instance.program_pointer + )) + .collect::>() + ) + .white(), + ); + } + } + + let programs = get_programs(&api, &rpc).await?; + + println!("\nThere are {} stored programs\n", programs.len().to_string().green()); + + if !programs.is_empty() { + println!( + "{:<11} {:<48} {:<11} {:<14} {} {}", + "Hash".blue(), + "Stored by:".green(), + "Times used:".purple(), + "Size in bytes:".cyan(), + "Configurable?".yellow(), + "Has auxiliary?".yellow(), + ); + for (hash, program_info) in programs { + println!( + "{} {} {:>11} {:>14} {:<13} {}", + hash, + program_info.deployer, + program_info.ref_counter, + program_info.bytecode.len(), + !program_info.configuration_schema.is_empty(), + !program_info.auxiliary_data_schema.is_empty(), + ); + } + } + + Ok("Got status".to_string()) + }, + } +} + +/// Represents a keyshare stored in a file, serialized using [bincode] +struct KeyShareFile(String); + +impl KeyShareFile { + fn new(verifying_key: &Vec) -> Self { + Self(format!("keyshare-{}", hex::encode(verifying_key))) + } + + fn read(&self) -> anyhow::Result> { + let keyshare_vec = fs::read(&self.0)?; + println!("Reading keyshare from file: {}", self.0); + Ok(bincode::deserialize(&keyshare_vec)?) + } + + fn write(&self, keyshare: KeyShare) -> anyhow::Result<()> { + println!("Writing keyshare to file: {}", self.0); + let keyshare_vec = bincode::serialize(&keyshare)?; + Ok(fs::write(&self.0, keyshare_vec)?) + } +} + +struct Program(ProgramInstance); + +impl Program { + fn new(program_pointer: H256, program_config: Vec) -> Self { + Self(ProgramInstance { program_pointer, program_config }) + } + + async fn from_hash_or_filename( + api: &OnlineClient, + rpc: &LegacyRpcMethods, + keypair: &sr25519::Pair, + hash_or_filename: String, + ) -> anyhow::Result { + match hex::decode(hash_or_filename.clone()) { + Ok(hash) => { + let hash_32_res: Result<[u8; 32], _> = hash.try_into(); + match hash_32_res { + Ok(hash_32) => { + // If there is a file called .json use that as the + // configuration: + let configuration = { + let mut configuration_file = PathBuf::from(&hash_or_filename); + configuration_file.set_extension("json"); + fs::read(&configuration_file).unwrap_or_default() + }; + Ok(Self::new(H256(hash_32), configuration)) + }, + Err(_) => Self::from_file(api, rpc, keypair, hash_or_filename).await, + } + }, + Err(_) => Self::from_file(api, rpc, keypair, hash_or_filename).await, + } + } + + /// Given a path to a .wasm file, read it, store the program if it doesn't already exist, and + /// return the hash. + async fn from_file( + api: &OnlineClient, + rpc: &LegacyRpcMethods, + keypair: &sr25519::Pair, + filename: String, + ) -> anyhow::Result { + let program_bytecode = fs::read(&filename)?; + + // If there is a file with the same name with the '.config-description' extension, read it + let config_description = { + let mut config_description_file = PathBuf::from(&filename); + config_description_file.set_extension("config-description"); + fs::read(&config_description_file).unwrap_or_default() + }; + + // If there is a file with the same name with the '.aux-description' extension, read it + let auxiliary_data_schema = { + let mut auxiliary_data_schema_file = PathBuf::from(&filename); + auxiliary_data_schema_file.set_extension("aux-description"); + fs::read(&auxiliary_data_schema_file).unwrap_or_default() + }; + + // If there is a file with the same name with the '.json' extension, read it + let configuration = { + let mut configuration_file = PathBuf::from(&filename); + configuration_file.set_extension("json"); + fs::read(&configuration_file).unwrap_or_default() + }; + + ensure!( + (config_description.is_empty() && configuration.is_empty()) + || (!config_description.is_empty() && !configuration.is_empty()), + "If giving an interface description you must also give a configuration" + ); + + match store_program( + api, + rpc, + keypair, + program_bytecode.clone(), + config_description, + auxiliary_data_schema, + vec![], + ) + .await + { + Ok(hash) => Ok(Self::new(hash, configuration)), + Err(error) => { + if error.to_string().ends_with("ProgramAlreadySet") { + println!("Program is already stored - using existing one"); + let hash = BlakeTwo256::hash(&program_bytecode); + Ok(Self::new(H256(hash.into()), configuration)) + } else { + Err(error.into()) + } + }, + } + } +} + +/// Derive a x25519 secret from a sr25519 pair. In production we should not do this, +/// but for this test-cli which anyway uses insecure keypairs it is convenient +fn derive_x25519_static_secret(sr25519_pair: &sr25519::Pair) -> StaticSecret { + let (derived_sr25519_pair, _) = sr25519_pair + .derive([DeriveJunction::hard(b"x25519")].into_iter(), None) + .expect("Cannot derive keypair"); + let mut secret: [u8; 32] = [0; 32]; + secret.copy_from_slice(&derived_sr25519_pair.to_raw_vec()); + secret.into() +} diff --git a/crates/test-cli/src/main.rs b/crates/test-cli/src/main.rs index a21d4af1c..dc555d019 100644 --- a/crates/test-cli/src/main.rs +++ b/crates/test-cli/src/main.rs @@ -1,171 +1,7 @@ -// Copyright (C) 2023 Entropy Cryptography Inc. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program 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 Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . +use std::time::Instant; -//! Simple CLI to test registering, updating programs and signing -use std::{ - fmt::{self, Display}, - fs, - path::PathBuf, - time::Instant, -}; - -use anyhow::{anyhow, ensure}; -use clap::{Parser, Subcommand}; use colored::Colorize; -use entropy_client::{ - chain_api::{ - entropy::runtime_types::{ - bounded_collections::bounded_vec::BoundedVec, pallet_registry::pallet::ProgramInstance, - }, - EntropyConfig, - }, - client::{ - get_accounts, get_api, get_programs, get_rpc, register, sign, store_program, - update_programs, KeyParams, KeyShare, KeyVisibility, VERIFYING_KEY_LENGTH, - }, -}; -use entropy_testing_utils::constants::TEST_PROGRAM_WASM_BYTECODE; -use sp_core::{sr25519, DeriveJunction, Hasher, Pair}; -use sp_runtime::traits::BlakeTwo256; -use subxt::{ - backend::legacy::LegacyRpcMethods, - utils::{AccountId32 as SubxtAccountId32, H256}, - OnlineClient, -}; -use x25519_dalek::StaticSecret; - -#[derive(Parser, Debug, Clone)] -#[clap( - version, - about = "CLI tool for testing Entropy", - long_about = "This is a CLI test client.\nIt requires a running deployment of Entropy with at least two chain nodes and two TSS servers." -)] -struct Cli { - #[clap(subcommand)] - command: CliCommand, - /// The chain endpoint to use. - /// - /// The format should be in the form of `scheme://hostname:port`. - /// - /// Default to `ws://localhost:9944`. If a value exists for `ENTROPY_DEVNET`, that takes - /// priority. - #[arg(short, long)] - chain_endpoint: Option, -} - -#[derive(Subcommand, Debug, Clone)] -enum CliCommand { - /// Register with Entropy and create keyshares - Register { - /// A name from which to generate a program modification keypair, eg: "Bob" - /// This is used to send the register extrinsic and so it must be funded - /// - /// Optionally may be preceeded with "//" eg: "//Bob" - mnemonic: String, - /// The access mode of the Entropy account - #[arg(value_enum, default_value_t = Default::default())] - key_visibility: Visibility, - /// Either hex-encoded hashes of existing programs, or paths to wasm files to store. - /// - /// Specifying program configurations - /// - /// If there exists a file in the current directory of the same name or hex hash and - /// a '.json' extension, it will be read and used as the configuration for that program. - /// - /// If the path to a wasm file is given, and there is a file with the same name with a - /// '.interface-description' extension, it will be stored as that program's configuration - /// interface. If no such file exists, it is assumed the program has no configuration - /// interface. - programs: Vec, - }, - /// Ask the network to sign a given message - Sign { - /// The verifying key of the account to sign with, given as hex - signature_verifying_key: String, - /// The message to be signed - message: String, - /// Optional auxiliary data passed to the program, given as hex - auxilary_data: Option, - /// A name from which to generate a keypair, eg: "Alice" - /// This is only needed when using private mode. - /// - /// Optionally may be preceeded with "//", eg: "//Alice" - #[arg(short, long)] - mnemonic: Option, - }, - /// Update the program for a particular account - UpdatePrograms { - /// The verifying key of the account to update their programs, given as hex - signature_verifying_key: String, - /// A name from which to generate a program modification keypair, eg: "Bob" - /// - /// Optionally may be preceeded with "//", eg: "//Bob" - mnemonic: String, - /// Either hex-encoded program hashes, or paths to wasm files to store. - /// - /// Specifying program configurations - /// - /// If there exists a file in the current directory of the same name or hex hash and - /// a '.json' extension, it will be read and used as the configuration for that program. - /// - /// If the path to a wasm file is given, and there is a file with the same name with a - /// '.interface-description' extension, it will be stored as that program's configuration - /// interface. If no such file exists, it is assumed the program has no configuration - /// interface. - programs: Vec, - }, - /// Store a given program on chain - StoreProgram { - /// A name from which to generate a keypair, eg: "Alice" - /// - /// Optionally may be preceeded with "//", eg: "//Alice" - mnemonic: String, - /// The path to a .wasm file containing the program (defaults to a test program) - program_file: Option, - /// The path to a file containing the program config interface (defaults to empty) - config_interface_file: Option, - /// The path to a file containing the program aux interface (defaults to empty) - aux_data_interface_file: Option, - }, - /// Display a list of registered Entropy accounts - Status, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum, Default)] -enum Visibility { - /// User holds keyshare - Private, - /// User does not hold a keyshare - #[default] - Public, -} - -impl From for Visibility { - fn from(key_visibility: KeyVisibility) -> Self { - match key_visibility { - KeyVisibility::Private(_) => Visibility::Private, - KeyVisibility::Public => Visibility::Public, - } - } -} - -impl Display for Visibility { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{:?}", self) - } -} +use entropy_test_cli::run_command; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -182,341 +18,3 @@ async fn main() -> anyhow::Result<()> { }, } } - -async fn run_command() -> anyhow::Result { - let cli = Cli::parse(); - - let endpoint_addr = cli.chain_endpoint.unwrap_or_else(|| { - std::env::var("ENTROPY_DEVNET").unwrap_or("ws://localhost:9944".to_string()) - }); - let api = get_api(&endpoint_addr).await?; - let rpc = get_rpc(&endpoint_addr).await?; - - match cli.command { - CliCommand::Register { mnemonic, key_visibility, programs } => { - let program_keypair = ::from_string(&mnemonic, None)?; - let program_account = SubxtAccountId32(program_keypair.public().0); - println!("Program account: {}", program_keypair.public()); - - let (key_visibility_converted, x25519_secret) = match key_visibility { - Visibility::Private => { - let x25519_secret = derive_x25519_static_secret(&program_keypair); - let x25519_public = x25519_dalek::PublicKey::from(&x25519_secret); - (KeyVisibility::Private(x25519_public.to_bytes()), Some(x25519_secret)) - }, - Visibility::Public => (KeyVisibility::Public, None), - }; - let mut programs_info = vec![]; - - for program in programs { - programs_info.push( - Program::from_hash_or_filename(&api, &rpc, &program_keypair, program).await?.0, - ); - } - - let (verifying_key, registered_info, keyshare_option) = register( - &api, - &rpc, - program_keypair.clone(), - program_account, - key_visibility_converted, - BoundedVec(programs_info), - x25519_secret, - ) - .await?; - - // If we got a keyshare, write it to a file - if let Some(keyshare) = keyshare_option { - let verifying_key = - keyshare.verifying_key().to_encoded_point(true).as_bytes().to_vec(); - KeyShareFile::new(&verifying_key).write(keyshare)?; - } - - Ok(format!("Verfiying key: {},\n{:?}", hex::encode(verifying_key), registered_info)) - }, - CliCommand::Sign { signature_verifying_key, message, auxilary_data, mnemonic } => { - // If an account name is not provided, use the Alice key - let user_keypair = ::from_string( - &mnemonic.unwrap_or_else(|| "//Alice".to_string()), - None, - )?; - - println!("User account: {}", user_keypair.public()); - - let auxilary_data = - if let Some(data) = auxilary_data { Some(hex::decode(data)?) } else { None }; - - let signature_verifying_key: [u8; VERIFYING_KEY_LENGTH] = - hex::decode(signature_verifying_key)? - .try_into() - .map_err(|_| anyhow!("Verifying key must be 33 bytes"))?; - - // If we have a keyshare file for this account, get it - let private_keyshare = KeyShareFile::new(&signature_verifying_key.to_vec()).read().ok(); - - let private_details = private_keyshare.map(|keyshare| { - let x25519_secret = derive_x25519_static_secret(&user_keypair); - (keyshare, x25519_secret) - }); - - let recoverable_signature = sign( - &api, - &rpc, - user_keypair, - signature_verifying_key, - message.as_bytes().to_vec(), - private_details, - auxilary_data, - ) - .await?; - Ok(format!("Message signed: {:?}", recoverable_signature)) - }, - CliCommand::StoreProgram { - mnemonic, - program_file, - config_interface_file, - aux_data_interface_file, - } => { - let keypair = ::from_string(&mnemonic, None)?; - println!("Storing program using account: {}", keypair.public()); - - let program = match program_file { - Some(file_name) => fs::read(file_name)?, - None => TEST_PROGRAM_WASM_BYTECODE.to_owned(), - }; - - let config_interface = match config_interface_file { - Some(file_name) => fs::read(file_name)?, - None => vec![], - }; - - let aux_data_interface = match aux_data_interface_file { - Some(file_name) => fs::read(file_name)?, - None => vec![], - }; - - let hash = store_program( - &api, - &rpc, - &keypair, - program, - config_interface, - aux_data_interface, - vec![], - ) - .await?; - Ok(format!("Program stored {hash}")) - }, - CliCommand::UpdatePrograms { signature_verifying_key, mnemonic, programs } => { - let program_keypair = ::from_string(&mnemonic, None)?; - println!("Program account: {}", program_keypair.public()); - - let mut programs_info = Vec::new(); - for program in programs { - programs_info.push( - Program::from_hash_or_filename(&api, &rpc, &program_keypair, program).await?.0, - ); - } - - let verifying_key: [u8; VERIFYING_KEY_LENGTH] = hex::decode(signature_verifying_key)? - .try_into() - .map_err(|_| anyhow!("Verifying key must be 33 bytes"))?; - - update_programs(&api, &rpc, verifying_key, &program_keypair, BoundedVec(programs_info)) - .await?; - - Ok("Programs updated".to_string()) - }, - CliCommand::Status => { - let accounts = get_accounts(&api, &rpc).await?; - println!( - "There are {} registered Entropy accounts.\n", - accounts.len().to_string().green() - ); - if !accounts.is_empty() { - println!( - "{:<64} {:<12} Programs:", - "Verifying key:".green(), - "Visibility:".purple(), - ); - for (account_id, info) in accounts { - let visibility: Visibility = info.key_visibility.0.into(); - println!( - "{} {:<12} {}", - hex::encode(account_id).green(), - format!("{}", visibility).purple(), - format!( - "{:?}", - info.programs_data - .0 - .iter() - .map(|program_instance| format!( - "{}", - program_instance.program_pointer - )) - .collect::>() - ) - .white(), - ); - } - } - - let programs = get_programs(&api, &rpc).await?; - - println!("\nThere are {} stored programs\n", programs.len().to_string().green()); - - if !programs.is_empty() { - println!( - "{:<11} {:<48} {:<11} {:<14} {} {}", - "Hash".blue(), - "Stored by:".green(), - "Times used:".purple(), - "Size in bytes:".cyan(), - "Configurable?".yellow(), - "Has auxiliary?".yellow(), - ); - for (hash, program_info) in programs { - println!( - "{} {} {:>11} {:>14} {:<13} {}", - hash, - program_info.deployer, - program_info.ref_counter, - program_info.bytecode.len(), - !program_info.configuration_schema.is_empty(), - !program_info.auxiliary_data_schema.is_empty(), - ); - } - } - - Ok("Got status".to_string()) - }, - } -} - -/// Represents a keyshare stored in a file, serialized using [bincode] -struct KeyShareFile(String); - -impl KeyShareFile { - fn new(verifying_key: &Vec) -> Self { - Self(format!("keyshare-{}", hex::encode(verifying_key))) - } - - fn read(&self) -> anyhow::Result> { - let keyshare_vec = fs::read(&self.0)?; - println!("Reading keyshare from file: {}", self.0); - Ok(bincode::deserialize(&keyshare_vec)?) - } - - fn write(&self, keyshare: KeyShare) -> anyhow::Result<()> { - println!("Writing keyshare to file: {}", self.0); - let keyshare_vec = bincode::serialize(&keyshare)?; - Ok(fs::write(&self.0, keyshare_vec)?) - } -} - -struct Program(ProgramInstance); - -impl Program { - fn new(program_pointer: H256, program_config: Vec) -> Self { - Self(ProgramInstance { program_pointer, program_config }) - } - - async fn from_hash_or_filename( - api: &OnlineClient, - rpc: &LegacyRpcMethods, - keypair: &sr25519::Pair, - hash_or_filename: String, - ) -> anyhow::Result { - match hex::decode(hash_or_filename.clone()) { - Ok(hash) => { - let hash_32_res: Result<[u8; 32], _> = hash.try_into(); - match hash_32_res { - Ok(hash_32) => { - // If there is a file called .json use that as the - // configuration: - let configuration = { - let mut configuration_file = PathBuf::from(&hash_or_filename); - configuration_file.set_extension("json"); - fs::read(&configuration_file).unwrap_or_default() - }; - Ok(Self::new(H256(hash_32), configuration)) - }, - Err(_) => Self::from_file(api, rpc, keypair, hash_or_filename).await, - } - }, - Err(_) => Self::from_file(api, rpc, keypair, hash_or_filename).await, - } - } - - /// Given a path to a .wasm file, read it, store the program if it doesn't already exist, and - /// return the hash. - async fn from_file( - api: &OnlineClient, - rpc: &LegacyRpcMethods, - keypair: &sr25519::Pair, - filename: String, - ) -> anyhow::Result { - let program_bytecode = fs::read(&filename)?; - - // If there is a file with the same name with the '.config-description' extension, read it - let config_description = { - let mut config_description_file = PathBuf::from(&filename); - config_description_file.set_extension("config-description"); - fs::read(&config_description_file).unwrap_or_default() - }; - - // If there is a file with the same name with the '.aux-description' extension, read it - let auxiliary_data_schema = { - let mut auxiliary_data_schema_file = PathBuf::from(&filename); - auxiliary_data_schema_file.set_extension("aux-description"); - fs::read(&auxiliary_data_schema_file).unwrap_or_default() - }; - - // If there is a file with the same name with the '.json' extension, read it - let configuration = { - let mut configuration_file = PathBuf::from(&filename); - configuration_file.set_extension("json"); - fs::read(&configuration_file).unwrap_or_default() - }; - - ensure!( - (config_description.is_empty() && configuration.is_empty()) - || (!config_description.is_empty() && !configuration.is_empty()), - "If giving an interface description you must also give a configuration" - ); - - match store_program( - api, - rpc, - keypair, - program_bytecode.clone(), - config_description, - auxiliary_data_schema, - vec![], - ) - .await - { - Ok(hash) => Ok(Self::new(hash, configuration)), - Err(error) => { - if error.to_string().ends_with("ProgramAlreadySet") { - println!("Program is already stored - using existing one"); - let hash = BlakeTwo256::hash(&program_bytecode); - Ok(Self::new(H256(hash.into()), configuration)) - } else { - Err(error.into()) - } - }, - } - } -} - -/// Derive a x25519 secret from a sr25519 pair. In production we should not do this, -/// but for this test-cli which anyway uses insecure keypairs it is convenient -fn derive_x25519_static_secret(sr25519_pair: &sr25519::Pair) -> StaticSecret { - let (derived_sr25519_pair, _) = sr25519_pair - .derive([DeriveJunction::hard(b"x25519")].into_iter(), None) - .expect("Cannot derive keypair"); - let mut secret: [u8; 32] = [0; 32]; - secret.copy_from_slice(&derived_sr25519_pair.to_raw_vec()); - secret.into() -} From 78bc9d609b3b37c25bdeada8cec7164ed1447b43 Mon Sep 17 00:00:00 2001 From: Jesse Abramowitz Date: Tue, 28 May 2024 10:30:08 -0400 Subject: [PATCH 2/8] add changes to store program --- crates/test-cli/src/lib.rs | 17 ++++++++++++----- crates/test-cli/src/main.rs | 2 +- crates/testing-utils/null_interface | 1 + 3 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 crates/testing-utils/null_interface diff --git a/crates/test-cli/src/lib.rs b/crates/test-cli/src/lib.rs index 6297b9ae1..7a520eb8f 100644 --- a/crates/test-cli/src/lib.rs +++ b/crates/test-cli/src/lib.rs @@ -35,7 +35,6 @@ use entropy_client::{ update_programs, KeyParams, KeyShare, KeyVisibility, VERIFYING_KEY_LENGTH, }, }; -use entropy_testing_utils::constants::TEST_PROGRAM_WASM_BYTECODE; use sp_core::{sr25519, DeriveJunction, Hasher, Pair}; use sp_runtime::traits::BlakeTwo256; use subxt::{ @@ -169,7 +168,11 @@ impl Display for Visibility { } } -pub async fn run_command() -> anyhow::Result { +pub async fn run_command( + program_file_option: Option, + config_interface_file_option: Option, + aux_data_interface_file_option: Option, +) -> anyhow::Result { let cli = Cli::parse(); let endpoint_addr = cli.chain_endpoint.unwrap_or_else(|| { @@ -284,17 +287,21 @@ pub async fn run_command() -> anyhow::Result { let program = match program_file { Some(file_name) => fs::read(file_name)?, - None => TEST_PROGRAM_WASM_BYTECODE.to_owned(), + None => fs::read(program_file_option.expect("No program file passed in"))?, }; let config_interface = match config_interface_file { Some(file_name) => fs::read(file_name)?, - None => vec![], + None => fs::read( + config_interface_file_option.expect("No config interface file passed"), + )?, }; let aux_data_interface = match aux_data_interface_file { Some(file_name) => fs::read(file_name)?, - None => vec![], + None => fs::read( + aux_data_interface_file_option.expect("No aux data interface file passed"), + )?, }; let hash = store_program( diff --git a/crates/test-cli/src/main.rs b/crates/test-cli/src/main.rs index dc555d019..bcc6d3c89 100644 --- a/crates/test-cli/src/main.rs +++ b/crates/test-cli/src/main.rs @@ -6,7 +6,7 @@ use entropy_test_cli::run_command; #[tokio::main] async fn main() -> anyhow::Result<()> { let now = Instant::now(); - match run_command().await { + match run_command(None, None, None).await { Ok(output) => { println!("Success: {}", output.green()); println!("{}", format!("That took {:?}", now.elapsed()).yellow()); diff --git a/crates/testing-utils/null_interface b/crates/testing-utils/null_interface new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/crates/testing-utils/null_interface @@ -0,0 +1 @@ +[] \ No newline at end of file From 7f711f4086502ba36e25e3a94ab468ad150ce9ee Mon Sep 17 00:00:00 2001 From: Jesse Abramowitz Date: Tue, 28 May 2024 12:01:17 -0400 Subject: [PATCH 3/8] add licence header --- crates/test-cli/src/main.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/test-cli/src/main.rs b/crates/test-cli/src/main.rs index bcc6d3c89..49a9bd95f 100644 --- a/crates/test-cli/src/main.rs +++ b/crates/test-cli/src/main.rs @@ -1,3 +1,19 @@ +// Copyright (C) 2023 Entropy Cryptography Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Simple CLI to test registering, updating programs and signing use std::time::Instant; use colored::Colorize; From efc2507bd63ace5f947ba5421fa8eb58400fd2e7 Mon Sep 17 00:00:00 2001 From: Jesse Abramowitz Date: Wed, 29 May 2024 10:43:40 -0400 Subject: [PATCH 4/8] remove testing utils from cargo toml --- Cargo.lock | 1 - crates/test-cli/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4187ae921..70343e093 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2654,7 +2654,6 @@ dependencies = [ "clap", "colored", "entropy-client", - "entropy-testing-utils", "hex", "sp-core 31.0.0", "sp-runtime 32.0.0", diff --git a/crates/test-cli/Cargo.toml b/crates/test-cli/Cargo.toml index aad448821..0f4d4d750 100644 --- a/crates/test-cli/Cargo.toml +++ b/crates/test-cli/Cargo.toml @@ -9,7 +9,6 @@ repository ='https://github.com/entropyxyz/entropy-core' edition ='2021' [dependencies] -entropy-testing-utils={ version="0.1.0", path="../testing-utils" } entropy-client ={ version="0.1.0", path="../client" } clap ={ version="4.4.6", features=["derive"] } colored ="2.0.4" From 2933cdca87c60fb73dd76cc030a650dad216772c Mon Sep 17 00:00:00 2001 From: Jesse Abramowitz Date: Wed, 29 May 2024 11:05:03 -0400 Subject: [PATCH 5/8] update menmoic handling --- crates/test-cli/Cargo.toml | 22 +++++++++++----------- crates/test-cli/README.md | 29 ++++++++--------------------- crates/test-cli/src/lib.rs | 10 +++++----- 3 files changed, 24 insertions(+), 37 deletions(-) diff --git a/crates/test-cli/Cargo.toml b/crates/test-cli/Cargo.toml index 0f4d4d750..a2e340384 100644 --- a/crates/test-cli/Cargo.toml +++ b/crates/test-cli/Cargo.toml @@ -9,14 +9,14 @@ repository ='https://github.com/entropyxyz/entropy-core' edition ='2021' [dependencies] -entropy-client ={ version="0.1.0", path="../client" } -clap ={ version="4.4.6", features=["derive"] } -colored ="2.0.4" -subxt ="0.35.3" -sp-core ="31.0.0" -anyhow ="1.0.86" -tokio ={ version="1.16", features=["macros", "rt-multi-thread", "io-util", "process"] } -hex ="0.4.3" -bincode ="1.3.3" -x25519-dalek ="2.0.1" -sp-runtime ={ version="32.0.0", default-features=false } +entropy-client={ version="0.1.0", path="../client" } +clap ={ version="4.4.6", features=["derive"] } +colored ="2.0.4" +subxt ="0.35.3" +sp-core ="31.0.0" +anyhow ="1.0.86" +tokio ={ version="1.16", features=["macros", "rt-multi-thread", "io-util", "process"] } +hex ="0.4.3" +bincode ="1.3.3" +x25519-dalek ="2.0.1" +sp-runtime ={ version="32.0.0", default-features=false } diff --git a/crates/test-cli/README.md b/crates/test-cli/README.md index 7965812b0..6b1c31104 100644 --- a/crates/test-cli/README.md +++ b/crates/test-cli/README.md @@ -34,18 +34,9 @@ When using the local docker compose setup, be aware you need to set the TSS host ## Usage -### Account names +### Mnemonic -As this is a test client, there is no private key storage. Instead we use 'account names'. An 'account -name' is a string from which to derive a substrate sr25519 keypair. They are the same as -the account names the command line tool [`subkey`](https://docs.substrate.io/reference/command-line-tools/subkey) uses. - -For example the name `Alice` will give you the same keypair as `subkey inspect //Alice` will give you. - -You can use `subkey inspect` to find the seed, private key and account ID associated with a name you choose. - -With this `test-cli`, giving the `//` prefix is optional. That is, `Alice` and `//Alice` are identical. Note -however that account names are case sensitive, so `//Alice` and `//alice` are different accounts. +As this is a test client, there is no private key storage. Instead we pass in a mnemonic that can be stored as an enviroment variable or passed in on the command line ### Help @@ -88,18 +79,14 @@ You also need to decide which ['access mode' or 'key visibility'](https://docs.e you want to register with: private or public. If you are not sure, 'public' is the simplest 'vanilla' access mode. -For example, to register with `//Alice` as the signature request account and `//Bob` as the program -modification account, in permissioned access mode, using the `template_barebones` program: +For example, to register with `//Alice` as the signature request account in public access mode, using the `template_barebones` program: -`entropy-test-cli register Alice public template_barebones.wasm` +`entropy-test-cli register public template_barebones.wasm //Alice` -Example of registering in private access mode, with two programs, one given as a binary file and one +Example of registering in public access mode, with two programs, one given as a binary file and one given as a hash of an existing program: -`entropy-test-cli register Alice private my-program.wasm 3b3993c957ed9342cbb011eb9029c53fb253345114eff7da5951e98a41ba5ad5` - -When registering with private access mode, a keyshare file will be written to the directory where you -run the command. You must make subsequent `sign` commands in the same directory. +`entropy-test-cli register public my-program.wasm 3b3993c957ed9342cbb011eb9029c53fb253345114eff7da5951e98a41ba5ad5 //Alice` If registration was successful you will see the verifying key of your account, which is the public secp256k1 key of your distributed keypair. You will need this in order to specify the account when @@ -129,7 +116,7 @@ a program you can use the `store-program` command. You need to give the account which will store the program, and the path to a program binary file you wish to store, for example: -`entropy-test-cli store-program Alice ./crates/testing-utils/example_barebones_with_auxilary.wasm` +`entropy-test-cli store-program ./crates/testing-utils/example_barebones_with_auxilary.wasm //Alice` ### Update programs @@ -138,6 +125,6 @@ account. It takes the signature verifying key, and the program modification acco programs to evaluate when signing. Programs may be given as either the path to a .wasm binary file or hashes of existing programs. -`entropy-test-cli update-programs 039fa2a16982fa6176e3fa9ae8dc408386ff040bf91196d3ec0aa981e5ba3fc1bb Alice my-new-program.wasm` +`entropy-test-cli update-programs 039fa2a16982fa6176e3fa9ae8dc408386ff040bf91196d3ec0aa981e5ba3fc1bb my-new-program.wasm //Alice` Note that the program modification account must be funded for this to work. diff --git a/crates/test-cli/src/lib.rs b/crates/test-cli/src/lib.rs index 7a520eb8f..0b6718b2b 100644 --- a/crates/test-cli/src/lib.rs +++ b/crates/test-cli/src/lib.rs @@ -179,7 +179,7 @@ pub async fn run_command( std::env::var("ENTROPY_DEVNET").unwrap_or("ws://localhost:9944".to_string()) }); - let passed_mnemonic = std::env::var("DEPLOYER_MNEMONIC").unwrap_or("//Alice".to_string()); + let passed_mnemonic = std::env::var("DEPLOYER_MNEMONIC"); let api = get_api(&endpoint_addr).await?; let rpc = get_rpc(&endpoint_addr).await?; @@ -189,7 +189,7 @@ pub async fn run_command( let mnemonic = if let Some(mnemonic_option) = mnemonic_option { mnemonic_option } else { - passed_mnemonic + passed_mnemonic.expect("No mnemonic set") }; let program_keypair = ::from_string(&mnemonic, None)?; @@ -236,7 +236,7 @@ pub async fn run_command( let mnemonic = if let Some(mnemonic_option) = mnemonic_option { mnemonic_option } else { - passed_mnemonic + passed_mnemonic.unwrap_or("//Alice".to_string()) }; // If an account name is not provided, use the Alice key let user_keypair = ::from_string(&mnemonic, None)?; @@ -280,7 +280,7 @@ pub async fn run_command( let mnemonic = if let Some(mnemonic_option) = mnemonic_option { mnemonic_option } else { - passed_mnemonic + passed_mnemonic.expect("No Mnemonic set") }; let keypair = ::from_string(&mnemonic, None)?; println!("Storing program using account: {}", keypair.public()); @@ -320,7 +320,7 @@ pub async fn run_command( let mnemonic = if let Some(mnemonic_option) = mnemonic_option { mnemonic_option } else { - passed_mnemonic + passed_mnemonic.expect("No Mnemonic set") }; let program_keypair = ::from_string(&mnemonic, None)?; println!("Program account: {}", program_keypair.public()); From 254ce55b11c38b7b204123a2bcc58d706e7765b5 Mon Sep 17 00:00:00 2001 From: Jesse Abramowitz Date: Wed, 29 May 2024 11:09:07 -0400 Subject: [PATCH 6/8] update readme --- crates/test-cli/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/test-cli/README.md b/crates/test-cli/README.md index 6b1c31104..01ba5be61 100644 --- a/crates/test-cli/README.md +++ b/crates/test-cli/README.md @@ -81,7 +81,7 @@ access mode. For example, to register with `//Alice` as the signature request account in public access mode, using the `template_barebones` program: -`entropy-test-cli register public template_barebones.wasm //Alice` +`entropy-test-cli register public template_barebones.wasm template_barebones_config_data template_barebones_aux_data //Alice` Example of registering in public access mode, with two programs, one given as a binary file and one given as a hash of an existing program: From 93135ed0855efe4dd483a0a60f489e22bfedf1bd Mon Sep 17 00:00:00 2001 From: Jesse Abramowitz Date: Wed, 29 May 2024 12:08:44 -0400 Subject: [PATCH 7/8] fix reademe mnemonic --- crates/test-cli/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/test-cli/README.md b/crates/test-cli/README.md index 01ba5be61..2c59d1345 100644 --- a/crates/test-cli/README.md +++ b/crates/test-cli/README.md @@ -81,12 +81,12 @@ access mode. For example, to register with `//Alice` as the signature request account in public access mode, using the `template_barebones` program: -`entropy-test-cli register public template_barebones.wasm template_barebones_config_data template_barebones_aux_data //Alice` +`entropy-test-cli register public template_barebones.wasm template_barebones_config_data template_barebones_aux_data -m //Alice` Example of registering in public access mode, with two programs, one given as a binary file and one given as a hash of an existing program: -`entropy-test-cli register public my-program.wasm 3b3993c957ed9342cbb011eb9029c53fb253345114eff7da5951e98a41ba5ad5 //Alice` +`entropy-test-cli register public my-program.wasm 3b3993c957ed9342cbb011eb9029c53fb253345114eff7da5951e98a41ba5ad5 -m //Alice` If registration was successful you will see the verifying key of your account, which is the public secp256k1 key of your distributed keypair. You will need this in order to specify the account when @@ -125,6 +125,6 @@ account. It takes the signature verifying key, and the program modification acco programs to evaluate when signing. Programs may be given as either the path to a .wasm binary file or hashes of existing programs. -`entropy-test-cli update-programs 039fa2a16982fa6176e3fa9ae8dc408386ff040bf91196d3ec0aa981e5ba3fc1bb my-new-program.wasm //Alice` +`entropy-test-cli update-programs 039fa2a16982fa6176e3fa9ae8dc408386ff040bf91196d3ec0aa981e5ba3fc1bb my-new-program.wasm -m //Alice` Note that the program modification account must be funded for this to work. From a1865d46c28c9a8ddcb77959b60418b007383197 Mon Sep 17 00:00:00 2001 From: Jesse Abramowitz Date: Wed, 29 May 2024 12:09:50 -0400 Subject: [PATCH 8/8] add changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35623503d..acafd4ad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ At the moment this project **does not** adhere to ### Added - Add a way to change program modification account ([#843](https://github.com/entropyxyz/entropy-core/pull/843)) +### Changed +- Prepare test CLI for use in Programs repo ([#856](https://github.com/entropyxyz/entropy-core/pull/856)) + ## [0.1.0](https://github.com/entropyxyz/entropy-core/compare/release/v0.0.12...release/v0.1.0) - 2024-05-20 This is the first publicly available version of Entropy 🥳