diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index acf38416b29..1e16bd3efb7 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -2123,11 +2123,30 @@ unsafe fn address_info_from_ffi( let address_str = CStr::from_ptr(entry.address_base58) .to_str() .map_err(|e| format!("address_base58 not UTF-8: {}", e))?; - let address = dashcore::Address::from_str(address_str) + let parsed = dashcore::Address::from_str(address_str) .map_err(|e| format!("failed to parse address '{}': {}", address_str, e))? .require_network(network) .map_err(|e| format!("address '{}' not on {:?}: {}", address_str, network, e))?; - let script_pubkey = address.script_pubkey(); + let script_pubkey = parsed.script_pubkey(); + // Re-tag with the wallet's exact network. Devnet (and regtest) + // share testnet's base58 prefixes, so `require_network` only + // VALIDATES the parse — the returned value keeps the as-parsed + // (Testnet) tag. `Address` equality and hashing include the + // network, and every runtime lookup key is built via + // `Address::from_script(script, wallet_network)`, so a + // Testnet-tagged restored key silently misses the pool's + // address-keyed maps (`get_address_info`) on a devnet wallet. + // The observable failure: outputs paying restored addresses are + // counted (`contains_script_pub_key` is script-keyed and hits) + // but never credited as UTXOs — a restored wallet permanently + // loses change returned by its own transactions. Rebuild from + // the script so the restored key is identical to runtime keys. + let address = dashcore::Address::from_script(&script_pubkey, network).map_err(|e| { + format!( + "failed to rebuild address '{}' from its script for {:?}: {}", + address_str, network, e + ) + })?; let path_str = CStr::from_ptr(entry.derivation_path) .to_str() .map_err(|e| format!("derivation_path not UTF-8: {}", e))?; @@ -3635,6 +3654,46 @@ mod tests { use key_wallet::mnemonic::{Language, Mnemonic}; use key_wallet::wallet::Wallet; + /// Regression: restored pool addresses must be tagged with the + /// WALLET's network, not the network the base58 string parses as. + /// Devnet shares testnet's base58 prefixes, so a devnet wallet's + /// persisted "y…" address parses as Testnet, and `require_network` + /// only validates — it keeps the as-parsed tag. `Address` equality + /// includes the network, so a Testnet-tagged restored key misses + /// every runtime lookup built via `Address::from_script(script, + /// Devnet)`: outputs paying restored addresses were matched + /// (script-keyed `contains_script_pub_key` hits) but never credited + /// as UTXOs (`get_address_info` missed) — a restored devnet wallet + /// permanently lost the change of its own transactions. + #[test] + fn restored_address_info_is_tagged_with_wallet_network() { + use std::ffi::CString; + // A valid testnet-prefixed (0x8C, "y…") P2PKH address, as a + // devnet wallet persists them. + let addr = "yMqShkrgjTRuReBGFpQr7FozEF1QcNBBYA"; + let addr_c = CString::new(addr).unwrap(); + let path_c = CString::new("m/44'/1'/0'/1/0").unwrap(); + let entry = CoreAddressEntryFFI { + public_key: [0u8; 33], + has_public_key: false, + pool_type_tag: AddressPoolTypeTagFFI::Internal as u8, + address_index: 0, + is_used: false, + balance: 0, + address_base58: addr_c.as_ptr(), + derivation_path: path_c.as_ptr(), + }; + let info = unsafe { address_info_from_ffi(&entry, Network::Devnet) } + .expect("restore must accept a testnet-prefixed string on devnet"); + let runtime_key = dashcore::Address::from_script(&info.script_pubkey, Network::Devnet) + .expect("p2pkh script must convert back to an address"); + assert_eq!( + info.address, runtime_key, + "restored address must be identical (network tag included) to the \ + runtime `from_script` lookup key" + ); + } + /// Pins the contract that an `InBlock` unresolved-asset-lock row /// projects onto the matching BIP44 account's in-memory /// `transactions()` map with the correct context — the precise