From 3641865ea0e0763353b6f43e12d7cc1c119f471b Mon Sep 17 00:00:00 2001 From: Philipp Gackstatter Date: Tue, 4 Mar 2025 02:29:30 +0100 Subject: [PATCH] feat: Implement bech32 encoding and decoding for `AccountId` (#1185) --- CHANGELOG.md | 1 + Cargo.lock | 7 + crates/miden-objects/Cargo.toml | 1 + .../src/account/account_id/address_type.rs | 6 + .../src/account/account_id/mod.rs | 194 +++++++++++++++++- .../src/account/account_id/network_id.rs | 107 ++++++++++ .../src/account/account_id/v0/mod.rs | 63 +++++- crates/miden-objects/src/account/mod.rs | 2 +- crates/miden-objects/src/errors.rs | 28 ++- 9 files changed, 402 insertions(+), 7 deletions(-) create mode 100644 crates/miden-objects/src/account/account_id/address_type.rs create mode 100644 crates/miden-objects/src/account/account_id/network_id.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a81846f1..c3b68a4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - [BREAKING] Added native types to `AccountComponentTemplate` (#1124). - Implemented `RemoteBlockProver`. `miden-proving-service` workers can prove blocks (#1169). - Use `Smt::with_entries` to error on duplicates in `StorageMap::with_entries` (#1167) +- Implement user-facing bech32 encoding for `AccountId`s (#1185). ## 0.7.2 (2025-01-28) - `miden-objects` crate only diff --git a/Cargo.lock b/Cargo.lock index 55665f30f..ec3d84d80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -308,6 +308,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bech32" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" + [[package]] name = "beef" version = "0.5.2" @@ -1937,6 +1943,7 @@ version = "0.8.0" dependencies = [ "anyhow", "assert_matches", + "bech32", "criterion", "getrandom 0.2.15", "log", diff --git a/crates/miden-objects/Cargo.toml b/crates/miden-objects/Cargo.toml index 18e5ae572..455ff1c9b 100644 --- a/crates/miden-objects/Cargo.toml +++ b/crates/miden-objects/Cargo.toml @@ -26,6 +26,7 @@ testing = ["dep:winter-rand-utils", "dep:rand", "dep:rand_xoshiro"] [dependencies] assembly = { workspace = true } +bech32 = { version = "0.11", default-features = false, features = ["alloc"] } log = { version = "0.4", optional = true } miden-crypto = { workspace = true } miden-verifier = { workspace = true } diff --git a/crates/miden-objects/src/account/account_id/address_type.rs b/crates/miden-objects/src/account/account_id/address_type.rs new file mode 100644 index 000000000..e5a090b25 --- /dev/null +++ b/crates/miden-objects/src/account/account_id/address_type.rs @@ -0,0 +1,6 @@ +/// The type of an address in Miden. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[repr(u8)] +pub enum AddressType { + AccountId = 0, +} diff --git a/crates/miden-objects/src/account/account_id/mod.rs b/crates/miden-objects/src/account/account_id/mod.rs index 357b49f5d..bce79c3cc 100644 --- a/crates/miden-objects/src/account/account_id/mod.rs +++ b/crates/miden-objects/src/account/account_id/mod.rs @@ -9,10 +9,18 @@ pub use id_prefix::AccountIdPrefix; mod seed; +mod network_id; +pub use network_id::NetworkId; + +mod address_type; +pub use address_type::AddressType; + mod account_type; pub use account_type::AccountType; + mod storage_mode; pub use storage_mode::AccountStorageMode; + mod id_version; use alloc::string::{String, ToString}; use core::fmt; @@ -298,6 +306,53 @@ impl AccountId { } } + /// Encodes the [`AccountId`] into a [bech32](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki) string. + /// + /// # Encoding + /// + /// The encoding of an account ID into bech32 is done as follows: + /// - Convert the account ID into its `[u8; 15]` data format. + /// - Insert the address type [`AddressType::AccountId`] byte at index 0, shifting all other + /// elements to the right. + /// - Choose an HRP, defined as a [`NetworkId`], for example [`NetworkId::Mainnet`] whose string + /// representation is `mm`. + /// - Encode the resulting HRP together with the data into a bech32 string using the + /// [`bech32::Bech32m`] checksum algorithm. + /// + /// This is an example of an account ID in hex and bech32 representations: + /// + /// ```text + /// hex: 0x140fa04a1e61fc100000126ef8f1d6 + /// bech32: mm1qq2qlgz2reslcyqqqqfxa7836chrjcvk + /// ``` + /// + /// ## Rationale + /// + /// Having the address type at the very beginning is so that it can be decoded to detect the + /// type of the address without having to decode the entire data. Moreover, choosing the + /// address type as a multiple of 8 means the first character of the bech32 string after the + /// `1` separator will be different for every address type. This makes the type of the address + /// conveniently human-readable. + /// + /// The only allowed checksum algorithm is [`Bech32m`](bech32::Bech32m) due to being the best + /// available checksum algorithm with no known weaknesses (unlike [`Bech32`](bech32::Bech32)). + /// No checksum is also not allowed since the intended use of bech32 is to have error + /// detection capabilities. + pub fn to_bech32(&self, network_id: NetworkId) -> String { + match self { + AccountId::V0(account_id_v0) => account_id_v0.to_bech32(network_id), + } + } + + /// Decodes a [bech32](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki) string into an [`AccountId`]. + /// + /// See [`AccountId::to_bech32`] for details on the format. The procedure for decoding the + /// bech32 data into the ID consists of the inverse operations of encoding. + pub fn from_bech32(bech32_string: &str) -> Result<(NetworkId, Self), AccountIdError> { + AccountIdV0::from_bech32(bech32_string) + .map(|(network_id, account_id)| (network_id, AccountId::V0(account_id))) + } + /// Returns the [`AccountIdPrefix`] of this ID. /// /// The prefix of an account ID is guaranteed to be unique. @@ -471,11 +526,21 @@ impl Deserializable for AccountId { #[cfg(test)] mod tests { + use assert_matches::assert_matches; + use bech32::{Bech32, Bech32m, Hrp, NoChecksum}; + use super::*; - use crate::testing::account_id::{ - ACCOUNT_ID_FUNGIBLE_FAUCET_ON_CHAIN, ACCOUNT_ID_NON_FUNGIBLE_FAUCET_OFF_CHAIN, - ACCOUNT_ID_OFF_CHAIN_SENDER, ACCOUNT_ID_REGULAR_ACCOUNT_IMMUTABLE_CODE_ON_CHAIN, - ACCOUNT_ID_REGULAR_ACCOUNT_UPDATABLE_CODE_OFF_CHAIN, + use crate::{ + account::account_id::{ + address_type::AddressType, + v0::{extract_storage_mode, extract_type, extract_version}, + }, + errors::Bech32Error, + testing::account_id::{ + ACCOUNT_ID_FUNGIBLE_FAUCET_ON_CHAIN, ACCOUNT_ID_NON_FUNGIBLE_FAUCET_OFF_CHAIN, + ACCOUNT_ID_OFF_CHAIN_SENDER, ACCOUNT_ID_REGULAR_ACCOUNT_IMMUTABLE_CODE_ON_CHAIN, + ACCOUNT_ID_REGULAR_ACCOUNT_UPDATABLE_CODE_OFF_CHAIN, + }, }; #[test] @@ -498,4 +563,125 @@ mod tests { ); } } + + #[test] + fn bech32_encode_decode_roundtrip() { + // We use this to check that encoding does not panic even when using the longest possible + // HRP. + let longest_possible_hrp = + "01234567890123456789012345678901234567890123456789012345678901234567890123456789012"; + assert_eq!(longest_possible_hrp.len(), 83); + + for network_id in [ + NetworkId::Mainnet, + NetworkId::Custom(Hrp::parse("custom").unwrap()), + NetworkId::Custom(Hrp::parse(longest_possible_hrp).unwrap()), + ] { + for (idx, account_id) in [ + ACCOUNT_ID_REGULAR_ACCOUNT_IMMUTABLE_CODE_ON_CHAIN, + ACCOUNT_ID_REGULAR_ACCOUNT_UPDATABLE_CODE_OFF_CHAIN, + ACCOUNT_ID_FUNGIBLE_FAUCET_ON_CHAIN, + ACCOUNT_ID_NON_FUNGIBLE_FAUCET_OFF_CHAIN, + ACCOUNT_ID_OFF_CHAIN_SENDER, + ] + .into_iter() + .enumerate() + { + let account_id = AccountId::try_from(account_id).unwrap(); + + let bech32_string = account_id.to_bech32(network_id); + let (decoded_network_id, decoded_account_id) = + AccountId::from_bech32(&bech32_string).unwrap(); + + assert_eq!(network_id, decoded_network_id, "network id failed in {idx}"); + assert_eq!(account_id, decoded_account_id, "account id failed in {idx}"); + + let (_, data) = bech32::decode(&bech32_string).unwrap(); + + // Raw bech32 data should contain the address type as the first byte. + assert_eq!(data[0], AddressType::AccountId as u8); + + // Raw bech32 data should contain the metadata byte at index 8. + assert_eq!(extract_version(data[8] as u64).unwrap(), account_id.version()); + assert_eq!(extract_type(data[8] as u64), account_id.account_type()); + assert_eq!( + extract_storage_mode(data[8] as u64).unwrap(), + account_id.storage_mode() + ); + } + } + } + + #[test] + fn bech32_invalid_checksum() { + let network_id = NetworkId::Mainnet; + let account_id = AccountId::try_from(ACCOUNT_ID_FUNGIBLE_FAUCET_ON_CHAIN).unwrap(); + + let bech32_string = account_id.to_bech32(network_id); + let mut invalid_bech32_1 = bech32_string.clone(); + invalid_bech32_1.remove(0); + let mut invalid_bech32_2 = bech32_string.clone(); + invalid_bech32_2.remove(7); + + let error = AccountId::from_bech32(&invalid_bech32_1).unwrap_err(); + assert_matches!(error, AccountIdError::Bech32DecodeError(Bech32Error::DecodeError(_))); + + let error = AccountId::from_bech32(&invalid_bech32_2).unwrap_err(); + assert_matches!(error, AccountIdError::Bech32DecodeError(Bech32Error::DecodeError(_))); + } + + #[test] + fn bech32_invalid_address_type() { + let account_id = AccountId::try_from(ACCOUNT_ID_FUNGIBLE_FAUCET_ON_CHAIN).unwrap(); + let mut id_bytes = account_id.to_bytes(); + + // Set invalid address type. + id_bytes.insert(0, 16); + + let invalid_bech32 = + bech32::encode::(NetworkId::Mainnet.into_hrp(), &id_bytes).unwrap(); + + let error = AccountId::from_bech32(&invalid_bech32).unwrap_err(); + assert_matches!( + error, + AccountIdError::Bech32DecodeError(Bech32Error::UnknownAddressType(16)) + ); + } + + #[test] + fn bech32_invalid_other_checksum() { + let account_id = AccountId::try_from(ACCOUNT_ID_FUNGIBLE_FAUCET_ON_CHAIN).unwrap(); + let mut id_bytes = account_id.to_bytes(); + id_bytes.insert(0, AddressType::AccountId as u8); + + // Use Bech32 instead of Bech32m which is disallowed. + let invalid_bech32_regular = + bech32::encode::(NetworkId::Mainnet.into_hrp(), &id_bytes).unwrap(); + let error = AccountId::from_bech32(&invalid_bech32_regular).unwrap_err(); + assert_matches!(error, AccountIdError::Bech32DecodeError(Bech32Error::DecodeError(_))); + + // Use no checksum instead of Bech32m which is disallowed. + let invalid_bech32_no_checksum = + bech32::encode::(NetworkId::Mainnet.into_hrp(), &id_bytes).unwrap(); + let error = AccountId::from_bech32(&invalid_bech32_no_checksum).unwrap_err(); + assert_matches!(error, AccountIdError::Bech32DecodeError(Bech32Error::DecodeError(_))); + } + + #[test] + fn bech32_invalid_length() { + let account_id = AccountId::try_from(ACCOUNT_ID_FUNGIBLE_FAUCET_ON_CHAIN).unwrap(); + let mut id_bytes = account_id.to_bytes(); + id_bytes.insert(0, AddressType::AccountId as u8); + // Add one byte to make the length invalid. + id_bytes.push(5); + + let invalid_bech32 = + bech32::encode::(NetworkId::Mainnet.into_hrp(), &id_bytes).unwrap(); + + let error = AccountId::from_bech32(&invalid_bech32).unwrap_err(); + assert_matches!( + error, + AccountIdError::Bech32DecodeError(Bech32Error::InvalidDataLength { .. }) + ); + } } diff --git a/crates/miden-objects/src/account/account_id/network_id.rs b/crates/miden-objects/src/account/account_id/network_id.rs new file mode 100644 index 000000000..9b38a73d1 --- /dev/null +++ b/crates/miden-objects/src/account/account_id/network_id.rs @@ -0,0 +1,107 @@ +use alloc::string::ToString; +use core::str::FromStr; + +use bech32::Hrp; + +use crate::errors::NetworkIdError; + +// This is essentially a wrapper around [`bech32::Hrp`] but that type does not actually appear in +// the public API since that crate does not have a stable release. + +/// The identifier of a Miden network. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum NetworkId { + Mainnet, + Testnet, + Devnet, + Custom(Hrp), +} + +impl NetworkId { + const MAINNET: &str = "mm"; + const TESTNET: &str = "mtst"; + const DEVNET: &str = "mdev"; + + /// Constructs a new [`NetworkId`] from a string. + /// + /// # Errors + /// + /// Returns an error if: + /// - the string does not contain between 1 to 83 US-ASCII characters. + /// - each character is not in the range 33-126. + pub fn new(string: &str) -> Result { + Hrp::parse(string) + .map(Self::from_hrp) + .map_err(|source| NetworkIdError::NetworkIdParseError(source.to_string().into())) + } + + /// Constructs a new [`NetworkId`] from an [`Hrp`]. + /// + /// This method should not be made public to avoid having `bech32` types in the public API. + pub(crate) fn from_hrp(hrp: Hrp) -> Self { + match hrp.as_str() { + NetworkId::MAINNET => NetworkId::Mainnet, + NetworkId::TESTNET => NetworkId::Testnet, + NetworkId::DEVNET => NetworkId::Devnet, + _ => NetworkId::Custom(hrp), + } + } + + /// Returns the [`Hrp`] of this network ID. + /// + /// This method should not be made public to avoid having `bech32` types in the public API. + pub(crate) fn into_hrp(self) -> Hrp { + match self { + NetworkId::Mainnet => { + Hrp::parse(NetworkId::MAINNET).expect("mainnet hrp should be valid") + }, + NetworkId::Testnet => { + Hrp::parse(NetworkId::TESTNET).expect("testnet hrp should be valid") + }, + NetworkId::Devnet => Hrp::parse(NetworkId::DEVNET).expect("devnet hrp should be valid"), + NetworkId::Custom(custom) => custom, + } + } + + /// Returns the string representation of the network ID. + pub fn as_str(&self) -> &str { + match self { + NetworkId::Mainnet => NetworkId::MAINNET, + NetworkId::Testnet => NetworkId::TESTNET, + NetworkId::Devnet => NetworkId::DEVNET, + NetworkId::Custom(custom) => custom.as_str(), + } + } + + /// Returns `true` if the network ID is the Miden mainnet, `false` otherwise. + pub fn is_mainnet(&self) -> bool { + matches!(self, NetworkId::Mainnet) + } + + /// Returns `true` if the network ID is the Miden testnet, `false` otherwise. + pub fn is_testnet(&self) -> bool { + matches!(self, NetworkId::Testnet) + } + + /// Returns `true` if the network ID is the Miden devnet, `false` otherwise. + pub fn is_devnet(&self) -> bool { + matches!(self, NetworkId::Devnet) + } +} + +impl FromStr for NetworkId { + type Err = NetworkIdError; + + /// Constructs a new [`NetworkId`] from a string. + /// + /// See [`NetworkId::new`] for details on errors. + fn from_str(string: &str) -> Result { + Self::new(string) + } +} + +impl core::fmt::Display for NetworkId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/crates/miden-objects/src/account/account_id/v0/mod.rs b/crates/miden-objects/src/account/account_id/v0/mod.rs index 69cf8d99d..7e06e3316 100644 --- a/crates/miden-objects/src/account/account_id/v0/mod.rs +++ b/crates/miden-objects/src/account/account_id/v0/mod.rs @@ -5,6 +5,7 @@ use alloc::{ }; use core::fmt; +use bech32::{primitives::decode::CheckedHrpstring, Bech32m}; use miden_crypto::{merkle::LeafIndex, utils::hex_to_bytes}; pub use prefix::AccountIdPrefixV0; use vm_core::{ @@ -20,11 +21,13 @@ use crate::{ FUNGIBLE_FAUCET, NON_FUNGIBLE_FAUCET, REGULAR_ACCOUNT_IMMUTABLE_CODE, REGULAR_ACCOUNT_UPDATABLE_CODE, }, + address_type::AddressType, storage_mode::{PRIVATE, PUBLIC}, + NetworkId, }, AccountIdAnchor, AccountIdVersion, AccountStorageMode, AccountType, }, - errors::AccountIdError, + errors::{AccountIdError, Bech32Error}, AccountError, Hasher, ACCOUNT_TREE_DEPTH, }; @@ -223,6 +226,64 @@ impl AccountIdV0 { hex_string } + /// See [`AccountId::to_bech32`](super::AccountId::to_bech32) for details. + pub fn to_bech32(&self, network_id: NetworkId) -> String { + let id_bytes: [u8; Self::SERIALIZED_SIZE] = (*self).into(); + + let mut data = [0; Self::SERIALIZED_SIZE + 1]; + data[0] = AddressType::AccountId as u8; + data[1..16].copy_from_slice(&id_bytes); + + // SAFETY: Encoding only panics if the total length of the hrp, data (in GF(32)), separator + // and checksum exceeds Bech32m::CODE_LENGTH, which is 1023. Since the data is 26 bytes in + // that field and the hrp is at most 83 in size we are way below the limit. + bech32::encode::(network_id.into_hrp(), &data) + .expect("code length of bech32 should not be exceeded") + } + + /// See [`AccountId::from_bech32`](super::AccountId::from_bech32) for details. + pub fn from_bech32(bech32_string: &str) -> Result<(NetworkId, Self), AccountIdError> { + // We use CheckedHrpString with an explicit checksum algorithm so we don't allow the + // `Bech32` or `NoChecksum` algorithms. + let checked_string = CheckedHrpstring::new::(bech32_string).map_err(|source| { + // The CheckedHrpStringError does not implement core::error::Error, only + // std::error::Error, so for now we convert it to a String. Even if it will + // implement the trait in the future, we should include it as an opaque + // error since the crate does not have a stable release yet. + AccountIdError::Bech32DecodeError(Bech32Error::DecodeError(source.to_string().into())) + })?; + + let hrp = checked_string.hrp(); + let network_id = NetworkId::from_hrp(hrp); + + let mut byte_iter = checked_string.byte_iter(); + // The length must be the serialized size of the account ID plus the address byte. + if byte_iter.len() != Self::SERIALIZED_SIZE + 1 { + return Err(AccountIdError::Bech32DecodeError(Bech32Error::InvalidDataLength { + expected: Self::SERIALIZED_SIZE + 1, + actual: byte_iter.len(), + })); + } + + let address_byte = byte_iter.next().expect("there should be at least one byte"); + if address_byte != AddressType::AccountId as u8 { + return Err(AccountIdError::Bech32DecodeError(Bech32Error::UnknownAddressType( + address_byte, + ))); + } + + // Every byte is guaranteed to be overwritten since we've checked the length of the + // iterator. + let mut id_bytes = [0_u8; Self::SERIALIZED_SIZE]; + for (i, byte) in byte_iter.enumerate() { + id_bytes[i] = byte; + } + + let account_id = Self::try_from(id_bytes)?; + + Ok((network_id, account_id)) + } + /// Returns the [`AccountIdPrefixV0`] of this account ID. /// /// See also [`AccountId::prefix`](super::AccountId::prefix) for details. diff --git a/crates/miden-objects/src/account/mod.rs b/crates/miden-objects/src/account/mod.rs index 2b46de3c6..b555716f0 100644 --- a/crates/miden-objects/src/account/mod.rs +++ b/crates/miden-objects/src/account/mod.rs @@ -7,7 +7,7 @@ use crate::{ mod account_id; pub use account_id::{ AccountId, AccountIdAnchor, AccountIdPrefix, AccountIdPrefixV0, AccountIdV0, AccountIdVersion, - AccountStorageMode, AccountType, + AccountStorageMode, AccountType, AddressType, NetworkId, }; pub mod auth; diff --git a/crates/miden-objects/src/errors.rs b/crates/miden-objects/src/errors.rs index e8b50130d..d188781f3 100644 --- a/crates/miden-objects/src/errors.rs +++ b/crates/miden-objects/src/errors.rs @@ -16,7 +16,7 @@ use super::{ }; use crate::{ account::{ - AccountCode, AccountIdPrefix, AccountStorage, AccountType, StorageValueName, + AccountCode, AccountIdPrefix, AccountStorage, AccountType, AddressType, StorageValueName, StorageValueNameError, TemplateTypeError, }, batch::BatchId, @@ -154,6 +154,32 @@ pub enum AccountIdError { BlockNumber::EPOCH_LENGTH_EXPONENT )] AnchorBlockMustBeEpochBlock, + #[error("failed to decode bech32 string into account ID")] + Bech32DecodeError(#[source] Bech32Error), +} + +// BECH32 ERROR +// ================================================================================================ + +#[derive(Debug, Error)] +pub enum Bech32Error { + #[error("failed to decode bech32 string")] + DecodeError(#[source] Box), + #[error("found unknown address type {0} which is not the expected {account_addr} account ID address type", + account_addr = AddressType::AccountId as u8 + )] + UnknownAddressType(u8), + #[error("expected bech32 data to be of length {expected} but it was of length {actual}")] + InvalidDataLength { expected: usize, actual: usize }, +} + +// NETWORK ID ERROR +// ================================================================================================ + +#[derive(Debug, Error)] +pub enum NetworkIdError { + #[error("failed to parse string into a network ID")] + NetworkIdParseError(#[source] Box), } // ACCOUNT DELTA ERROR