diff --git a/packages/rs-dpp/src/address_funds/orchard_address.rs b/packages/rs-dpp/src/address_funds/orchard_address.rs index 9e27a6f9dc..c2e3114f9c 100644 --- a/packages/rs-dpp/src/address_funds/orchard_address.rs +++ b/packages/rs-dpp/src/address_funds/orchard_address.rs @@ -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; @@ -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 { let (hrp, data) = bech32::decode(s).map_err(|e| ProtocolError::DecodingError(format!("{}", e)))?; - 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 { @@ -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) } } @@ -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] @@ -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] diff --git a/packages/rs-dpp/src/address_funds/platform_address.rs b/packages/rs-dpp/src/address_funds/platform_address.rs index c7967df080..959e1c5550 100644 --- a/packages/rs-dpp/src/address_funds/platform_address.rs +++ b/packages/rs-dpp/src/address_funds/platform_address.rs @@ -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 { + 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; @@ -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 { let (hrp, data) = bech32::decode(s).map_err(|e| ProtocolError::DecodingError(format!("{}", e)))?; - // 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 { @@ -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 { + 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. @@ -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::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())) } } @@ -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] @@ -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] @@ -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] @@ -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] @@ -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::(wrong_hrp, &payload).unwrap(); @@ -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 ); } @@ -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); @@ -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); } @@ -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); } @@ -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}" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 8a95c25d21..7c22401f9c 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -607,8 +607,9 @@ impl PlatformWallet { /// Unshield from `account`'s notes to a transparent platform /// address (`"dash1…"` / `"tdash1…"`). Parsed via - /// `PlatformAddress::from_bech32m_string` and verified against - /// the wallet's network. + /// `PlatformAddress::from_bech32m_string`; the recipient's HRP is + /// verified against the wallet's network HRP class here, since the + /// network-agnostic decoder no longer enforces it. #[cfg(feature = "shielded")] pub async fn shielded_unshield_to( &self, @@ -627,23 +628,13 @@ impl PlatformWallet { "shielded account {account} not bound" )) })?; - let (to, addr_network) = - dpp::address_funds::PlatformAddress::from_bech32m_string(to_platform_addr_bech32m) - .map_err(|e| { - PlatformWalletError::ShieldedBuildError(format!( - "invalid platform address: {e}" - )) - })?; - // Compare HRPs, not raw networks: testnet/devnet/regtest all share - // the "tdash" HRP, so a parsed address can never carry Devnet. - if dpp::address_funds::PlatformAddress::hrp_for_network(addr_network) - != dpp::address_funds::PlatformAddress::hrp_for_network(self.sdk.network) - { - return Err(PlatformWalletError::ShieldedBuildError(format!( - "platform address network mismatch: address {addr_network:?}, wallet {:?}", - self.sdk.network - ))); - } + // The decoder is network-agnostic, so guard the recipient's HRP class + // against the wallet's network before decoding. + check_recipient_hrp(to_platform_addr_bech32m, self.sdk.network)?; + let to = dpp::address_funds::PlatformAddress::from_bech32m_string(to_platform_addr_bech32m) + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!("invalid platform address: {e}")) + })?; super::shielded::operations::unshield( &self.sdk, coordinator.store(), @@ -1243,6 +1234,137 @@ fn select_shield_inputs( Ok(chosen) } +/// Verify a bech32m recipient's network class matches `network` before decoding. +/// +/// The address decoder is network-agnostic (`tdash` is shared by +/// Testnet/Devnet/Regtest), so the wrong-network guard lives here. Network +/// classification (mainnet vs non-mainnet, plus malformed/non-platform input +/// rejection) is delegated to [`PlatformAddress::is_mainnet_bech32m`]. A +/// mainnet wallet requires a mainnet (`dash`) address; any non-mainnet wallet +/// requires a non-mainnet (`tdash`) address. +#[cfg(feature = "shielded")] +fn check_recipient_hrp( + recipient: &str, + network: dashcore::Network, +) -> Result<(), PlatformWalletError> { + use dpp::address_funds::PlatformAddress; + + let addr_is_mainnet = PlatformAddress::is_mainnet_bech32m(recipient).map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!("invalid platform address: {e}")) + })?; + if addr_is_mainnet != (network == dashcore::Network::Mainnet) { + let addr_class = if addr_is_mainnet { + "mainnet" + } else { + "non-mainnet" + }; + return Err(PlatformWalletError::ShieldedBuildError(format!( + "platform address network mismatch: {addr_class} address, wallet {network:?}" + ))); + } + Ok(()) +} + +#[cfg(all(test, feature = "shielded"))] +mod check_recipient_hrp_tests { + use super::*; + use dpp::address_funds::PlatformAddress; + + fn recipient(network: dashcore::Network) -> String { + PlatformAddress::P2pkh([0x11; 20]).to_bech32m_string(network) + } + + #[test] + fn devnet_address_into_devnet_wallet_is_accepted() { + // The paloma regression: a devnet `tdash1…` recipient must be + // accepted by a devnet wallet (it was previously mis-rejected as + // Testnet). + let addr = recipient(dashcore::Network::Devnet); + assert!(addr.starts_with("tdash1")); + assert!(check_recipient_hrp(&addr, dashcore::Network::Devnet).is_ok()); + } + + #[test] + fn testnet_address_into_testnet_wallet_is_accepted() { + let addr = recipient(dashcore::Network::Testnet); + assert!(check_recipient_hrp(&addr, dashcore::Network::Testnet).is_ok()); + } + + #[test] + fn tdash_address_crosses_the_tdash_shared_networks() { + // `tdash` is shared, so a testnet-encoded address is accepted by a + // devnet/regtest wallet and vice versa. + let testnet_addr = recipient(dashcore::Network::Testnet); + assert!(check_recipient_hrp(&testnet_addr, dashcore::Network::Devnet).is_ok()); + assert!(check_recipient_hrp(&testnet_addr, dashcore::Network::Regtest).is_ok()); + let devnet_addr = recipient(dashcore::Network::Devnet); + assert!(check_recipient_hrp(&devnet_addr, dashcore::Network::Testnet).is_ok()); + } + + #[test] + fn mainnet_address_into_testnet_wallet_is_rejected() { + let addr = recipient(dashcore::Network::Mainnet); + assert!(addr.starts_with("dash1")); + let err = check_recipient_hrp(&addr, dashcore::Network::Testnet).unwrap_err(); + assert!( + matches!(&err, PlatformWalletError::ShieldedBuildError(m) if m.contains("network mismatch")), + "unexpected error: {err:?}" + ); + } + + #[test] + fn mainnet_address_into_devnet_wallet_is_rejected() { + let addr = recipient(dashcore::Network::Mainnet); + let err = check_recipient_hrp(&addr, dashcore::Network::Devnet).unwrap_err(); + assert!( + matches!(&err, PlatformWalletError::ShieldedBuildError(m) if m.contains("network mismatch")), + "unexpected error: {err:?}" + ); + } + + #[test] + fn uppercase_recipient_is_accepted() { + let addr = recipient(dashcore::Network::Testnet).to_uppercase(); + assert!(check_recipient_hrp(&addr, dashcore::Network::Testnet).is_ok()); + } + + #[test] + fn non_platform_hrp_reports_not_a_platform_address() { + // A valid Bitcoin bech32 SegWit address has HRP "bc", which decodes fine + // but is not a platform HRP — so classification rejects it cleanly. + let err = check_recipient_hrp( + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + dashcore::Network::Testnet, + ) + .unwrap_err(); + assert!( + matches!(&err, PlatformWalletError::ShieldedBuildError(m) if m.contains("not a platform address")), + "unexpected error: {err:?}" + ); + } + + #[test] + fn missing_separator_errors_without_panic() { + let err = check_recipient_hrp("nodelimiterhere", dashcore::Network::Testnet).unwrap_err(); + // bech32::decode emits "parsing failed" for strings without the separator + assert!( + matches!(&err, PlatformWalletError::ShieldedBuildError(m) + if m.contains("invalid platform address")), + "unexpected error: {err:?}" + ); + } + + #[test] + fn empty_recipient_errors_without_panic() { + let err = check_recipient_hrp("", dashcore::Network::Testnet).unwrap_err(); + assert!( + matches!(&err, PlatformWalletError::ShieldedBuildError(m) + if m.contains("invalid platform address")), + "unexpected error: {err:?}" + ); + } +} + #[cfg(all(test, feature = "shielded"))] mod shield_input_selection_tests { use super::*; diff --git a/packages/wasm-dpp2/src/platform_address/address.rs b/packages/wasm-dpp2/src/platform_address/address.rs index 0b3377a4d7..55dc3ee3be 100644 --- a/packages/wasm-dpp2/src/platform_address/address.rs +++ b/packages/wasm-dpp2/src/platform_address/address.rs @@ -151,7 +151,7 @@ impl TryFrom<&str> for PlatformAddressWasm { fn try_from(value: &str) -> Result { // Try parsing as bech32m string first (e.g., "dash1..." or "tdash1...") - if let Ok((addr, _network)) = PlatformAddress::from_bech32m_string(value) { + if let Ok(addr) = PlatformAddress::from_bech32m_string(value) { return Ok(PlatformAddressWasm(addr)); } @@ -327,7 +327,7 @@ impl PlatformAddressWasm { #[wasm_bindgen(js_name = "fromBech32m")] pub fn from_bech32m(address: &str) -> WasmDppResult { PlatformAddress::from_bech32m_string(address) - .map(|(addr, _)| PlatformAddressWasm(addr)) + .map(PlatformAddressWasm) .map_err(|e| WasmDppError::invalid_argument(e.to_string())) }