diff --git a/Cargo.lock b/Cargo.lock index 6cbfcdcf..30ec678a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -654,6 +654,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" +[[package]] +name = "fastrand" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" +dependencies = [ + "instant", +] + [[package]] name = "features" version = "0.10.0" @@ -2135,6 +2144,7 @@ dependencies = [ "serde_cbor", "serde_json", "simple_asn1", + "tempfile", "tiny-hderive", "tokio", ] @@ -2346,9 +2356,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.2.8" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "742739e41cd49414de871ea5e549afb7e2a3ac77b589bcbebe8c82fab37147fc" +checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" dependencies = [ "bitflags", ] @@ -2801,13 +2811,13 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.2.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" dependencies = [ "cfg-if 1.0.0", + "fastrand", "libc", - "rand 0.8.4", "redox_syscall", "remove_dir_all", "winapi", diff --git a/Cargo.toml b/Cargo.toml index eee111d3..9dd3d04d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,5 +35,8 @@ serde_json = "1.0.57" tiny-hderive = "0.3.0" tokio = { version = "1.2.0", features = [ "fs" ] } +[dev-dependencies] +tempfile = "3.3.0" + [features] static-ssl = ["openssl/vendored"] diff --git a/default.nix b/default.nix index f92fa57e..beecb01e 100644 --- a/default.nix +++ b/default.nix @@ -14,7 +14,7 @@ with pkgs; rustPlatform.buildRustPackage rec { src = ./.; - cargoSha256 = "sha256-zFRMDnSNOVDCh+cupe7ZieR5UPrwHDZ9oi7MnzWpk2s="; + cargoSha256 = "sha256-t+N0UoVOJIxi1q0SBfCqxNIcmXVBo44hhKE0SllJ63M="; cargoBuildFlags = []; diff --git a/src/commands/generate.rs b/src/commands/generate.rs index 97195f31..ddfbdca6 100644 --- a/src/commands/generate.rs +++ b/src/commands/generate.rs @@ -1,5 +1,5 @@ use crate::lib::{mnemonic_to_pem, AnyhowResult}; -use anyhow::anyhow; +use anyhow::{anyhow, Context}; use bip39::{Language, Mnemonic}; use clap::Parser; use rand::{rngs::OsRng, RngCore}; @@ -49,14 +49,14 @@ pub fn exec(opts: GenerateOpts) -> AnyhowResult { _ => return Err(anyhow!("Words must be 12 or 24.")), }; let mnemonic = match opts.phrase { - Some(phrase) => Mnemonic::parse(phrase).unwrap(), + Some(phrase) => Mnemonic::parse(phrase).context("Failed to parse mnemonic")?, None => { let mut key = vec![0u8; bytes]; OsRng.fill_bytes(&mut key); Mnemonic::from_entropy_in(Language::English, &key).unwrap() } }; - let pem = mnemonic_to_pem(&mnemonic); + let pem = mnemonic_to_pem(&mnemonic).context("Failed to convert mnemonic to PEM")?; let mut phrase = mnemonic .word_iter() .collect::>() diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c74a08b8..0bbe1ae4 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ //! This module implements the command-line API. use crate::lib::{qr, require_pem, AnyhowResult}; +use anyhow::Context; use clap::Parser; use std::io::{self, Write}; use tokio::runtime::Runtime; @@ -159,7 +160,7 @@ where print(arg) } else { for (i, a) in arg.iter().enumerate() { - print_qr(&a, i != arg.len() - 1).expect("print_qr"); + print_qr(&a, i != arg.len() - 1).context("Failed to print QR code")?; } Ok(()) } diff --git a/src/commands/neuron_manage.rs b/src/commands/neuron_manage.rs index ef895218..4c4f5cb0 100644 --- a/src/commands/neuron_manage.rs +++ b/src/commands/neuron_manage.rs @@ -3,7 +3,7 @@ use crate::lib::{ signing::{sign_ingress_with_request_status_query, IngressWithRequestId}, AnyhowResult, }; -use anyhow::anyhow; +use anyhow::{anyhow, Context}; use candid::{CandidType, Encode}; use clap::Parser; use ic_types::Principal; @@ -166,7 +166,7 @@ pub fn exec(pem: &str, opts: ManageOpts) -> AnyhowResult AnyhowResult ONE_YEAR_SECONDS * 7, "EIGHT_YEARS" => ONE_YEAR_SECONDS * 8, - s => s.parse::().expect("Couldn't parse the dissolve delay"), + s => s + .parse::() + .context("Failed to parse the dissolve delay")?, } })) })), @@ -298,7 +300,7 @@ pub fn exec(pem: &str, opts: ManageOpts) -> AnyhowResult AnyhowResult u64 { +fn parse_neuron_id(id: String) -> AnyhowResult { id.replace("_", "") .parse() - .expect("Couldn't parse the neuron id") + .context("Failed to parse the neuron id") } diff --git a/src/commands/public.rs b/src/commands/public.rs index 25ca3a8f..f456573f 100644 --- a/src/commands/public.rs +++ b/src/commands/public.rs @@ -35,9 +35,7 @@ fn get_public_ids( /// Returns the account id and the principal id if the private key was provided. pub fn get_ids(pem: &Option) -> AnyhowResult<(Principal, AccountIdentifier)> { - require_pem(pem)?; - let principal_id = get_identity(pem.as_ref().unwrap()) - .sender() - .map_err(|e| anyhow!(e))?; + let pem = require_pem(pem)?; + let principal_id = get_identity(&pem).sender().map_err(|e| anyhow!(e))?; Ok((principal_id, get_account_id(principal_id)?)) } diff --git a/src/commands/request_status.rs b/src/commands/request_status.rs index 23a802c9..cbcf92f0 100644 --- a/src/commands/request_status.rs +++ b/src/commands/request_status.rs @@ -1,22 +1,25 @@ use crate::lib::get_ic_url; use crate::lib::{get_agent, get_idl_string, signing::RequestStatus, AnyhowResult}; use anyhow::{anyhow, Context}; -use ic_agent::agent::{Replied, RequestStatusResponse}; +use ic_agent::agent::{ReplicaV2Transport, Replied, RequestStatusResponse}; +use ic_agent::AgentError::MessageError; use ic_agent::{AgentError, RequestId}; use ic_types::Principal; +use std::future::Future; +use std::pin::Pin; use std::str::FromStr; use std::sync::Arc; pub async fn submit(req: &RequestStatus, method_name: Option) -> AnyhowResult { - let canister_id = Principal::from_text(&req.canister_id).expect("Couldn't parse canister id"); - let request_id = - RequestId::from_str(&req.request_id).context("Invalid argument: request_id")?; + let canister_id = + Principal::from_text(&req.canister_id).context("Cannot parse the canister id")?; + let request_id = RequestId::from_str(&req.request_id).context("Cannot parse the request_id")?; let mut agent = get_agent("")?; agent.set_transport(ProxySignReplicaV2Transport { req: req.clone(), http_transport: Arc::new( ic_agent::agent::http_transport::ReqwestHttpReplicaV2Transport::create(get_ic_url()) - .unwrap(), + .context("Failed to create an agent")?, ), }); let Replied::CallReplied(blob) = async { @@ -57,20 +60,28 @@ pub(crate) struct ProxySignReplicaV2Transport { http_transport: Arc, } -use ic_agent::agent::ReplicaV2Transport; -use std::future::Future; -use std::pin::Pin; - impl ReplicaV2Transport for ProxySignReplicaV2Transport { fn read_state<'a>( &'a self, _canister_id: Principal, _content: Vec, ) -> Pin, AgentError>> + Send + 'a>> { - self.http_transport.read_state( - Principal::from_text(self.req.canister_id.clone()).unwrap(), - hex::decode(self.req.content.clone()).unwrap(), - ) + async fn run(transport: &ProxySignReplicaV2Transport) -> Result, AgentError> { + let canister_id = Principal::from_text(transport.req.canister_id.clone()) + .map_err(|err| MessageError(format!("Unable to parse canister_id: {}", err)))?; + let envelope = hex::decode(transport.req.content.clone()).map_err(|err| { + MessageError(format!( + "Unable to decode request content (should be hexadecimal encoded): {}", + err + )) + })?; + transport + .http_transport + .read_state(canister_id, envelope) + .await + } + + Box::pin(run(self)) } fn call<'a>( diff --git a/src/commands/send.rs b/src/commands/send.rs index 358f80c4..d3feb056 100644 --- a/src/commands/send.rs +++ b/src/commands/send.rs @@ -4,7 +4,7 @@ use crate::lib::{ signing::{Ingress, IngressWithRequestId}, AnyhowResult, }; -use anyhow::anyhow; +use anyhow::{anyhow, Context}; use candid::CandidType; use clap::Parser; use ic_agent::agent::ReplicaV2Transport; @@ -153,7 +153,7 @@ async fn send(message: &Ingress, opts: &SendOpts) -> AnyhowResult { &message .clone() .request_id - .expect("Cannot get request_id from the update message"), + .context("Cannot get request_id from the update message")?, )?; transport.call(canister_id, content, request_id).await?; let request_id = format!("0x{}", String::from(request_id)); diff --git a/src/commands/transfer.rs b/src/commands/transfer.rs index 92bd8d81..3316d713 100644 --- a/src/commands/transfer.rs +++ b/src/commands/transfer.rs @@ -4,7 +4,7 @@ use crate::lib::{ signing::{sign_ingress_with_request_status_query, IngressWithRequestId}, AnyhowResult, }; -use anyhow::anyhow; +use anyhow::{anyhow, bail, Context}; use candid::Encode; use clap::Parser; use ledger_canister::{ICPTs, TRANSACTION_FEE}; @@ -29,16 +29,15 @@ pub struct TransferOpts { } pub fn exec(pem: &str, opts: TransferOpts) -> AnyhowResult> { - let amount = - parse_icpts(&opts.amount).map_err(|err| anyhow!("Could not add ICPs and e8s: {}", err))?; + let amount = parse_icpts(&opts.amount).context("Cannot parse amount")?; let fee = opts.fee.map_or(Ok(TRANSACTION_FEE), |v| { - parse_icpts(&v).map_err(|err| anyhow!(err)) + parse_icpts(&v).context("Cannot parse fee") })?; let memo = Memo( opts.memo .unwrap_or_else(|| "0".to_string()) .parse::() - .unwrap(), + .context("Failed to parse memo as unsigned integer")?, ); let to = opts.to; @@ -55,26 +54,32 @@ pub fn exec(pem: &str, opts: TransferOpts) -> AnyhowResult Result { +fn new_icps(icpt: u64, e8s: u64) -> AnyhowResult { + ICPTs::new(icpt, e8s) + .map_err(|err| anyhow!(err)) + .context("Cannot create new ICPs") +} + +fn parse_icpts(amount: &str) -> AnyhowResult { let parse = |s: &str| { s.parse::() - .map_err(|err| format!("Couldn't parse as u64: {:?}", err)) + .context("Failed to parse ICPTs as unsigned integer") }; match &amount.split('.').collect::>().as_slice() { - [icpts] => ICPTs::new(parse(icpts)?, 0), + [icpts] => new_icps(parse(icpts)?, 0), [icpts, e8s] => { let mut e8s = e8s.to_string(); while e8s.len() < 8 { e8s.push('0'); } let e8s = &e8s[..8]; - ICPTs::new(parse(icpts)?, parse(e8s)?) + new_icps(parse(icpts)?, parse(e8s)?) } - _ => Err(format!("Can't parse amount {}", amount)), + _ => bail!("Cannot parse amount {}", amount), } } -fn icpts_amount_validator(icpts: &str) -> Result<(), String> { +fn icpts_amount_validator(icpts: &str) -> AnyhowResult<()> { parse_icpts(icpts).map(|_| ()) } diff --git a/src/lib/mod.rs b/src/lib/mod.rs index fff06b5f..85adda13 100644 --- a/src/lib/mod.rs +++ b/src/lib/mod.rs @@ -1,6 +1,6 @@ //! All the common functionality. -use anyhow::anyhow; +use anyhow::{anyhow, Context}; use bip39::Mnemonic; use candid::{ parser::typing::{check_prog, TypeEnv}, @@ -49,12 +49,13 @@ pub fn genesis_token_canister_id() -> Principal { pub fn get_local_candid(canister_id: Principal) -> AnyhowResult { if canister_id == governance_canister_id() { String::from_utf8(include_bytes!("../../candid/governance.did").to_vec()) - .map_err(|e| anyhow!(e)) + .context("Cannot load governance.did") } else if canister_id == ledger_canister_id() { String::from_utf8(include_bytes!("../../candid/ledger.did").to_vec()) - .map_err(|e| anyhow!(e)) + .context("Cannot load ledger.did") } else if canister_id == genesis_token_canister_id() { - String::from_utf8(include_bytes!("../../candid/gtc.did").to_vec()).map_err(|e| anyhow!(e)) + String::from_utf8(include_bytes!("../../candid/gtc.did").to_vec()) + .context("Cannot load gtc.did") } else { unreachable!() } @@ -101,10 +102,9 @@ pub fn read_from_file(path: &str) -> AnyhowResult { std::io::stdin().read_to_string(&mut content)?; } else { let path = std::path::Path::new(&path); - let mut file = - std::fs::File::open(&path).map_err(|_| anyhow!("Message file doesn't exist"))?; + let mut file = std::fs::File::open(&path).context("Cannot open the message file.")?; file.read_to_string(&mut content) - .map_err(|_| anyhow!("Cannot read the message file."))?; + .context("Cannot read the message file.")?; } Ok(content) } @@ -158,7 +158,7 @@ pub fn parse_query_response( method_name: &str, ) -> AnyhowResult { let cbor: Value = serde_cbor::from_slice(&response) - .map_err(|_| anyhow!("Invalid cbor data in the content of the message."))?; + .context("Invalid cbor data in the content of the message.")?; if let Value::Map(m) = cbor { // Try to decode a rejected response. if let (_, Some(Value::Integer(reject_code)), Some(Value::Text(reject_message))) = ( @@ -196,8 +196,8 @@ pub fn get_account_id(principal_id: Principal) -> AnyhowResult String { - fn der_encode_secret_key(public_key: Vec, secret: Vec) -> Vec { +pub fn mnemonic_to_pem(mnemonic: &Mnemonic) -> AnyhowResult { + fn der_encode_secret_key(public_key: Vec, secret: Vec) -> AnyhowResult> { let secp256k1_id = ObjectIdentifier(0, oid!(1, 3, 132, 0, 10)); let data = Sequence( 0, @@ -218,18 +218,20 @@ pub fn mnemonic_to_pem(mnemonic: &Mnemonic) -> String { ), ], ); - to_der(&data).expect("Cannot encode secret key.") + to_der(&data).context("Failed to encode secp256k1 secret key to DER") } let seed = mnemonic.to_seed(""); - let ext = tiny_hderive::bip32::ExtendedPrivKey::derive(&seed, "m/44'/223'/0'/0/0").unwrap(); + let ext = tiny_hderive::bip32::ExtendedPrivKey::derive(&seed, "m/44'/223'/0'/0/0") + .map_err(|err| anyhow!("{:?}", err)) + .context("Failed to derive BIP32 extended private key")?; let secret = ext.secret(); - let secret_key = SecretKey::parse(&secret).unwrap(); + let secret_key = SecretKey::parse(&secret).context("Failed to parse secret key")?; let public_key = PublicKey::from_secret_key(&secret_key); - let der = der_encode_secret_key(public_key.serialize().to_vec(), secret.to_vec()); + let der = der_encode_secret_key(public_key.serialize().to_vec(), secret.to_vec())?; let pem = Pem { tag: String::from("EC PRIVATE KEY"), contents: der, }; - encode(&pem).replace("\r", "").replace("\n\n", "\n") + Ok(encode(&pem).replace("\r", "").replace("\n\n", "\n")) } diff --git a/src/lib/signing.rs b/src/lib/signing.rs index 91ca9e3b..2c90c637 100644 --- a/src/lib/signing.rs +++ b/src/lib/signing.rs @@ -1,7 +1,7 @@ use crate::lib::get_idl_string; use crate::lib::AnyhowResult; use crate::lib::{get_candid_type, get_local_candid}; -use anyhow::anyhow; +use anyhow::{anyhow, Context}; use ic_agent::agent::QueryBuilder; use ic_agent::agent::UpdateBuilder; use ic_agent::RequestId; @@ -53,7 +53,7 @@ pub struct IngressWithRequestId { impl Ingress { pub fn parse(&self) -> AnyhowResult<(Principal, Principal, String, String)> { let cbor: Value = serde_cbor::from_slice(&hex::decode(&self.content)?) - .map_err(|_| anyhow!("Invalid cbor data in the content of the message."))?; + .context("Invalid cbor data in the content of the message.")?; if let Value::Map(m) = cbor { let cbor_content = m .get(&Value::Text("content".to_string())) @@ -154,7 +154,7 @@ pub fn sign_ingress_with_request_status_query( let msg_with_req_id = sign(pem, canister_id, method_name, args)?; let request_id = msg_with_req_id .request_id - .expect("No request id for transfer call found"); + .context("No request id for transfer call found")?; let request_status = request_status_sign(pem, request_id, canister_id)?; let message = IngressWithRequestId { ingress: msg_with_req_id.message, diff --git a/src/main.rs b/src/main.rs index 8e76cea4..8b34f494 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,10 @@ #![warn(unused_extern_crates)] + +use crate::lib::AnyhowResult; +use anyhow::Context; use bip39::Mnemonic; use clap::{crate_version, Parser}; + mod commands; mod lib; @@ -26,39 +30,112 @@ pub struct CliOpts { fn main() { let opts = CliOpts::parse(); - let command = opts.command; - // Get PEM from the file if provided, or try to convert from the seed file - let pem = match opts.pem_file { - Some(path) => Some(read_file(path)), - None => opts.seed_file.map(|path| { - let phrase = read_file(path); - lib::mnemonic_to_pem( - &Mnemonic::parse(phrase) - .expect("Couldn't parse the seed phrase as a valid mnemonic"), - ) - }), - }; - if let Err(err) = commands::exec(&pem, opts.qr, command) { - eprintln!("{}", err); + if let Err(err) = run(opts) { + for (level, cause) in err.chain().enumerate() { + if level == 0 { + eprintln!("Error: {}", err); + continue; + } + if level == 1 { + eprintln!("Caused by:"); + } + eprintln!("{:width$}{}", "", cause, width = level * 2); + } std::process::exit(1); } } -fn read_file(path: String) -> String { - match path.as_str() { +fn run(opts: CliOpts) -> AnyhowResult<()> { + let pem = read_pem(opts.pem_file, opts.seed_file)?; + commands::exec(&pem, opts.qr, opts.command) +} + +// Get PEM from the file if provided, or try to convert from the seed file +fn read_pem(pem_file: Option, seed_file: Option) -> AnyhowResult> { + match (pem_file, seed_file) { + (Some(pem_file), _) => read_file(&pem_file, "PEM").map(Some), + (_, Some(seed_file)) => { + let seed = read_file(&seed_file, "seed")?; + let mnemonic = parse_mnemonic(&seed)?; + let mnemonic = lib::mnemonic_to_pem(&mnemonic)?; + Ok(Some(mnemonic)) + } + _ => Ok(None), + } +} + +fn parse_mnemonic(phrase: &str) -> AnyhowResult { + Mnemonic::parse(phrase).context("Couldn't parse the seed phrase as a valid mnemonic. {:?}") +} + +fn read_file(path: &str, name: &str) -> AnyhowResult { + match path { // read from STDIN "-" => { let mut buffer = String::new(); use std::io::Read; - if let Err(err) = std::io::stdin().read_to_string(&mut buffer) { - eprintln!("Couldn't read from STDIN: {:?}", err); - std::process::exit(1); - } - buffer + std::io::stdin() + .read_to_string(&mut buffer) + .map(|_| buffer) + .context(format!("Couldn't read {} from STDIN", name)) } - path => std::fs::read_to_string(path).unwrap_or_else(|err| { - eprintln!("Couldn't read PEM file: {:?}", err); - std::process::exit(1); - }), + path => std::fs::read_to_string(path).context(format!("Couldn't read {} file", name)), } } + +#[test] +fn test_read_pem_none_none() { + let res = read_pem(None, None); + assert_eq!(None, res.expect("read_pem(None, None) failed")); +} + +#[test] +fn test_read_pem_from_pem_file() { + use std::io::Write; + + let mut pem_file = tempfile::NamedTempFile::new().expect("Cannot create temp file"); + + let content = "pem".to_string(); + pem_file + .write_all(content.as_bytes()) + .expect("Cannot write to temp file"); + + let res = read_pem(Some(pem_file.path().to_str().unwrap().to_string()), None); + + assert_eq!(Some(content), res.expect("read_pem from pem file")); +} + +#[test] +fn test_read_pem_from_seed_file() { + use std::io::Write; + + let mut seed_file = tempfile::NamedTempFile::new().expect("Cannot create temp file"); + + let phrase = "ozone drill grab fiber curtain grace pudding thank cruise elder eight about"; + seed_file + .write_all(phrase.as_bytes()) + .expect("Cannot write to temp file"); + let mnemonic = lib::mnemonic_to_pem(&Mnemonic::parse(phrase).unwrap()).unwrap(); + + let pem = read_pem(None, Some(seed_file.path().to_str().unwrap().to_string())) + .expect("Unable to read seed_file") + .expect("None returned instead of Some"); + + assert_eq!(mnemonic, pem); +} + +#[test] +fn test_read_pem_from_non_existing_file() { + let dir = tempfile::tempdir().expect("Cannot create temp dir"); + let non_existing_file = dir + .path() + .join("non_existing_pem_file") + .as_path() + .to_str() + .unwrap() + .to_string(); + + read_pem(Some(non_existing_file.clone()), None).unwrap_err(); + + read_pem(None, Some(non_existing_file)).unwrap_err(); +}