From 2851d1cb2d54c584e5d193aa4491ab5517b91dc2 Mon Sep 17 00:00:00 2001 From: Angelos Stylianidis Date: Tue, 12 Nov 2024 11:52:53 +0200 Subject: [PATCH] support bech32m Message Id format --- Cargo.lock | 2 + Cargo.toml | 1 + contracts/voting-verifier/Cargo.toml | 1 + contracts/voting-verifier/src/contract.rs | 13 ++ contracts/voting-verifier/src/events.rs | 7 +- packages/axelar-wasm-std/Cargo.toml | 1 + .../axelar-wasm-std/src/msg_id/bech32m.rs | 199 ++++++++++++++++++ packages/axelar-wasm-std/src/msg_id/mod.rs | 46 ++++ 8 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 packages/axelar-wasm-std/src/msg_id/bech32m.rs diff --git a/Cargo.lock b/Cargo.lock index 91b7e1a96..93478d4e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -837,6 +837,7 @@ dependencies = [ "alloy-primitives", "assert_ok", "axelar-wasm-std-derive", + "bech32 0.11.0", "bs58 0.5.1", "cosmwasm-schema", "cosmwasm-std", @@ -9477,6 +9478,7 @@ dependencies = [ "alloy-primitives", "assert_ok", "axelar-wasm-std", + "bech32 0.11.0", "client", "cosmwasm-schema", "cosmwasm-std", diff --git a/Cargo.toml b/Cargo.toml index f8b73745e..723b93ba9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ axelar-wasm-std = { version = "^1.0.0", path = "packages/axelar-wasm-std" } axelar-wasm-std-derive = { version = "^1.0.0", path = "packages/axelar-wasm-std-derive" } axelarnet-gateway = { version = "^1.0.0", path = "contracts/axelarnet-gateway" } bcs = "0.1.5" +bech32 = "0.11.0" client = { version = "^1.0.0", path = "packages/client" } coordinator = { version = "^1.1.0", path = "contracts/coordinator" } cosmwasm-schema = "1.5.5" diff --git a/contracts/voting-verifier/Cargo.toml b/contracts/voting-verifier/Cargo.toml index 639e80fe1..46a00bae1 100644 --- a/contracts/voting-verifier/Cargo.toml +++ b/contracts/voting-verifier/Cargo.toml @@ -53,6 +53,7 @@ thiserror = { workspace = true } [dev-dependencies] alloy-primitives = { version = "0.7.7", features = ["getrandom"] } assert_ok = { workspace = true } +bech32 = { workspace = true } cw-multi-test = "0.15.1" goldie = { workspace = true } integration-tests = { workspace = true } diff --git a/contracts/voting-verifier/src/contract.rs b/contracts/voting-verifier/src/contract.rs index f406704ec..2aac7083f 100644 --- a/contracts/voting-verifier/src/contract.rs +++ b/contracts/voting-verifier/src/contract.rs @@ -131,6 +131,7 @@ mod test { assert_err_contains, err_contains, nonempty, MajorityThreshold, Threshold, VerificationStatus, }; + use bech32::{Bech32m, Hrp}; use cosmwasm_std::testing::{ mock_dependencies, mock_env, mock_info, MockApi, MockQuerier, MockStorage, }; @@ -265,6 +266,18 @@ mod test { .to_string() .parse() .unwrap(), + MessageIdFormat::Bech32m { + prefix, + length: _length, + } => { + let data = format!("{id}-{index}"); + let hrp = Hrp::parse(prefix).expect("valid hrp"); + bech32::encode::(hrp, data.as_bytes()) + .unwrap() + .to_string() + .parse() + .unwrap() + } } } diff --git a/contracts/voting-verifier/src/events.rs b/contracts/voting-verifier/src/events.rs index 6a4b527ac..202ee0b38 100644 --- a/contracts/voting-verifier/src/events.rs +++ b/contracts/voting-verifier/src/events.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use std::vec::Vec; use axelar_wasm_std::msg_id::{ - Base58SolanaTxSignatureAndEventIndex, Base58TxDigestAndEventIndex, HexTxHash, + Base58SolanaTxSignatureAndEventIndex, Base58TxDigestAndEventIndex, Bech32mFormat, HexTxHash, HexTxHashAndEventIndex, MessageIdFormat, }; use axelar_wasm_std::voting::{PollId, Vote}; @@ -186,6 +186,11 @@ fn parse_message_id( Ok((id.tx_hash_as_hex(), 0)) } + MessageIdFormat::Bech32m { prefix, length } => { + let bech32m_message_id = Bech32mFormat::from_str(prefix, *length, message_id) + .map_err(|_| ContractError::InvalidMessageID(message_id.into()))?; + Ok((bech32m_message_id.to_string().try_into()?, 0)) + } } } diff --git a/packages/axelar-wasm-std/Cargo.toml b/packages/axelar-wasm-std/Cargo.toml index 3472b6de8..e70de054b 100644 --- a/packages/axelar-wasm-std/Cargo.toml +++ b/packages/axelar-wasm-std/Cargo.toml @@ -29,6 +29,7 @@ optimize = """docker run --rm -v "$(pwd)":/code \ [dependencies] alloy-primitives = { workspace = true } axelar-wasm-std-derive = { workspace = true, optional = true } +bech32 = { workspace = true } bs58 = "0.5.1" cosmwasm-schema = { workspace = true } cosmwasm-std = { workspace = true } diff --git a/packages/axelar-wasm-std/src/msg_id/bech32m.rs b/packages/axelar-wasm-std/src/msg_id/bech32m.rs new file mode 100644 index 000000000..94dd6e726 --- /dev/null +++ b/packages/axelar-wasm-std/src/msg_id/bech32m.rs @@ -0,0 +1,199 @@ +use std::fmt::{self, Display}; + +use bech32::primitives::decode::CheckedHrpstring; +use bech32::Bech32m; +use error_stack::{bail, ensure, Report, ResultExt}; +use regex::Regex; + +use super::Error; + +#[derive(Debug)] +pub struct Bech32mFormat { + pub encoded: String, +} + +impl Bech32mFormat { + pub fn new(encoded: String) -> Self { + Self { encoded } + } + + pub fn from_str(prefix: &str, length: usize, message_id: &str) -> Result> { + // The Bech32m prefix should be between 1 and 83 characters + ensure!( + !prefix.is_empty() && prefix.len() <= 83, + Error::InvalidBech32mLocalChecks("Prefix size should be between 1 and 83".to_string()) + ); + + let data_part_length = length.saturating_sub(prefix.len()).saturating_sub(1); + ensure!( + data_part_length >= 6, + Error::InvalidBech32mLocalChecks( + "The data part should be at least 6 characters long".to_string() + ) + ); + + ensure!( + prefix.chars().all(|c| { + c.is_alphanumeric() + }), + Error::InvalidBech32mLocalChecks( + "The prefix should contain only Bech32m valid characters".to_string() + ) + ); + + let pattern = format!("^({prefix}1[02-9ac-hj-np-z]{{{data_part_length}}})$"); + + let regex = Regex::new(pattern.as_str()).change_context( + Error::InvalidBech32mLocalChecks("Failed to create regex".to_string()), + )?; + + let (_, [string]) = regex + .captures(message_id) + .ok_or(Error::InvalidMessageID { + id: message_id.to_string(), + expected_format: format!("Bech32m with '{}' prefix", prefix), + })? + .extract(); + + verify_bech32m(string, prefix)?; + + Ok(Self { + encoded: string.to_string(), + }) + } +} + +impl Display for Bech32mFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.encoded) + } +} + +fn verify_bech32m(input: &str, expected_prefix: &str) -> Result<(), Report> { + let checked_bech32m = CheckedHrpstring::new::(input) + .change_context(Error::InvalidBech32mExternalChecks(input.to_string()))?; + + ensure!( + checked_bech32m.hrp().as_str() == expected_prefix, + Error::InvalidBech32mExternalChecks(format!( + "Expected prefix '{expected_prefix}' not found: '{input}'" + )) + ); + + if checked_bech32m.data_part_ascii_no_checksum().is_empty() { + bail!(Error::InvalidBech32mExternalChecks(format!( + "Message Id is missing the data part: '{input}'" + ))); + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use bech32::Hrp; + use rand::Rng; + + use crate::assert_err_contains; + + use super::*; + + #[test] + fn should_pass_bech32m() { + let mut rng = rand::thread_rng(); + + const CHARS: [char; 32] = [ + 'q', 'p', 'z', 'r', 'y', '9', 'x', '8', 'g', 'f', '2', 't', 'v', 'd', 'w', '0', 's', + '3', 'j', 'n', '5', '4', 'k', 'h', 'c', 'e', '6', 'm', 'u', 'a', '7', 'l', + ]; + let char_set = CHARS.len(); + + for _ in 0..100 { + let hrp_str = (0..rng.gen_range(1..=83)) + .map(|_| CHARS[rng.gen_range(0..char_set)]) + .collect::(); + + let data = (0..80) + .map(|_| char::from(rng.gen_range(32..=126))) + .collect::(); + + let hrp = Hrp::parse(hrp_str.as_str()).expect("valid hrp"); + let string = + bech32::encode::(hrp, data.as_bytes()).expect("failed to encode string"); + + assert!(Bech32mFormat::from_str(hrp.as_str(), string.len(), string.as_str()).is_ok()); + } + } + + #[test] + fn should_pass_edge_cases() { + let mut rng = rand::thread_rng(); + let data = (0..80) + .map(|_| char::from(rng.gen_range(32..=126))) + .collect::(); + + // Minimum prefix length + let hrp_str = "a"; + let hrp = Hrp::parse(hrp_str).expect("valid hrp"); + let string = + bech32::encode::(hrp, data.as_bytes()).expect("failed to encode string"); + + assert!(Bech32mFormat::from_str(hrp.as_str(), string.len(), string.as_str()).is_ok()); + + // Maximum prefix length + let hrp_string = "a".repeat(83); + let hrp = Hrp::parse(hrp_string.as_str()).expect("valid hrp"); + let string = + bech32::encode::(hrp, data.as_bytes()).expect("failed to encode string"); + assert!(Bech32mFormat::from_str(hrp.as_str(), string.len(), string.as_str()).is_ok()); + } + + #[test] + fn should_fail_with_invalid_message_id() { + let string = "at1hs0xk375g4kvw53rcem9nyjsdw5lsv94fl065n77cpt0774nsyysdecaju"; + let hrp = "at"; + + assert_err_contains!( + Bech32mFormat::from_str(hrp, string.len() + 1, string), + Error, + Error::InvalidMessageID { .. } + ); + + assert_err_contains!( + Bech32mFormat::from_str(hrp, string.len() - 1, string), + Error, + Error::InvalidMessageID { .. } + ); + + assert_err_contains!( + Bech32mFormat::from_str("au", string.len(), string), + Error, + Error::InvalidMessageID { .. } + ); + } + + #[test] + fn should_not_pass_empty_data_part() { + let hrp_string = "a"; + let hrp = Hrp::parse(hrp_string).expect("valid hrp"); + let string = "a1"; + assert_err_contains!( + Bech32mFormat::from_str(hrp.as_str(), string.len(), string), + Error, + Error::InvalidBech32mLocalChecks(..) + ); + + // Minimum data part length + let data = ""; + let hrp_string = "a"; + let hrp = Hrp::parse(hrp_string).expect("valid hrp"); + let string = + bech32::encode::(hrp, data.as_bytes()).expect("failed to encode string"); + + assert_err_contains!( + Bech32mFormat::from_str(hrp.as_str(), string.len(), string.as_str()), + Error, + Error::InvalidBech32mExternalChecks(..) + ); + } +} diff --git a/packages/axelar-wasm-std/src/msg_id/mod.rs b/packages/axelar-wasm-std/src/msg_id/mod.rs index 8a0ced623..032e410c0 100644 --- a/packages/axelar-wasm-std/src/msg_id/mod.rs +++ b/packages/axelar-wasm-std/src/msg_id/mod.rs @@ -6,11 +6,13 @@ use error_stack::Report; pub use self::base_58_event_index::Base58TxDigestAndEventIndex; pub use self::base_58_solana_event_index::Base58SolanaTxSignatureAndEventIndex; +pub use self::bech32m::Bech32mFormat; pub use self::tx_hash::HexTxHash; pub use self::tx_hash_event_index::HexTxHashAndEventIndex; mod base_58_event_index; mod base_58_solana_event_index; +mod bech32m; mod tx_hash; mod tx_hash_event_index; @@ -25,6 +27,10 @@ pub enum Error { InvalidTxHash(String), #[error("invalid tx digest in message id '{0}'")] InvalidTxDigest(String), + #[error("Invalid bech32m: '{0}'")] + InvalidBech32mLocalChecks(String), + #[error("Invalid bech32m: '{0}'")] + InvalidBech32mExternalChecks(String), } /// Any message id format must implement this trait. @@ -45,6 +51,7 @@ pub enum MessageIdFormat { Base58TxDigestAndEventIndex, Base58SolanaTxSignatureAndEventIndex, HexTxHash, + Bech32m { prefix: String, length: usize }, } // function the router calls to verify msg ids @@ -60,6 +67,9 @@ pub fn verify_msg_id(message_id: &str, format: &MessageIdFormat) -> Result<(), R Base58SolanaTxSignatureAndEventIndex::from_str(message_id).map(|_| ()) } MessageIdFormat::HexTxHash => HexTxHash::from_str(message_id).map(|_| ()), + MessageIdFormat::Bech32m { prefix, length } => { + Bech32mFormat::from_str(prefix, *length, message_id).map(|_| ()) + } } } @@ -111,4 +121,40 @@ mod test { .to_string(); assert!(verify_msg_id(&msg_id, &MessageIdFormat::HexTxHashAndEventIndex).is_err()); } + + #[test] + fn should_verify_bech32m() { + let message_id = "at1hs0xk375g4kvw53rcem9nyjsdw5lsv94fl065n77cpt0774nsyysdecaju"; + assert!(verify_msg_id( + message_id, + &MessageIdFormat::Bech32m { + prefix: "at".to_string(), + length: 61 + } + ) + .is_ok()); + } + + #[test] + fn should_not_verify_bech32m() { + let message_id = "aths0xk375g4kvw53rcem9nyjsdw5lsv94fl065n77cpt0774nsyysdecaju"; + assert!(verify_msg_id( + message_id, + &MessageIdFormat::Bech32m { + prefix: "at".to_string(), + length: 61 + } + ) + .is_err()); + + let message_id = "ath1s0xk375g4kvw53rcem9nyjsdw5lsv94fl065n77cpt0774nsyysdecaj"; + assert!(verify_msg_id( + message_id, + &MessageIdFormat::Bech32m { + prefix: "at".to_string(), + length: 61 + } + ) + .is_err()); + } }