diff --git a/packages/rs-dpp/src/address_funds/orchard_address.rs b/packages/rs-dpp/src/address_funds/orchard_address.rs index 9e27a6f9dcd..8196815b997 100644 --- a/packages/rs-dpp/src/address_funds/orchard_address.rs +++ b/packages/rs-dpp/src/address_funds/orchard_address.rs @@ -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. + /// /// # 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 { let (hrp, data) = bech32::decode(s).map_err(|e| ProtocolError::DecodingError(format!("{}", e)))?; + // 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 { + 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 { @@ -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) } } @@ -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] @@ -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] diff --git a/packages/rs-dpp/src/address_funds/platform_address.rs b/packages/rs-dpp/src/address_funds/platform_address.rs index 34dcd536593..f8394039921 100644 --- a/packages/rs-dpp/src/address_funds/platform_address.rs +++ b/packages/rs-dpp/src/address_funds/platform_address.rs @@ -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 + /// [`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 { // Decode the bech32m string let (hrp, data) = bech32::decode(s).map_err(|e| ProtocolError::DecodingError(format!("{}", e)))?; - // 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 { + 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 { @@ -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 { + 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(), + ) + })?; + + 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}'" + ))) + } } /// Converts the PlatformAddress to a dashcore Address with the specified network. @@ -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::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())) } } @@ -1132,10 +1172,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] @@ -1157,10 +1196,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] @@ -1182,10 +1220,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] @@ -1207,10 +1244,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] @@ -1336,8 +1372,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); @@ -1348,7 +1384,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); } @@ -1357,7 +1393,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); } @@ -1408,10 +1444,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}" + ); + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/mod.rs index 19f4acede13..1d6476dd336 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/mod.rs @@ -4,7 +4,8 @@ mod proved; #[cfg(all( test, feature = "state-transition-signing", - feature = "core_key_wallet" + feature = "core_key_wallet", + feature = "shielded-client" ))] mod signing_tests; mod state_transition_estimated_fee_validation; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index eabc914911e..159f653c680 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -13,7 +13,8 @@ #![cfg(all( test, feature = "state-transition-signing", - feature = "core_key_wallet" + feature = "core_key_wallet", + feature = "shielded-client" ))] use crate::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index f2fe60c090f..26be83ff548 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -605,8 +605,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, @@ -625,19 +626,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}" - )) - })?; - if addr_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(), @@ -1126,6 +1121,129 @@ 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 core/segwit `bc1…` recipient is not a platform address at all. + let err = check_recipient_hrp("bc1qexampledata", 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(); + assert!(matches!( + &err, + PlatformWalletError::ShieldedBuildError(m) if m.contains("missing bech32 separator") + )); + } + + #[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("missing bech32 separator") + )); + } +} + #[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 546a12dc3dc..8c9b2c07f61 100644 --- a/packages/wasm-dpp2/src/platform_address/address.rs +++ b/packages/wasm-dpp2/src/platform_address/address.rs @@ -137,7 +137,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)); } @@ -305,7 +305,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())) }