From 716e38cd2ae1ace9b376332d68eceeded2615cf5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:44:11 +0200 Subject: [PATCH 1/5] test(dpp): feature-gate signing_tests behind shielded-client `signing_tests` for `ShieldFromAssetLockTransition` imports `crate::shielded::builder`, which only exists under `shielded-client`, but the module was gated on `state-transition-signing` + `core_key_wallet` only. With `core_key_wallet` on but `shielded-client` off, the dpp lib-test harness failed to compile (`unresolved import crate::shielded::builder`). Add `feature = "shielded-client"` to the module's gate (both the `mod` declaration and the file's inner `#![cfg]`) so the test only compiles when its `builder` dependency is available. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../shielded/shield_from_asset_lock_transition/mod.rs | 3 ++- .../shield_from_asset_lock_transition/signing_tests.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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; From ac3d3ed0c5b7902ddb7690bf5de657777fd8d4ef Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:55:45 +0200 Subject: [PATCH 2/5] fix(dpp)!: make platform/orchard address decoders network-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlatformAddress::from_bech32m_string` and `OrchardAddress::from_bech32m_string` previously returned `(Self, Network)`, deriving the Network from the bech32m HRP. That value was untruthful: the `tdash` HRP is shared by Testnet, Devnet, and Regtest, so the decoder hardcoded `tdash → Testnet` and mis-reported devnet/regtest addresses as Testnet — which broke devnet (a devnet platform address tripped a wrong-network guard). A `PlatformAddress`/`OrchardAddress` is network-agnostic internally; the network is supplied only at `to_bech32m_string(network)` encode time. Both decoders now make no network claim: they validate the HRP is a recognized platform HRP (`dash`/`tdash`) and return just `Self`. The wrong-network safety check moves to the one caller that needs it, `platform-wallet::shielded_unshield_to`, as an HRP-class comparison: the recipient's HRP (segment before the final bech32 `1` separator) is matched case-insensitively against `hrp_for_network(wallet.network)`, catching e.g. a mainnet `dash1…` address pasted into a `tdash` wallet. `shielded_withdraw_to` uses a separate Base58 core-address parser and is unaffected. BREAKING: `from_bech32m_string` return type changed `(Self, Network)` → `Self`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/address_funds/orchard_address.rs | 40 +++-- .../src/address_funds/platform_address.rs | 65 ++++--- .../src/wallet/platform_wallet.rs | 160 ++++++++++++++++-- .../wasm-dpp2/src/platform_address/address.rs | 4 +- 4 files changed, 200 insertions(+), 69 deletions(-) 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..a6f71e54648 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,7 @@ impl PlatformAddress { ))), }?; - Ok((address, network)) + Ok(address) } /// Converts the PlatformAddress to a dashcore Address with the specified network. @@ -683,17 +688,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 +1133,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 +1157,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 +1181,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 +1205,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 +1333,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 +1345,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 +1354,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,8 +1405,8 @@ 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); diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index f2fe60c090f..4075d04e728 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,141 @@ fn select_shield_inputs( Ok(chosen) } +/// Verify a bech32m recipient's HRP 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. The HRP is +/// the segment before the final `'1'` separator — bech32's data charset +/// excludes `'1'`, so the last `'1'` is always the separator (BIP-173). The +/// compare is case-insensitive since bech32m permits all-uppercase. +#[cfg(feature = "shielded")] +fn check_recipient_hrp( + recipient: &str, + network: dashcore::Network, +) -> Result<(), PlatformWalletError> { + use dpp::address_funds::{PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET}; + + let actual_hrp = recipient + .rsplit_once('1') + .map(|(hrp, _)| hrp) + .filter(|hrp| !hrp.is_empty()) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "invalid platform address: missing bech32 separator".to_string(), + ) + })?; + + let is_platform_hrp = actual_hrp.eq_ignore_ascii_case(PLATFORM_HRP_MAINNET) + || actual_hrp.eq_ignore_ascii_case(PLATFORM_HRP_TESTNET); + if !is_platform_hrp { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "not a platform address: HRP '{actual_hrp}' is neither \ + '{PLATFORM_HRP_MAINNET}' nor '{PLATFORM_HRP_TESTNET}'" + ))); + } + + let expected_hrp = dpp::address_funds::PlatformAddress::hrp_for_network(network); + if !actual_hrp.eq_ignore_ascii_case(expected_hrp) { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "platform address network mismatch: address HRP '{actual_hrp}', \ + wallet {network:?} expects HRP '{expected_hrp}'" + ))); + } + 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())) } From 7a027281db56e23be9bb48d78f7c03f87b6361c5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:32:03 +0200 Subject: [PATCH 3/5] feat(dpp): add is_mainnet_bech32m platform-address network-class detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the truthful subset of the network information that the network-agnostic `from_bech32m_string` decoder no longer returns. Per DIP-0018, a bech32m platform address carries no network byte and the prefix `tdash` is shared by Testnet/Devnet/Regtest, so the only network fact recoverable from an address string is mainnet vs non-mainnet. `PlatformAddress::is_mainnet_bech32m(s) -> Result` classifies by HRP alone (no payload decode): `dash` → Ok(true), `tdash` → Ok(false), a non-platform HRP or a malformed/separator-less string → Err. Comparison is case-insensitive (bech32m permits all-uppercase). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/address_funds/platform_address.rs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/packages/rs-dpp/src/address_funds/platform_address.rs b/packages/rs-dpp/src/address_funds/platform_address.rs index a6f71e54648..f8394039921 100644 --- a/packages/rs-dpp/src/address_funds/platform_address.rs +++ b/packages/rs-dpp/src/address_funds/platform_address.rs @@ -299,6 +299,45 @@ impl PlatformAddress { 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. pub fn to_address_with_network(&self, network: Network) -> Address { match self { @@ -1411,4 +1450,68 @@ mod tests { 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}" + ); + } } From 71052a45ca675643330a81f4701a45ddc161a4b0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:34:10 +0200 Subject: [PATCH 4/5] refactor(platform-wallet): route shielded-unshield HRP guard through is_mainnet_bech32m MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check_recipient_hrp` no longer inlines the bech32 HRP extraction and HRP-class comparison; it delegates network classification (and malformed/non-platform rejection) to `PlatformAddress::is_mainnet_bech32m`, making the dpp detector the single source of truth. The guard reduces to `addr_is_mainnet != (network == Network::Mainnet)` — exactly the previous HRP-class check (mainnet wallet requires `dash`, any non-mainnet wallet requires `tdash`). Behavior-preserving: the existing `check_recipient_hrp_tests` (network mismatch, tdash cross-acceptance, uppercase, non-platform, missing-separator, empty) pass unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/wallet/platform_wallet.rs | 46 +++++++------------ 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 4075d04e728..26be83ff548 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1121,44 +1121,32 @@ fn select_shield_inputs( Ok(chosen) } -/// Verify a bech32m recipient's HRP class matches `network` before decoding. +/// 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. The HRP is -/// the segment before the final `'1'` separator — bech32's data charset -/// excludes `'1'`, so the last `'1'` is always the separator (BIP-173). The -/// compare is case-insensitive since bech32m permits all-uppercase. +/// 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::{PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET}; - - let actual_hrp = recipient - .rsplit_once('1') - .map(|(hrp, _)| hrp) - .filter(|hrp| !hrp.is_empty()) - .ok_or_else(|| { - PlatformWalletError::ShieldedBuildError( - "invalid platform address: missing bech32 separator".to_string(), - ) - })?; - - let is_platform_hrp = actual_hrp.eq_ignore_ascii_case(PLATFORM_HRP_MAINNET) - || actual_hrp.eq_ignore_ascii_case(PLATFORM_HRP_TESTNET); - if !is_platform_hrp { - return Err(PlatformWalletError::ShieldedBuildError(format!( - "not a platform address: HRP '{actual_hrp}' is neither \ - '{PLATFORM_HRP_MAINNET}' nor '{PLATFORM_HRP_TESTNET}'" - ))); - } + use dpp::address_funds::PlatformAddress; - let expected_hrp = dpp::address_funds::PlatformAddress::hrp_for_network(network); - if !actual_hrp.eq_ignore_ascii_case(expected_hrp) { + 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: address HRP '{actual_hrp}', \ - wallet {network:?} expects HRP '{expected_hrp}'" + "platform address network mismatch: {addr_class} address, wallet {network:?}" ))); } Ok(()) From f983080936659ea86f86b926c8d6755610424a79 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:21:13 +0200 Subject: [PATCH 5/5] fix(platform-address): strict is_mainnet_bech32m, DRY HRP validation, drop double-prefix error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A. is_mainnet_bech32m now calls bech32::decode() instead of rsplit_once('1'), so malformed strings like "dash1!" no longer return Ok(true) — the data part and checksum are fully validated before the HRP is inspected. B. Extracted classify_platform_hrp() as a pub(crate) free function that is the single source of truth for HRP validation. Both from_bech32m_string implementations (PlatformAddress and OrchardAddress) and is_mainnet_bech32m delegate to it, eliminating the duplicated inline if-chain. C. Removed the "invalid platform address: " prefix from is_mainnet_bech32m's error messages; check_recipient_hrp already wraps with that prefix once, so the previous code produced double-prefixed messages like "invalid platform address: Decoding Error - invalid platform address: ...". The final user-facing string is now clean and non-redundant. D. Trimmed the from_bech32m_string doc comments in both platform_address.rs and orchard_address.rs: removed the cross-reference to to_bech32m_string, added a reference to is_mainnet_bech32m as the network-guard entry point. Updated tests: constructed valid bech32m strings for non-platform HRP cases (replacing the previously-used "bc1qexampledata" which now fails checksum before HRP classification); updated separator-error assertions to match bech32::decode's actual error messages; added dash1! regression test. Co-Authored-By: Claude Opus 4.6 --- .../src/address_funds/orchard_address.rs | 23 +--- .../src/address_funds/platform_address.rs | 104 ++++++++---------- .../src/wallet/platform_wallet.rs | 28 +++-- 3 files changed, 71 insertions(+), 84 deletions(-) diff --git a/packages/rs-dpp/src/address_funds/orchard_address.rs b/packages/rs-dpp/src/address_funds/orchard_address.rs index 8196815b997..c2e3114f9ca 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,30 +87,19 @@ 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. + /// 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)` - The decoded address - /// - `Err(ProtocolError)` - If the address is invalid or its HRP is not a + /// - `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)))?; - // 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(); - 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 - ))); - } + 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 { diff --git a/packages/rs-dpp/src/address_funds/platform_address.rs b/packages/rs-dpp/src/address_funds/platform_address.rs index 92efda439cb..959e1c5550a 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,34 +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. - /// - /// 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. + /// 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)` - The decoded address - /// - `Err(ProtocolError)` - If the address is invalid or its HRP is not a + /// - `Err(ProtocolError)` - If the string is malformed 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)))?; - // 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(); - 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 - ))); - } + 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 { @@ -299,43 +299,20 @@ impl PlatformAddress { Ok(address) } - /// Classifies a bech32m platform-address string as mainnet or non-mainnet - /// by its HRP alone, without decoding the payload. + /// Classifies a bech32m platform-address string as mainnet or non-mainnet. /// - /// 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. + /// 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 (no bech32 separator) or a - /// non-platform HRP + /// - `Err(ProtocolError)` - malformed address or 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}'" - ))) - } + 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. @@ -1297,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(); @@ -1306,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 ); } @@ -1508,18 +1484,32 @@ mod tests { #[test] fn test_is_mainnet_bech32m_non_platform_hrp_errors() { - let err = PlatformAddress::is_mainnet_bech32m("bc1qexampledata").unwrap_err(); + // 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("missing bech32 separator"), + err.to_string().contains("parsing failed") || err.to_string().contains("separator"), "unexpected error: {err}" ); } @@ -1528,7 +1518,7 @@ mod tests { fn test_is_mainnet_bech32m_empty_errors() { let err = PlatformAddress::is_mainnet_bech32m("").unwrap_err(); assert!( - err.to_string().contains("missing bech32 separator"), + 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 a63218da0f3..7c22401f9c3 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1330,8 +1330,13 @@ mod check_recipient_hrp_tests { #[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(); + // 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:?}" @@ -1341,19 +1346,22 @@ mod check_recipient_hrp_tests { #[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") - )); + // 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("missing bech32 separator") - )); + assert!( + matches!(&err, PlatformWalletError::ShieldedBuildError(m) + if m.contains("invalid platform address")), + "unexpected error: {err:?}" + ); } }