Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 13 additions & 20 deletions packages/rs-dpp/src/address_funds/orchard_address.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use bech32::{Bech32m, Hrp};
use dashcore::Network;

use crate::address_funds::platform_address::{PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET};
use crate::address_funds::platform_address::classify_platform_hrp;
use crate::address_funds::PlatformAddress;
use crate::ProtocolError;

Expand Down Expand Up @@ -87,24 +87,19 @@ impl OrchardAddress {

/// Decodes a bech32m-encoded Orchard address string.
///
/// Accepts both `dash` (mainnet) and `tdash` (non-mainnet) HRPs.
/// The address is network-agnostic; callers that need a network guard should
/// use [`PlatformAddress::is_mainnet_bech32m`] before decoding.
///
/// # Returns
/// - `Ok((OrchardAddress, Network))` - The decoded address and its network
/// - `Err(ProtocolError)` - If the address is invalid
pub fn from_bech32m_string(s: &str) -> Result<(Self, Network), ProtocolError> {
/// - `Ok(OrchardAddress)` - The decoded address
/// - `Err(ProtocolError)` - If the string is malformed or its HRP is not a
/// recognized platform HRP
pub fn from_bech32m_string(s: &str) -> Result<Self, ProtocolError> {
let (hrp, data) =
bech32::decode(s).map_err(|e| ProtocolError::DecodingError(format!("{}", e)))?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let hrp_lower = hrp.as_str().to_ascii_lowercase();
let network = match hrp_lower.as_str() {
s if s == PLATFORM_HRP_MAINNET => Network::Mainnet,
s if s == PLATFORM_HRP_TESTNET => Network::Testnet,
_ => {
return Err(ProtocolError::DecodingError(format!(
"invalid HRP '{}': expected '{}' or '{}'",
hrp, PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET
)))
}
};
classify_platform_hrp(&hrp.as_str().to_ascii_lowercase())?;

// Validate payload: 1 type byte + 11 diversifier + 32 pk_d = 44 bytes
if data.len() != 1 + ORCHARD_ADDRESS_SIZE {
Expand All @@ -125,7 +120,7 @@ impl OrchardAddress {

let mut raw = [0u8; ORCHARD_ADDRESS_SIZE];
raw.copy_from_slice(&data[1..]);
Self::from_raw_bytes(&raw).map(|addr| (addr, network))
Self::from_raw_bytes(&raw)
}
}

Expand Down Expand Up @@ -189,10 +184,9 @@ mod tests {
encoded
);

let (decoded, network) =
let decoded =
OrchardAddress::from_bech32m_string(&encoded).expect("decoding should succeed");
assert_eq!(decoded, address);
assert_eq!(network, Network::Mainnet);
}

#[test]
Expand All @@ -206,10 +200,9 @@ mod tests {
encoded
);

let (decoded, network) =
let decoded =
OrchardAddress::from_bech32m_string(&encoded).expect("decoding should succeed");
assert_eq!(decoded, address);
assert_eq!(network, Network::Testnet);
}

#[test]
Expand Down
172 changes: 131 additions & 41 deletions packages/rs-dpp/src/address_funds/platform_address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,21 @@ pub const PLATFORM_HRP_MAINNET: &str = "dash";
/// Human-readable part for Platform addresses on testnet/devnet/regtest (DIP-0018)
pub const PLATFORM_HRP_TESTNET: &str = "tdash";

/// Validates an already-lowercased HRP and returns whether it is mainnet.
///
/// `true` = mainnet (`dash`), `false` = non-mainnet (`tdash`).
/// Returns an error for any other value.
pub(crate) fn classify_platform_hrp(hrp: &str) -> Result<bool, ProtocolError> {
match hrp {
PLATFORM_HRP_MAINNET => Ok(true),
PLATFORM_HRP_TESTNET => Ok(false),
other => Err(ProtocolError::DecodingError(format!(
"not a platform address: HRP '{other}' is neither \
'{PLATFORM_HRP_MAINNET}' nor '{PLATFORM_HRP_TESTNET}'"
))),
}
}

impl PlatformAddress {
/// Type byte for P2PKH addresses in bech32m encoding (user-facing)
pub const P2PKH_TYPE: u8 = 0xb0;
Expand Down Expand Up @@ -243,29 +258,19 @@ impl PlatformAddress {

/// Decodes a bech32m-encoded Platform address string per DIP-0018.
///
/// NOTE: This expects bech32m type bytes (0xb0/0x80) in the encoded string,
/// NOT the storage type bytes (0x00/0x01) used in GroveDB keys.
/// Accepts both `dash` (mainnet) and `tdash` (non-mainnet) HRPs.
/// The address is network-agnostic; callers that need a network guard should
/// use [`is_mainnet_bech32m`](Self::is_mainnet_bech32m) before decoding.
///
/// # Returns
/// - `Ok((PlatformAddress, Network))` - The decoded address and its network
/// - `Err(ProtocolError)` - If the address is invalid
pub fn from_bech32m_string(s: &str) -> Result<(Self, Network), ProtocolError> {
// Decode the bech32m string
/// - `Ok(PlatformAddress)` - The decoded address
/// - `Err(ProtocolError)` - If the string is malformed or its HRP is not a
/// recognized platform HRP
pub fn from_bech32m_string(s: &str) -> Result<Self, ProtocolError> {
let (hrp, data) =
bech32::decode(s).map_err(|e| ProtocolError::DecodingError(format!("{}", e)))?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Determine network from HRP (case-insensitive per DIP-0018)
let hrp_lower = hrp.as_str().to_ascii_lowercase();
let network = match hrp_lower.as_str() {
s if s == PLATFORM_HRP_MAINNET => Network::Mainnet,
s if s == PLATFORM_HRP_TESTNET => Network::Testnet,
_ => {
return Err(ProtocolError::DecodingError(format!(
"invalid HRP '{}': expected '{}' or '{}'",
hrp, PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET
)))
}
};
classify_platform_hrp(&hrp.as_str().to_ascii_lowercase())?;

// Validate payload length: 1 type byte + 20 hash bytes = 21 bytes
if data.len() != 1 + ADDRESS_HASH_SIZE {
Expand All @@ -291,7 +296,23 @@ impl PlatformAddress {
))),
}?;

Ok((address, network))
Ok(address)
}

/// Classifies a bech32m platform-address string as mainnet or non-mainnet.
///
/// Fully decodes `s` (validating checksum and data part) then classifies
/// the HRP: `dash` means mainnet, `tdash` means non-mainnet (Testnet /
/// Devnet / Regtest — these are indistinguishable by HRP alone per DIP-0018).
///
/// # Returns
/// - `Ok(true)` - mainnet (`dash` HRP)
/// - `Ok(false)` - non-mainnet (`tdash` HRP: Testnet/Devnet/Regtest)
/// - `Err(ProtocolError)` - malformed address or non-platform HRP
pub fn is_mainnet_bech32m(s: &str) -> Result<bool, ProtocolError> {
let (hrp, _) =
bech32::decode(s).map_err(|e| ProtocolError::DecodingError(format!("{e}")))?;
classify_platform_hrp(&hrp.to_lowercase())
}

/// Converts the PlatformAddress to a dashcore Address with the specified network.
Expand Down Expand Up @@ -683,17 +704,13 @@ impl FromStr for PlatformAddress {
/// Parses a bech32m-encoded Platform address string.
///
/// This accepts addresses with either mainnet ("dash") or testnet ("tdash") HRP.
/// The network information is discarded; use `from_bech32m_string` if you need
/// to preserve the network.
///
/// # Example
/// ```ignore
/// let address: PlatformAddress = "dash1k...".parse()?;
/// ```
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_bech32m_string(s)
.map(|(addr, _network)| addr)
.map_err(|e| PlatformAddressParseError(e.to_string()))
Self::from_bech32m_string(s).map_err(|e| PlatformAddressParseError(e.to_string()))
}
}

Expand Down Expand Up @@ -1150,10 +1167,9 @@ mod tests {
);

// Decode and verify roundtrip
let (decoded, network) =
let decoded =
PlatformAddress::from_bech32m_string(&encoded).expect("decoding should succeed");
assert_eq!(decoded, address);
assert_eq!(network, Network::Mainnet);
}

#[test]
Expand All @@ -1175,10 +1191,9 @@ mod tests {
);

// Decode and verify roundtrip
let (decoded, network) =
let decoded =
PlatformAddress::from_bech32m_string(&encoded).expect("decoding should succeed");
assert_eq!(decoded, address);
assert_eq!(network, Network::Testnet);
}

#[test]
Expand All @@ -1200,10 +1215,9 @@ mod tests {
);

// Decode and verify roundtrip
let (decoded, network) =
let decoded =
PlatformAddress::from_bech32m_string(&encoded).expect("decoding should succeed");
assert_eq!(decoded, address);
assert_eq!(network, Network::Mainnet);
}

#[test]
Expand All @@ -1225,10 +1239,9 @@ mod tests {
);

// Decode and verify roundtrip
let (decoded, network) =
let decoded =
PlatformAddress::from_bech32m_string(&encoded).expect("decoding should succeed");
assert_eq!(decoded, address);
assert_eq!(network, Network::Testnet);
}

#[test]
Expand Down Expand Up @@ -1261,7 +1274,6 @@ mod tests {

#[test]
fn test_bech32m_invalid_hrp_fails() {
// Create a valid bech32m address with wrong HRP using the bech32 crate directly
let wrong_hrp = Hrp::parse("bitcoin").unwrap();
let payload: [u8; 21] = [0x00; 21];
let wrong_hrp_address = bech32::encode::<Bech32m>(wrong_hrp, &payload).unwrap();
Expand All @@ -1270,8 +1282,8 @@ mod tests {
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("invalid HRP"),
"Error should mention invalid HRP: {}",
err.to_string().contains("not a platform address"),
"Error should mention non-platform HRP: {}",
err
);
}
Expand Down Expand Up @@ -1354,8 +1366,8 @@ mod tests {
let uppercase = lowercase.to_uppercase();

// Both should decode to the same address
let (decoded_lower, _) = PlatformAddress::from_bech32m_string(&lowercase).unwrap();
let (decoded_upper, _) = PlatformAddress::from_bech32m_string(&uppercase).unwrap();
let decoded_lower = PlatformAddress::from_bech32m_string(&lowercase).unwrap();
let decoded_upper = PlatformAddress::from_bech32m_string(&uppercase).unwrap();

assert_eq!(decoded_lower, decoded_upper);
assert_eq!(decoded_lower, address);
Expand All @@ -1366,7 +1378,7 @@ mod tests {
// Edge case: all-zero hash
let address = PlatformAddress::P2pkh([0u8; 20]);
let encoded = address.to_bech32m_string(Network::Mainnet);
let (decoded, _) = PlatformAddress::from_bech32m_string(&encoded).unwrap();
let decoded = PlatformAddress::from_bech32m_string(&encoded).unwrap();
assert_eq!(decoded, address);
}

Expand All @@ -1375,7 +1387,7 @@ mod tests {
// Edge case: all-ones hash
let address = PlatformAddress::P2sh([0xFF; 20]);
let encoded = address.to_bech32m_string(Network::Mainnet);
let (decoded, _) = PlatformAddress::from_bech32m_string(&encoded).unwrap();
let decoded = PlatformAddress::from_bech32m_string(&encoded).unwrap();
assert_eq!(decoded, address);
}

Expand Down Expand Up @@ -1426,10 +1438,88 @@ mod tests {
let p2pkh_encoded = p2pkh.to_bech32m_string(Network::Mainnet);
let p2sh_encoded = p2sh.to_bech32m_string(Network::Mainnet);

let (p2pkh_decoded, _) = PlatformAddress::from_bech32m_string(&p2pkh_encoded).unwrap();
let (p2sh_decoded, _) = PlatformAddress::from_bech32m_string(&p2sh_encoded).unwrap();
let p2pkh_decoded = PlatformAddress::from_bech32m_string(&p2pkh_encoded).unwrap();
let p2sh_decoded = PlatformAddress::from_bech32m_string(&p2sh_encoded).unwrap();

assert_eq!(p2pkh_decoded, p2pkh);
assert_eq!(p2sh_decoded, p2sh);
}

#[test]
fn test_is_mainnet_bech32m_mainnet_is_true() {
let encoded = PlatformAddress::P2pkh([0x11; 20]).to_bech32m_string(Network::Mainnet);
assert!(encoded.starts_with("dash1"));
assert!(PlatformAddress::is_mainnet_bech32m(&encoded).unwrap());
}

#[test]
fn test_is_mainnet_bech32m_all_non_mainnet_networks_are_false() {
// Testnet, Devnet, and Regtest all share the `tdash` HRP, so all three
// classify as non-mainnet (false) — the only truthful answer DIP-0018
// allows from the address string alone.
for network in [Network::Testnet, Network::Devnet, Network::Regtest] {
let encoded = PlatformAddress::P2pkh([0x22; 20]).to_bech32m_string(network);
assert!(encoded.starts_with("tdash1"), "network {network:?}");
assert!(
!PlatformAddress::is_mainnet_bech32m(&encoded).unwrap(),
"network {network:?} must classify as non-mainnet"
);
}
}

#[test]
fn test_is_mainnet_bech32m_is_case_insensitive() {
let mainnet = PlatformAddress::P2pkh([0x33; 20])
.to_bech32m_string(Network::Mainnet)
.to_uppercase();
assert!(mainnet.starts_with("DASH1"));
assert!(PlatformAddress::is_mainnet_bech32m(&mainnet).unwrap());

let testnet = PlatformAddress::P2pkh([0x44; 20])
.to_bech32m_string(Network::Testnet)
.to_uppercase();
assert!(testnet.starts_with("TDASH1"));
assert!(!PlatformAddress::is_mainnet_bech32m(&testnet).unwrap());
}

#[test]
fn test_is_mainnet_bech32m_non_platform_hrp_errors() {
// Valid Bitcoin bech32 address: decode succeeds, HRP "bc" triggers error.
let err = PlatformAddress::is_mainnet_bech32m("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
.unwrap_err();
assert!(
err.to_string().contains("not a platform address"),
"unexpected error: {err}"
);
}

#[test]
fn test_is_mainnet_bech32m_malformed_data_part_errors() {
// `dash1!` has a valid HRP but `!` is not a bech32 character.
// Previously this returned Ok(true) (the HRP-only check); now it must
// return an error because bech32::decode validates the full string.
assert!(
PlatformAddress::is_mainnet_bech32m("dash1!").is_err(),
"dash1! must error, not return Ok(true)"
);
}

#[test]
fn test_is_mainnet_bech32m_missing_separator_errors() {
let err = PlatformAddress::is_mainnet_bech32m("nodelimiterhere").unwrap_err();
// bech32::decode returns "parsing failed" for strings without separator
assert!(
err.to_string().contains("parsing failed") || err.to_string().contains("separator"),
"unexpected error: {err}"
);
}

#[test]
fn test_is_mainnet_bech32m_empty_errors() {
let err = PlatformAddress::is_mainnet_bech32m("").unwrap_err();
assert!(
err.to_string().contains("parsing failed") || err.to_string().contains("separator"),
"unexpected error: {err}"
);
}
}
Loading
Loading