Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
40 changes: 22 additions & 18 deletions packages/rs-dpp/src/address_funds/orchard_address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,24 +87,30 @@ impl OrchardAddress {

/// Decodes a bech32m-encoded Orchard address string.
///
/// An `OrchardAddress` is network-agnostic: the network is supplied only at
/// [`Self::to_bech32m_string`] encode time. The HRP is validated to be a
/// recognized platform HRP (`dash`/`tdash`), but no network is inferred —
/// `tdash` is shared by Testnet/Devnet/Regtest, so the HRP cannot identify
/// the network. Callers needing a network guard must enforce it themselves.
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Resolved
///
/// # 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 address is invalid 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.
Resolved

// Validate the HRP is a recognized platform HRP (case-insensitive). No
// network is derived — the HRP is ambiguous across the tdash-shared
// networks.
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
)))
}
};
if hrp_lower != PLATFORM_HRP_MAINNET && hrp_lower != PLATFORM_HRP_TESTNET {
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Resolved
return Err(ProtocolError::DecodingError(format!(
"invalid HRP '{}': expected '{}' or '{}'",
hrp, PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET
)));
}

// Validate payload: 1 type byte + 11 diversifier + 32 pk_d = 44 bytes
if data.len() != 1 + ORCHARD_ADDRESS_SIZE {
Expand All @@ -125,7 +131,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 +195,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 +211,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
168 changes: 134 additions & 34 deletions packages/rs-dpp/src/address_funds/platform_address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,26 +246,31 @@ impl PlatformAddress {
/// NOTE: This expects bech32m type bytes (0xb0/0x80) in the encoded string,
/// NOT the storage type bytes (0x00/0x01) used in GroveDB keys.
///
/// A `PlatformAddress` is network-agnostic: the network is supplied only at
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Resolved
/// [`Self::to_bech32m_string`] encode time. The HRP is validated to be a
/// recognized platform HRP (`dash`/`tdash`), but no network is inferred —
/// `tdash` is shared by Testnet/Devnet/Regtest, so the HRP cannot identify
/// the network. Callers needing a network guard must enforce it themselves.
///
/// # 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> {
/// - `Ok(PlatformAddress)` - The decoded address
/// - `Err(ProtocolError)` - If the address is invalid or its HRP is not a
/// recognized platform HRP
pub fn from_bech32m_string(s: &str) -> Result<Self, ProtocolError> {
// Decode the bech32m string
let (hrp, data) =
bech32::decode(s).map_err(|e| ProtocolError::DecodingError(format!("{}", e)))?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// Determine network from HRP (case-insensitive per DIP-0018)
// Validate the HRP is a recognized platform HRP (case-insensitive per
// DIP-0018). No network is derived — the HRP is ambiguous across the
// tdash-shared networks.
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
)))
}
};
if hrp_lower != PLATFORM_HRP_MAINNET && hrp_lower != PLATFORM_HRP_TESTNET {
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Resolved
return Err(ProtocolError::DecodingError(format!(
"invalid HRP '{}': expected '{}' or '{}'",
hrp, PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET
)));
}

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

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

/// Classifies a bech32m platform-address string as mainnet or non-mainnet
/// by its HRP alone, without decoding the payload.
///
/// This is the only truthful network signal an address string carries: per
/// DIP-0018 the prefix `dash` means mainnet and `tdash` means non-mainnet,
/// but `tdash` is shared by Testnet, Devnet, and Regtest and the payload
/// holds no network byte — so the specific non-mainnet network is NOT
/// recoverable from an address string. The HRP is the segment before the
/// final `'1'` separator (bech32's data charset excludes `'1'`); the
/// comparison is case-insensitive since bech32m permits all-uppercase.
///
/// # Returns
/// - `Ok(true)` - mainnet (`dash` HRP)
/// - `Ok(false)` - non-mainnet (`tdash` HRP: Testnet/Devnet/Regtest)
/// - `Err(ProtocolError)` - malformed (no bech32 separator) or a
/// non-platform HRP
pub fn is_mainnet_bech32m(s: &str) -> Result<bool, ProtocolError> {
let hrp = s
.rsplit_once('1')
.map(|(hrp, _)| hrp)
.filter(|h| !h.is_empty())
.ok_or_else(|| {
ProtocolError::DecodingError(
"invalid platform address: missing bech32 separator".to_string(),
)
})?;
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Resolved

if hrp.eq_ignore_ascii_case(PLATFORM_HRP_MAINNET) {
Ok(true)
} else if hrp.eq_ignore_ascii_case(PLATFORM_HRP_TESTNET) {
Ok(false)
} else {
Err(ProtocolError::DecodingError(format!(
"not a platform address: HRP '{hrp}' is neither \
'{PLATFORM_HRP_MAINNET}' nor '{PLATFORM_HRP_TESTNET}'"
)))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

/// Converts the PlatformAddress to a dashcore Address with the specified network.
Expand Down Expand Up @@ -683,17 +727,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 +1190,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 +1214,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 +1238,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 +1262,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 @@ -1354,8 +1390,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 +1402,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 +1411,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 +1462,74 @@ 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() {
let err = PlatformAddress::is_mainnet_bech32m("bc1qexampledata").unwrap_err();
assert!(
err.to_string().contains("not a platform address"),
"unexpected error: {err}"
);
}

#[test]
fn test_is_mainnet_bech32m_missing_separator_errors() {
let err = PlatformAddress::is_mainnet_bech32m("nodelimiterhere").unwrap_err();
assert!(
err.to_string().contains("missing bech32 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("missing bech32 separator"),
"unexpected error: {err}"
);
}
}
Loading
Loading