Skip to content

Commit

Permalink
feat: Implement bech32 encoding and decoding for AccountId (#1185)
Browse files Browse the repository at this point in the history
  • Loading branch information
PhilippGackstatter authored Mar 4, 2025
1 parent 1472d0f commit 3641865
Show file tree
Hide file tree
Showing 9 changed files with 402 additions and 7 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/miden-objects/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
6 changes: 6 additions & 0 deletions crates/miden-objects/src/account/account_id/address_type.rs
Original file line number Diff line number Diff line change
@@ -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,
}
194 changes: 190 additions & 4 deletions crates/miden-objects/src/account/account_id/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand All @@ -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::<Bech32m>(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::<Bech32>(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::<NoChecksum>(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::<Bech32m>(NetworkId::Mainnet.into_hrp(), &id_bytes).unwrap();

let error = AccountId::from_bech32(&invalid_bech32).unwrap_err();
assert_matches!(
error,
AccountIdError::Bech32DecodeError(Bech32Error::InvalidDataLength { .. })
);
}
}
107 changes: 107 additions & 0 deletions crates/miden-objects/src/account/account_id/network_id.rs
Original file line number Diff line number Diff line change
@@ -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<Self, NetworkIdError> {
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, Self::Err> {
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())
}
}
Loading

0 comments on commit 3641865

Please sign in to comment.