Skip to content

Commit ca20afa

Browse files
committed
support bech32m Message Id format
1 parent e14f415 commit ca20afa

File tree

8 files changed

+270
-1
lines changed

8 files changed

+270
-1
lines changed

Cargo.lock

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ axelar-wasm-std = { version = "^1.0.0", path = "packages/axelar-wasm-std" }
2121
axelar-wasm-std-derive = { version = "^1.0.0", path = "packages/axelar-wasm-std-derive" }
2222
axelarnet-gateway = { version = "^1.0.0", path = "contracts/axelarnet-gateway" }
2323
bcs = "0.1.5"
24+
bech32 = "0.11.0"
2425
client = { version = "^1.0.0", path = "packages/client" }
2526
coordinator = { version = "^1.1.0", path = "contracts/coordinator" }
2627
cosmwasm-schema = "1.5.5"

contracts/voting-verifier/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ thiserror = { workspace = true }
5353
[dev-dependencies]
5454
alloy-primitives = { version = "0.7.7", features = ["getrandom"] }
5555
assert_ok = { workspace = true }
56+
bech32 = { workspace = true }
5657
cw-multi-test = "0.15.1"
5758
goldie = { workspace = true }
5859
integration-tests = { workspace = true }

contracts/voting-verifier/src/contract.rs

+13
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ mod test {
131131
assert_err_contains, err_contains, nonempty, MajorityThreshold, Threshold,
132132
VerificationStatus,
133133
};
134+
use bech32::{Bech32m, Hrp};
134135
use cosmwasm_std::testing::{
135136
mock_dependencies, mock_env, mock_info, MockApi, MockQuerier, MockStorage,
136137
};
@@ -265,6 +266,18 @@ mod test {
265266
.to_string()
266267
.parse()
267268
.unwrap(),
269+
MessageIdFormat::Bech32m {
270+
prefix,
271+
length: _length,
272+
} => {
273+
let data = format!("{id}-{index}");
274+
let hrp = Hrp::parse(prefix).expect("valid hrp");
275+
bech32::encode::<Bech32m>(hrp, data.as_bytes())
276+
.unwrap()
277+
.to_string()
278+
.parse()
279+
.unwrap()
280+
}
268281
}
269282
}
270283

contracts/voting-verifier/src/events.rs

+6-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::str::FromStr;
22
use std::vec::Vec;
33

44
use axelar_wasm_std::msg_id::{
5-
Base58SolanaTxSignatureAndEventIndex, Base58TxDigestAndEventIndex, HexTxHash,
5+
Base58SolanaTxSignatureAndEventIndex, Base58TxDigestAndEventIndex, Bech32mFormat, HexTxHash,
66
HexTxHashAndEventIndex, MessageIdFormat,
77
};
88
use axelar_wasm_std::voting::{PollId, Vote};
@@ -186,6 +186,11 @@ fn parse_message_id(
186186

187187
Ok((id.tx_hash_as_hex(), 0))
188188
}
189+
MessageIdFormat::Bech32m { prefix, length } => {
190+
let bech32m_message_id = Bech32mFormat::from_str(prefix, *length, message_id)
191+
.map_err(|_| ContractError::InvalidMessageID(message_id.into()))?;
192+
Ok((bech32m_message_id.to_string().try_into()?, 0))
193+
}
189194
}
190195
}
191196

packages/axelar-wasm-std/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ optimize = """docker run --rm -v "$(pwd)":/code \
2929
[dependencies]
3030
alloy-primitives = { workspace = true }
3131
axelar-wasm-std-derive = { workspace = true, optional = true }
32+
bech32 = { workspace = true }
3233
bs58 = "0.5.1"
3334
cosmwasm-schema = { workspace = true }
3435
cosmwasm-std = { workspace = true }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
use std::fmt::{self, Display};
2+
3+
use bech32::primitives::decode::CheckedHrpstring;
4+
use bech32::Bech32m;
5+
use error_stack::{bail, ensure, Report, ResultExt};
6+
use regex::Regex;
7+
8+
use super::Error;
9+
10+
#[derive(Debug)]
11+
pub struct Bech32mFormat {
12+
pub encoded: String,
13+
}
14+
15+
impl Bech32mFormat {
16+
pub fn new(encoded: String) -> Self {
17+
Self { encoded }
18+
}
19+
20+
pub fn from_str(prefix: &str, length: usize, message_id: &str) -> Result<Self, Report<Error>> {
21+
// The Bech32m prefix should be between 1 and 83 characters
22+
ensure!(
23+
!prefix.is_empty() && prefix.len() <= 83,
24+
Error::InvalidBech32mFormat("Prefix size should be between 1 and 83".to_string())
25+
);
26+
27+
let data_part_length = length.saturating_sub(prefix.len()).saturating_sub(1);
28+
ensure!(
29+
data_part_length >= 6,
30+
Error::InvalidBech32mFormat(
31+
"The data part should be at least 6 characters long".to_string()
32+
)
33+
);
34+
35+
ensure!(
36+
prefix.chars().all(|c| { c.is_alphanumeric() }),
37+
Error::InvalidBech32mFormat(
38+
"The prefix should contain only Bech32m valid characters".to_string()
39+
)
40+
);
41+
42+
let pattern = format!("^({prefix}1[02-9ac-hj-np-z]{{{data_part_length}}})$");
43+
44+
let regex = Regex::new(pattern.as_str()).change_context(Error::InvalidBech32mFormat(
45+
"Failed to create regex".to_string(),
46+
))?;
47+
48+
let (_, [string]) = regex
49+
.captures(message_id)
50+
.ok_or(Error::InvalidMessageID {
51+
id: message_id.to_string(),
52+
expected_format: format!("Bech32m with '{}' prefix", prefix),
53+
})?
54+
.extract();
55+
56+
verify_bech32m(string, prefix)?;
57+
58+
Ok(Self {
59+
encoded: string.to_string(),
60+
})
61+
}
62+
}
63+
64+
impl Display for Bech32mFormat {
65+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66+
write!(f, "{}", self.encoded)
67+
}
68+
}
69+
70+
fn verify_bech32m(input: &str, expected_prefix: &str) -> Result<(), Report<Error>> {
71+
let checked_bech32m = CheckedHrpstring::new::<Bech32m>(input)
72+
.change_context(Error::InvalidBech32m(input.to_string()))?;
73+
74+
ensure!(
75+
checked_bech32m.hrp().as_str() == expected_prefix,
76+
Error::InvalidBech32m(format!(
77+
"Expected prefix '{expected_prefix}' not found: '{input}'"
78+
))
79+
);
80+
81+
if checked_bech32m.data_part_ascii_no_checksum().is_empty() {
82+
bail!(Error::InvalidBech32m(format!(
83+
"Message Id is missing the data part: '{input}'"
84+
)));
85+
}
86+
87+
Ok(())
88+
}
89+
90+
#[cfg(test)]
91+
mod test {
92+
use bech32::Hrp;
93+
use rand::Rng;
94+
95+
use super::*;
96+
use crate::assert_err_contains;
97+
98+
#[test]
99+
fn should_pass_bech32m() {
100+
let mut rng = rand::thread_rng();
101+
102+
const CHARS: [char; 32] = [
103+
'q', 'p', 'z', 'r', 'y', '9', 'x', '8', 'g', 'f', '2', 't', 'v', 'd', 'w', '0', 's',
104+
'3', 'j', 'n', '5', '4', 'k', 'h', 'c', 'e', '6', 'm', 'u', 'a', '7', 'l',
105+
];
106+
let char_set = CHARS.len();
107+
108+
for _ in 0..100 {
109+
let hrp_str = (0..rng.gen_range(1..=83))
110+
.map(|_| CHARS[rng.gen_range(0..char_set)])
111+
.collect::<String>();
112+
113+
let data = (0..80)
114+
.map(|_| char::from(rng.gen_range(32..=126)))
115+
.collect::<String>();
116+
117+
let hrp = Hrp::parse(hrp_str.as_str()).expect("valid hrp");
118+
let string =
119+
bech32::encode::<Bech32m>(hrp, data.as_bytes()).expect("failed to encode string");
120+
121+
assert!(Bech32mFormat::from_str(hrp.as_str(), string.len(), string.as_str()).is_ok());
122+
}
123+
}
124+
125+
#[test]
126+
fn should_pass_edge_cases() {
127+
let mut rng = rand::thread_rng();
128+
let data = (0..80)
129+
.map(|_| char::from(rng.gen_range(32..=126)))
130+
.collect::<String>();
131+
132+
// Minimum prefix length
133+
let hrp_str = "a";
134+
let hrp = Hrp::parse(hrp_str).expect("valid hrp");
135+
let string =
136+
bech32::encode::<Bech32m>(hrp, data.as_bytes()).expect("failed to encode string");
137+
138+
assert!(Bech32mFormat::from_str(hrp.as_str(), string.len(), string.as_str()).is_ok());
139+
140+
// Maximum prefix length
141+
let hrp_string = "a".repeat(83);
142+
let hrp = Hrp::parse(hrp_string.as_str()).expect("valid hrp");
143+
let string =
144+
bech32::encode::<Bech32m>(hrp, data.as_bytes()).expect("failed to encode string");
145+
assert!(Bech32mFormat::from_str(hrp.as_str(), string.len(), string.as_str()).is_ok());
146+
}
147+
148+
#[test]
149+
fn should_fail_with_invalid_message_id() {
150+
let string = "at1hs0xk375g4kvw53rcem9nyjsdw5lsv94fl065n77cpt0774nsyysdecaju";
151+
let hrp = "at";
152+
153+
assert_err_contains!(
154+
Bech32mFormat::from_str(hrp, string.len() + 1, string),
155+
Error,
156+
Error::InvalidMessageID { .. }
157+
);
158+
159+
assert_err_contains!(
160+
Bech32mFormat::from_str(hrp, string.len() - 1, string),
161+
Error,
162+
Error::InvalidMessageID { .. }
163+
);
164+
165+
assert_err_contains!(
166+
Bech32mFormat::from_str("au", string.len(), string),
167+
Error,
168+
Error::InvalidMessageID { .. }
169+
);
170+
}
171+
172+
#[test]
173+
fn should_not_pass_empty_data_part() {
174+
let hrp_string = "a";
175+
let hrp = Hrp::parse(hrp_string).expect("valid hrp");
176+
let string = "a1";
177+
assert_err_contains!(
178+
Bech32mFormat::from_str(hrp.as_str(), string.len(), string),
179+
Error,
180+
Error::InvalidBech32mFormat(..)
181+
);
182+
183+
// Minimum data part length
184+
let data = "";
185+
let hrp_string = "a";
186+
let hrp = Hrp::parse(hrp_string).expect("valid hrp");
187+
let string =
188+
bech32::encode::<Bech32m>(hrp, data.as_bytes()).expect("failed to encode string");
189+
190+
assert_err_contains!(
191+
Bech32mFormat::from_str(hrp.as_str(), string.len(), string.as_str()),
192+
Error,
193+
Error::InvalidBech32m(..)
194+
);
195+
}
196+
}

packages/axelar-wasm-std/src/msg_id/mod.rs

+50
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ use error_stack::Report;
66

77
pub use self::base_58_event_index::Base58TxDigestAndEventIndex;
88
pub use self::base_58_solana_event_index::Base58SolanaTxSignatureAndEventIndex;
9+
pub use self::bech32m::Bech32mFormat;
910
pub use self::tx_hash::HexTxHash;
1011
pub use self::tx_hash_event_index::HexTxHashAndEventIndex;
12+
use crate::nonempty;
1113

1214
mod base_58_event_index;
1315
mod base_58_solana_event_index;
16+
mod bech32m;
1417
mod tx_hash;
1518
mod tx_hash_event_index;
1619

@@ -25,6 +28,10 @@ pub enum Error {
2528
InvalidTxHash(String),
2629
#[error("invalid tx digest in message id '{0}'")]
2730
InvalidTxDigest(String),
31+
#[error("Invalid bech32m: '{0}'")]
32+
InvalidBech32mFormat(String),
33+
#[error("Invalid bech32m: '{0}'")]
34+
InvalidBech32m(String),
2835
}
2936

3037
/// Any message id format must implement this trait.
@@ -45,6 +52,10 @@ pub enum MessageIdFormat {
4552
Base58TxDigestAndEventIndex,
4653
Base58SolanaTxSignatureAndEventIndex,
4754
HexTxHash,
55+
Bech32m {
56+
prefix: nonempty::String,
57+
length: usize,
58+
},
4859
}
4960

5061
// function the router calls to verify msg ids
@@ -60,6 +71,9 @@ pub fn verify_msg_id(message_id: &str, format: &MessageIdFormat) -> Result<(), R
6071
Base58SolanaTxSignatureAndEventIndex::from_str(message_id).map(|_| ())
6172
}
6273
MessageIdFormat::HexTxHash => HexTxHash::from_str(message_id).map(|_| ()),
74+
MessageIdFormat::Bech32m { prefix, length } => {
75+
Bech32mFormat::from_str(prefix, *length, message_id).map(|_| ())
76+
}
6377
}
6478
}
6579

@@ -111,4 +125,40 @@ mod test {
111125
.to_string();
112126
assert!(verify_msg_id(&msg_id, &MessageIdFormat::HexTxHashAndEventIndex).is_err());
113127
}
128+
129+
#[test]
130+
fn should_verify_bech32m() {
131+
let message_id = "at1hs0xk375g4kvw53rcem9nyjsdw5lsv94fl065n77cpt0774nsyysdecaju";
132+
assert!(verify_msg_id(
133+
message_id,
134+
&MessageIdFormat::Bech32m {
135+
prefix: "at".to_string().to_string().try_into().unwrap(),
136+
length: 61
137+
}
138+
)
139+
.is_ok());
140+
}
141+
142+
#[test]
143+
fn should_not_verify_bech32m() {
144+
let message_id = "aths0xk375g4kvw53rcem9nyjsdw5lsv94fl065n77cpt0774nsyysdecaju";
145+
assert!(verify_msg_id(
146+
message_id,
147+
&MessageIdFormat::Bech32m {
148+
prefix: "at".to_string().to_string().try_into().unwrap(),
149+
length: 61
150+
}
151+
)
152+
.is_err());
153+
154+
let message_id = "ath1s0xk375g4kvw53rcem9nyjsdw5lsv94fl065n77cpt0774nsyysdecaj";
155+
assert!(verify_msg_id(
156+
message_id,
157+
&MessageIdFormat::Bech32m {
158+
prefix: "at".to_string().to_string().try_into().unwrap(),
159+
length: 61
160+
}
161+
)
162+
.is_err());
163+
}
114164
}

0 commit comments

Comments
 (0)