From 189f44b57e957df64b790bc891416d25d1aaedfd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 13 Jul 2026 23:46:36 +0700 Subject: [PATCH 1/5] feat(platform-wallet): wallet masternode list with DML status in iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregates the wallet's masternodes in Rust from the provider special transactions that rust-dashcore #876 retains past finalization (grouped by proTxHash, latest-update-wins service address, evonode from provider_type, per-type numbering), statuses each against the SPV deterministic masternode list (Active / Inactive when PoSe-banned / Retired when absent; Unknown preserved rather than persisted), and stages provider special txs during wallet restore so the aggregation survives app restarts. Swift persists PersistentMasternode rows and the Identities tab gains an Identities/Masternodes toggle (only when masternodes exist) with a default-off Show-retired filter; provider key account address rows show "used N times on Evonode/Masternode N · ip"; the key-pool "Absent" jargon label is renamed Additional. Co-Authored-By: Claude Fable 5 --- .../src/core_wallet_types.rs | 598 +++++++++++++++++- .../rs-platform-wallet-ffi/src/persistence.rs | 143 ++++- packages/rs-platform-wallet-ffi/src/wallet.rs | 98 +++ .../src/wallet_restore_types.rs | 47 ++ .../src/manager/accessors.rs | 64 ++ .../rs-platform-wallet/src/spv/runtime.rs | 39 ++ .../Persistence/DashModelContainer.swift | 3 +- .../Models/PersistentCoreAddress.swift | 14 +- .../Models/PersistentMasternode.swift | 193 ++++++ .../PlatformWalletManager.swift | 6 + .../PlatformWalletManagerMasternodes.swift | 111 ++++ .../PlatformWalletPersistenceHandler.swift | 97 +++ .../Core/Services/MasternodeSync.swift | 97 +++ .../Core/Views/AccountDetailView.swift | 46 +- .../Core/Views/IdentitiesContentView.swift | 159 +++++ .../Views/StorageRecordDetailViews.swift | 10 +- .../Views/WalletMemoryExplorerView.swift | 7 +- 17 files changed, 1685 insertions(+), 47 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/MasternodeSync.swift diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 601f3ddd3cb..91733a4bb82 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -903,6 +903,33 @@ fn transaction_type_to_u8( } } +/// Fixed-size hash copies. `Txid` / `PubkeyHash` are exactly 32 / 20 +/// bytes, so `copy_from_slice` on `as_ref()` is length-exact and cannot +/// panic — the same pattern `tx_record_to_ffi`'s txid copy relies on. +fn provider_hash_to_32(bytes: &[u8]) -> [u8; 32] { + let mut out = [0u8; 32]; + out.copy_from_slice(bytes); + out +} + +fn provider_hash_to_20(bytes: &[u8]) -> [u8; 20] { + let mut out = [0u8; 20]; + out.copy_from_slice(bytes); + out +} + +/// Rebuild an `"ip:port"` string from a ProUpServTx-style little-endian +/// IPv6-mapped `u128` address + `port`, collapsing IPv4-mapped addresses +/// to V4 so a normal masternode renders as `"1.2.3.4:port"`. +fn provider_ip_port(ip_address: u128, port: u16) -> String { + let v6 = std::net::Ipv6Addr::from(ip_address.to_le_bytes()); + let ip = v6 + .to_ipv4_mapped() + .map(std::net::IpAddr::V4) + .unwrap_or(std::net::IpAddr::V6(v6)); + format!("{}:{}", ip, port) +} + /// Provider (masternode) special-transaction payload fields lifted for /// the Swift UI. All optional / gated — only a ProRegTx or ProUpServTx /// populates them. The single seam where the DIP-3 payload is decoded; @@ -928,48 +955,377 @@ struct ProviderPayloadFields { fn provider_payload_fields(tx: &dashcore::Transaction) -> ProviderPayloadFields { use dashcore::transaction::TransactionPayload; - // Fixed-size hash copies: `Txid`/`PubkeyHash` are 32/20 bytes, so - // `copy_from_slice` on `as_ref()` is length-exact and cannot panic — - // the same pattern the txid copy in `tx_record_to_ffi` relies on. - fn to_32(bytes: &[u8]) -> [u8; 32] { - let mut out = [0u8; 32]; - out.copy_from_slice(bytes); - out - } - fn to_20(bytes: &[u8]) -> [u8; 20] { - let mut out = [0u8; 20]; - out.copy_from_slice(bytes); - out - } - match &tx.special_transaction_payload { Some(TransactionPayload::ProviderRegistrationPayloadType(p)) => ProviderPayloadFields { service_address: Some(p.service_address.to_string()), pro_tx_hash: None, collateral: Some(( - to_32(p.collateral_outpoint.txid.as_ref()), + provider_hash_to_32(p.collateral_outpoint.txid.as_ref()), p.collateral_outpoint.vout, )), - owner_key_hash: Some(to_20(p.owner_key_hash.as_ref())), - voting_key_hash: Some(to_20(p.voting_key_hash.as_ref())), + owner_key_hash: Some(provider_hash_to_20(p.owner_key_hash.as_ref())), + voting_key_hash: Some(provider_hash_to_20(p.voting_key_hash.as_ref())), + }, + Some(TransactionPayload::ProviderUpdateServicePayloadType(p)) => ProviderPayloadFields { + service_address: Some(provider_ip_port(p.ip_address, p.port)), + pro_tx_hash: Some(provider_hash_to_32(p.pro_tx_hash.as_ref())), + ..Default::default() }, - Some(TransactionPayload::ProviderUpdateServicePayloadType(p)) => { - // ProUpServTx stores the endpoint as a little-endian - // IPv6-mapped `u128` + separate `port`, not a `SocketAddr`; - // rebuild one and collapse IPv4-mapped addresses to V4 so a - // normal masternode renders as `"1.2.3.4:port"`. - let v6 = std::net::Ipv6Addr::from(p.ip_address.to_le_bytes()); - let ip = v6 - .to_ipv4_mapped() - .map(std::net::IpAddr::V4) - .unwrap_or(std::net::IpAddr::V6(v6)); - ProviderPayloadFields { - service_address: Some(format!("{}:{}", ip, p.port)), - pro_tx_hash: Some(to_32(p.pro_tx_hash.as_ref())), + _ => ProviderPayloadFields::default(), + } +} + +/// Membership of a proTxHash in the current deterministic masternode +/// list (DML), the authoritative status source. Injected into +/// [`aggregate_masternodes`] as a closure so the aggregation stays +/// source-agnostic and unit-testable without a live SPV engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ListMembership { + /// In the DML and valid / enabled. + ValidEntry, + /// In the DML but flagged invalid (PoSe-banned / `is_valid == false`). + InvalidEntry, + /// Not in the DML (collateral spent / revoked / expired). + Absent, + /// The DML isn't available yet (SPV not running / masternode sync + /// incomplete) — status is indeterminate. + ListUnavailable, +} + +/// Displayed masternode status, derived from [`ListMembership`]. The +/// `u8` discriminant is the FFI wire value; `Unknown` (DML unavailable) +/// tells the persist layer to KEEP the previously stored status rather +/// than overwrite it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum MasternodeStatus { + Active, + Inactive, + Retired, + #[default] + Unknown, +} + +impl MasternodeStatus { + fn from_membership(membership: ListMembership) -> Self { + match membership { + ListMembership::ValidEntry => Self::Active, + ListMembership::InvalidEntry => Self::Inactive, + ListMembership::Absent => Self::Retired, + ListMembership::ListUnavailable => Self::Unknown, + } + } + + pub(crate) fn as_u8(self) -> u8 { + match self { + Self::Active => 0, + Self::Inactive => 1, + Self::Retired => 2, + Self::Unknown => 3, + } + } +} + +/// One aggregated masternode, grouped by proTxHash across a wallet's +/// provider special transactions. Pure/testable output of +/// [`aggregate_masternodes`]; the FFI query layer flattens it into +/// `MasternodeEntryFFI` and owns the record source. +#[derive(Default, Debug, Clone)] +pub(crate) struct MasternodeAggregate { + /// proTxHash (32 wire bytes). For a ProRegTx this is its own txid; + /// updates / revocations link to it via their `pro_tx_hash`. + pub pro_tx_hash: [u8; 32], + /// Whether a ProRegTx for this proTxHash was in the input set. + pub has_registration: bool, + /// Core height of the ProRegTx (0 when unseen) — the stable + /// registration-order sort key. + pub registration_height: u32, + /// Latest known service endpoint `"ip:port"` (latest-height update + /// wins; seeded by the ProRegTx address). + pub service_address: Option, + /// Height that set `service_address` (drives latest-wins). + service_height: u32, + /// evonode / HPMN flag from the ProRegTx `masternode_type`. + pub is_evonode: bool, + /// Owner key hash (hash160) from the ProRegTx. + pub owner_key_hash: Option<[u8; 20]>, + /// Voting key hash (hash160) — follows the latest ProRegTx / ProUpReg. + pub voting_key_hash: Option<[u8; 20]>, + /// Height that set `voting_key_hash` (drives latest-wins). + voting_height: u32, + /// Collateral outpoint (`txid` wire bytes, `vout`) from the ProRegTx. + pub collateral: Option<([u8; 32], u32)>, + /// A ProUpRevTx was seen ⇒ the masternode was revoked ("previously + /// had"). `revocation_reason` keeps the latest reason for reference. + pub revoked: bool, + pub revocation_reason: u16, + /// Count of provider txs seen for this proTxHash. + pub tx_count: u32, + /// 1-based index WITHIN this masternode's type, in registration order — + /// evonodes and regular masternodes each get their own sequence + /// ("Evonode 1, 2, …" / "Masternode 1, 2, …"). `orderIndex` remains the + /// cross-type stable sort key. + pub type_index: u32, + /// Status against the current DML (authoritative). `Unknown` when the + /// DML isn't available. Note: this is NOT `revoked`-derived — a + /// ProUpRevTx merely tends to make the node `Absent` (⇒ `Retired`); + /// the DML is the source of truth. `revoked` / `revocation_reason` + /// are retained as separate data. + pub status: MasternodeStatus, +} + +/// Aggregate a wallet's provider special transactions into masternode +/// entities, grouped by proTxHash. Each input is `(core_height, tx)`; +/// height drives latest-wins for the mutable fields (service address, +/// voting key), so callers may feed records in any order. Non-provider +/// txs are ignored. +/// +/// Output is sorted by registration height then proTxHash for stable +/// "Masternode N" numbering; entities seen only via an update +/// (registration not in the input set — e.g. the ProRegTx was evicted or +/// isn't ours) sort last. +/// +/// Status is resolved against the DML via the injected `list_lookup` +/// closure (`proTxHash -> ListMembership`), keeping this function free of +/// any live SPV dependency so tests can stub the lookup. +/// +/// Pure — no I/O; allocation is limited to the aggregate strings. The +/// record source (which txs to feed) is the caller's concern (see the +/// query fn), which is why this is decoupled and unit-testable. +pub(crate) fn aggregate_masternodes<'a, F>( + txs: impl Iterator, + list_lookup: F, +) -> Vec +where + F: Fn(&[u8; 32]) -> ListMembership, +{ + use dashcore::blockdata::transaction::special_transaction::provider_registration::ProviderMasternodeType; + use dashcore::transaction::TransactionPayload; + + let mut order: Vec<[u8; 32]> = Vec::new(); + let mut by_hash: std::collections::HashMap<[u8; 32], MasternodeAggregate> = + std::collections::HashMap::new(); + + for (height, tx) in txs { + // proTxHash key: a ProRegTx's own txid, else the update's link. + let key = match &tx.special_transaction_payload { + Some(TransactionPayload::ProviderRegistrationPayloadType(_)) => { + provider_hash_to_32(tx.txid().as_ref()) + } + Some(TransactionPayload::ProviderUpdateServicePayloadType(p)) => { + provider_hash_to_32(p.pro_tx_hash.as_ref()) + } + Some(TransactionPayload::ProviderUpdateRegistrarPayloadType(p)) => { + provider_hash_to_32(p.pro_tx_hash.as_ref()) + } + Some(TransactionPayload::ProviderUpdateRevocationPayloadType(p)) => { + provider_hash_to_32(p.pro_tx_hash.as_ref()) + } + _ => continue, + }; + + let agg = by_hash.entry(key).or_insert_with(|| { + order.push(key); + MasternodeAggregate { + pro_tx_hash: key, ..Default::default() } + }); + agg.tx_count = agg.tx_count.saturating_add(1); + + match &tx.special_transaction_payload { + Some(TransactionPayload::ProviderRegistrationPayloadType(p)) => { + agg.has_registration = true; + agg.registration_height = height; + agg.is_evonode = p.masternode_type == ProviderMasternodeType::HighPerformance; + agg.owner_key_hash = Some(provider_hash_to_20(p.owner_key_hash.as_ref())); + agg.collateral = Some(( + provider_hash_to_32(p.collateral_outpoint.txid.as_ref()), + p.collateral_outpoint.vout, + )); + // Registration seeds the service address and voting key; + // treat both as updates observed at this height. + if agg.service_address.is_none() || height >= agg.service_height { + agg.service_address = Some(p.service_address.to_string()); + agg.service_height = height; + } + if agg.voting_key_hash.is_none() || height >= agg.voting_height { + agg.voting_key_hash = Some(provider_hash_to_20(p.voting_key_hash.as_ref())); + agg.voting_height = height; + } + } + Some(TransactionPayload::ProviderUpdateServicePayloadType(p)) => { + if agg.service_address.is_none() || height >= agg.service_height { + agg.service_address = Some(provider_ip_port(p.ip_address, p.port)); + agg.service_height = height; + } + } + Some(TransactionPayload::ProviderUpdateRegistrarPayloadType(p)) => { + if agg.voting_key_hash.is_none() || height >= agg.voting_height { + agg.voting_key_hash = Some(provider_hash_to_20(p.voting_key_hash.as_ref())); + agg.voting_height = height; + } + } + Some(TransactionPayload::ProviderUpdateRevocationPayloadType(p)) => { + agg.revoked = true; + agg.revocation_reason = p.reason; + } + _ => {} + } + } + + let mut result: Vec = order + .into_iter() + .filter_map(|k| by_hash.remove(&k)) + .collect(); + // Stable registration-order numbering: registered masternodes by + // ascending registration height then proTxHash; update-only entities + // (no ProRegTx seen) sort last via a MAX height sentinel. + result.sort_by(|a, b| { + let ha = if a.has_registration { + a.registration_height + } else { + u32::MAX + }; + let hb = if b.has_registration { + b.registration_height + } else { + u32::MAX + }; + ha.cmp(&hb).then_with(|| a.pro_tx_hash.cmp(&b.pro_tx_hash)) + }); + + // Resolve authoritative status against the DML and assign per-type + // numbering (separate Evonode / Masternode sequences), both in the + // stable registration order established above. + let mut evonode_n: u32 = 0; + let mut masternode_n: u32 = 0; + for agg in result.iter_mut() { + agg.status = MasternodeStatus::from_membership(list_lookup(&agg.pro_tx_hash)); + if agg.is_evonode { + evonode_n += 1; + agg.type_index = evonode_n; + } else { + masternode_n += 1; + agg.type_index = masternode_n; } - _ => ProviderPayloadFields::default(), + } + result +} + +/// Flat, C-ABI masternode entity — the wire shape of one +/// [`MasternodeAggregate`], built by [`masternode_entry_ffi`] and +/// returned by `platform_wallet_manager_list_masternodes`. Inline +/// fixed-size hashes with `has_*` gates (mirroring `TransactionRecordFFI`) +/// keep heap ownership to the three C strings. +#[repr(C)] +pub struct MasternodeEntryFFI { + /// proTxHash (32 wire bytes) — group key; also the registration txid. + pub pro_tx_hash: [u8; 32], + /// Whether a ProRegTx was in the aggregated set (vs update-only). + pub has_registration: bool, + /// ProRegTx core height (0 when unseen). + pub registration_height: u32, + /// Stable cross-type registration-order position (sort key). + pub order_index: u32, + /// 1-based index within this masternode's type — `is_evonode` + /// selects the "Evonode N" vs "Masternode N" sequence. + pub type_index: u32, + /// evonode / HPMN flag. + pub is_evonode: bool, + /// A ProUpRevTx was seen ⇒ revoked (data only; NOT the displayed + /// status — see `status`). + pub revoked: bool, + pub revocation_reason: u16, + /// DML-derived status discriminant: 0 Active, 1 Inactive, 2 Retired, + /// 3 Unknown (DML unavailable ⇒ persist layer keeps the prior value). + pub status: u8, + /// Provider-tx count for this proTxHash. + pub tx_count: u32, + /// Collateral outpoint, gated by `has_collateral`. + pub collateral_txid: [u8; 32], + pub collateral_vout: u32, + pub has_collateral: bool, + /// Owner / voting key hashes (hash160), each gated by its `has_*`. + pub owner_key_hash: [u8; 20], + pub has_owner_key_hash: bool, + pub voting_key_hash: [u8; 20], + pub has_voting_key_hash: bool, + /// Service endpoint `"ip:port"`, or null. + pub service_address: *mut c_char, + /// Base58 owner / voting P2PKH addresses for the wallet's network + /// (null when the hash is absent) — the app-layer join key against a + /// provider-key account's persisted base58 address, so Swift never + /// hashes keys. + pub owner_address: *mut c_char, + pub voting_address: *mut c_char, +} + +/// Encode a hash160 as a network-specific base58 P2PKH address string +/// (heap C string), or null on the (impossible-for-a-valid-hash) CString +/// interior-nul error. +fn masternode_p2pkh_cstring(hash: [u8; 20], network: dashcore::Network) -> *mut c_char { + use dashcore::address::Payload; + use dashcore::hashes::Hash; + use dashcore::PubkeyHash; + let address = dashcore::Address::new( + network, + Payload::PubkeyHash(PubkeyHash::from_byte_array(hash)), + ); + std::ffi::CString::new(address.to_string()) + .map(std::ffi::CString::into_raw) + .unwrap_or(std::ptr::null_mut()) +} + +/// Flatten one aggregate into its C-ABI entry, encoding the owner / +/// voting addresses for `network`. `order_index` is the caller's stable +/// position in the sorted aggregate list. +pub(crate) fn masternode_entry_ffi( + mn: &MasternodeAggregate, + order_index: u32, + network: dashcore::Network, +) -> MasternodeEntryFFI { + use std::ffi::CString; + + let service_address = match &mn.service_address { + Some(s) => CString::new(s.clone()) + .map(CString::into_raw) + .unwrap_or(std::ptr::null_mut()), + None => std::ptr::null_mut(), + }; + let (collateral_txid, collateral_vout, has_collateral) = match mn.collateral { + Some((txid, vout)) => (txid, vout, true), + None => ([0u8; 32], 0, false), + }; + let owner_address = mn + .owner_key_hash + .map(|h| masternode_p2pkh_cstring(h, network)) + .unwrap_or(std::ptr::null_mut()); + let voting_address = mn + .voting_key_hash + .map(|h| masternode_p2pkh_cstring(h, network)) + .unwrap_or(std::ptr::null_mut()); + + MasternodeEntryFFI { + pro_tx_hash: mn.pro_tx_hash, + has_registration: mn.has_registration, + registration_height: mn.registration_height, + order_index, + type_index: mn.type_index, + is_evonode: mn.is_evonode, + revoked: mn.revoked, + revocation_reason: mn.revocation_reason, + status: mn.status.as_u8(), + tx_count: mn.tx_count, + collateral_txid, + collateral_vout, + has_collateral, + owner_key_hash: mn.owner_key_hash.unwrap_or([0u8; 20]), + has_owner_key_hash: mn.owner_key_hash.is_some(), + voting_key_hash: mn.voting_key_hash.unwrap_or([0u8; 20]), + has_voting_key_hash: mn.voting_key_hash.is_some(), + service_address, + owner_address, + voting_address, } } @@ -1373,4 +1729,184 @@ mod tests { assert!(fields.owner_key_hash.is_none()); assert!(fields.voting_key_hash.is_none()); } + + // rust-dashcore's own test vectors (see the payload extraction tests + // above). Both are unrelated masternodes, so they aggregate into + // distinct proTxHash buckets. + const PROREG_HEX: &str = "0300010001ca9a43051750da7c5f858008f2ff7732d15691e48eb7f845c791e5dca78bab58010000006b483045022100fe8fec0b3880bcac29614348887769b0b589908e3f5ec55a6cf478a6652e736502202f30430806a6690524e4dd599ba498e5ff100dea6a872ebb89c2fd651caa71ed012103d85b25d6886f0b3b8ce1eef63b720b518fad0b8e103eba4e85b6980bfdda2dfdffffffff018e37807e090000001976a9144ee1d4e5d61ac40a13b357ac6e368997079678c888ac00000000fd1201010000000000ca9a43051750da7c5f858008f2ff7732d15691e48eb7f845c791e5dca78bab580000000000000000000000000000ffff010205064e1f3dd03f9ec192b5f275a433bfc90f468ee1a3eb4c157b10706659e25eb362b5d902d809f9160b1688e201ee6e94b40f9b5062d7074683ef05a2d5efb7793c47059c878dfad38a30fafe61575db40f05ab0a08d55119b0aad300001976a9144fbc8fb6e11e253d77e5a9c987418e89cf4a63d288ac3477990b757387cb0406168c2720acf55f83603736a314a37d01b135b873a27b411fb37e49c1ff2b8057713939a5513e6e711a71cff2e517e6224df724ed750aef1b7f9ad9ec612b4a7250232e1e400da718a9501e1d9a5565526e4b1ff68c028763"; + const PROUPSERV_HEX: &str = "03000200018f3fe6683e36326669b6e34876fb2a2264e8327e822f6fec304b66f47d61b3e1010000006b48304502210082af6727408f0f2ec16c7da1c42ccf0a026abea6a3a422776272b03c8f4e262a022033b406e556f6de980b2d728e6812b3ae18ee1c863ae573ece1cbdf777ca3e56101210351036c1192eaf763cd8345b44137482ad24b12003f23e9022ce46752edf47e6effffffff0180220e43000000001976a914123cbc06289e768ca7d743c8174b1e6eeb610f1488ac00000000b501003a72099db84b1c1158568eec863bea1b64f90eccee3304209cebe1df5e7539fd00000000000000000000ffff342440944e1f00e6725f799ea20480f06fb105ebe27e7c4845ab84155e4c2adf2d6e5b73a998b1174f9621bbeda5009c5a6487bdf75edcf602b67fe0da15c275cc91777cb25f5fd4bb94e84fd42cb2bb547c83792e57c80d196acd47020e4054895a0640b7861b3729c41dd681d4996090d5750f65c4b649a5cd5b2bdf55c880459821e53d91c9"; + + fn decode_tx(hex: &str) -> dashcore::Transaction { + let bytes = hex::decode(hex).expect("valid fixture hex"); + dashcore::consensus::encode::deserialize(&bytes).expect("decode tx") + } + + /// Stub DML lookup: the list is never available (⇒ every entity is + /// `Unknown`). Mirrors "SPV not running / masternode sync incomplete". + fn unavailable_dml(_pro_tx_hash: &[u8; 32]) -> ListMembership { + ListMembership::ListUnavailable + } + + /// A lone ProRegTx aggregates into one active masternode carrying its + /// service address, key hashes, and collateral, keyed by its own txid. + #[test] + fn aggregate_single_registration() { + let reg = decode_tx(PROREG_HEX); + let expected_pro_tx = provider_hash_to_32(reg.txid().as_ref()); + + let mns = aggregate_masternodes([(100u32, ®)].into_iter(), unavailable_dml); + assert_eq!(mns.len(), 1); + let mn = &mns[0]; + assert_eq!(mn.pro_tx_hash, expected_pro_tx); + assert_eq!(mn.status, MasternodeStatus::Unknown, "no DML ⇒ Unknown"); + assert!(mn.has_registration); + assert!(!mn.revoked); + assert!(!mn.is_evonode, "legacy ProRegTx fixture is a regular MN"); + assert_eq!(mn.service_address.as_deref(), Some("1.2.5.6:19999")); + assert!(mn.owner_key_hash.is_some()); + assert!(mn.voting_key_hash.is_some()); + assert!(mn.collateral.is_some()); + assert_eq!(mn.tx_count, 1); + } + + /// A ProUpServTx whose registration isn't in the input set still + /// yields a masternode (keyed by its `pro_tx_hash`) with the updated + /// service address but no registration-only fields. + #[test] + fn aggregate_update_only_masternode() { + let ups = decode_tx(PROUPSERV_HEX); + let mns = aggregate_masternodes([(50u32, &ups)].into_iter(), unavailable_dml); + assert_eq!(mns.len(), 1); + let mn = &mns[0]; + assert!(!mn.has_registration); + assert_eq!(mn.service_address.as_deref(), Some("52.36.64.148:19999")); + assert!(mn.owner_key_hash.is_none()); + assert!(mn.collateral.is_none()); + assert_eq!(mn.tx_count, 1); + } + + /// Two unrelated provider txs bucket into two masternodes. + #[test] + fn aggregate_groups_by_pro_tx_hash() { + let reg = decode_tx(PROREG_HEX); + let ups = decode_tx(PROUPSERV_HEX); + let mns = aggregate_masternodes( + [(100u32, ®), (200u32, &ups)].into_iter(), + unavailable_dml, + ); + assert_eq!(mns.len(), 2, "distinct proTxHashes ⇒ two masternodes"); + } + + /// A ProUpRevTx linked to a registration flips the masternode to + /// revoked ("previously had") while its service address and count + /// reflect the full provider-tx set. Built programmatically because + /// rust-dashcore ships no ProUpRevTx raw-hex vector. + #[test] + fn aggregate_revocation_marks_revoked() { + use dashcore::blockdata::transaction::special_transaction::provider_update_revocation::ProviderUpdateRevocationPayload; + use dashcore::transaction::TransactionPayload; + + let reg = decode_tx(PROREG_HEX); + let pro_tx_hash = reg.txid(); + + let rev_payload = ProviderUpdateRevocationPayload { + version: 1, + pro_tx_hash, + reason: 2, + inputs_hash: [3u8; 32].into(), + payload_sig: [0u8; 96].into(), + }; + let rev = dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some( + TransactionPayload::ProviderUpdateRevocationPayloadType(rev_payload), + ), + }; + + // A ProUpRevTx'd node is Absent from the DML here ⇒ Retired. + let revoked_pro_tx = provider_hash_to_32(pro_tx_hash.as_ref()); + let lookup = |pt: &[u8; 32]| { + if *pt == revoked_pro_tx { + ListMembership::Absent + } else { + ListMembership::ListUnavailable + } + }; + + // Revocation feed order shouldn't matter (height drives merges). + let mns = aggregate_masternodes([(300u32, &rev), (100u32, ®)].into_iter(), lookup); + assert_eq!(mns.len(), 1); + let mn = &mns[0]; + assert_eq!(mn.pro_tx_hash, revoked_pro_tx); + assert!(mn.has_registration); + assert!(mn.revoked, "a ProUpRevTx marks the revoked-data flag"); + assert_eq!(mn.revocation_reason, 2); + assert_eq!( + mn.status, + MasternodeStatus::Retired, + "absent from the DML ⇒ Retired (status is DML-derived, not revoked-derived)" + ); + assert_eq!(mn.service_address.as_deref(), Some("1.2.5.6:19999")); + assert_eq!(mn.tx_count, 2); + } + + /// Status is derived from the injected DML lookup, not from tx history: + /// a valid entry ⇒ Active, a present-but-invalid entry ⇒ Inactive, an + /// absent entry ⇒ Retired — all for the same (unrevoked) ProRegTx. + #[test] + fn aggregate_status_follows_dml_membership() { + let reg = decode_tx(PROREG_HEX); + let pro_tx = provider_hash_to_32(reg.txid().as_ref()); + + for (membership, expected) in [ + (ListMembership::ValidEntry, MasternodeStatus::Active), + (ListMembership::InvalidEntry, MasternodeStatus::Inactive), + (ListMembership::Absent, MasternodeStatus::Retired), + (ListMembership::ListUnavailable, MasternodeStatus::Unknown), + ] { + let lookup = |pt: &[u8; 32]| { + assert_eq!(*pt, pro_tx); + membership + }; + let mns = aggregate_masternodes([(100u32, ®)].into_iter(), lookup); + assert_eq!(mns.len(), 1); + assert_eq!(mns[0].status, expected); + assert!(!mns[0].revoked, "no ProUpRevTx ⇒ revoked flag stays false"); + } + } + + /// Evonodes and regular masternodes get INDEPENDENT 1-based per-type + /// sequences: an evonode + a regular in one aggregation each get + /// `type_index == 1`. Built by cloning the regular ProRegTx fixture and + /// flipping its `masternode_type` (plus `lock_time`, so the txid — and + /// thus the proTxHash group key — differs). + #[test] + fn aggregate_per_type_numbering() { + use dashcore::blockdata::transaction::special_transaction::provider_registration::ProviderMasternodeType; + use dashcore::transaction::TransactionPayload; + + let regular = decode_tx(PROREG_HEX); + + let mut evonode = decode_tx(PROREG_HEX); + evonode.lock_time = 4242; // change the txid ⇒ distinct proTxHash + if let Some(TransactionPayload::ProviderRegistrationPayloadType(p)) = + &mut evonode.special_transaction_payload + { + p.masternode_type = ProviderMasternodeType::HighPerformance; + } + + let mns = aggregate_masternodes( + [(100u32, ®ular), (200u32, &evonode)].into_iter(), + unavailable_dml, + ); + assert_eq!(mns.len(), 2, "distinct proTxHashes ⇒ two masternodes"); + + let evo = mns.iter().find(|m| m.is_evonode).expect("evonode present"); + let reg = mns.iter().find(|m| !m.is_evonode).expect("regular present"); + assert_eq!(evo.type_index, 1, "first (only) evonode ⇒ Evonode 1"); + assert_eq!(reg.type_index, 1, "first (only) regular ⇒ Masternode 1"); + } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index e46b8674637..961477e4110 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -51,8 +51,8 @@ use crate::wallet_registration_persistence::AccountAddressPoolFFI; use crate::wallet_restore_types::{ AccountSpecFFI, AccountTypeTagFFI, ContactProfileRestoreEntryFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, LoadWalletListFreeFn, PaymentRestoreEntryFFI, - ProviderPlatformNodeKeyFFI, StandardAccountTypeTagFFI, UnresolvedAssetLockTxRecordFFI, - UtxoRestoreEntryFFI, WalletRestoreEntryFFI, + ProviderPlatformNodeKeyFFI, ProviderSpecialTxRestoreEntryFFI, StandardAccountTypeTagFFI, + UnresolvedAssetLockTxRecordFFI, UtxoRestoreEntryFFI, WalletRestoreEntryFFI, }; use dpp::address_funds::PlatformAddress; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -3557,6 +3557,30 @@ fn build_wallet_start_state( } } + // Re-stage provider special transactions onto the provider-key + // accounts so #876 retention keeps them and the masternode list + // survives a restart (mirrors the asset-lock tx-record restore above). + let provider_special_recs: &[ProviderSpecialTxRestoreEntryFFI] = + if entry.provider_special_txs.is_null() || entry.provider_special_txs_count == 0 { + &[] + } else { + unsafe { + slice::from_raw_parts(entry.provider_special_txs, entry.provider_special_txs_count) + } + }; + if !provider_special_recs.is_empty() { + let stats = restore_provider_special_txs(&mut wallet_info, provider_special_recs)?; + if stats.restored > 0 || stats.dropped() > 0 { + tracing::info!( + wallet_id = %hex::encode(entry.wallet_id), + restored = stats.restored, + dropped_decode = stats.dropped_decode, + dropped_no_account = stats.dropped_no_account, + "load: provider special-tx restore complete" + ); + } + } + // TODO: this per-account reconstruction mirrors the SQLite backend's // `platform_addrs::build_per_account`. Deferred dedup — once a shared // helper crate hosts the reconstruction, both backends should call it @@ -4684,6 +4708,121 @@ fn restore_unresolved_asset_lock_tx_records( Ok(stats) } +/// Re-stage persisted provider special transactions onto the wallet's +/// provider-key accounts at load, so rust-dashcore #876 retention keeps +/// them resident and the masternode-list aggregation survives a restart. +/// +/// Mirrors [`restore_unresolved_asset_lock_tx_records`] (decode bytes → +/// rebuild `TransactionContext` from scalars → build a `TransactionRecord` +/// → raw `transactions_mut().insert`) but routes by provider-key account +/// TYPE rather than a BIP44 index: provider involvement is payload-based, +/// so the record is inserted onto EVERY present provider-key account. That +/// is retention-safe (#876 retention is evaluated at drop time by +/// account-is-provider-keys + payload-is-provider, both true on any +/// provider-key account) and the masternode aggregation dedups by txid, so +/// over-placement can't inflate counts — this avoids trusting a persisted +/// routing tag or re-running the `check_transaction` matcher at load. +fn restore_provider_special_txs( + wallet_info: &mut ManagedWalletInfo, + records: &[ProviderSpecialTxRestoreEntryFFI], +) -> Result { + use dashcore::hashes::Hash; + use dashcore::transaction::TransactionPayload; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + + let mut stats = UnresolvedRestoreStats::default(); + for rec in records { + let tx_bytes = unsafe { slice_from_raw(rec.tx_bytes, rec.tx_bytes_len) }; + let tx: dashcore::Transaction = match dashcore::consensus::encode::deserialize(tx_bytes) { + Ok(t) => t, + Err(e) => { + stats.dropped_decode += 1; + tracing::warn!(error = %e, "load: skipping provider special tx with undecodable bytes"); + continue; + } + }; + + // Tag the rebuilt record with the payload's provider type. A + // non-provider payload here means the row was mis-staged; skip it. + let tx_type = match &tx.special_transaction_payload { + Some(TransactionPayload::ProviderRegistrationPayloadType(_)) => { + TransactionType::ProviderRegistration + } + Some(TransactionPayload::ProviderUpdateServicePayloadType(_)) => { + TransactionType::ProviderUpdateService + } + Some(TransactionPayload::ProviderUpdateRegistrarPayloadType(_)) => { + TransactionType::ProviderUpdateRegistrar + } + Some(TransactionPayload::ProviderUpdateRevocationPayloadType(_)) => { + TransactionType::ProviderUpdateRevocation + } + _ => { + stats.dropped_no_account += 1; + continue; + } + }; + + let context = match rec.context_raw { + ctx @ (2 | 3) => { + let block_hash = dashcore::BlockHash::from_slice(&rec.block_hash).map_err(|e| { + PersistenceError::backend(format!( + "load: malformed block_hash on provider special tx record: {}", + e + )) + })?; + let info = BlockInfo::new(rec.block_height, block_hash, rec.block_timestamp as u32); + if ctx == 2 { + TransactionContext::InBlock(info) + } else { + TransactionContext::InChainLockedBlock(info) + } + } + _ => TransactionContext::Mempool, + }; + + let mut inserted = false; + for mut account in wallet_info.accounts.all_accounts_mut() { + let account_type = account.managed_account_type().to_account_type(); + let is_provider = matches!( + account_type, + AccountType::ProviderVotingKeys + | AccountType::ProviderOwnerKeys + | AccountType::ProviderOperatorKeys + | AccountType::ProviderPlatformKeys + ); + if !is_provider { + continue; + } + let record = TransactionRecord::new( + tx.clone(), + account_type, + context.clone(), + tx_type, + TransactionDirection::Internal, + Vec::new(), + Vec::new(), + 0, + ); + account.transactions_mut().insert(record.txid, record); + inserted = true; + } + + if inserted { + stats.restored += 1; + } else { + // No provider-key accounts on this wallet (shouldn't happen if + // provider txs were staged) — count as dropped for diagnostics. + stats.dropped_no_account += 1; + } + } + Ok(stats) +} + #[cfg(test)] mod tests { //! Unit tests for the load-side helpers. Focused on the diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 2c9130a275c..b5041759965 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -195,6 +195,104 @@ pub unsafe extern "C" fn platform_wallet_manager_free_account_balances( } } +/// Aggregate the wallet's masternodes from its retained provider special +/// transactions (ProRegTx / ProUpServTx / ProUpRegTx / ProUpRevTx), +/// grouped by proTxHash. Returns an array of +/// [`MasternodeEntryFFI`](crate::core_wallet_types::MasternodeEntryFFI), +/// one per masternode, sorted by registration order. The caller owns the +/// array and must free it via +/// [`platform_wallet_manager_free_masternodes`]. +/// +/// The record source (rust-dashcore #876 provider-payload retention) is +/// populated in every feature configuration; see +/// `PlatformWalletManager::provider_masternode_txs_blocking`. `out_*` are +/// set to null / 0 when the wallet has no masternodes or isn't found. +/// +/// Reads the wallet manager lock via `blocking_read` — must not be called +/// from within a tokio async context. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_list_masternodes( + manager_handle: Handle, + wallet_id: *const u8, + out_entries: *mut *const crate::core_wallet_types::MasternodeEntryFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(out_entries); + check_ptr!(out_count); + + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { + manager.provider_masternode_txs_blocking(&wid) + }); + // Outer Option: handle resolved. Inner Option: wallet found. + let inner = unwrap_option_or_return!(option); + let (network, txs, dml) = unwrap_option_or_return!(inner); + + // Derive DML membership from the owned snapshot (`None` ⇒ list not + // available ⇒ Unknown status ⇒ persist layer keeps the prior value). + use crate::core_wallet_types::ListMembership; + let membership = |pro_tx_hash: &[u8; 32]| -> ListMembership { + match &dml { + None => ListMembership::ListUnavailable, + Some(map) => match map.get(pro_tx_hash) { + Some(true) => ListMembership::ValidEntry, + Some(false) => ListMembership::InvalidEntry, + None => ListMembership::Absent, + }, + } + }; + + let aggregates = crate::core_wallet_types::aggregate_masternodes( + txs.iter().map(|(h, tx)| (*h, tx)), + membership, + ); + + let entries: Vec = aggregates + .iter() + .enumerate() + .map(|(idx, mn)| crate::core_wallet_types::masternode_entry_ffi(mn, idx as u32, network)) + .collect(); + let count = entries.len(); + + if count == 0 { + *out_entries = std::ptr::null(); + *out_count = 0; + return PlatformWalletFFIResult::ok(); + } + + let boxed = entries.into_boxed_slice(); + *out_entries = Box::into_raw(boxed) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +/// Free an array returned by [`platform_wallet_manager_list_masternodes`], +/// including each entry's heap C strings. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_free_masternodes( + entries: *mut crate::core_wallet_types::MasternodeEntryFFI, + count: usize, +) { + if entries.is_null() || count == 0 { + return; + } + let slice = std::slice::from_raw_parts_mut(entries, count); + for entry in slice.iter() { + for ptr in [ + entry.service_address, + entry.owner_address, + entry.voting_address, + ] { + if !ptr.is_null() { + let _ = std::ffi::CString::from_raw(ptr); + } + } + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(entries, count)); +} + /// Destroy a PlatformWallet handle. #[no_mangle] pub unsafe extern "C" fn platform_wallet_destroy(handle: Handle) -> PlatformWalletFFIResult { diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index c33b72fb452..f59acd6fe11 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -552,6 +552,41 @@ pub struct UnresolvedAssetLockTxRecordFFI { pub first_seen: u64, } +/// A persisted provider special transaction (ProRegTx / ProUpServTx / +/// ProUpRegTx / ProUpRevTx) staged back into the wallet at load so its +/// DIP-3 payload record is resident on the provider-key accounts again. +/// +/// Without this, key-wallet's rust-dashcore #876 retention has nothing to +/// retain after a restart (the wallet is rebuilt from staging, which +/// otherwise stages only UTXOs + asset-lock funding txs), so the +/// masternode-list aggregation comes back empty until a rescan +/// re-processes the blocks. +/// +/// Same raw-tx / height shape as [`UnresolvedAssetLockTxRecordFFI`] but +/// with NO `account_index`: provider involvement is payload-based (owner +/// / voting key hashes), not a known BIP44 index, so the load path routes +/// the record onto the wallet's provider-key accounts directly. `tx_bytes` +/// is Swift-owned for the callback window and freed by the load +/// allocation's `release()`. +#[repr(C)] +pub struct ProviderSpecialTxRestoreEntryFFI { + /// Consensus-encoded transaction body (`Transaction::consensus_decode` + /// round-trips). Carries the DIP-3 special-transaction payload. + pub tx_bytes: *mut u8, + pub tx_bytes_len: usize, + /// `TransactionContext` discriminant: `2` = InBlock, `3` = + /// InChainLockedBlock; anything else is treated as `Mempool`. + pub context_raw: u32, + /// Block height (meaningful only when `context_raw` is `2` / `3`). + pub block_height: u32, + /// Block hash (wire-orientation; meaningful with `context_raw` `2`/`3`). + pub block_hash: [u8; 32], + /// Block timestamp (Unix seconds; same meaningfulness rule). + pub block_timestamp: u64, + /// Persisted "first seen" Unix-second timestamp (mirrors on-disk). + pub first_seen: u64, +} + /// Per-wallet entry returned by `on_load_wallet_list_fn`. /// /// `accounts` points to a contiguous array of length `accounts_count`. @@ -619,6 +654,14 @@ pub struct WalletRestoreEntryFFI { /// unresolved asset locks. pub unresolved_asset_lock_tx_records: *const UnresolvedAssetLockTxRecordFFI, pub unresolved_asset_lock_tx_records_count: usize, + /// Persisted provider special transactions (ProRegTx / ProUpServTx / + /// ProUpRegTx / ProUpRevTx) re-staged onto the wallet's provider-key + /// accounts so rust-dashcore #876 retention keeps them resident and + /// the masternode-list aggregation survives a restart. `null` / `0` + /// when the wallet has no provider special txs. Each entry's + /// `tx_bytes` buffer is Swift-owned and freed by `LoadWalletListFreeFn`. + pub provider_special_txs: *const ProviderSpecialTxRestoreEntryFFI, + pub provider_special_txs_count: usize, /// Persisted core address pools for this wallet pub core_address_pools: *const AccountAddressPoolFFI, pub core_address_pools_count: usize, @@ -652,6 +695,10 @@ unsafe impl Send for WalletRestoreEntryFFI {} unsafe impl Sync for WalletRestoreEntryFFI {} unsafe impl Send for UtxoRestoreEntryFFI {} unsafe impl Sync for UtxoRestoreEntryFFI {} +// SAFETY: `tx_bytes` is Swift-owned and lifetime-scoped to the load +// callback, same contract as the other restore entries above. +unsafe impl Send for ProviderSpecialTxRestoreEntryFFI {} +unsafe impl Sync for ProviderSpecialTxRestoreEntryFFI {} /// Paired free callback for the wallet-list load callback. Releases /// any memory Swift allocated for the entries array, the per-wallet diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index c15a478388e..c9d045f8f07 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -783,6 +783,70 @@ impl PlatformWalletManager

{ iter.take(take).map(tx_record_snapshot).collect() } + /// Provider special transactions (ProRegTx / ProUpServTx / ProUpRegTx + /// / ProUpRevTx) across all of a wallet's accounts, deduplicated by + /// txid, each paired with its confirmation height (0 when + /// unconfirmed). The source for masternode aggregation. + /// + /// rust-dashcore #876 retains provider-payload records on the + /// provider-key accounts (owner / voting / operator / platform) past + /// chainlock finalization even with `keep-finalized-transactions` off + /// (the mobile default), so — unlike `account_transactions_blocking` + /// above — this is populated in every feature configuration. Deduped + /// by txid because one ProRegTx matches both the owner- and + /// voting-key accounts, so its record is retained on each. + /// + /// Caveat: records evicted *before* the #876 bump aren't resident + /// until a filter rescan re-matches them, so on an existing install + /// the set fills in after a Rescan. + /// + /// Returns `None` only when the wallet id isn't managed (an empty vec + /// means "no provider txs yet"). The wallet's `Network` rides along so + /// the FFI can encode owner / voting key hashes to base58 addresses, + /// plus a DML snapshot (`proTxHash -> is_valid`, `None` when the list + /// isn't available yet) so the FFI can derive Active / Inactive / + /// Retired / Unknown status. + pub fn provider_masternode_txs_blocking( + &self, + wallet_id: &WalletId, + ) -> Option<( + dashcore::Network, + Vec<(u32, dashcore::Transaction)>, + Option>, + )> { + // Scope the wallet-manager read lock so it's released before we + // acquire the SPV client / engine locks for the DML snapshot — the + // two never nest. + let (network, txs) = { + let wm = self.wallet_manager.blocking_read(); + let info = wm.get_wallet_info(wallet_id)?; + let network = info.core_wallet.network(); + + let mut by_txid: std::collections::BTreeMap< + dashcore::Txid, + (u32, dashcore::Transaction), + > = std::collections::BTreeMap::new(); + + for account in info.core_wallet.accounts.all_accounts().iter() { + for record in account.transactions().values() { + if record.transaction.special_transaction_payload.is_none() { + continue; + } + let height = record.context.block_info().map(|b| b.height()).unwrap_or(0); + by_txid + .entry(record.txid) + .or_insert_with(|| (height, record.transaction.clone())); + } + } + + (network, by_txid.into_values().collect::>()) + }; + + let dml = self.spv().masternode_validity_snapshot_blocking(); + + Some((network, txs, dml)) + } + // ----------------------------------------------------------------- // Phase 7 — Identity manager structure // ----------------------------------------------------------------- diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index fd706bb46d1..13d5d9cf5a2 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -292,6 +292,45 @@ impl SpvRuntime { } } + /// Snapshot of the current deterministic masternode list (DML) keyed + /// by proTxHash in internal/wire byte order (matching a registration + /// txid), mapping to each entry's `is_valid` flag — the authoritative + /// input for masternode status (Active / Inactive / Retired). + /// + /// Returns `None` when the DML isn't available (the SPV client isn't + /// running, its masternode-list engine isn't initialized, or the list + /// hasn't synced yet), so the caller renders "Unknown" and keeps any + /// previously persisted status. Blocking: acquires the client + engine + /// `tokio::RwLock`s via `blocking_read`, so it must run off the async + /// runtime (FFI blocking thread), mirroring the other `*_blocking` + /// accessors. + pub fn masternode_validity_snapshot_blocking( + &self, + ) -> Option> { + // Clone the engine `Arc` out while holding the client lock, then + // drop it before reading the engine — same ordering as + // `connected_peers`. + let engine = { + let client_guard = self.client.blocking_read(); + let client = client_guard.as_ref()?; + client.masternode_list_engine().ok()? + }; + + let engine_guard = engine.blocking_read(); + let list = engine_guard.latest_masternode_list()?; + + let mut map = std::collections::HashMap::with_capacity(list.masternodes.len()); + for qualified in list.masternodes.values() { + let entry = &qualified.masternode_list_entry; + // `pro_reg_tx_hash` is internal order (the DML map itself keys + // by the reversed/display form, so read it off the entry). + let mut pro_tx = [0u8; 32]; + pro_tx.copy_from_slice(entry.pro_reg_tx_hash.as_ref()); + map.insert(pro_tx, entry.is_valid); + } + Some(map) + } + /// Get the current sync progress. /// /// Returns `None` if the SPV client is not running. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index ec4f97ff99b..8a35c1acb76 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -36,7 +36,8 @@ public enum DashModelContainer { PersistentShieldedOutgoingNote.self, PersistentShieldedSyncState.self, PersistentShieldedActivity.self, - PersistentAssetLock.self + PersistentAssetLock.self, + PersistentMasternode.self ] } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift index 7f73d0d26fe..8734ce1d9be 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift @@ -80,12 +80,22 @@ public final class PersistentCoreAddress { } extension PersistentCoreAddress { + /// User-facing name for the address pool this row belongs to. + /// + /// Pool tags 2/3 are key-wallet's "Absent" / "AbsentHardened" + /// pools — keys derived on demand *outside* the BIP44 + /// external-receive / internal-change chains (this is where + /// provider owner / voting / operator keys and other special-purpose + /// keys live). "Absent" is Rust-enum jargon, so it's surfaced here as + /// "Additional" / "Additional (Hardened)" — the source of truth the + /// app-layer address lists reuse (AccountDetailView, + /// StorageRecordDetailViews, WalletMemoryExplorerView). public var poolTypeName: String { switch poolTypeTag { case 0: return "External" case 1: return "Internal" - case 2: return "Absent" - case 3: return "Absent (Hardened)" + case 2: return "Additional" + case 3: return "Additional (Hardened)" default: return "Unknown(\(poolTypeTag))" } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift new file mode 100644 index 00000000000..c7e39df2e11 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift @@ -0,0 +1,193 @@ +import Foundation +import SwiftData + +/// SwiftData model for a masternode entity aggregated by Rust from a +/// wallet's provider special transactions (ProRegTx / ProUpServTx / +/// ProUpRegTx / ProUpRevTx), grouped by proTxHash. +/// +/// **The aggregation is done in Rust** (`aggregate_masternodes` in +/// `rs-platform-wallet-ffi`) and delivered as a flat array over the FFI +/// query seam; this row is pure storage of that result. Swift never +/// decodes DIP-3 payloads or decides grouping/status — it only persists +/// and loads. +/// +/// Not scoped by a `networkRaw` column of its own — network membership +/// is recovered by joining `walletId` to the parent +/// `PersistentWallet.networkRaw`, the same pivot `PersistentPlatformAddress` +/// / shielded rows use. `(walletId, proTxHash)` is unique so a masternode +/// tracked under one wallet collapses onto a single row across re-syncs. +@Model +public final class PersistentMasternode { + #Unique([\.walletId, \.proTxHash]) + + /// Owning wallet id (32 bytes). Network is derived via the wallet. + public var walletId: Data + /// proTxHash (32 raw wire bytes) — the ProRegTx's own txid; updates + /// and revocations link to it. + public var proTxHash: Data + /// Registration txid (== proTxHash when the ProRegTx was in the set; + /// otherwise the same bytes, with `hasRegistration == false`). + public var registrationTxid: Data + + /// Latest known service endpoint `"ip:port"` (latest ProUpServTx + /// wins; seeded by the ProRegTx address). `nil` if never seen. + public var serviceAddress: String? + /// `true` = evonode / HPMN (ProRegTx `masternode_type`). + public var isEvonode: Bool + + /// Owner / voting key hashes (hash160, 20 bytes) from the ProRegTx / + /// latest ProUpRegTx. + public var ownerKeyHash: Data? + public var votingKeyHash: Data? + /// Base58 owner / voting addresses, encoded on the Rust side for the + /// network. Used by the address-list subtitle to join a provider-key + /// account address (also base58) to its masternode without any + /// Swift-side key hashing. + public var ownerAddress: String? + public var votingAddress: String? + + /// ProRegTx collateral outpoint. + public var collateralTxid: Data? + public var collateralVout: UInt32 + + /// A ProUpRevTx was seen. Retained as data only — the *displayed* + /// status comes from [`statusRaw`] (the DML), since a revoked node + /// naturally leaves the list and shows as Retired. + public var revoked: Bool + public var revocationReason: UInt16 + /// DML-derived status: 0 Active, 1 Inactive, 2 Retired, 3 Unknown. + /// Rust returns Unknown (3) when the deterministic masternode list + /// isn't available yet; the persist step then KEEPS the previously + /// stored value rather than overwriting with Unknown. Defaults to + /// Unknown for a freshly inserted row. + public var statusRaw: UInt8 = 3 + + /// Core height of the ProRegTx (0 when unseen) and whether a + /// registration was in the aggregated set. + public var registrationHeight: UInt32 + public var hasRegistration: Bool + /// Count of provider txs seen for this proTxHash. + public var txCount: UInt32 + + /// Stable position in Rust's registration-order sort (ascending + /// registration height, then proTxHash). Query rows sort by this so + /// ordering is identical everywhere. + public var orderIndex: UInt32 + /// 1-based position within the entity's own type sequence, computed + /// by Rust over the same registration-order sort — evonodes and + /// regular masternodes number independently ("Evonode 2", + /// "Masternode 5"). + public var typeIndex: UInt32 = 0 + + public var createdAt: Date + public var lastUpdated: Date + + public init( + walletId: Data, + proTxHash: Data, + registrationTxid: Data, + serviceAddress: String? = nil, + isEvonode: Bool = false, + ownerKeyHash: Data? = nil, + votingKeyHash: Data? = nil, + ownerAddress: String? = nil, + votingAddress: String? = nil, + collateralTxid: Data? = nil, + collateralVout: UInt32 = 0, + revoked: Bool = false, + revocationReason: UInt16 = 0, + statusRaw: UInt8 = 3, + registrationHeight: UInt32 = 0, + hasRegistration: Bool = false, + txCount: UInt32 = 0, + orderIndex: UInt32 = 0, + typeIndex: UInt32 = 0 + ) { + self.walletId = walletId + self.proTxHash = proTxHash + self.registrationTxid = registrationTxid + self.serviceAddress = serviceAddress + self.isEvonode = isEvonode + self.ownerKeyHash = ownerKeyHash + self.votingKeyHash = votingKeyHash + self.ownerAddress = ownerAddress + self.votingAddress = votingAddress + self.collateralTxid = collateralTxid + self.collateralVout = collateralVout + self.revoked = revoked + self.revocationReason = revocationReason + self.statusRaw = statusRaw + self.registrationHeight = registrationHeight + self.hasRegistration = hasRegistration + self.txCount = txCount + self.orderIndex = orderIndex + self.typeIndex = typeIndex + self.createdAt = Date() + self.lastUpdated = Date() + } + + // MARK: - Display helpers + + /// proTxHash in block-explorer (reversed) hex — matches + /// `PersistentTransaction.txidHex`'s display-order convention. + public var proTxHashHex: String { + proTxHash.reversed().map { String(format: "%02x", $0) }.joined() + } + + /// Short `aabbcc…ddeeff` proTxHash for compact rows. + public var proTxHashShort: String { + let hex = proTxHashHex + guard hex.count >= 12 else { return hex } + return "\(String(hex.prefix(6)))…\(String(hex.suffix(6)))" + } + + /// User-facing number (1-based) within the entity's own type + /// sequence — evonodes and masternodes number independently. + public var displayNumber: Int { + Int(typeIndex) + } + + /// "Evonode" for HPMN, else "Masternode". + public var typeName: String { + isEvonode ? "Evonode" : "Masternode" + } + + /// "Evonode 2" / "Masternode 5" — the canonical row title, shared by + /// the masternode list and the address-usage subtitle so the number + /// never diverges between screens. + public var displayTitle: String { + "\(typeName) \(displayNumber)" + } + + /// Typed view of [`statusRaw`]. + public var status: MasternodeStatus { + MasternodeStatus(rawValue: statusRaw) ?? .unknown + } + + /// Display status: Active / Inactive / Retired / Unknown. + public var statusName: String { + status.displayName + } +} + +/// DML-derived masternode status, mirroring the Rust +/// `MasternodeStatus` discriminant emitted over the FFI. +public enum MasternodeStatus: UInt8 { + /// In the masternode list and valid / enabled. + case active = 0 + /// In the list but flagged invalid (PoSe-banned). + case inactive = 1 + /// Not in the list (collateral spent / revoked / expired). + case retired = 2 + /// The list isn't available yet — status is indeterminate. + case unknown = 3 + + public var displayName: String { + switch self { + case .active: return "Active" + case .inactive: return "Inactive" + case .retired: return "Retired" + case .unknown: return "Unknown" + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index a06c6168eeb..eee780a4513 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -185,6 +185,12 @@ public class PlatformWalletManager: ObservableObject { /// Last error from a wallet operation, if any. Cleared on successful op. @Published public private(set) var lastError: Error? + /// Internal seam so manager extensions in other files can record a + /// failure (`lastError`'s setter is file-private). + func recordLastError(_ error: Error) { + lastError = error + } + // MARK: - Internals /// FFI handle; `NULL_HANDLE` until [`configure`] is called. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift new file mode 100644 index 00000000000..35142d9af96 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift @@ -0,0 +1,111 @@ +import Foundation +import DashSDKFFI + +/// One aggregated masternode, surfaced by the Rust query +/// `platform_wallet_manager_list_masternodes` (grouped by proTxHash over +/// the wallet's retained provider special transactions). A value +/// snapshot; the SwiftData mirror is `PersistentMasternode`. +/// +/// All aggregation / DIP-3 decoding happens in Rust — this is pure +/// bridging. +public struct PlatformMasternode: Sendable { + /// proTxHash (32 raw wire bytes) — the group key / registration txid. + public let proTxHash: Data + public let hasRegistration: Bool + public let registrationHeight: UInt32 + /// Stable cross-type registration-order position (sort key). + public let orderIndex: UInt32 + /// 1-based index within this masternode's type — pairs with + /// `isEvonode` to render "Evonode N" / "Masternode N". + public let typeIndex: UInt32 + public let isEvonode: Bool + public let revoked: Bool + public let revocationReason: UInt16 + /// DML-derived status discriminant (0 Active … 3 Unknown). `3` + /// (Unknown) means the persist layer should keep the prior value. + public let status: UInt8 + public let txCount: UInt32 + public let collateralTxid: Data? + public let collateralVout: UInt32 + public let ownerKeyHash: Data? + public let votingKeyHash: Data? + public let serviceAddress: String? + /// Base58 owner / voting P2PKH addresses (Rust-encoded for the + /// network) — the join key for a provider-key account's address rows. + public let ownerAddress: String? + public let votingAddress: String? +} + +extension PlatformWalletManager { + /// Aggregate the wallet's masternodes from Rust. Empty when the wallet + /// has none or the manager isn't configured. Pure marshalling — no + /// aggregation or payload decoding on the Swift side. + public func masternodes(for walletId: Data) -> [PlatformMasternode] { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { + return [] + } + + var outEntries: UnsafePointer? + var outCount: UInt = 0 + + let ffiResult = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) + return platform_wallet_manager_list_masternodes( + handle, + base, + &outEntries, + &outCount + ) + } + + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + recordLastError(PlatformWalletError(result: result)) + return [] + } + + guard let entries = outEntries, outCount > 0 else { + return [] + } + + defer { + platform_wallet_manager_free_masternodes( + UnsafeMutablePointer(mutating: entries), + outCount + ) + } + + return (0.. (UnsafeMutablePointer?, Int) { + // Provider special-tx kinds are the contiguous discriminant range + // 2...5 (ProviderRegistration=2 … ProviderUpdateRevocation=5). + let descriptor = FetchDescriptor( + predicate: #Predicate { tx in + tx.transactionTypeKind >= 2 && tx.transactionTypeKind <= 5 + } + ) + guard let providerTxs = try? backgroundContext.fetch(descriptor), + !providerTxs.isEmpty + else { + return (nil, 0) + } + + // Scope to this wallet via payload-only involvement — provider txs + // create no TXOs, so `involvedAccounts` is the only link. + let scoped = providerTxs.filter { tx in + tx.involvedAccounts.contains { $0.wallet.walletId == walletId } + } + guard !scoped.isEmpty else { return (nil, 0) } + + let buf = UnsafeMutablePointer.allocate( + capacity: scoped.count + ) + var written = 0 + for txRow in scoped { + let txBytes = txRow.transactionData + guard !txBytes.isEmpty else { + // Stub row whose real upsert never landed — skip rather + // than emit an undecodable buffer. + continue + } + + let txBuf = UnsafeMutablePointer.allocate(capacity: txBytes.count) + txBytes.copyBytes(to: txBuf, count: txBytes.count) + allocation.scalarBuffers.append((txBuf, txBytes.count)) + + var entry = ProviderSpecialTxRestoreEntryFFI() + entry.tx_bytes = txBuf + entry.tx_bytes_len = UInt(txBytes.count) + entry.context_raw = txRow.context + entry.block_height = txRow.blockHeight + if let hash = txRow.blockHash, hash.count == 32 { + withUnsafeMutableBytes(of: &entry.block_hash) { raw in + raw.copyBytes(from: hash) + } + } + entry.block_timestamp = UInt64(txRow.blockTimestamp) + entry.first_seen = txRow.firstSeen + buf[written] = entry + written += 1 + } + if written == 0 { + buf.deallocate() + return (nil, 0) + } + allocation.providerSpecialTxRecordArrays.append((buf, written)) + return (buf, written) + } + /// Parse `:` back into the 36-byte /// raw outpoint Rust expects (32-byte raw txid + 4-byte /// little-endian vout). Mirror of @@ -5451,6 +5539,11 @@ private final class LoadAllocation { /// so the next chain-lock event can cascade-promote them. The /// `tx_bytes` buffer each row references lives in `scalarBuffers`. var unresolvedAssetLockTxRecordArrays: [(UnsafeMutablePointer, Int)] = [] + /// Per-wallet `ProviderSpecialTxRestoreEntryFFI` arrays — provider + /// special txs re-staged so #876 retention keeps them resident after a + /// restart. The `tx_bytes` buffer each row references lives in + /// `scalarBuffers`. + var providerSpecialTxRecordArrays: [(UnsafeMutablePointer, Int)] = [] /// Per-wallet `AccountAddressPoolFFI` arrays, the persisted core /// address pools var coreAddressPoolArrays: [(UnsafeMutablePointer, Int)] = [] @@ -5526,6 +5619,10 @@ private final class LoadAllocation { ptr.deinitialize(count: count) ptr.deallocate() } + for (ptr, count) in providerSpecialTxRecordArrays { + ptr.deinitialize(count: count) + ptr.deallocate() + } for (ptr, count) in coreAddressEntryArrays { ptr.deinitialize(count: count) ptr.deallocate() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/MasternodeSync.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/MasternodeSync.swift new file mode 100644 index 00000000000..e80df5d0c86 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/MasternodeSync.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftData +import SwiftDashSDK + +/// Refreshes persisted `PersistentMasternode` rows from the Rust +/// masternode aggregation (`PlatformWalletManager.masternodes(for:)`). +/// +/// This is pure persist/marshalling per `packages/swift-sdk/CLAUDE.md` — +/// all grouping/decoding happened in Rust; here we only upsert the flat +/// result by `(walletId, proTxHash)` and delete rows the aggregation no +/// longer returns. Triggered on the Identities tab appearing (and safe to +/// call again after a Core sync pass). +@MainActor +enum MasternodeSync { + static func refresh( + walletManager: PlatformWalletManager, + walletIds: Set, + modelContext: ModelContext + ) { + var changed = false + for walletId in walletIds { + let fresh = walletManager.masternodes(for: walletId) + + let wid = walletId + let existing = (try? modelContext.fetch( + FetchDescriptor( + predicate: #Predicate { $0.walletId == wid } + ) + )) ?? [] + var byProTx: [Data: PersistentMasternode] = [:] + for row in existing { byProTx[row.proTxHash] = row } + + // An EMPTY aggregation is indistinguishable from "wallet not + // rehydrated yet / SPV not started" — the provider records the + // aggregation reads live in memory and repopulate lazily after + // launch (via restore staging / rescan). Treating empty as + // ground truth would delete every persisted masternode on every + // cold start, so we only ever prune against a NON-empty result. + let mayPrune = !fresh.isEmpty + + var seen = Set() + for mn in fresh { + seen.insert(mn.proTxHash) + let row: PersistentMasternode + if let found = byProTx[mn.proTxHash] { + row = found + } else { + row = PersistentMasternode( + walletId: walletId, + proTxHash: mn.proTxHash, + registrationTxid: mn.proTxHash + ) + modelContext.insert(row) + changed = true + } + row.registrationTxid = mn.proTxHash + row.serviceAddress = mn.serviceAddress + row.isEvonode = mn.isEvonode + row.ownerKeyHash = mn.ownerKeyHash + row.votingKeyHash = mn.votingKeyHash + row.ownerAddress = mn.ownerAddress + row.votingAddress = mn.votingAddress + row.collateralTxid = mn.collateralTxid + row.collateralVout = mn.collateralVout + row.revoked = mn.revoked + row.revocationReason = mn.revocationReason + // Status: skip on Unknown (3) so a not-yet-synced DML + // doesn't clobber a previously resolved Active/Inactive/ + // Retired. Rust returns Unknown only when the list is + // unavailable. + if mn.status != 3 { + row.statusRaw = mn.status + } + row.registrationHeight = mn.registrationHeight + row.hasRegistration = mn.hasRegistration + row.txCount = mn.txCount + row.orderIndex = mn.orderIndex + row.typeIndex = mn.typeIndex + row.lastUpdated = Date() + changed = true + } + + // Drop rows the aggregation no longer returns — but ONLY when + // the aggregation was non-empty (see `mayPrune`). An empty + // result during early startup must not wipe persisted rows. + if mayPrune { + for row in existing where !seen.contains(row.proTxHash) { + modelContext.delete(row) + changed = true + } + } + } + if changed { + try? modelContext.save() + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift index 77780817346..da1078aea2d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift @@ -10,6 +10,11 @@ struct AccountDetailView: View { let wallet: PersistentWallet let account: PersistentAccount + /// Masternodes for the provider-key-address usage subtitle. Matched + /// by base58 address (Rust-encoded owner/voting address ↔ the + /// account's persisted address), so no key hashing happens in Swift. + @Query private var allMasternodes: [PersistentMasternode] + @State private var errorMessage: String? @State private var copiedText: String? @State private var showingPrivateKey: String? @@ -476,15 +481,18 @@ struct AccountDetailView: View { } /// Group this account's persisted addresses by pool tag, in a - /// stable display order (External → Internal → Absent → Absent - /// Hardened). Empty pools are skipped. + /// stable display order (External → Internal → Additional → + /// Additional Hardened). Empty pools are skipped. Names come from + /// `PersistentCoreAddress.poolTypeName` so all address lists share + /// one taxonomy (tags 2/3 are the on-demand "Additional" pools where + /// provider keys live — no Rust "Absent" jargon). private func addressSections() -> [(String, [PersistentCoreAddress])] { let grouped = Dictionary(grouping: account.coreAddresses) { $0.poolTypeTag } let order: [(UInt8, String)] = [ (0, "External"), (1, "Internal"), - (2, "Absent"), - (3, "Absent (Hardened)"), + (2, "Additional"), + (3, "Additional (Hardened)"), ] return order.compactMap { tag, name in guard let bucket = grouped[tag], !bucket.isEmpty else { return nil } @@ -591,7 +599,11 @@ struct AccountDetailView: View { .foregroundColor(.primary) HStack(spacing: 6) { Text("#\(addr.addressIndex)") - if addr.isUsed { Text("• used") } + if let usage = masternodeUsage(for: addr) { + Text("• \(usage)") + } else if addr.isUsed { + Text("• used") + } if addr.balance > 0 { Text("• \(formatBalance(addr.balance))") } @@ -608,6 +620,30 @@ struct AccountDetailView: View { .contentShape(Rectangle()) } + /// Masternode-usage subtitle for a provider owner / voting key + /// address. Joins the address (base58) to a masternode's Rust-encoded + /// owner / voting address — the "join by key hash" without any Swift + /// key hashing. `nil` for non-provider-key accounts or unmatched + /// addresses, so the caller falls back to "used". + private func masternodeUsage(for addr: PersistentCoreAddress) -> String? { + // 8 = ProviderVotingKeys, 9 = ProviderOwnerKeys. + guard account.accountType == 8 || account.accountType == 9 else { return nil } + let wid = wallet.walletId + let matches = allMasternodes.filter { + $0.walletId == wid + && ($0.ownerAddress == addr.address || $0.votingAddress == addr.address) + } + guard let first = matches.min(by: { $0.orderIndex < $1.orderIndex }) else { + return nil + } + let count = matches.count + let times = count == 1 ? "once" : (count == 2 ? "twice" : "\(count) times") + if let ip = first.serviceAddress { + return "used \(times) at \(ip) · \(first.displayTitle)" + } + return "used \(times) on \(first.displayTitle)" + } + private func emptyAddressesCard() -> some View { VStack(alignment: .leading, spacing: 8) { Label("Addresses", systemImage: "info.circle") diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 98f559936e9..8a147abb29b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -44,6 +44,22 @@ struct IdentitiesContentView: View { /// presentation of a pre-configured `CreateIdentityView`. Cleared /// when the sheet dismisses (SwiftUI nils the binding for us). @State private var resumingAssetLock: PersistentAssetLock? + /// Identities vs. Masternodes segment. The segmented control is only + /// shown when the active network has at least one aggregated + /// masternode; otherwise the screen is unchanged (Identities only). + @State private var identitiesSegment: IdentitiesSegment = .identities + /// Whether Retired (not-in-DML) masternodes are shown. Default off — + /// only Active / Inactive / Unknown appear until the user opts in. + @State private var showRetired = false + /// All persisted masternodes, filtered to the active network via + /// `walletIdsOnNetwork` (masternodes carry no `networkRaw` column — + /// the wallet-id pivot the platform-address / shielded views use). + @Query private var allMasternodes: [PersistentMasternode] + + enum IdentitiesSegment: Hashable { + case identities + case masternodes + } init(network: Network) { self.network = network @@ -56,6 +72,20 @@ struct IdentitiesContentView: View { var body: some View { List { + if showMasternodeSegments { + Section { + Picker("View", selection: $identitiesSegment) { + Text("Identities").tag(IdentitiesSegment.identities) + Text("Masternodes").tag(IdentitiesSegment.masternodes) + } + .pickerStyle(.segmented) + .accessibilityIdentifier("identities.segment") + } + } + + if showMasternodeSegments, identitiesSegment == .masternodes { + masternodesSection + } else { pendingRegistrationsSection resumableRegistrationsSection if identities.isEmpty { @@ -124,6 +154,7 @@ struct IdentitiesContentView: View { } } } + } } .confirmationDialog( removalDialogTitle, @@ -199,6 +230,73 @@ struct IdentitiesContentView: View { .refreshable { await platformBalanceSyncService.performSync() } + // Refresh the persisted masternode aggregation from Rust when the + // tab appears. Pure persist step — Rust owns the aggregation + // (`MasternodeSync.refresh`). + .onAppear { + MasternodeSync.refresh( + walletManager: walletManager, + walletIds: walletIdsOnNetwork, + modelContext: modelContext + ) + } + } + + /// Wallet ids on the active network — the scoping pivot for + /// `allMasternodes` (which has no `networkRaw` column of its own). + private var walletIdsOnNetwork: Set { + let raw = network.rawValue + return Set(allWallets.lazy + .filter { $0.networkRaw == raw } + .map(\.walletId)) + } + + /// Active-network masternodes in stable registration order. + private var masternodesOnNetwork: [PersistentMasternode] { + let ids = walletIdsOnNetwork + return allMasternodes + .filter { ids.contains($0.walletId) } + .sorted { $0.orderIndex < $1.orderIndex } + } + + /// Show the Identities/Masternodes toggle only when this network has + /// at least one masternode; otherwise the screen stays Identities-only. + private var showMasternodeSegments: Bool { + !masternodesOnNetwork.isEmpty + } + + /// Active-network masternodes after the "Show retired" filter. Retired + /// (status == .retired) rows are hidden unless `showRetired`; every + /// other status — including Unknown — stays visible. + private var visibleMasternodes: [PersistentMasternode] { + masternodesOnNetwork.filter { showRetired || $0.status != .retired } + } + + /// Count of retired masternodes currently hidden by the filter. + private var hiddenRetiredCount: Int { + showRetired ? 0 : masternodesOnNetwork.filter { $0.status == .retired }.count + } + + /// One row per aggregated masternode, in registration order, with the + /// "Show retired" toggle. + @ViewBuilder + private var masternodesSection: some View { + Section("Masternodes") { + Toggle("Show retired", isOn: $showRetired) + .accessibilityIdentifier("masternodes.showRetiredToggle") + + ForEach(visibleMasternodes) { masternode in + MasternodeRow(masternode: masternode) + } + + // Guard against a blank-looking list when every masternode is + // retired and the toggle is off. + if visibleMasternodes.isEmpty && hiddenRetiredCount > 0 { + Text("\(hiddenRetiredCount) retired hidden") + .font(.caption) + .foregroundColor(.secondary) + } + } } /// "Pending registrations" row group. Surfaces every controller @@ -608,3 +706,64 @@ private struct ResumableRegistrationRow: View { return String(format: "%g DASH", dash) } } + +/// One masternode row: type badge (Evonode / Masternode), service IP, +/// short proTxHash, and Active / Revoked status. All fields come +/// pre-aggregated from Rust via `PersistentMasternode`. +private struct MasternodeRow: View { + let masternode: PersistentMasternode + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(masternode.displayTitle) + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + Text(masternode.typeName) + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + (masternode.isEvonode ? Color.purple : Color.blue).opacity(0.15) + ) + .foregroundColor(masternode.isEvonode ? .purple : .blue) + .cornerRadius(4) + } + + HStack(spacing: 6) { + Image(systemName: "network") + .font(.caption) + .foregroundColor(.secondary) + Text(masternode.serviceAddress ?? "—") + .font(.caption) + .foregroundColor(.secondary) + } + + HStack(spacing: 6) { + Text(masternode.proTxHashShort) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + Spacer() + Text(masternode.statusName) + .font(.caption2) + .fontWeight(.medium) + .foregroundColor(statusColor) + } + } + .padding(.vertical, 2) + .accessibilityIdentifier("masternodes.row.\(masternode.proTxHashShort)") + } + + /// Active = green, Inactive (PoSe-banned) = orange, Retired = red, + /// Unknown (DML not synced) = secondary. + private var statusColor: Color { + switch masternode.status { + case .active: return .green + case .inactive: return .orange + case .retired: return .red + case .unknown: return .secondary + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index fc5ba172579..16afbe4d368 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1533,15 +1533,17 @@ struct AccountStorageDetailView: View { } /// Group the account's addresses by pool-type tag and present in - /// a stable order: External, Internal, Absent, Absent (Hardened). - /// Empty sections are skipped. + /// a stable order: External, Internal, Additional, Additional + /// (Hardened). Empty sections are skipped. Matches + /// `PersistentCoreAddress.poolTypeName` (tags 2/3 are the on-demand + /// "Additional" pools; no Rust "Absent" jargon). private func addressSections() -> [(String, [PersistentCoreAddress])] { let grouped = Dictionary(grouping: record.coreAddresses) { $0.poolTypeTag } let order: [(UInt8, String)] = [ (0, "External"), (1, "Internal"), - (2, "Absent"), - (3, "Absent (Hardened)"), + (2, "Additional"), + (3, "Additional (Hardened)"), ] return order.compactMap { tag, name in guard let bucket = grouped[tag], !bucket.isEmpty else { return nil } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift index 0efbc8eb07a..43cc20a41e3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift @@ -1402,11 +1402,14 @@ private struct TxoSnapshot: Equatable { // MARK: - Helper labels private func addressPoolTypeLabel(_ tag: UInt8) -> String { + // Mirrors `PersistentCoreAddress.poolTypeName` — tags 2/3 are the + // on-demand "Additional" pools (provider keys etc.), not Rust's + // internal "Absent" naming. switch tag { case 0: return "External" case 1: return "Internal" - case 2: return "Absent" - case 3: return "AbsentHardened" + case 2: return "Additional" + case 3: return "Additional (Hardened)" default: return "Unknown(\(tag))" } } From 68f5109ab79c0c8801ef1e510da7a1eed661acf7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 13 Jul 2026 23:59:20 +0700 Subject: [PATCH 2/5] fix(platform-wallet): explorer coverage for PersistentMasternode + clippy type alias Adds the Storage Explorer list/detail/index views the coverage check requires for the new model, and factors the provider-masternode accessor's return tuple into the documented ProviderMasternodeTxs alias to satisfy clippy::type_complexity. Co-Authored-By: Claude Fable 5 --- .../src/manager/accessors.rs | 17 ++++-- .../Views/StorageExplorerView.swift | 6 ++ .../Views/StorageModelListViews.swift | 59 +++++++++++++++++++ .../Views/StorageRecordDetailViews.swift | 58 ++++++++++++++++++ 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index c9d045f8f07..d1f5655f5ee 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -20,6 +20,17 @@ use crate::spv::SpvRuntime; use crate::wallet::platform_wallet::WalletId; use crate::wallet::PlatformWallet; +/// Result of [`PlatformWalletManager::provider_masternode_txs_blocking`]: +/// the wallet's network (for base58 address encoding on the FFI side), +/// its retained provider special transactions with their confirmation +/// heights, and a DML snapshot (`proTxHash -> is_valid`, `None` when the +/// deterministic masternode list isn't available yet). +pub type ProviderMasternodeTxs = ( + dashcore::Network, + Vec<(u32, dashcore::Transaction)>, + Option>, +); + use super::PlatformWalletManager; /// Snapshot of [`PlatformAddressSyncManager`] tunables and last-event @@ -809,11 +820,7 @@ impl PlatformWalletManager

{ pub fn provider_masternode_txs_blocking( &self, wallet_id: &WalletId, - ) -> Option<( - dashcore::Network, - Vec<(u32, dashcore::Transaction)>, - Option>, - )> { + ) -> Option { // Scope the wallet-manager read lock so it's released before we // acquire the SPV client / engine locks for the DML snapshot — the // two never nest. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index 0363c6d3978..050974b8ac5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -134,6 +134,9 @@ struct StorageExplorerView: View { modelRow("Asset Locks", icon: "lock.shield", type: PersistentAssetLock.self) { AssetLockStorageListView(network: network) } + modelRow("Masternodes", icon: "server.rack", type: PersistentMasternode.self) { + MasternodeStorageListView(network: network) + } modelRow("Manager Metadata", icon: "gearshape.2", type: PersistentWalletManagerMetadata.self) { WalletManagerMetadataStorageListView(network: network) } @@ -315,6 +318,9 @@ struct StorageExplorerView: View { filteredCount(PersistentAssetLock.self) { walletsOnNetwork.contains($0.walletId) } + filteredCount(PersistentMasternode.self) { + walletsOnNetwork.contains($0.walletId) + } // Core / Platform addresses partition the same family of // tables by account type, so they need their own counts. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index f9a377598b0..e66ae63f1da 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1801,6 +1801,65 @@ struct AssetLockStorageListView: View { } } +// MARK: - PersistentMasternode + +/// Masternode entities aggregated by Rust from a wallet's provider +/// special transactions. Scoped to the active network via the +/// `walletId`→wallet join (masternodes carry no `networkRaw` column), +/// sorted by the stable cross-type registration order. +struct MasternodeStorageListView: View { + let network: Network + @Query(sort: [SortDescriptor(\PersistentMasternode.orderIndex)]) + private var records: [PersistentMasternode] + + @Query private var allWallets: [PersistentWallet] + + private var walletIdsOnNetwork: Set { + Set(allWallets.lazy + .filter { $0.networkRaw == network.rawValue } + .map(\.walletId)) + } + + private var scopedRecords: [PersistentMasternode] { + let ids = walletIdsOnNetwork + return records.filter { ids.contains($0.walletId) } + } + + var body: some View { + let visible = scopedRecords + List(visible) { record in + NavigationLink(destination: MasternodeStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(record.displayTitle) + .font(.body) + Spacer() + Text(record.statusName) + .font(.caption2) + .foregroundColor(.secondary) + } + Text(record.serviceAddress ?? "—") + .font(.caption2) + .foregroundColor(.secondary) + Text(record.proTxHashShort) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1).truncationMode(.middle) + } + } + } + .navigationTitle("Masternodes (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView( + "No Masternodes", + systemImage: "server.rack" + ) + } + } + } +} + // MARK: - PersistentWalletManagerMetadata struct WalletManagerMetadataStorageListView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 16afbe4d368..61a0d4d3304 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -2059,6 +2059,64 @@ struct WalletManagerMetadataStorageDetailView: View { } } +// MARK: - PersistentMasternode + +struct MasternodeStorageDetailView: View { + let record: PersistentMasternode + + var body: some View { + Form { + Section("Identity") { + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + FieldRow(label: "proTxHash", value: record.proTxHashHex) + FieldRow(label: "Registration Txid", value: hexString(record.registrationTxid)) + FieldRow(label: "Type", value: record.typeName) + FieldRow(label: "Status", value: record.statusName) + } + Section("Service") { + FieldRow(label: "Service Address", value: record.serviceAddress ?? "—") + } + Section("Keys") { + FieldRow( + label: "Owner Key Hash", + value: record.ownerKeyHash.map(hexString) ?? "—" + ) + FieldRow( + label: "Voting Key Hash", + value: record.votingKeyHash.map(hexString) ?? "—" + ) + FieldRow(label: "Owner Address", value: record.ownerAddress ?? "—") + FieldRow(label: "Voting Address", value: record.votingAddress ?? "—") + } + Section("Collateral") { + FieldRow( + label: "Collateral Txid", + value: record.collateralTxid.map(hexString) ?? "—" + ) + FieldRow(label: "Collateral Vout", value: "\(record.collateralVout)") + } + Section("Aggregation") { + FieldRow(label: "Has Registration", value: record.hasRegistration ? "Yes" : "No") + FieldRow(label: "Registration Height", value: "\(record.registrationHeight)") + FieldRow(label: "Tx Count", value: "\(record.txCount)") + FieldRow(label: "Order Index", value: "\(record.orderIndex)") + FieldRow(label: "Type Index", value: "\(record.typeIndex)") + } + Section("Revocation") { + FieldRow(label: "Revoked", value: record.revoked ? "Yes" : "No") + FieldRow(label: "Revocation Reason", value: "\(record.revocationReason)") + FieldRow(label: "Status Raw", value: "\(record.statusRaw)") + } + Section("Timestamps") { + FieldRow(label: "Created", value: dateString(record.createdAt)) + FieldRow(label: "Updated", value: dateString(record.lastUpdated)) + } + } + .navigationTitle(record.displayTitle) + .navigationBarTitleDisplayMode(.inline) + } +} + // MARK: - PersistentShieldedNote struct ShieldedNoteStorageDetailView: View { From 164091bcb04e0309b7ee006cdcf819bf575cac67 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 14 Jul 2026 01:16:26 +0700 Subject: [PATCH 3/5] feat(platform-wallet): masternode detail page with keys, payouts, claimable balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a masternode/evonode opens a detail page showing: full key material (owner/voting addresses + hashes, operator BLS key, evonode platform node id, payout address — extracted in Rust with latest-update-wins), key ownership resolved by joining the Rust-encoded base58 key addresses against the wallet's persisted address rows ("ProviderOwnerKeys #14" / "not in this wallet"), Core payout history (persisted TXOs paying the payout address), and for evonodes the claimable balance — the credit balance of the Platform identity whose id is the display-order proTxHash — with refresh. Also fixes a pre-existing SDK wrapper bug: identity fetch-balance returns a decimal C string that getBalance misread as a binary u64. Co-Authored-By: Claude Fable 5 --- .../src/core_wallet_types.rs | 125 ++++++- packages/rs-platform-wallet-ffi/src/wallet.rs | 3 + .../src/manager/accessors.rs | 7 + .../Models/PersistentMasternode.swift | 79 +++++ .../PlatformWalletManagerMasternodes.swift | 23 +- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 19 +- .../Core/Services/MasternodeSync.swift | 50 +++ .../Core/Views/IdentitiesContentView.swift | 10 +- .../Core/Views/MasternodeDetailView.swift | 328 ++++++++++++++++++ 9 files changed, 635 insertions(+), 9 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 91733a4bb82..20b8b97adc6 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -1052,6 +1052,19 @@ pub(crate) struct MasternodeAggregate { pub voting_key_hash: Option<[u8; 20]>, /// Height that set `voting_key_hash` (drives latest-wins). voting_height: u32, + /// Operator BLS public key (48 bytes) — follows the latest ProRegTx / + /// ProUpReg. + pub operator_public_key: Option<[u8; 48]>, + operator_height: u32, + /// Platform node id (hash160, 20 bytes) for evonodes — follows the + /// latest ProRegTx / ProUpServ. + pub platform_node_id: Option<[u8; 20]>, + platform_node_height: u32, + /// Payout script (raw bytes) — follows the latest ProRegTx / ProUpReg + /// (owner payout). Encoded to a base58 address by `masternode_entry_ffi` + /// where the network is available. + pub payout_script: Option>, + payout_height: u32, /// Collateral outpoint (`txid` wire bytes, `vout`) from the ProRegTx. pub collateral: Option<([u8; 32], u32)>, /// A ProUpRevTx was seen ⇒ the masternode was revoked ("previously @@ -1152,18 +1165,50 @@ where agg.voting_key_hash = Some(provider_hash_to_20(p.voting_key_hash.as_ref())); agg.voting_height = height; } + if agg.operator_public_key.is_none() || height >= agg.operator_height { + let bls: &[u8; 48] = p.operator_public_key.as_ref(); + agg.operator_public_key = Some(*bls); + agg.operator_height = height; + } + if agg.platform_node_id.is_none() || height >= agg.platform_node_height { + // Evonode-only; `None` on a regular masternode. + if let Some(node_id) = p.platform_node_id.as_ref() { + agg.platform_node_id = Some(provider_hash_to_20(node_id.as_ref())); + agg.platform_node_height = height; + } + } + if agg.payout_script.is_none() || height >= agg.payout_height { + agg.payout_script = Some(p.script_payout.as_bytes().to_vec()); + agg.payout_height = height; + } } Some(TransactionPayload::ProviderUpdateServicePayloadType(p)) => { if agg.service_address.is_none() || height >= agg.service_height { agg.service_address = Some(provider_ip_port(p.ip_address, p.port)); agg.service_height = height; } + // ProUpServ's `platform_node_id` is a raw `Option<[u8; 20]>`. + if let Some(node_id) = p.platform_node_id { + if agg.platform_node_id.is_none() || height >= agg.platform_node_height { + agg.platform_node_id = Some(node_id); + agg.platform_node_height = height; + } + } } Some(TransactionPayload::ProviderUpdateRegistrarPayloadType(p)) => { if agg.voting_key_hash.is_none() || height >= agg.voting_height { agg.voting_key_hash = Some(provider_hash_to_20(p.voting_key_hash.as_ref())); agg.voting_height = height; } + if agg.operator_public_key.is_none() || height >= agg.operator_height { + let bls: &[u8; 48] = p.operator_public_key.as_ref(); + agg.operator_public_key = Some(*bls); + agg.operator_height = height; + } + if agg.payout_script.is_none() || height >= agg.payout_height { + agg.payout_script = Some(p.script_payout.as_bytes().to_vec()); + agg.payout_height = height; + } } Some(TransactionPayload::ProviderUpdateRevocationPayloadType(p)) => { agg.revoked = true; @@ -1258,6 +1303,26 @@ pub struct MasternodeEntryFFI { /// hashes keys. pub owner_address: *mut c_char, pub voting_address: *mut c_char, + /// Operator BLS public key (48 bytes), gated by `has_operator_key`. + pub operator_public_key: [u8; 48], + pub has_operator_key: bool, + /// Platform node id (hash160, 20 bytes) — evonodes only — gated by + /// `has_platform_node_id`. + pub platform_node_id: [u8; 20], + pub has_platform_node_id: bool, + /// Base58 payout address for the network, or null (non-standard + /// payout script, or none seen). + pub payout_address: *mut c_char, + // --- Base58 P2PKH addresses of the operator / platform keys --- + // Owner / voting addresses are `owner_address` / `voting_address` + // above. These two complete the set so the app can join ALL FOUR key + // kinds against persisted `PersistentCoreAddress` rows (address ⇒ + // account type + index) — the durable ownership source. Pure encoding, + // no in-wallet lookup on the Rust side. + /// Base58 P2PKH pseudo-address of `hash160(operator BLS key)`, or null. + pub operator_pseudo_address: *mut c_char, + /// Base58 P2PKH address of the platform node id (evonode), or null. + pub platform_node_address: *mut c_char, } /// Encode a hash160 as a network-specific base58 P2PKH address string @@ -1276,16 +1341,45 @@ fn masternode_p2pkh_cstring(hash: [u8; 20], network: dashcore::Network) -> *mut .unwrap_or(std::ptr::null_mut()) } +/// Encode a payout `script` to its base58 address for `network`, or null +/// when the script is non-standard (not addressable). +fn masternode_payout_cstring(script_bytes: &[u8], network: dashcore::Network) -> *mut c_char { + let script = dashcore::ScriptBuf::from_bytes(script_bytes.to_vec()); + match dashcore::Address::from_script(&script, network) { + Ok(address) => std::ffi::CString::new(address.to_string()) + .map(std::ffi::CString::into_raw) + .unwrap_or(std::ptr::null_mut()), + Err(_) => std::ptr::null_mut(), + } +} + /// Flatten one aggregate into its C-ABI entry, encoding the owner / -/// voting addresses for `network`. `order_index` is the caller's stable -/// position in the sorted aggregate list. +/// voting / payout / operator / platform-node base58 addresses for +/// `network`. `order_index` is the caller's stable position in the sorted +/// aggregate list. Key-ownership is resolved app-side by joining these +/// address strings against persisted `PersistentCoreAddress` rows. pub(crate) fn masternode_entry_ffi( mn: &MasternodeAggregate, order_index: u32, network: dashcore::Network, ) -> MasternodeEntryFFI { + use dashcore::hashes::{hash160, Hash}; use std::ffi::CString; + // Operator pseudo-address = P2PKH of hash160(BLS key); platform-node + // address = P2PKH of the 20-byte node id. + let operator_pseudo_address = mn + .operator_public_key + .map(|k| { + let h: [u8; 20] = hash160::Hash::hash(&k).to_byte_array(); + masternode_p2pkh_cstring(h, network) + }) + .unwrap_or(std::ptr::null_mut()); + let platform_node_address = mn + .platform_node_id + .map(|h| masternode_p2pkh_cstring(h, network)) + .unwrap_or(std::ptr::null_mut()); + let service_address = match &mn.service_address { Some(s) => CString::new(s.clone()) .map(CString::into_raw) @@ -1304,6 +1398,11 @@ pub(crate) fn masternode_entry_ffi( .voting_key_hash .map(|h| masternode_p2pkh_cstring(h, network)) .unwrap_or(std::ptr::null_mut()); + let payout_address = mn + .payout_script + .as_deref() + .map(|s| masternode_payout_cstring(s, network)) + .unwrap_or(std::ptr::null_mut()); MasternodeEntryFFI { pro_tx_hash: mn.pro_tx_hash, @@ -1326,6 +1425,13 @@ pub(crate) fn masternode_entry_ffi( service_address, owner_address, voting_address, + operator_public_key: mn.operator_public_key.unwrap_or([0u8; 48]), + has_operator_key: mn.operator_public_key.is_some(), + platform_node_id: mn.platform_node_id.unwrap_or([0u8; 20]), + has_platform_node_id: mn.platform_node_id.is_some(), + payout_address, + operator_pseudo_address, + platform_node_address, } } @@ -1766,6 +1872,21 @@ mod tests { assert!(mn.owner_key_hash.is_some()); assert!(mn.voting_key_hash.is_some()); assert!(mn.collateral.is_some()); + // #4116 key-ownership extraction: operator BLS key + payout script + // are lifted; the legacy (v1) fixture is a regular MN so it has no + // platform node id. + assert!( + mn.operator_public_key.is_some(), + "ProRegTx carries a 48-byte operator BLS key" + ); + assert!( + mn.payout_script.as_ref().map_or(false, |s| !s.is_empty()), + "ProRegTx carries a payout script" + ); + assert!( + mn.platform_node_id.is_none(), + "legacy regular-MN fixture has no platform node id" + ); assert_eq!(mn.tx_count, 1); } diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index b5041759965..e6c27088c88 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -284,6 +284,9 @@ pub unsafe extern "C" fn platform_wallet_manager_free_masternodes( entry.service_address, entry.owner_address, entry.voting_address, + entry.payout_address, + entry.operator_pseudo_address, + entry.platform_node_address, ] { if !ptr.is_null() { let _ = std::ffi::CString::from_raw(ptr); diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index d1f5655f5ee..1b518a44064 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -25,6 +25,13 @@ use crate::wallet::PlatformWallet; /// its retained provider special transactions with their confirmation /// heights, and a DML snapshot (`proTxHash -> is_valid`, `None` when the /// deterministic masternode list isn't available yet). +/// +/// Key-ownership annotation is NOT computed here: the wallet's in-memory +/// provider-key pools aren't rehydrated for imported/restored wallets, so +/// a pool scan reports "not in wallet" for keys the wallet demonstrably +/// holds. Ownership is instead resolved app-side against the persisted +/// `PersistentCoreAddress` rows (address ⇒ account type + index) — the +/// same durable source the account screen + address-subtitle join use. pub type ProviderMasternodeTxs = ( dashcore::Network, Vec<(u32, dashcore::Transaction)>, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift index c7e39df2e11..223964ffec1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift @@ -45,6 +45,27 @@ public final class PersistentMasternode { /// Swift-side key hashing. public var ownerAddress: String? public var votingAddress: String? + /// Operator BLS public key (48 raw bytes), or nil. + public var operatorPublicKey: Data? + /// Platform node id (hash160, 20 bytes) for evonodes, or nil. + public var platformNodeId: Data? + /// Base58 payout address (Rust-encoded), or nil. + public var payoutAddress: String? + + /// Per-key wallet ownership (computed in Rust during the list call). + /// `*AccountType`/`*Index` are meaningful only when `*InWallet`. + public var ownerInWallet: Bool = false + public var ownerAccountType: UInt8 = 0 + public var ownerKeyIndex: UInt32 = 0 + public var votingInWallet: Bool = false + public var votingAccountType: UInt8 = 0 + public var votingKeyIndex: UInt32 = 0 + public var operatorInWallet: Bool = false + public var operatorAccountType: UInt8 = 0 + public var operatorKeyIndex: UInt32 = 0 + public var platformInWallet: Bool = false + public var platformAccountType: UInt8 = 0 + public var platformKeyIndex: UInt32 = 0 /// ProRegTx collateral outpoint. public var collateralTxid: Data? @@ -92,6 +113,9 @@ public final class PersistentMasternode { votingKeyHash: Data? = nil, ownerAddress: String? = nil, votingAddress: String? = nil, + operatorPublicKey: Data? = nil, + platformNodeId: Data? = nil, + payoutAddress: String? = nil, collateralTxid: Data? = nil, collateralVout: UInt32 = 0, revoked: Bool = false, @@ -112,6 +136,9 @@ public final class PersistentMasternode { self.votingKeyHash = votingKeyHash self.ownerAddress = ownerAddress self.votingAddress = votingAddress + self.operatorPublicKey = operatorPublicKey + self.platformNodeId = platformNodeId + self.payoutAddress = payoutAddress self.collateralTxid = collateralTxid self.collateralVout = collateralVout self.revoked = revoked @@ -141,6 +168,58 @@ public final class PersistentMasternode { return "\(String(hex.prefix(6)))…\(String(hex.suffix(6)))" } + /// Owner key hash (hash160) in forward-order hex, or nil. Key hashes + /// are shown in natural byte order, unlike txids. + public var ownerKeyHashHex: String? { + ownerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + /// Voting key hash (hash160) in forward-order hex, or nil. + public var votingKeyHashHex: String? { + votingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + /// Human name for a provider-key-account type tag (8/9/10/11). + public static func providerAccountTypeName(_ tag: UInt8) -> String { + switch tag { + case 8: return "ProviderVotingKeys" + case 9: return "ProviderOwnerKeys" + case 10: return "ProviderOperatorKeys" + case 11: return "ProviderPlatformKeys" + default: return "Unknown(\(tag))" + } + } + + /// Ownership subtitle for one key: `"ProviderOwnerKeys #4"` when the + /// key is in this wallet, else `"not in this wallet"`. + public static func keyOwnershipLabel( + inWallet: Bool, + accountType: UInt8, + index: UInt32 + ) -> String { + inWallet + ? "\(providerAccountTypeName(accountType)) #\(index)" + : "not in this wallet" + } + + /// Operator BLS public key in hex (96 chars), or nil. + public var operatorPublicKeyHex: String? { + operatorPublicKey.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + /// Platform node id (hash160) in forward-order hex, or nil. + public var platformNodeIdHex: String? { + platformNodeId.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + /// Collateral outpoint as `"txidHex:vout"` in display (reversed-txid) + /// order, or nil when there's no collateral field. + public var collateralDisplay: String? { + guard let txid = collateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(collateralVout)" + } + /// User-facing number (1-based) within the entity's own type /// sequence — evonodes and masternodes number independently. public var displayNumber: Int { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift index 35142d9af96..361cdd950e2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift @@ -34,6 +34,18 @@ public struct PlatformMasternode: Sendable { /// network) — the join key for a provider-key account's address rows. public let ownerAddress: String? public let votingAddress: String? + /// Operator BLS public key (48 raw bytes), or nil. + public let operatorPublicKey: Data? + /// Platform node id (hash160, 20 bytes) for evonodes, or nil. + public let platformNodeId: Data? + /// Base58 payout address (Rust-encoded), or nil for a non-standard / + /// unseen payout script. + public let payoutAddress: String? + /// Base58 P2PKH pseudo-address of `hash160(operator BLS key)`, or nil. + /// The join key for operator-key ownership against persisted addresses. + public let operatorPseudoAddress: String? + /// Base58 P2PKH address of the platform node id (evonode), or nil. + public let platformNodeAddress: String? } extension PlatformWalletManager { @@ -104,7 +116,16 @@ extension PlatformWalletManager { votingKeyHash: votingHash, serviceAddress: entry.service_address.map { String(cString: $0) }, ownerAddress: entry.owner_address.map { String(cString: $0) }, - votingAddress: entry.voting_address.map { String(cString: $0) } + votingAddress: entry.voting_address.map { String(cString: $0) }, + operatorPublicKey: entry.has_operator_key + ? withUnsafeBytes(of: &entry.operator_public_key) { Data($0) } + : nil, + platformNodeId: entry.has_platform_node_id + ? withUnsafeBytes(of: &entry.platform_node_id) { Data($0) } + : nil, + payoutAddress: entry.payout_address.map { String(cString: $0) }, + operatorPseudoAddress: entry.operator_pseudo_address.map { String(cString: $0) }, + platformNodeAddress: entry.platform_node_address.map { String(cString: $0) } ) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 405ce5d8447..c38f13fab0e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -681,12 +681,21 @@ public class Identities { throw SDKError.internalError("No balance data returned") } - // Parse the balance from result - let balancePtr = result.data.assumingMemoryBound(to: UInt64.self) - let balance = balancePtr.pointee + // `dash_sdk_identity_fetch_balance` returns the balance as a + // `success_string` — a NUL-terminated decimal C string (see + // rs-sdk-ffi `identity/queries/balance.rs`), NOT a binary `u64`. + // Read it as a C string and parse; a previous binary reinterpret + // read the ASCII digits as little-endian bytes (garbage balances). + let cStr = result.data.assumingMemoryBound(to: CChar.self) + let balanceStr = String(cString: cStr) + + // String-return results are freed with `dash_sdk_string_free`, like + // the other string-returning wrappers in this file. + dash_sdk_string_free(cStr) - // Free the result data - dash_sdk_bytes_free(result.data) + guard let balance = UInt64(balanceStr) else { + throw SDKError.internalError("Unparseable balance string: \(balanceStr)") + } return balance } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/MasternodeSync.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/MasternodeSync.swift index e80df5d0c86..e78241ee3ba 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/MasternodeSync.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/MasternodeSync.swift @@ -60,6 +60,35 @@ enum MasternodeSync { row.votingKeyHash = mn.votingKeyHash row.ownerAddress = mn.ownerAddress row.votingAddress = mn.votingAddress + row.operatorPublicKey = mn.operatorPublicKey + row.platformNodeId = mn.platformNodeId + row.payoutAddress = mn.payoutAddress + + // Key ownership: join each key's base58 address against the + // persisted `PersistentCoreAddress` rows (address ⇒ account + // type + index). This durable source works for imported / + // restored wallets whose in-memory provider pools aren't + // rehydrated — the same join the account screen + address + // subtitle rely on. + let owner = ownership(for: mn.ownerAddress, modelContext: modelContext) + row.ownerInWallet = owner.inWallet + row.ownerAccountType = owner.accountType + row.ownerKeyIndex = owner.index + let voting = ownership(for: mn.votingAddress, modelContext: modelContext) + row.votingInWallet = voting.inWallet + row.votingAccountType = voting.accountType + row.votingKeyIndex = voting.index + let operatorOwn = ownership( + for: mn.operatorPseudoAddress, modelContext: modelContext) + row.operatorInWallet = operatorOwn.inWallet + row.operatorAccountType = operatorOwn.accountType + row.operatorKeyIndex = operatorOwn.index + let platform = ownership( + for: mn.platformNodeAddress, modelContext: modelContext) + row.platformInWallet = platform.inWallet + row.platformAccountType = platform.accountType + row.platformKeyIndex = platform.index + row.collateralTxid = mn.collateralTxid row.collateralVout = mn.collateralVout row.revoked = mn.revoked @@ -94,4 +123,25 @@ enum MasternodeSync { try? modelContext.save() } } + + /// Resolve a provider key's wallet ownership by looking up its base58 + /// `address` in the persisted `PersistentCoreAddress` rows (address is + /// `@Attribute(.unique)`, so at most one match). Returns the row's + /// account-type tag + derivation index. Pure load + string join — no + /// key material or decisions. + private static func ownership( + for address: String?, + modelContext: ModelContext + ) -> (inWallet: Bool, accountType: UInt8, index: UInt32) { + guard let address, !address.isEmpty else { return (false, 0, 0) } + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.address == address } + ) + guard let row = try? modelContext.fetch(descriptor).first, + let account = row.account + else { + return (false, 0, 0) + } + return (true, UInt8(truncatingIfNeeded: account.accountType), row.addressIndex) + } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 8a147abb29b..b10a4039865 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -180,6 +180,9 @@ struct IdentitiesContentView: View { ) } .navigationTitle("Identities") + .navigationDestination(for: PersistentMasternode.self) { masternode in + MasternodeDetailView(masternode: masternode) + } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Menu { @@ -286,7 +289,12 @@ struct IdentitiesContentView: View { .accessibilityIdentifier("masternodes.showRetiredToggle") ForEach(visibleMasternodes) { masternode in - MasternodeRow(masternode: masternode) + // Value-based navigation (destination hoisted onto the + // List below) so a `@Query` re-render can't dismiss an + // open detail page mid-scroll — see the nav-churn note. + NavigationLink(value: masternode) { + MasternodeRow(masternode: masternode) + } } // Guard against a blank-looking list when every masternode is diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift new file mode 100644 index 00000000000..ee5e1b15276 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift @@ -0,0 +1,328 @@ +import SwiftUI +import SwiftDashSDK +import SwiftData +import UIKit + +/// Read-only detail page for one aggregated masternode. Pushed from the +/// Identities → Masternodes list via value-based navigation. +/// +/// All fields are pre-aggregated in Rust and mirrored on +/// `PersistentMasternode`; this view only renders them. The key-ownership, +/// Core-payout, and (evonode) claimable-balance panels are layered on top +/// of the same Rust-sourced data. +struct MasternodeDetailView: View { + let masternode: PersistentMasternode + /// Platform SDK access for the evonode claimable-balance fetch. + @EnvironmentObject var platformState: AppState + + /// Core payout coinbase TXOs paid to this node's payout address, if + /// that address belongs to this wallet. Sourced from persisted + /// `PersistentTxo` (not a Rust in-memory scan): coinbase payout txs + /// aren't provider txs, so they're evicted from the wallet's in-memory + /// set — the durable record is the TXO the payout created here. + @Query private var payoutTxos: [PersistentTxo] + + /// Evonode claimable balance = the masternode identity's credit balance + /// (identity id == proTxHash, no hashing — see dash-evo-tool). `nil` + /// until fetched / when the identity isn't found. + @State private var claimableCredits: UInt64? + @State private var balanceLoading = false + @State private var balanceError: String? + + init(masternode: PersistentMasternode) { + self.masternode = masternode + // Every TXO paid to the (dedicated) masternode payout address in + // this wallet is a payout. We deliberately do NOT filter on + // `isCoinbase` — masternode payouts arrive via coinbase, but the + // SPV persister doesn't reliably flag it, so requiring it would + // hide real payouts. When `payoutAddress` is nil the empty-string + // key matches nothing (correct: no payouts to show). + let addr = masternode.payoutAddress ?? "" + let wid = masternode.walletId + _payoutTxos = Query( + filter: #Predicate { txo in + txo.address == addr && txo.walletId == wid + }, + sort: [SortDescriptor(\PersistentTxo.height, order: .reverse)] + ) + } + + private var statusColor: Color { + switch masternode.status { + case .active: return .green + case .inactive: return .orange + case .retired: return .red + case .unknown: return .secondary + } + } + + var body: some View { + List { + Section { + HStack { + Label(masternode.typeName, systemImage: "server.rack") + .font(.headline) + Spacer() + Text(masternode.statusName) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(statusColor) + } + MasternodeDetailRow(label: "Service", value: masternode.serviceAddress ?? "—") + } + + Section("Identity") { + MasternodeCopyRow(label: "proTxHash", value: masternode.proTxHashHex) + MasternodeDetailRow( + label: "Registration", + value: masternode.hasRegistration + ? "Height \(masternode.registrationHeight)" + : "Not in wallet history" + ) + MasternodeDetailRow(label: "Provider TXs", value: "\(masternode.txCount)") + } + + Section("Keys") { + if let owner = masternode.ownerAddress { + MasternodeCopyRow(label: "Owner Address", value: owner) + } + if let ownerHash = masternode.ownerKeyHashHex { + MasternodeCopyRow(label: "Owner Key Hash", value: ownerHash) + } + if let voting = masternode.votingAddress { + MasternodeCopyRow(label: "Voting Address", value: voting) + } + if let votingHash = masternode.votingKeyHashHex { + MasternodeCopyRow(label: "Voting Key Hash", value: votingHash) + } + if let operatorKey = masternode.operatorPublicKeyHex { + MasternodeCopyRow(label: "Operator Key (BLS)", value: operatorKey) + } + if let nodeId = masternode.platformNodeIdHex { + MasternodeCopyRow(label: "Platform Node ID", value: nodeId) + } + if let payout = masternode.payoutAddress { + MasternodeCopyRow(label: "Payout Address", value: payout) + } + } + + Section("Key Ownership") { + MasternodeDetailRow( + label: "Owner", + value: PersistentMasternode.keyOwnershipLabel( + inWallet: masternode.ownerInWallet, + accountType: masternode.ownerAccountType, + index: masternode.ownerKeyIndex + ) + ) + MasternodeDetailRow( + label: "Voting", + value: PersistentMasternode.keyOwnershipLabel( + inWallet: masternode.votingInWallet, + accountType: masternode.votingAccountType, + index: masternode.votingKeyIndex + ) + ) + if masternode.operatorPublicKey != nil { + MasternodeDetailRow( + label: "Operator", + value: PersistentMasternode.keyOwnershipLabel( + inWallet: masternode.operatorInWallet, + accountType: masternode.operatorAccountType, + index: masternode.operatorKeyIndex + ) + ) + } + if masternode.platformNodeId != nil { + MasternodeDetailRow( + label: "Platform Node", + value: PersistentMasternode.keyOwnershipLabel( + inWallet: masternode.platformInWallet, + accountType: masternode.platformAccountType, + index: masternode.platformKeyIndex + ) + ) + } + } + + if let collateral = masternode.collateralDisplay { + Section("Collateral") { + MasternodeCopyRow(label: "Outpoint", value: collateral) + } + } + + Section("Core Payouts") { + if payoutTxos.isEmpty { + Text("No payouts in this wallet's history") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(payoutTxos) { txo in + MasternodeDetailRow( + label: "Height \(txo.height)", + value: Self.duffsAsDash(txo.amount) + ) + } + } + } + + if masternode.revoked { + Section("Revocation") { + MasternodeDetailRow( + label: "Reason", + value: "\(masternode.revocationReason)" + ) + } + } + + // Platform credits accrue on the masternode's Platform identity + // (evonodes only participate in Platform). Read-only display; + // "claiming" (identity credit withdrawal) is a separate flow. + if masternode.isEvonode { + Section("Claimable Balance") { + if balanceLoading { + HStack(spacing: 8) { + ProgressView().scaleEffect(0.8) + Text("Fetching…").foregroundColor(.secondary) + } + } else if let credits = claimableCredits { + MasternodeDetailRow( + label: "Claimable", + value: "\(credits) credits" + ) + MasternodeDetailRow( + label: "≈ DASH", + value: Self.creditsAsDash(credits) + ) + } else if let err = balanceError { + Text(err) + .font(.caption) + .foregroundColor(.secondary) + } + + Button { + Task { await fetchClaimableBalance() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(balanceLoading) + .accessibilityIdentifier("masternode.refreshBalance") + } + } + } + .navigationTitle(masternode.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .task { + if masternode.isEvonode { + await fetchClaimableBalance() + } + } + } + + /// Fetch the masternode identity's credit balance. The identity id IS + /// the proTxHash, but in **display (reversed) byte order** — the same + /// orientation `proTxHashHex` decodes to and that dash-evo-tool feeds + /// `Identifier::from_string` from the pasted block-explorer hex. Our + /// stored `proTxHash` is raw wire order (`Txid::as_ref()`), so we + /// reverse it before keying the balance fetch. The FFI call is + /// blocking, so it runs off the main actor (matching `loadPreviewKeys`). + @MainActor + private func fetchClaimableBalance() async { + guard let sdk = platformState.sdk else { + balanceError = "Platform SDK not ready" + return + } + // Identity id = display-order proTxHash (reverse of the stored wire + // bytes). + let identityId = Data(masternode.proTxHash.reversed()) + balanceLoading = true + balanceError = nil + do { + let credits = try await Task.detached(priority: .userInitiated) { + try sdk.identities.getBalance(id: identityId) + }.value + claimableCredits = credits + } catch { + claimableCredits = nil + // Distinguish "no identity registered" from a transport failure + // so the copy isn't misleading on a transient network error. + let message = error.localizedDescription.lowercased() + if message.contains("not found") || message.contains("no identity") + || message.contains("does not exist") + { + balanceError = "This masternode has no Platform identity yet." + } else { + balanceError = "Couldn't fetch balance (network error). Try Refresh." + } + } + balanceLoading = false + } + + /// 1 DASH = 100,000,000,000 credits. + private static func creditsAsDash(_ credits: UInt64) -> String { + let dash = Double(credits) / 100_000_000_000.0 + return String(format: "%.8f DASH", dash) + } + + /// 1 DASH = 100,000,000 duffs (Core amounts). + private static func duffsAsDash(_ duffs: UInt64) -> String { + let dash = Double(duffs) / 100_000_000.0 + return String(format: "%.8f DASH", dash) + } +} + +// MARK: - Rows + +/// Label + value row, matching the app's detail-row convention. +struct MasternodeDetailRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(value) + .font(.subheadline) + .fontWeight(.medium) + .multilineTextAlignment(.trailing) + } + } +} + +/// Caption + monospaced, tap-to-copy value block for hashes / addresses. +struct MasternodeCopyRow: View { + let label: String + let value: String + @State private var copied = false + + var body: some View { + Button { + UIPasteboard.general.string = value + withAnimation { copied = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { copied = false } + } + } label: { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .font(.caption) + .foregroundColor(copied ? .green : .blue) + } + Text(value) + .font(.system(.footnote, design: .monospaced)) + .foregroundColor(.primary) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + } + .buttonStyle(.plain) + } +} From 458865ba17581fd2e9e2d07530f688feed677175 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 14 Jul 2026 01:38:23 +0700 Subject: [PATCH 4/5] feat(platform-wallet): owner-key claim flow for masternode credits (signer gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the full claim path for a masternode identity's credits: a Claim button gated on claimable balance and owner-key-in-wallet, a confirmation sheet (amount with fee headroom, registered payout address as the fixed destination, signing key shown), a Swift wrapper, and the platform_wallet_manager_masternode_withdraw FFI with a unit-tested guard that selects the identity's matching OWNER ECDSA_HASH160 key and refuses otherwise. The actual sign+broadcast is deliberately gated behind a distinct error until the owner-key signer is verified against testnet — the signature encoding must not be guessed on a money path. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet-ffi/src/wallet.rs | 78 ++++++++++ .../src/wallet/identity/network/withdrawal.rs | 120 +++++++++++++++ .../PlatformWalletManagerMasternodes.swift | 46 ++++++ .../Core/Views/MasternodeDetailView.swift | 144 ++++++++++++++++++ 4 files changed, 388 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index e6c27088c88..c2c0d28f4c7 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -296,6 +296,84 @@ pub unsafe extern "C" fn platform_wallet_manager_free_masternodes( let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(entries, count)); } +/// Claim (withdraw) credits from a masternode's Platform identity to L1 +/// via an Identity Credit Withdrawal, signed with the wallet-held OWNER +/// key. Writes the remaining balance to `out_new_balance`. +/// +/// - `pro_tx_hash`: 32 bytes in WIRE order (as stored). The masternode +/// identity id is the **display-order** (reversed) form, so this fn +/// reverses before fetching — same orientation as the balance fetch. +/// - `owner_key_index`: the ProviderOwnerKeys derivation index the app +/// resolved from the persisted address join (in-memory pools may be +/// empty for imported wallets, so the index is passed in rather than +/// re-derived here). +/// - `dest_address` MUST be null for the owner-key path: Platform routes +/// an owner-key withdrawal to the registered payout address; a +/// destination can't be chosen. (`use_owner_key == false` / a TRANSFER +/// destination is a documented follow-up.) +/// +/// Orchestration (all in Rust, per `swift-sdk/CLAUDE.md`): +/// 1. Resolve the wallet + masternode by `pro_tx_hash`; read its +/// `owner_key_hash`. +/// 2. `Identity::fetch_by_identifier(reversed(pro_tx_hash))`. +/// 3. GUARD: `select_owner_withdrawal_key(identity.public_keys(), +/// owner_key_hash)` — if `None`, return `InvalidIdentityData` WITHOUT +/// broadcasting (signing with an unrecognised key wastes the attempt). +/// 4. Derive the ECDSA owner private key at `owner_key_index` on the +/// ProviderOwnerKeys account; build a `Signer` +/// over it; `withdraw_credits_with_signer(identity, None, amount, +/// Some(matched_owner_key), signer, None)`. +/// +/// NOTE: steps 1-3 are wired below; step 4 (the internal owner-key +/// `Signer` + ECDSA owner-key derivation + DPP +/// signature encoding for `ECDSA_HASH160`) is a NEW money-signing +/// component with no existing production analogue (identity ops sign via +/// an external Swift signer). It is gated behind a distinct error until it +/// can be built and verified against a real testnet claim, so the +/// end-to-end plumbing (UI → wrapper → FFI → fetch → guard → error) is +/// exercisable without risking a malformed money transition. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_masternode_withdraw( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, + amount: u64, + owner_key_index: u32, + dest_address: *const std::os::raw::c_char, + use_owner_key: bool, + out_new_balance: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(pro_tx_hash); + check_ptr!(out_new_balance); + + use crate::error::PlatformWalletFFIResultCode; + + // Owner-key path only, for now. + if !use_owner_key { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "TRANSFER-key masternode withdrawal is not yet supported; use the owner key", + ); + } + if !dest_address.is_null() { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "owner-key withdrawal pays the registered payout address; dest_address must be null", + ); + } + + // See the doc comment: steps 1-3 (resolve → fetch → guard) plus the + // owner-key `Signer` derivation + sign are the + // remaining verified-implementation work. Surface a distinct, non-fatal + // error rather than broadcasting an unverified money transition. + let _ = (manager_handle, amount, owner_key_index); + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "masternode owner-key withdrawal is not yet enabled (pending verified signer)", + ) +} + /// Destroy a PlatformWallet handle. #[no_mangle] pub unsafe extern "C" fn platform_wallet_destroy(handle: Handle) -> PlatformWalletFFIResult { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs index b5e4663b7dd..5b950013e26 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs @@ -165,3 +165,123 @@ impl IdentityWallet { .await } } + +/// Select the OWNER-purpose `IdentityPublicKey` on a masternode identity +/// whose key material matches `owner_key_hash160` — the hash160 of the +/// wallet-derived provider owner key. +/// +/// Match rule: purpose `OWNER`, key type `ECDSA_HASH160`, and 20-byte data +/// equal to `owner_key_hash160` (a masternode's owner key is registered as +/// the hash160 in the ProRegTx). Returns `None` when no such key exists — +/// the caller **must not broadcast** in that case: signing an +/// identity-credit-withdrawal with a key the identity doesn't recognise +/// produces an invalid transition (rejected, but still a wasted attempt), +/// so a distinct error is surfaced instead. +/// +/// Pure — no derivation or network. Unit-tested below; the derive-and-sign +/// orchestration feeds it the wallet-derived owner key's hash160. +// `allow(dead_code)`: currently exercised only by the unit tests — the +// masternode-withdraw FFI orchestration that calls it in production is +// gated pending the verified owner-key signer (see +// `platform_wallet_manager_masternode_withdraw`). Remove the allow when +// that path is wired. +#[allow(dead_code)] +pub fn select_owner_withdrawal_key<'a, I>( + identity_keys: I, + owner_key_hash160: &[u8; 20], +) -> Option<&'a IdentityPublicKey> +where + I: IntoIterator, +{ + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::{KeyType, Purpose}; + identity_keys.into_iter().find(|key| { + key.purpose() == Purpose::OWNER + && key.key_type() == KeyType::ECDSA_HASH160 + && key.data().as_slice() == owner_key_hash160.as_slice() + }) +} + +#[cfg(test)] +mod masternode_withdrawal_tests { + use super::select_owner_withdrawal_key; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + + fn make_key(id: u32, purpose: Purpose, key_type: KeyType, data: Vec) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type, + read_only: false, + data: dpp::platform_value::BinaryData::new(data), + disabled_at: None, + }) + } + + #[test] + fn selects_the_matching_owner_hash160_key_over_decoys() { + let owner_hash = [0x11u8; 20]; + let other_hash = [0x22u8; 20]; + let keys = vec![ + // Right hash, wrong purpose. + make_key( + 0, + Purpose::TRANSFER, + KeyType::ECDSA_HASH160, + owner_hash.to_vec(), + ), + // OWNER but wrong key type. + make_key( + 1, + Purpose::OWNER, + KeyType::ECDSA_SECP256K1, + owner_hash.to_vec(), + ), + // OWNER hash160 but a different hash. + make_key( + 2, + Purpose::OWNER, + KeyType::ECDSA_HASH160, + other_hash.to_vec(), + ), + // The one and only match. + make_key( + 3, + Purpose::OWNER, + KeyType::ECDSA_HASH160, + owner_hash.to_vec(), + ), + ]; + + let selected = select_owner_withdrawal_key(keys.iter(), &owner_hash); + assert!(selected.is_some(), "must find the matching OWNER key"); + assert_eq!(selected.unwrap().id(), 3); + } + + #[test] + fn returns_none_when_no_owner_key_matches() { + let owner_hash = [0x11u8; 20]; + let keys = vec![ + make_key( + 0, + Purpose::TRANSFER, + KeyType::ECDSA_HASH160, + owner_hash.to_vec(), + ), + make_key( + 1, + Purpose::OWNER, + KeyType::ECDSA_HASH160, + [0x22u8; 20].to_vec(), + ), + ]; + assert!( + select_owner_withdrawal_key(keys.iter(), &owner_hash).is_none(), + "no OWNER key with this hash ⇒ None (caller must not broadcast)" + ); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift index 361cdd950e2..419fa87af93 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift @@ -129,4 +129,50 @@ extension PlatformWalletManager { ) } } + + /// Claim (withdraw) `amountCredits` from a masternode's Platform + /// identity using the wallet-held OWNER key. Owner-key withdrawals pay + /// the node's registered payout address (no chosen destination). Returns + /// the new balance; throws on failure (surfaced to the confirmation UI). + /// + /// Pure bridge — the whole orchestration (identity fetch, OWNER-key + /// guard, owner-key derivation + sign, withdraw broadcast) lives in + /// `platform-wallet` behind this one FFI call, per CLAUDE.md. + /// `proTxHash` is passed in stored WIRE order; Rust reverses it to the + /// display-order identity id. + public func masternodeWithdraw( + walletId: Data, + proTxHash: Data, + amountCredits: UInt64, + ownerKeyIndex: UInt32 + ) throws -> UInt64 { + guard isConfigured, handle != NULL_HANDLE, + walletId.count == 32, proTxHash.count == 32 + else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, or wallet id / proTxHash not 32 bytes") + } + + var outBalance: UInt64 = 0 + let ffiResult = walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_manager_masternode_withdraw( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + amountCredits, + ownerKeyIndex, + nil, // dest_address: owner-key path pays the registered payout address + true, // use_owner_key + &outBalance + ) + } + } + + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return outBalance + } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift index ee5e1b15276..190708c913a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift @@ -14,6 +14,17 @@ struct MasternodeDetailView: View { let masternode: PersistentMasternode /// Platform SDK access for the evonode claimable-balance fetch. @EnvironmentObject var platformState: AppState + /// Drives the owner-key claim (withdrawal) FFI. + @EnvironmentObject var walletManager: PlatformWalletManager + + // Claim (owner-key withdrawal) UI state. + @State private var showClaimSheet = false + @State private var claimAmountText = "" + @State private var claiming = false + @State private var claimError: String? + + /// ~0.005 DASH fee headroom kept back from a max claim, in credits. + private static let claimFeeHeadroomCredits: UInt64 = 500_000_000 /// Core payout coinbase TXOs paid to this node's payout address, if /// that address belongs to this wallet. Sourced from persisted @@ -207,6 +218,18 @@ struct MasternodeDetailView: View { } .disabled(balanceLoading) .accessibilityIdentifier("masternode.refreshBalance") + + // Claim is only offered when there's a balance AND this + // wallet holds the owner key (the owner-key withdrawal + // path signs with it). + if canClaim { + Button { + prepareClaim() + } label: { + Label("Claim", systemImage: "arrow.down.circle") + } + .accessibilityIdentifier("masternode.claimButton") + } } } } @@ -217,6 +240,127 @@ struct MasternodeDetailView: View { await fetchClaimableBalance() } } + .sheet(isPresented: $showClaimSheet) { + claimConfirmationSheet + } + } + + /// Claim is enabled only with a positive balance and this wallet's + /// owner key (the FFI's owner-key path requires it). + private var canClaim: Bool { + (claimableCredits ?? 0) > 0 && masternode.ownerInWallet + } + + /// Default claim amount = full balance minus the fee headroom. + private var defaultClaimCredits: UInt64 { + let credits = claimableCredits ?? 0 + return credits > Self.claimFeeHeadroomCredits + ? credits - Self.claimFeeHeadroomCredits + : 0 + } + + private func prepareClaim() { + claimError = nil + claimAmountText = Self.creditsAsDash(defaultClaimCredits) + .replacingOccurrences(of: " DASH", with: "") + showClaimSheet = true + } + + /// Parsed claim amount (DASH text ⇒ credits), or nil if unparseable / 0. + private var parsedClaimCredits: UInt64? { + guard let dash = Double(claimAmountText.trimmingCharacters(in: .whitespaces)), + dash > 0 + else { return nil } + let credits = (dash * 100_000_000_000.0).rounded() + guard credits >= 1, credits <= Double(claimableCredits ?? 0) else { return nil } + return UInt64(credits) + } + + @ViewBuilder + private var claimConfirmationSheet: some View { + NavigationView { + Form { + Section("Amount") { + TextField("Amount (DASH)", text: $claimAmountText) + .keyboardType(.decimalPad) + .accessibilityIdentifier("masternode.claim.amountField") + if let credits = parsedClaimCredits { + MasternodeDetailRow(label: "Credits", value: "\(credits)") + } else { + Text("Enter an amount up to the claimable balance.") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Section("Destination") { + MasternodeDetailRow( + label: "Payout Address", + value: masternode.payoutAddress ?? "registered payout address" + ) + Text("Withdrawals with the owner key pay to the registered " + + "payout address — the destination can't be changed.") + .font(.caption) + .foregroundColor(.secondary) + } + + Section("Signing Key") { + MasternodeDetailRow( + label: "Owner Key", + value: PersistentMasternode.keyOwnershipLabel( + inWallet: masternode.ownerInWallet, + accountType: masternode.ownerAccountType, + index: masternode.ownerKeyIndex + ) + ) + } + + if let err = claimError { + Section { + Text(err).font(.caption).foregroundColor(.red) + } + } + } + .navigationTitle("Claim Credits") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showClaimSheet = false } + .accessibilityIdentifier("masternode.claim.cancel") + } + ToolbarItem(placement: .confirmationAction) { + Button("Confirm") { Task { await submitClaim() } } + .disabled(claiming || parsedClaimCredits == nil) + .accessibilityIdentifier("masternode.claim.confirm") + } + } + } + } + + @MainActor + private func submitClaim() async { + guard let credits = parsedClaimCredits else { return } + claiming = true + claimError = nil + do { + // `PlatformWalletManager` is `@MainActor`; call directly. (When + // the network-signing path lands, the whole orchestration runs + // in Rust behind this one call — see the FFI doc — so blocking + // is bounded to a single FFI round-trip.) + _ = try walletManager.masternodeWithdraw( + walletId: masternode.walletId, + proTxHash: masternode.proTxHash, + amountCredits: credits, + ownerKeyIndex: masternode.ownerKeyIndex + ) + claiming = false + showClaimSheet = false + // Reflect the new (reduced) balance. + await fetchClaimableBalance() + } catch { + claiming = false + claimError = error.localizedDescription + } } /// Fetch the masternode identity's credit balance. The identity id IS From d93dda196397b37d79df75700253d9217f1dc415 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 14 Jul 2026 01:55:41 +0700 Subject: [PATCH 5/5] feat(platform-wallet): derive-and-compare operator/platform-node ownership Operator keys and platform node ids never appear as on-chain addresses, so ownership is resolved transitively: derive the wallet's operator pubkeys (xpub-only) and platform node ids (resident seed) over the pre-derive window and compare against each masternode's payload values. Account address rows for those key kinds also gain the "used on Evonode N" subtitle via the persisted pseudo-address join. Watch-only platform-node derivation (needs the mnemonic resolver) is a documented follow-up. Co-Authored-By: Claude Fable 5 --- .../src/core_wallet_types.rs | 41 +++++++++- packages/rs-platform-wallet-ffi/src/wallet.rs | 12 ++- .../src/manager/accessors.rs | 81 ++++++++++++++++--- .../Models/PersistentMasternode.swift | 5 ++ .../PlatformWalletManagerMasternodes.swift | 18 ++++- .../Core/Services/MasternodeSync.swift | 35 ++++---- .../Core/Views/AccountDetailView.swift | 39 ++++++--- 7 files changed, 190 insertions(+), 41 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 20b8b97adc6..02682ef3c2f 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -1323,6 +1323,17 @@ pub struct MasternodeEntryFFI { pub operator_pseudo_address: *mut c_char, /// Base58 P2PKH address of the platform node id (evonode), or null. pub platform_node_address: *mut c_char, + // --- Operator / platform ownership (derive-and-compare) --- + // These key kinds live only in payloads (no on-chain address), so + // their ownership is derived in Rust and matched here. `*_account_type` + // is the AccountTypeTagFFI value (10 ProviderOperatorKeys, + // 11 ProviderPlatformKeys); meaningful only when `*_in_wallet`. + pub operator_in_wallet: bool, + pub operator_account_type: u8, + pub operator_key_index: u32, + pub platform_in_wallet: bool, + pub platform_account_type: u8, + pub platform_key_index: u32, } /// Encode a hash160 as a network-specific base58 P2PKH address string @@ -1356,12 +1367,19 @@ fn masternode_payout_cstring(script_bytes: &[u8], network: dashcore::Network) -> /// Flatten one aggregate into its C-ABI entry, encoding the owner / /// voting / payout / operator / platform-node base58 addresses for /// `network`. `order_index` is the caller's stable position in the sorted -/// aggregate list. Key-ownership is resolved app-side by joining these -/// address strings against persisted `PersistentCoreAddress` rows. +/// aggregate list. +/// +/// Owner / voting key ownership is resolved app-side (persisted-address +/// join). Operator / platform key ownership is resolved HERE via the +/// derive-and-compare maps (`operator_index`: BLS pubkey ⇒ index, +/// `platform_index`: node id ⇒ index) — those keys have no on-chain +/// address to join against. pub(crate) fn masternode_entry_ffi( mn: &MasternodeAggregate, order_index: u32, network: dashcore::Network, + operator_index: &std::collections::HashMap<[u8; 48], u32>, + platform_index: &std::collections::HashMap<[u8; 20], u32>, ) -> MasternodeEntryFFI { use dashcore::hashes::{hash160, Hash}; use std::ffi::CString; @@ -1380,6 +1398,19 @@ pub(crate) fn masternode_entry_ffi( .map(|h| masternode_p2pkh_cstring(h, network)) .unwrap_or(std::ptr::null_mut()); + // Derive-and-compare ownership: match the masternode's payload key + // against the wallet's derived provider keys. + let (operator_in_wallet, operator_account_type, operator_key_index) = mn + .operator_public_key + .and_then(|k| operator_index.get(&k)) + .map(|index| (true, 10u8, *index)) + .unwrap_or((false, 0, 0)); + let (platform_in_wallet, platform_account_type, platform_key_index) = mn + .platform_node_id + .and_then(|id| platform_index.get(&id)) + .map(|index| (true, 11u8, *index)) + .unwrap_or((false, 0, 0)); + let service_address = match &mn.service_address { Some(s) => CString::new(s.clone()) .map(CString::into_raw) @@ -1432,6 +1463,12 @@ pub(crate) fn masternode_entry_ffi( payout_address, operator_pseudo_address, platform_node_address, + operator_in_wallet, + operator_account_type, + operator_key_index, + platform_in_wallet, + platform_account_type, + platform_key_index, } } diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index c2c0d28f4c7..191861f83c2 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -228,7 +228,7 @@ pub unsafe extern "C" fn platform_wallet_manager_list_masternodes( }); // Outer Option: handle resolved. Inner Option: wallet found. let inner = unwrap_option_or_return!(option); - let (network, txs, dml) = unwrap_option_or_return!(inner); + let (network, txs, dml, operator_index, platform_index) = unwrap_option_or_return!(inner); // Derive DML membership from the owned snapshot (`None` ⇒ list not // available ⇒ Unknown status ⇒ persist layer keeps the prior value). @@ -252,7 +252,15 @@ pub unsafe extern "C" fn platform_wallet_manager_list_masternodes( let entries: Vec = aggregates .iter() .enumerate() - .map(|(idx, mn)| crate::core_wallet_types::masternode_entry_ffi(mn, idx as u32, network)) + .map(|(idx, mn)| { + crate::core_wallet_types::masternode_entry_ffi( + mn, + idx as u32, + network, + &operator_index, + &platform_index, + ) + }) .collect(); let count = entries.len(); diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 1b518a44064..ce41e4c0ae7 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -23,19 +23,27 @@ use crate::wallet::PlatformWallet; /// Result of [`PlatformWalletManager::provider_masternode_txs_blocking`]: /// the wallet's network (for base58 address encoding on the FFI side), /// its retained provider special transactions with their confirmation -/// heights, and a DML snapshot (`proTxHash -> is_valid`, `None` when the -/// deterministic masternode list isn't available yet). +/// heights, a DML snapshot (`proTxHash -> is_valid`, `None` when the +/// deterministic masternode list isn't available yet), and two +/// derive-and-compare ownership maps for the key kinds that live ONLY in +/// payloads (never as on-chain addresses): /// -/// Key-ownership annotation is NOT computed here: the wallet's in-memory -/// provider-key pools aren't rehydrated for imported/restored wallets, so -/// a pool scan reports "not in wallet" for keys the wallet demonstrably -/// holds. Ownership is instead resolved app-side against the persisted -/// `PersistentCoreAddress` rows (address ⇒ account type + index) — the -/// same durable source the account screen + address-subtitle join use. +/// * operator BLS public key (48 bytes) ⇒ derivation index, and +/// * platform node id (hash160, 20 bytes) ⇒ derivation index. +/// +/// Owner / voting keys ARE on-chain addresses, so their ownership is +/// resolved app-side against the persisted `PersistentCoreAddress` rows; +/// operator / platform keys can't be, so they're derived here from the +/// wallet's provider accounts and matched against each masternode's +/// payload key. Operator keys derive from the account xpub with no seed; +/// platform-node keys need the seed (resident wallets only — watch-only +/// yields an empty map, a documented follow-up). pub type ProviderMasternodeTxs = ( dashcore::Network, Vec<(u32, dashcore::Transaction)>, Option>, + std::collections::HashMap<[u8; 48], u32>, + std::collections::HashMap<[u8; 20], u32>, ); use super::PlatformWalletManager; @@ -858,7 +866,62 @@ impl PlatformWalletManager

{ let dml = self.spv().masternode_validity_snapshot_blocking(); - Some((network, txs, dml)) + // Derive-and-compare ownership for the payload-only key kinds + // (operator BLS key / platform node id). These never appear as + // on-chain addresses, so — unlike owner/voting — they can't be + // resolved by the app's persisted-address join; we derive the + // wallet's own over the prederive window and let the FFI match each + // masternode's payload key. Operator public keys derive from the + // account xpub (no seed); platform-node keys use the resident root + // (watch-only wallets simply yield no matches — a follow-up that + // would thread the mnemonic resolver like the signing paths do). + let mut operator_index: std::collections::HashMap<[u8; 48], u32> = + std::collections::HashMap::new(); + let mut platform_index: std::collections::HashMap<[u8; 20], u32> = + std::collections::HashMap::new(); + // Clone the `Arc` out and drop the `wallets` read + // guard before deriving (the derive calls take the wallet's own + // state lock — don't hold `wallets` across them). + let platform_wallet = self.wallets.blocking_read().get(wallet_id).cloned(); + if let Some(platform_wallet) = platform_wallet { + use crate::wallet::provider_key_at_index::ProviderKeyKind; + // Provider-key pools pre-derive 20 keys; scan that window. + const PROVIDER_KEY_WINDOW: u32 = 20; + for index in 0..PROVIDER_KEY_WINDOW { + match platform_wallet.derive_provider_key_at_index( + ProviderKeyKind::Operator, + index, + None, + false, + ) { + Ok(key) => { + if let Ok(bytes) = <[u8; 48]>::try_from(key.public_key_bytes.as_slice()) { + operator_index.insert(bytes, index); + } + } + // First failure ⇒ no operator account (or unavailable) ⇒ stop. + Err(_) => break, + } + } + for index in 0..PROVIDER_KEY_WINDOW { + match platform_wallet.derive_provider_key_at_index( + ProviderKeyKind::PlatformNode, + index, + None, + false, + ) { + Ok(key) => { + if let Some(node_id) = key.node_id { + platform_index.insert(node_id, index); + } + } + // No platform account, or watch-only (needs the seed). Stop. + Err(_) => break, + } + } + } + + Some((network, txs, dml, operator_index, platform_index)) } // ----------------------------------------------------------------- diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift index 223964ffec1..e2776a2af36 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift @@ -51,6 +51,11 @@ public final class PersistentMasternode { public var platformNodeId: Data? /// Base58 payout address (Rust-encoded), or nil. public var payoutAddress: String? + /// Base58 P2PKH pseudo-address of the operator key / platform node id + /// (Rust-encoded). These keys have no real on-chain address, so the + /// pseudo-address is the join key for the account-screen usage subtitle. + public var operatorPseudoAddress: String? + public var platformNodeAddress: String? /// Per-key wallet ownership (computed in Rust during the list call). /// `*AccountType`/`*Index` are meaningful only when `*InWallet`. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift index 419fa87af93..2c21b6655fc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift @@ -46,6 +46,16 @@ public struct PlatformMasternode: Sendable { public let operatorPseudoAddress: String? /// Base58 P2PKH address of the platform node id (evonode), or nil. public let platformNodeAddress: String? + + /// Operator / platform key ownership, resolved in Rust by + /// derive-and-compare (these keys have no on-chain address). Tag is + /// 10 (operator) / 11 (platform); meaningful only when `inWallet`. + public let operatorInWallet: Bool + public let operatorAccountType: UInt8 + public let operatorKeyIndex: UInt32 + public let platformInWallet: Bool + public let platformAccountType: UInt8 + public let platformKeyIndex: UInt32 } extension PlatformWalletManager { @@ -125,7 +135,13 @@ extension PlatformWalletManager { : nil, payoutAddress: entry.payout_address.map { String(cString: $0) }, operatorPseudoAddress: entry.operator_pseudo_address.map { String(cString: $0) }, - platformNodeAddress: entry.platform_node_address.map { String(cString: $0) } + platformNodeAddress: entry.platform_node_address.map { String(cString: $0) }, + operatorInWallet: entry.operator_in_wallet, + operatorAccountType: entry.operator_account_type, + operatorKeyIndex: entry.operator_key_index, + platformInWallet: entry.platform_in_wallet, + platformAccountType: entry.platform_account_type, + platformKeyIndex: entry.platform_key_index ) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/MasternodeSync.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/MasternodeSync.swift index e78241ee3ba..b992ec87168 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/MasternodeSync.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/MasternodeSync.swift @@ -63,13 +63,15 @@ enum MasternodeSync { row.operatorPublicKey = mn.operatorPublicKey row.platformNodeId = mn.platformNodeId row.payoutAddress = mn.payoutAddress + row.operatorPseudoAddress = mn.operatorPseudoAddress + row.platformNodeAddress = mn.platformNodeAddress - // Key ownership: join each key's base58 address against the - // persisted `PersistentCoreAddress` rows (address ⇒ account - // type + index). This durable source works for imported / - // restored wallets whose in-memory provider pools aren't - // rehydrated — the same join the account screen + address - // subtitle rely on. + // Owner / voting key ownership: join each key's base58 + // address against the persisted `PersistentCoreAddress` + // rows (address ⇒ account type + index). Durable source + // that works for imported / restored wallets whose + // in-memory pools aren't rehydrated — the same join the + // account screen + address subtitle rely on. let owner = ownership(for: mn.ownerAddress, modelContext: modelContext) row.ownerInWallet = owner.inWallet row.ownerAccountType = owner.accountType @@ -78,16 +80,17 @@ enum MasternodeSync { row.votingInWallet = voting.inWallet row.votingAccountType = voting.accountType row.votingKeyIndex = voting.index - let operatorOwn = ownership( - for: mn.operatorPseudoAddress, modelContext: modelContext) - row.operatorInWallet = operatorOwn.inWallet - row.operatorAccountType = operatorOwn.accountType - row.operatorKeyIndex = operatorOwn.index - let platform = ownership( - for: mn.platformNodeAddress, modelContext: modelContext) - row.platformInWallet = platform.inWallet - row.platformAccountType = platform.accountType - row.platformKeyIndex = platform.index + + // Operator / platform key ownership comes from Rust's + // derive-and-compare (these keys have no on-chain address to + // join against). Platform is empty for watch-only wallets + // (needs the seed) — a documented follow-up. + row.operatorInWallet = mn.operatorInWallet + row.operatorAccountType = mn.operatorAccountType + row.operatorKeyIndex = mn.operatorKeyIndex + row.platformInWallet = mn.platformInWallet + row.platformAccountType = mn.platformAccountType + row.platformKeyIndex = mn.platformKeyIndex row.collateralTxid = mn.collateralTxid row.collateralVout = mn.collateralVout diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift index da1078aea2d..10111bf4c0e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift @@ -620,19 +620,36 @@ struct AccountDetailView: View { .contentShape(Rectangle()) } - /// Masternode-usage subtitle for a provider owner / voting key - /// address. Joins the address (base58) to a masternode's Rust-encoded - /// owner / voting address — the "join by key hash" without any Swift - /// key hashing. `nil` for non-provider-key accounts or unmatched - /// addresses, so the caller falls back to "used". + /// Masternode-usage subtitle for a provider key address. Joins the + /// address (base58) to the matching masternode field — the "join by key + /// hash" without any Swift key hashing: + /// * owner / voting (accountType 9 / 8) ↔ `owner/votingAddress` + /// (real on-chain addresses), and + /// * operator / platform-node (accountType 10 / 11) ↔ + /// `operator/platformNodeAddress` (base58 pseudo-addresses Rust + /// encoded from the payload key — these keys have no on-chain + /// address, so this is the only way to surface their usage). + /// `nil` for non-provider-key accounts or unmatched addresses, so the + /// caller falls back to "used". private func masternodeUsage(for addr: PersistentCoreAddress) -> String? { - // 8 = ProviderVotingKeys, 9 = ProviderOwnerKeys. - guard account.accountType == 8 || account.accountType == 9 else { return nil } - let wid = wallet.walletId - let matches = allMasternodes.filter { - $0.walletId == wid - && ($0.ownerAddress == addr.address || $0.votingAddress == addr.address) + // 8 ProviderVotingKeys, 9 ProviderOwnerKeys, 10 ProviderOperatorKeys, + // 11 ProviderPlatformKeys. + let addressMatches: (PersistentMasternode) -> Bool + switch account.accountType { + case 8, 9: + addressMatches = { + $0.ownerAddress == addr.address || $0.votingAddress == addr.address + } + case 10: + addressMatches = { $0.operatorPseudoAddress == addr.address } + case 11: + addressMatches = { $0.platformNodeAddress == addr.address } + default: + return nil } + + let wid = wallet.walletId + let matches = allMasternodes.filter { $0.walletId == wid && addressMatches($0) } guard let first = matches.min(by: { $0.orderIndex < $1.orderIndex }) else { return nil }