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 18e4eb25ad4..57e1e044c84 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -1,5 +1,6 @@ //! C-compatible types for core wallet changeset FFI. +use platform_wallet::masternode::{provider_payload_fields, MasternodeRecord}; use std::os::raw::c_char; // --------------------------------------------------------------------------- @@ -911,396 +912,8 @@ 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; -/// Swift only marshals the flat results. -#[derive(Default)] -struct ProviderPayloadFields { - /// Service endpoint as `"ip:port"`. - service_address: Option, - /// ProUpServTx registration linkage. `None` for ProRegTx (its own - /// txid is the proTxHash). - pro_tx_hash: Option<[u8; 32]>, - /// ProRegTx collateral outpoint (`txid` wire bytes, `vout`). - collateral: Option<([u8; 32], u32)>, - /// ProRegTx owner / voting key hashes (hash160, 20 bytes). - owner_key_hash: Option<[u8; 20]>, - voting_key_hash: Option<[u8; 20]>, -} - -/// Extract provider-registration (ProRegTx) / provider-update-service -/// (ProUpServTx) payload fields from a transaction for display. Returns -/// all-`None` for any other transaction. Pure; the only allocation is -/// the returned service-address `String`. -fn provider_payload_fields(tx: &dashcore::Transaction) -> ProviderPayloadFields { - use dashcore::transaction::TransactionPayload; - - match &tx.special_transaction_payload { - Some(TransactionPayload::ProviderRegistrationPayloadType(p)) => ProviderPayloadFields { - service_address: Some(p.service_address.to_string()), - pro_tx_hash: None, - collateral: Some(( - provider_hash_to_32(p.collateral_outpoint.txid.as_ref()), - p.collateral_outpoint.vout, - )), - 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() - }, - _ => 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, - /// Platform HTTP (DAPI gRPC) port from the same ProRegTx / ProUpServTx - /// that set `service_address` — evonodes only, `None` for a regular - /// masternode or a pre-v19 payload without platform fields. With the - /// service IP this addresses the node's DAPI (`https://:`). - pub platform_http_port: Option, - /// Height that set `service_address` / `platform_http_port` (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, - /// Operator BLS public key (48 bytes) — follows the latest ProRegTx / - /// ProUpReg. - pub operator_public_key: Option<[u8; 48]>, - operator_height: u32, - /// Platform node id (SHA256[..20] Tenderdash, #884, 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 - /// 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; - - // Each input item is `(height, in_block_position, tx)`. Core's - // `RebuildListFromBlock` applies same-block provider updates in - // `block.vtx` order, so the per-field latest-wins below must resolve - // ties by `(height, position)`, not by the arbitrary txid order the - // caller's `BTreeMap` dedup produces. Process ascending - // `(height, position)` so the block-latest write for each field lands - // last and wins under the `>= *_height` guards. Stable so equal keys - // keep their incoming order. - // - // The position is stamped onto `BlockInfo` during block processing - // (rust-dashcore#891) and round-tripped through persistence; legacy - // rows confirmed before the field existed come back as 0 and fall - // back to feed order among themselves. - let mut ordered: Vec<(u32, u32, &'a dashcore::Transaction)> = txs.collect(); - ordered.sort_by_key(|(height, position, _)| (*height, *position)); - - let mut order: Vec<[u8; 32]> = Vec::new(); - let mut by_hash: std::collections::HashMap<[u8; 32], MasternodeAggregate> = - std::collections::HashMap::new(); - - for (height, _position, tx) in ordered { - // 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.platform_http_port = p.platform_http_port; - 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; - } - 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. - // `platform_node_id` is a `PlatformNodeId` newtype - // (rust-dashcore #885) whose `consensus_decode` normalizes - // the wire's reversed uint160-internal bytes to the - // canonical Tenderdash `SHA256(pubkey)[..20]` order - // (rust-dashcore #887/#889), so `to_byte_array()` here is - // already canonical and matches the derived ownership - // index (`accessors.rs`) and dashmate display directly — - // do NOT reverse platform-side. - if let Some(node_id) = p.platform_node_id { - agg.platform_node_id = Some(node_id.to_byte_array()); - 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.platform_http_port = p.platform_http_port; - agg.service_height = height; - } - // ProUpServ's `platform_node_id` is now `Option` - // (rust-dashcore #885, was `Option<[u8; 20]>`); decoded bytes - // are canonical forward order (see the ProRegTx arm above). - 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.to_byte_array()); - 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; - 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; - } - } - result -} - /// Flat, C-ABI masternode entity — the wire shape of one -/// [`MasternodeAggregate`], built by [`masternode_entry_ffi`] and +/// [`MasternodeRecord`], 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. @@ -1381,6 +994,12 @@ pub struct MasternodeEntryFFI { pub platform_in_wallet: bool, pub platform_account_type: u8, pub platform_key_index: u32, + /// Where this record came from: 0 = one of the wallet's own masternodes + /// (aggregated from its provider transactions), 1 = tracked by the user + /// independently of every wallet. + pub source: u8, + /// User label of a tracked masternode, or null. + pub label: *mut c_char, /// Whether the platform-node ownership check was actually *possible* for /// this query: `true` when the wallet's derived platform-node index had /// entries to compare against, `false` when it was empty/unavailable (no @@ -1420,22 +1039,15 @@ 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. -/// -/// 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. +/// Flatten one record into its C-ABI entry, encoding the owner / voting / +/// payout / operator / platform-node base58 addresses for `network`. Pure +/// marshalling: ordering, status and operator / platform key ownership are +/// already resolved on the record by +/// `PlatformWalletManager::wallet_masternodes_blocking`; owner / voting key +/// ownership is resolved app-side (persisted-address join). pub(crate) fn masternode_entry_ffi( - mn: &MasternodeAggregate, - order_index: u32, + mn: &MasternodeRecord, 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; @@ -1454,17 +1066,16 @@ 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. + // Ownership flags from the record's resolved indexes. `*_account_type` + // is the AccountTypeTagFFI value (10 ProviderOperatorKeys, + // 11 ProviderPlatformKeys); meaningful only when `*_in_wallet`. 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)) + .operator_key_index + .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)) + .platform_key_index + .map(|index| (true, 11u8, index)) .unwrap_or((false, 0, 0)); let service_address = match &mn.service_address { @@ -1495,7 +1106,7 @@ pub(crate) fn masternode_entry_ffi( pro_tx_hash: mn.pro_tx_hash, has_registration: mn.has_registration, registration_height: mn.registration_height, - order_index, + order_index: mn.order_index, type_index: mn.type_index, is_evonode: mn.is_evonode, revoked: mn.revoked, @@ -1527,11 +1138,14 @@ pub(crate) fn masternode_entry_ffi( platform_in_wallet, platform_account_type, platform_key_index, - // The check was possible iff the wallet's derived platform-node index - // had entries to compare against. Empty index ⇒ no platform pool / not - // yet rehydrated ⇒ ownership is "unchecked", and the persister must - // retain any prior value rather than clobber it to false. - platform_ownership_checked: !platform_index.is_empty(), + source: mn.source.as_u8(), + label: mn + .label + .clone() + .and_then(|l| CString::new(l).ok()) + .map(CString::into_raw) + .unwrap_or(std::ptr::null_mut()), + platform_ownership_checked: mn.platform_ownership_checked, } } @@ -1858,444 +1472,21 @@ mod tests { unsafe { free_wallet_changeset_ffi(&ffi) }; } - /// ProRegTx provider payload is lifted from the DIP-3 special-tx - /// body for the UI. Fixture is the testnet - /// collateral-provider-registration transaction from rust-dashcore's - /// own `provider_registration` tests - /// (`test_collateral_provider_registration_transaction`), whose - /// service address is `1.2.5.6:19999` and whose owner/voting key - /// hashes are asserted below. ProRegTx carries no explicit - /// `pro_tx_hash` (its own txid is the proTxHash), so that field - /// stays `None`. - #[test] - fn provider_registration_payload_fields_extracted() { - let raw = "0300010001ca9a43051750da7c5f858008f2ff7732d15691e48eb7f845c791e5dca78bab58010000006b483045022100fe8fec0b3880bcac29614348887769b0b589908e3f5ec55a6cf478a6652e736502202f30430806a6690524e4dd599ba498e5ff100dea6a872ebb89c2fd651caa71ed012103d85b25d6886f0b3b8ce1eef63b720b518fad0b8e103eba4e85b6980bfdda2dfdffffffff018e37807e090000001976a9144ee1d4e5d61ac40a13b357ac6e368997079678c888ac00000000fd1201010000000000ca9a43051750da7c5f858008f2ff7732d15691e48eb7f845c791e5dca78bab580000000000000000000000000000ffff010205064e1f3dd03f9ec192b5f275a433bfc90f468ee1a3eb4c157b10706659e25eb362b5d902d809f9160b1688e201ee6e94b40f9b5062d7074683ef05a2d5efb7793c47059c878dfad38a30fafe61575db40f05ab0a08d55119b0aad300001976a9144fbc8fb6e11e253d77e5a9c987418e89cf4a63d288ac3477990b757387cb0406168c2720acf55f83603736a314a37d01b135b873a27b411fb37e49c1ff2b8057713939a5513e6e711a71cff2e517e6224df724ed750aef1b7f9ad9ec612b4a7250232e1e400da718a9501e1d9a5565526e4b1ff68c028763"; - let bytes = hex::decode(raw).expect("valid fixture hex"); - let tx: dashcore::Transaction = - dashcore::consensus::encode::deserialize(&bytes).expect("decode ProRegTx"); - - let fields = provider_payload_fields(&tx); - - assert_eq!( - fields.service_address.as_deref(), - Some("1.2.5.6:19999"), - "service address must be lifted from the ProRegTx payload" - ); - assert!( - fields.collateral.is_some(), - "ProRegTx carries a collateral outpoint" - ); - assert_eq!( - hex::encode(fields.owner_key_hash.expect("owner key hash")), - "3dd03f9ec192b5f275a433bfc90f468ee1a3eb4c" - ); - assert_eq!( - hex::encode(fields.voting_key_hash.expect("voting key hash")), - "d38a30fafe61575db40f05ab0a08d55119b0aad3" - ); - assert!( - fields.pro_tx_hash.is_none(), - "ProRegTx has no explicit pro_tx_hash" - ); - } - - /// ProUpServTx (provider-update-service) also carries a service - /// address — reconstructed here from its little-endian IPv6-mapped - /// `ip_address` + `port` — plus an explicit `pro_tx_hash` linking it - /// to the registration. Fixture is rust-dashcore's own - /// `test_provider_update_service_transaction` vector, whose endpoint - /// is `52.36.64.148:19999`. The `pro_tx_hash` is asserted in raw - /// wire order (what `to_32(txid.as_ref())` stores) — the reverse of - /// the block-explorer display form. - #[test] - fn provider_update_service_payload_fields_extracted() { - let raw = "03000200018f3fe6683e36326669b6e34876fb2a2264e8327e822f6fec304b66f47d61b3e1010000006b48304502210082af6727408f0f2ec16c7da1c42ccf0a026abea6a3a422776272b03c8f4e262a022033b406e556f6de980b2d728e6812b3ae18ee1c863ae573ece1cbdf777ca3e56101210351036c1192eaf763cd8345b44137482ad24b12003f23e9022ce46752edf47e6effffffff0180220e43000000001976a914123cbc06289e768ca7d743c8174b1e6eeb610f1488ac00000000b501003a72099db84b1c1158568eec863bea1b64f90eccee3304209cebe1df5e7539fd00000000000000000000ffff342440944e1f00e6725f799ea20480f06fb105ebe27e7c4845ab84155e4c2adf2d6e5b73a998b1174f9621bbeda5009c5a6487bdf75edcf602b67fe0da15c275cc91777cb25f5fd4bb94e84fd42cb2bb547c83792e57c80d196acd47020e4054895a0640b7861b3729c41dd681d4996090d5750f65c4b649a5cd5b2bdf55c880459821e53d91c9"; - let bytes = hex::decode(raw).expect("valid fixture hex"); - let tx: dashcore::Transaction = - dashcore::consensus::encode::deserialize(&bytes).expect("decode ProUpServTx"); - - let fields = provider_payload_fields(&tx); - - assert_eq!( - fields.service_address.as_deref(), - Some("52.36.64.148:19999"), - "ProUpServTx endpoint must be rebuilt from ip_address + port" - ); - assert_eq!( - fields.pro_tx_hash.map(hex::encode).as_deref(), - Some("3a72099db84b1c1158568eec863bea1b64f90eccee3304209cebe1df5e7539fd"), - "ProUpServTx carries an explicit pro_tx_hash (wire order)" - ); - assert!( - fields.collateral.is_none(), - "ProUpServTx has no collateral outpoint" - ); - assert!(fields.owner_key_hash.is_none()); - assert!(fields.voting_key_hash.is_none()); - } - - /// A plain (non-provider) transaction yields no provider fields, so - /// the FFI record emits null/zeroed/`false` for all of them. - #[test] - fn non_provider_tx_has_no_provider_fields() { - let tx = dashcore::Transaction { - version: 2, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: None, - }; - let fields = provider_payload_fields(&tx); - assert!(fields.service_address.is_none()); - assert!(fields.pro_tx_hash.is_none()); - assert!(fields.collateral.is_none()); - 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, 0u32, ®)].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()); - // #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().is_some_and(|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!( - mn.platform_http_port.is_none(), - "legacy regular-MN fixture has no platform HTTP port" - ); - 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, 0u32, &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, 0u32, ®), (200u32, 0u32, &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, 0u32, &rev), (100u32, 0u32, ®)].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, 0u32, ®)].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, 0u32, ®ular), (200u32, 0u32, &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"); - } - - /// Two provider updates for one masternode in the SAME block must resolve - /// the per-field latest-wins by in-block `position`, matching Core's - /// `block.vtx` order — NOT by the arbitrary txid order the caller's - /// `BTreeMap` dedup would otherwise impose. Feed the same pair in - /// both orders; the higher-positioned (block-latest) update wins each time, - /// proving position — not feed/txid order — decides the outcome. + /// The FFI entry carries the platform HTTP port gated by + /// `has_platform_http_port`, and releases its heap C strings through the + /// public free routine. #[test] - fn same_block_updates_resolve_by_position_not_txid() { - use dashcore::blockdata::transaction::special_transaction::provider_update_service::ProviderUpdateServicePayload; - use dashcore::transaction::TransactionPayload; - - // Shared registration linkage ⇒ both updates land in one bucket. - let pro_tx_hash = decode_tx(PROREG_HEX).txid(); - let group_key = provider_hash_to_32(pro_tx_hash.as_ref()); - - // Build a ProUpServTx directly (no raw-hex vector needed); `port` - // distinguishes the resulting service address, `inputs` perturbs the - // txid so the two txs are genuinely distinct. - let make_upserv = |port: u16, inputs: u8| -> dashcore::Transaction { - let payload = ProviderUpdateServicePayload { - version: 1, - mn_type: None, - pro_tx_hash, - ip_address: 42, - port, - script_payout: dashcore::ScriptBuf::new(), - inputs_hash: [inputs; 32].into(), - platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, - payload_sig: [0u8; 96].into(), - }; - dashcore::Transaction { - version: 3, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: Some( - TransactionPayload::ProviderUpdateServicePayloadType(payload), - ), - } - }; - - let low = make_upserv(19000, 3); // in-block position 0 - let high = make_upserv(19999, 4); // in-block position 1 (block-latest) - - for feed in [ - [(500u32, 0u32, &low), (500u32, 1u32, &high)], - // Reversed feed order (block-latest fed first): position, not feed - // order, must still pick the winner. - [(500u32, 1u32, &high), (500u32, 0u32, &low)], - ] { - let mns = aggregate_masternodes(feed.into_iter(), unavailable_dml); - assert_eq!(mns.len(), 1, "same proTxHash ⇒ one bucket"); - assert_eq!(mns[0].pro_tx_hash, group_key); - assert!( - mns[0] - .service_address - .as_deref() - .unwrap_or_default() - .ends_with(":19999"), - "higher in-block position (block-latest) must win; got {:?}", - mns[0].service_address - ); - assert_eq!(mns[0].tx_count, 2, "both updates counted"); - } - } - - /// The platform HTTP port travels with the service endpoint: the ProRegTx - /// seeds it and a later ProUpServTx replaces it (latest-wins), so the - /// DAPI address the wallet builds follows the node's current config. - #[test] - fn platform_http_port_follows_the_service_update() { - use dashcore::blockdata::transaction::special_transaction::provider_update_service::ProviderUpdateServicePayload; - use dashcore::transaction::special_transaction::provider_registration::ProviderMasternodeType; - use dashcore::transaction::TransactionPayload; - - let mut reg = decode_tx(PROREG_HEX); - if let Some(TransactionPayload::ProviderRegistrationPayloadType(p)) = - &mut reg.special_transaction_payload - { - p.masternode_type = ProviderMasternodeType::HighPerformance; - p.platform_http_port = Some(443); - } - let pro_tx_hash = reg.txid(); - - let upserv = dashcore::Transaction { - version: 3, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: Some( - TransactionPayload::ProviderUpdateServicePayloadType( - ProviderUpdateServicePayload { - version: 2, - mn_type: Some(1), // HighPerformance (evonode) - pro_tx_hash, - ip_address: 42, - port: 19999, - script_payout: dashcore::ScriptBuf::new(), - inputs_hash: [7u8; 32].into(), - platform_node_id: None, - platform_p2p_port: Some(36656), - platform_http_port: Some(1443), - payload_sig: [0u8; 96].into(), - }, - ), - ), - }; - - // Registration alone ⇒ the ProRegTx port. - let mns = aggregate_masternodes([(100u32, 0u32, ®)].into_iter(), unavailable_dml); - assert_eq!(mns.len(), 1); - assert_eq!(mns[0].platform_http_port, Some(443)); - - // A later ProUpServTx replaces it along with the service address. - let mns = aggregate_masternodes( - [(100u32, 0u32, ®), (200u32, 0u32, &upserv)].into_iter(), - unavailable_dml, - ); - assert_eq!(mns.len(), 1, "same proTxHash ⇒ one bucket"); - assert_eq!(mns[0].platform_http_port, Some(1443)); - assert!( - mns[0] - .service_address - .as_deref() - .unwrap_or_default() - .ends_with(":19999"), - "service address and platform port move together" - ); - - // The FFI entry carries it gated by `has_platform_http_port`. - let entry = masternode_entry_ffi( - &mns[0], - 0, - dashcore::Network::Testnet, - &std::collections::HashMap::new(), - &std::collections::HashMap::new(), - ); + fn masternode_entry_gates_platform_http_port() { + let mut mn = MasternodeRecord::default(); + mn.platform_http_port = Some(1443); + mn.service_address = Some("1.2.3.4:19999".to_string()); + let entry = masternode_entry_ffi(&mn, dashcore::Network::Testnet); assert!(entry.has_platform_http_port); assert_eq!(entry.platform_http_port, 1443); + assert!( + !entry.platform_ownership_checked, + "default record: unchecked" + ); // Release the entry's heap C strings through the public free routine. let entries = Box::into_raw(vec![entry].into_boxed_slice()) as *mut MasternodeEntryFFI; unsafe { crate::wallet::platform_wallet_manager_free_masternodes(entries, 1) }; diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index af417366e7b..945471e8a21 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -379,6 +379,17 @@ pub enum PlatformWalletFFIResultCode { /// codes and stay retryable. Siblings: [`Self::ErrorShieldedSpendUnconfirmed`], /// [`Self::ErrorTransactionBroadcastUnconfirmed`]. ErrorMasternodeWithdrawalUnconfirmed = 42, + /// The deterministic masternode list isn't available yet (SPV not + /// running or masternode sync incomplete), so a list-backed query — + /// `platform_wallet_manager_locate_masternode` — has nothing to search. + /// Transient: retry once `platform_wallet_manager_sync_progress` reports + /// the masternode list synced. + /// + /// Allocated 46 — 43/44/45 are held by the shielded-invite trio on the + /// in-flight #4313 (`ErrorShieldedInviteAlreadyClaimed` / + /// `ErrorShieldedScanBudgetExhausted` / `ErrorShieldedLifecycleBusy`), + /// per the error-code registry (#4318). + ErrorMasternodeListUnavailable = 46, /// The named thing does not exist. /// diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 10d89af2722..2ccd4f9097a 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -57,6 +57,7 @@ pub mod logging; pub mod managed_identity; pub mod manager; pub mod manager_diagnostics; +pub mod masternode_locator; pub mod masternode_withdrawal; pub mod memory_explorer; pub mod mnemonic_words; @@ -78,6 +79,7 @@ pub mod sign_with_mnemonic_resolver; pub mod spv; pub mod token_persistence; pub mod tokens; +pub mod tracked_masternode; pub mod types; pub mod utils; pub mod wallet; diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 3cfcebb4957..3a50ff916e4 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,8 +8,8 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, PersistDpnsNameStatesFn, PersistenceCallbacks, PersistenceCallbacksExtension, - PersistenceCapabilitiesFFI, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + FFIPersister, PersistenceCallbacks, PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, + PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; use crate::types::{FFINetwork, Network}; @@ -74,7 +74,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create( persistence, event_handler, PersistenceCapabilities::NONE, - None, + PersistenceExtensionCallbacks::default(), None, out_handle, ) @@ -100,7 +100,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_persistence_capabil persistence, event_handler, declaration, - None, + PersistenceExtensionCallbacks::default(), None, out_handle, ) @@ -127,13 +127,13 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_persistence_extensi check_ptr!(persistence_capabilities); check_ptr!(persistence_extension); let declaration = persistence_capabilities_declaration(&*persistence_capabilities); - let dpns_callback = persistence_extension_dpns_callback(persistence_extension); + let extensions = persistence_extension_callbacks(persistence_extension); platform_wallet_manager_create_impl( sdk_ptr, persistence, event_handler, declaration, - dpns_callback, + extensions, None, out_handle, ) @@ -157,40 +157,53 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_extensions( check_ptr!(persistence_extension); check_ptr!(event_extension); let declaration = persistence_capabilities_declaration(&*persistence_capabilities); - let dpns_persistence_callback = persistence_extension_dpns_callback(persistence_extension); + let persistence_extensions = persistence_extension_callbacks(persistence_extension); let dpns_event_callback = event_extension_dpns_callback(event_extension); platform_wallet_manager_create_impl( sdk_ptr, persistence, event_handler, declaration, - dpns_persistence_callback, + persistence_extensions, dpns_event_callback, out_handle, ) } -unsafe fn persistence_extension_dpns_callback( +unsafe fn persistence_extension_callbacks( extension: *const PersistenceCallbacksExtension, -) -> Option { +) -> PersistenceExtensionCallbacks { let supplied_size = std::ptr::addr_of!((*extension).struct_size).read(); let version_end = std::mem::offset_of!(PersistenceCallbacksExtension, version) + std::mem::size_of::(); if supplied_size < version_end { - return None; + return PersistenceExtensionCallbacks::default(); } let version = std::ptr::addr_of!((*extension).version).read(); if version != PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION { - return None; + return PersistenceExtensionCallbacks::default(); } - let callback_end = std::mem::offset_of!( - PersistenceCallbacksExtension, - on_persist_dpns_name_states_fn - ) + std::mem::size_of::>(); - if supplied_size < callback_end { - return None; + + /// Read one size-gated `Option` field: present only when the + /// caller's `struct_size` proves the complete field exists. + macro_rules! gated { + ($field:ident) => {{ + let end = std::mem::offset_of!(PersistenceCallbacksExtension, $field) + + std::mem::size_of_val(&(*extension).$field); + if supplied_size < end { + None + } else { + std::ptr::addr_of!((*extension).$field).read() + } + }}; + } + + PersistenceExtensionCallbacks { + dpns_name_states: gated!(on_persist_dpns_name_states_fn), + persist_tracked_masternodes: gated!(on_persist_tracked_masternodes_fn), + load_tracked_masternodes: gated!(on_load_tracked_masternodes_fn), + load_tracked_masternodes_free: gated!(on_load_tracked_masternodes_free_fn), } - std::ptr::addr_of!((*extension).on_persist_dpns_name_states_fn).read() } unsafe fn event_extension_dpns_callback( @@ -221,7 +234,7 @@ unsafe fn platform_wallet_manager_create_impl( persistence: *const PersistenceCallbacks, event_handler: *const EventHandlerCallbacks, declared_capabilities: PersistenceCapabilities, - dpns_name_states_callback: Option, + persistence_extensions: PersistenceExtensionCallbacks, dpns_event_callback: Option, out_handle: *mut Handle, ) -> PlatformWalletFFIResult { @@ -259,10 +272,10 @@ unsafe fn platform_wallet_manager_create_impl( let sdk = Arc::new((*(sdk_ptr as *const Sdk)).clone()); let persister = Arc::new( - FFIPersister::new_with_persistence_capabilities_and_dpns_callback( + FFIPersister::new_with_persistence_capabilities_and_extensions( std::ptr::read(persistence), declared_capabilities, - dpns_name_states_callback, + persistence_extensions, ), ); let handler: Arc = Arc::new(FFIEventHandler::new( @@ -1094,8 +1107,33 @@ mod tests { on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), ..Default::default() }; - assert!(unsafe { persistence_extension_dpns_callback(&short) }.is_none()); - assert!(unsafe { persistence_extension_dpns_callback(&unknown) }.is_none()); + let read_short = unsafe { persistence_extension_callbacks(&short) }; + assert!(read_short.dpns_name_states.is_none()); + assert!(read_short.persist_tracked_masternodes.is_none()); + let read_unknown = unsafe { persistence_extension_callbacks(&unknown) }; + assert!(read_unknown.dpns_name_states.is_none()); + assert!(read_unknown.load_tracked_masternodes.is_none()); + } + + /// A caller whose `struct_size` covers only the dpns field (an + /// older host recompiled before the tracked-masternode trio existed) + /// yields the dpns callback and nothing else — additive size gating. + #[test] + fn dpns_only_sized_extension_reads_only_the_dpns_field() { + let dpns_only_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_tracked_masternodes_fn + ); + let ext = PersistenceCallbacksExtension { + struct_size: dpns_only_size, + on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + ..Default::default() + }; + let read = unsafe { persistence_extension_callbacks(&ext) }; + assert!(read.dpns_name_states.is_some()); + assert!(read.persist_tracked_masternodes.is_none()); + assert!(read.load_tracked_masternodes.is_none()); + assert!(read.load_tracked_masternodes_free.is_none()); } } diff --git a/packages/rs-platform-wallet-ffi/src/masternode_locator.rs b/packages/rs-platform-wallet-ffi/src/masternode_locator.rs new file mode 100644 index 00000000000..372ccdb8277 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/masternode_locator.rs @@ -0,0 +1,412 @@ +//! FFI for the masternode locator: find a masternode from an IP, a +//! proTxHash or any of its private keys, and verify a key against a role. +//! Thin marshalling over `platform_wallet::masternode::locator`. + +use std::ffi::{c_char, CStr, CString}; + +use platform_wallet::masternode::{ + KeyVerification, LocateOptions, MasternodeKeyRole, MasternodeLocateError, + MasternodeLocateMatch, MasternodeLocateResult, PlatformLookup, +}; + +use crate::error::*; +use crate::handle::*; +use crate::runtime::block_on_worker; +use crate::{check_ptr, unwrap_result_or_return}; + +/// One masternode the locator text names — the list's view of it plus how it +/// was matched. Returned by [`platform_wallet_manager_locate_masternode`]; +/// free the array with [`platform_wallet_manager_free_masternode_matches`]. +#[repr(C)] +pub struct MasternodeLocateMatchFFI { + /// proTxHash (32 wire bytes) — same orientation as + /// `MasternodeEntryFFI.pro_tx_hash`. + pub pro_tx_hash: [u8; 32], + /// `"ip:port"` of the Core P2P endpoint, or null (Tor / I2P-only entry). + pub service_address: *mut c_char, + /// Platform HTTP (DAPI) port, gated by `has_platform_http_port` + /// (evonodes only). + pub platform_http_port: u16, + pub has_platform_http_port: bool, + /// Operator BLS public key as serialized in the list (48 bytes). + pub operator_public_key: [u8; 48], + /// Voting key id (hash160). + pub voting_key_id: [u8; 20], + /// Tenderdash node id, gated by `has_platform_node_id` (evonodes only). + pub platform_node_id: [u8; 20], + pub has_platform_node_id: bool, + /// `false` ⇒ PoSe-banned. + pub is_valid: bool, + pub is_evonode: bool, + /// How it was found: 0 proTxHash, 1 service address, 2 private key. + pub matched_by: u8, + /// Roles the pasted key fills on this masternode, as a bit mask over + /// `MasternodeKeyRole` (bit 0 owner, 1 voting, 2 operator, 3 platform + /// node, 4 owner payout, 5 operator payout). 0 unless `matched_by == 2`. + pub matched_key_roles: u8, + /// Already one of a loaded wallet's own masternodes (`wallet_id` set) — + /// hosts say "already in wallet" rather than offering to track it. + pub in_wallet: bool, + pub wallet_id: [u8; 32], + /// Already in the tracked-masternode registry — hosts jump to it + /// instead of tracking twice. + pub already_tracked: bool, +} + +fn roles_mask(roles: &[MasternodeKeyRole]) -> u8 { + roles + .iter() + .fold(0u8, |mask, role| mask | (1u8 << role.as_u8())) +} + +fn cstring_or_null(s: String) -> *mut c_char { + CString::new(s) + .map(CString::into_raw) + .unwrap_or(std::ptr::null_mut()) +} + +fn match_ffi(m: &MasternodeLocateMatch) -> MasternodeLocateMatchFFI { + let s = &m.summary; + MasternodeLocateMatchFFI { + pro_tx_hash: s.pro_tx_hash, + service_address: s + .service_address + .map(|a| cstring_or_null(a.to_string())) + .unwrap_or(std::ptr::null_mut()), + platform_http_port: s.platform_http_port.unwrap_or(0), + has_platform_http_port: s.platform_http_port.is_some(), + operator_public_key: s.operator_public_key, + voting_key_id: s.voting_key_id, + platform_node_id: s.platform_node_id.unwrap_or([0u8; 20]), + has_platform_node_id: s.platform_node_id.is_some(), + is_valid: s.is_valid, + is_evonode: s.is_evonode, + matched_by: m.matched_by.as_u8(), + matched_key_roles: roles_mask(&m.matched_keys), + in_wallet: m.in_wallet.is_some(), + wallet_id: m.in_wallet.unwrap_or([0u8; 32]), + already_tracked: m.already_tracked, + } +} + +fn write_matches( + result: MasternodeLocateResult, + out_matches: *mut *const MasternodeLocateMatchFFI, + out_count: *mut usize, + out_platform_lookup: *mut u8, + out_platform_error: *mut *mut c_char, +) { + let entries: Vec = result.matches.iter().map(match_ffi).collect(); + let count = entries.len(); + // SAFETY: pointers were null-checked by the caller. + unsafe { + *out_platform_lookup = result.platform_lookup.as_u8(); + if !out_platform_error.is_null() { + *out_platform_error = result + .platform_error + .map(cstring_or_null) + .unwrap_or(std::ptr::null_mut()); + } + if count == 0 { + *out_matches = std::ptr::null(); + *out_count = 0; + } else { + *out_matches = Box::into_raw(entries.into_boxed_slice()) as *const _; + *out_count = count; + } + } +} + +/// Find the masternode(s) `text` names — an IP (`1.2.3.4`, `1.2.3.4:9999`, +/// a DAPI URL), a proTxHash (display hex, as explorers and dashmate print +/// it), or a private key (owner / voting / payout WIF or hex, operator BLS +/// hex, Tenderdash node key in dashmate's base64 or hex). For a key, each +/// match says which role(s) it fills (`matched_key_roles`), so the host can +/// pre-fill that key field. +/// +/// `search_platform` additionally asks Platform for owner / payout roles of +/// a pasted secp256k1 key (one `getIdentityByNonUniquePublicKeyHash` per +/// key — it tells DAPI which key hash the user holds, so it is opt-in). +/// `out_platform_lookup` reports that step: 0 not needed (no secp key), +/// 1 not requested, 2 done, 3 unavailable (`out_platform_error` carries the +/// reason; free with `platform_wallet_string_free`). Local matches stand +/// either way. +/// +/// Errors: `ErrorInvalidParameter` with a user-facing message when the text +/// can't be read (empty, unrecognized, a WIF for the other network, a node +/// key whose public half doesn't match); `ErrorMasternodeListUnavailable` +/// when the DML hasn't synced yet. An empty match list with `Success` means +/// "nothing on the list by that locator". +/// +/// # Safety +/// `text` must be a valid NUL-terminated UTF-8 string; `out_matches`, +/// `out_count`, `out_platform_lookup` must be writable; +/// `out_platform_error` may be null. Free the matches with +/// [`platform_wallet_manager_free_masternode_matches`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_locate_masternode( + manager_handle: Handle, + text: *const c_char, + search_platform: bool, + out_matches: *mut *const MasternodeLocateMatchFFI, + out_count: *mut usize, + out_platform_lookup: *mut u8, + out_platform_error: *mut *mut c_char, +) -> PlatformWalletFFIResult { + check_ptr!(text); + check_ptr!(out_matches); + check_ptr!(out_count); + check_ptr!(out_platform_lookup); + *out_matches = std::ptr::null(); + *out_count = 0; + *out_platform_lookup = PlatformLookup::NotNeeded.as_u8(); + if !out_platform_error.is_null() { + *out_platform_error = std::ptr::null_mut(); + } + + let text = unwrap_result_or_return!(CStr::from_ptr(text).to_str()).to_string(); + + // Snapshot the locator (SPV / SDK handles + the wallets' own masternodes) + // under the handle guard, then run the lookup — which may round-trip to + // Platform — on a worker without holding anything. + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { + manager.masternode_locator_blocking() + }); + let Some(locator) = option else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "invalid platform wallet manager handle", + ); + }; + + let outcome = block_on_worker(async move { + locator + .locate(&text, LocateOptions { search_platform }) + .await + }); + + match outcome { + Ok(result) => { + write_matches( + result, + out_matches, + out_count, + out_platform_lookup, + out_platform_error, + ); + PlatformWalletFFIResult::ok() + } + Err(MasternodeLocateError::Parse(e)) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e.to_string(), + ), + Err(MasternodeLocateError::ListUnavailable) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorMasternodeListUnavailable, + "the masternode list is not available yet", + ), + } +} + +/// Free an array returned by [`platform_wallet_manager_locate_masternode`], +/// including each entry's heap C string. +/// +/// # Safety +/// `entries` / `count` must be exactly what the locate call returned; call +/// once. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_free_masternode_matches( + entries: *mut MasternodeLocateMatchFFI, + count: usize, +) { + if entries.is_null() || count == 0 { + return; + } + let slice = std::slice::from_raw_parts_mut(entries, count); + for entry in slice.iter() { + if !entry.service_address.is_null() { + drop(CString::from_raw(entry.service_address)); + } + } + drop(Box::from_raw(slice as *mut [MasternodeLocateMatchFFI])); +} + +/// Check `key_text` against the `role` key of the masternode `pro_tx_hash` +/// (32 wire bytes). `role` is a `MasternodeKeyRole` discriminant (0 owner, +/// 1 voting, 2 operator, 3 platform node, 4 owner payout, 5 operator +/// payout). `out_verification`: 0 matches, 1 does not match, 2 unverifiable +/// (the reference for that role isn't known — e.g. the owner key hash of a +/// node that isn't one of this wallet's and whose registration details +/// haven't been fetched). Unverifiable is NOT a pass. +/// +/// The reference comes from the DML entry (voting / operator / platform +/// node) merged with the owning wallet's record (owner / payout) when the +/// node is one of a loaded wallet's masternodes. +/// +/// Errors: `ErrorInvalidParameter` when `key_text` isn't a key of the +/// role's curve (or is a WIF for the other network), or `role` is out of +/// range; `NotFound` when neither the list nor any wallet knows +/// `pro_tx_hash`. +/// +/// # Safety +/// `pro_tx_hash` must point at 32 readable bytes; `key_text` must be a valid +/// NUL-terminated UTF-8 string; `out_verification` must be writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_masternode_verify_key( + manager_handle: Handle, + pro_tx_hash: *const u8, + role: u8, + key_text: *const c_char, + out_verification: *mut u8, +) -> PlatformWalletFFIResult { + check_ptr!(pro_tx_hash); + check_ptr!(key_text); + check_ptr!(out_verification); + *out_verification = KeyVerification::Unverifiable.as_u8(); + + let Some(role) = MasternodeKeyRole::from_u8(role) else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("unknown masternode key role {role}"), + ); + }; + let target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); + let key_text = unwrap_result_or_return!(CStr::from_ptr(key_text).to_str()).to_string(); + + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { + ( + manager.sdk().network, + manager.masternode_key_reference_blocking(&target), + ) + }); + let Some((network, reference)) = option else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "invalid platform wallet manager handle", + ); + }; + let Some(reference) = reference else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "no masternode with this proTxHash on the list or in a loaded wallet", + ); + }; + + match platform_wallet::masternode::verify_masternode_key_text( + &reference, role, &key_text, network, + ) { + Ok(verification) => { + *out_verification = verification.as_u8(); + PlatformWalletFFIResult::ok() + } + Err(e) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e.to_string(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use platform_wallet::masternode::{LocatorMatchKind, MasternodeListSummary}; + + fn summary() -> MasternodeListSummary { + MasternodeListSummary { + pro_tx_hash: [1u8; 32], + service_address: Some("1.2.3.4:9999".parse().unwrap()), + platform_http_port: Some(443), + operator_public_key: [2u8; 48], + voting_key_id: [3u8; 20], + platform_node_id: Some([4u8; 20]), + is_valid: true, + is_evonode: true, + } + } + + #[test] + fn match_marshals_fields_and_role_mask() { + let m = MasternodeLocateMatch { + summary: summary(), + matched_by: LocatorMatchKind::Key, + matched_keys: vec![MasternodeKeyRole::Owner, MasternodeKeyRole::Voting], + in_wallet: Some([9u8; 32]), + already_tracked: true, + }; + let ffi = match_ffi(&m); + assert_eq!(ffi.pro_tx_hash, [1u8; 32]); + assert_eq!( + unsafe { CStr::from_ptr(ffi.service_address) } + .to_str() + .unwrap(), + "1.2.3.4:9999" + ); + assert!(ffi.has_platform_http_port); + assert_eq!(ffi.platform_http_port, 443); + assert!(ffi.has_platform_node_id); + assert!(ffi.is_evonode); + assert_eq!(ffi.matched_by, 2); + assert_eq!(ffi.matched_key_roles, 0b11); + assert!(ffi.in_wallet); + assert_eq!(ffi.wallet_id, [9u8; 32]); + assert!(ffi.already_tracked); + let entries = Box::into_raw(vec![ffi].into_boxed_slice()) as *mut MasternodeLocateMatchFFI; + unsafe { platform_wallet_manager_free_masternode_matches(entries, 1) }; + } + + #[test] + fn null_args_are_rejected_and_out_params_initialised() { + let mut matches: *const MasternodeLocateMatchFFI = std::ptr::dangling(); + let mut count = 7usize; + let mut lookup = 9u8; + let text = CString::new("1.2.3.4").unwrap(); + let mut r = unsafe { + platform_wallet_manager_locate_masternode( + 0, + text.as_ptr(), + false, + &mut matches, + &mut count, + &mut lookup, + std::ptr::null_mut(), + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert!(matches.is_null()); + assert_eq!(count, 0); + assert_eq!(lookup, 0); + unsafe { platform_wallet_ffi_result_free(&mut r) }; + + let mut r = unsafe { + platform_wallet_manager_locate_masternode( + 0, + std::ptr::null(), + false, + &mut matches, + &mut count, + &mut lookup, + std::ptr::null_mut(), + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + unsafe { platform_wallet_ffi_result_free(&mut r) }; + } + + #[test] + fn verify_rejects_an_unknown_role_before_touching_handles() { + let hash = [0u8; 32]; + let key = CString::new("x").unwrap(); + let mut out = 0u8; + let mut r = unsafe { + platform_wallet_manager_masternode_verify_key( + 0, + hash.as_ptr(), + 42, + key.as_ptr(), + &mut out, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + assert_eq!(out, 2, "unverifiable until proven otherwise"); + unsafe { platform_wallet_ffi_result_free(&mut r) }; + } +} diff --git a/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs b/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs index 45947086bdc..4b83446381d 100644 --- a/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs +++ b/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs @@ -23,12 +23,12 @@ use std::str::FromStr; use std::sync::Arc; use dashcore::Address as DashAddress; +use platform_wallet::masternode::MasternodeRecord; use platform_wallet::{ MasternodeWithdrawalKey, MasternodeWithdrawalKeys, MasternodeWithdrawalRequest, PlatformWallet, }; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; -use crate::core_wallet_types::{aggregate_masternodes, ListMembership, MasternodeAggregate}; use crate::error::*; use crate::handle::*; use crate::runtime::block_on_worker; @@ -63,37 +63,26 @@ impl MasternodeWithdrawalKeysFFI { } } -/// Resolve `(wallet, masternode aggregate)` for a `pro_tx_hash` (wire -/// order) from the manager — the same aggregation the masternode list -/// renders. Clones the `Arc` out so callers can do network -/// work after the handle-storage guard is released. +/// Resolve `(wallet, masternode record)` for a `pro_tx_hash` (wire order) +/// from the manager — the same records the masternode list renders +/// (`PlatformWalletManager::wallet_masternodes_blocking`). Clones the +/// `Arc` out so callers can do network work after the +/// handle-storage guard is released. unsafe fn resolve_masternode( manager_handle: Handle, wallet_id: *const u8, pro_tx_hash: *const u8, -) -> Result<(Arc, MasternodeAggregate), PlatformWalletFFIResult> { +) -> Result<(Arc, MasternodeRecord), PlatformWalletFFIResult> { let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); let target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); let resolved = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { let wallet = manager.get_wallet_blocking(&wid)?; - let (_network, txs, dml, _operator_index, _platform_index) = - manager.provider_masternode_txs_blocking(&wid)?; - 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 aggregate = - aggregate_masternodes(txs.iter().map(|(h, p, tx)| (*h, *p, tx)), membership) - .into_iter() - .find(|mn| mn.pro_tx_hash == target); - Some((wallet, aggregate)) + let record = manager + .wallet_masternodes_blocking(&wid)? + .find(&target) + .cloned(); + Some((wallet, record)) }); match resolved { diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 04a4e29ea1d..dbf1ff0eb5c 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -33,6 +33,7 @@ use platform_wallet::wallet::platform_wallet::WalletId; use platform_wallet::wallet::{PerAccountPlatformAddressState, PerWalletPlatformAddressState}; use std::collections::BTreeMap; use std::ffi::CString; +use std::os::raw::c_char; use std::os::raw::c_void; use std::slice; @@ -115,6 +116,11 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DEFERRED_CONTACT_CRYPTO: u64 = pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_WALLET_RESTORE: u64 = 1 << 7; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DPNS_NAME_STATES: u64 = 1 << 8; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS: u64 = 1 << 9; +/// Tracked (wallet-independent) masternodes are persisted AND restored +/// across restarts. Requires the extension trio +/// `on_persist_tracked_masternodes_fn` + `on_load_tracked_masternodes_fn` +/// + `on_load_tracked_masternodes_free_fn`, and the host declaring the bit. +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES: u64 = 1 << 10; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -131,6 +137,39 @@ pub type PersistDpnsNameStatesFn = unsafe extern "C" fn( removed_count: usize, ) -> i32; +/// One tracked (wallet-independent) masternode row crossing the +/// persistence boundary. PUBLIC material only: the snapshot document is +/// produced by `platform_wallet::masternode::snapshot_to_json` and hosts +/// store it opaquely. +#[repr(C)] +pub struct TrackedMasternodeFFI { + /// proTxHash, 32 wire-order bytes. + pub pro_tx_hash: [u8; 32], + /// User label, or null. + pub label: *const c_char, + /// Unix seconds when the user tracked it. + pub added_at: u64, + /// Versioned snapshot JSON (never null). + pub snapshot_json: *const c_char, +} + +pub type PersistTrackedMasternodesFn = unsafe extern "C" fn( + context: *mut c_void, + network: *const c_char, + rows: *const TrackedMasternodeFFI, + rows_count: usize, +) -> i32; + +pub type LoadTrackedMasternodesFn = unsafe extern "C" fn( + context: *mut c_void, + network: *const c_char, + out_rows: *mut *const TrackedMasternodeFFI, + out_count: *mut usize, +) -> i32; + +pub type FreeTrackedMasternodesFn = + unsafe extern "C" fn(context: *mut c_void, rows: *const TrackedMasternodeFFI, count: usize); + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -162,6 +201,38 @@ pub struct PersistenceCallbacksExtension { removed_count: usize, ) -> i32, >, + /// Replace the persisted tracked-masternode set for `network` with + /// `rows` (whole-set write; the set is user-curated and small). The + /// pointers are valid only for the duration of the callback. Wired + /// together with the load + free pair below — the + /// `TRACKED_MASTERNODES` capability is attested only when all three + /// are present (and declared). Same additive size-gating as every + /// extension field: older hosts with a smaller `struct_size` simply + /// don't have it. + pub on_persist_tracked_masternodes_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + network: *const c_char, + rows: *const TrackedMasternodeFFI, + rows_count: usize, + ) -> i32, + >, + /// Return the persisted tracked-masternode rows for `network`. The + /// host allocates the array + strings and keeps them valid until Rust + /// hands them back through `on_load_tracked_masternodes_free_fn`. + pub on_load_tracked_masternodes_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + network: *const c_char, + out_rows: *mut *const TrackedMasternodeFFI, + out_count: *mut usize, + ) -> i32, + >, + /// Release an array previously returned by + /// `on_load_tracked_masternodes_fn`. + pub on_load_tracked_masternodes_free_fn: Option< + unsafe extern "C" fn(context: *mut c_void, rows: *const TrackedMasternodeFFI, count: usize), + >, } impl Default for PersistenceCallbacksExtension { @@ -171,10 +242,24 @@ impl Default for PersistenceCallbacksExtension { version: PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, reserved: 0, on_persist_dpns_name_states_fn: None, + on_persist_tracked_masternodes_fn: None, + on_load_tracked_masternodes_fn: None, + on_load_tracked_masternodes_free_fn: None, } } } +/// The additive (extension-negotiated) callbacks in Rust-side form, read +/// out of a size-gated [`PersistenceCallbacksExtension`] by the manager +/// create path. +#[derive(Clone, Copy, Default)] +pub struct PersistenceExtensionCallbacks { + pub dpns_name_states: Option, + pub persist_tracked_masternodes: Option, + pub load_tracked_masternodes: Option, + pub load_tracked_masternodes_free: Option, +} + /// C callback vtable for wallet persistence. /// /// General-purpose notifications (`on_store_fn`, `on_flush_fn`) plus @@ -950,6 +1035,9 @@ pub struct FFIPersister { callbacks: PersistenceCallbacks, /// Additive callbacks negotiated outside the legacy unsized vtable. dpns_name_states_callback: Option, + /// Additive tracked-masternode persistence trio (persist / load / + /// free), likewise extension-negotiated. + tracked_masternodes_callbacks: PersistenceExtensionCallbacks, /// Semantic capability declaration supplied separately from the callback /// vtable by the additive manager-create API. Keeping this out of /// `PersistenceCallbacks` preserves that established C struct's size. @@ -1013,10 +1101,26 @@ impl FFIPersister { callbacks: PersistenceCallbacks, declared_capabilities: PersistenceCapabilities, dpns_name_states_callback: Option, + ) -> Self { + Self::new_with_persistence_capabilities_and_extensions( + callbacks, + declared_capabilities, + PersistenceExtensionCallbacks { + dpns_name_states: dpns_name_states_callback, + ..Default::default() + }, + ) + } + + pub fn new_with_persistence_capabilities_and_extensions( + callbacks: PersistenceCallbacks, + declared_capabilities: PersistenceCapabilities, + extensions: PersistenceExtensionCallbacks, ) -> Self { Self { callbacks, - dpns_name_states_callback, + dpns_name_states_callback: extensions.dpns_name_states, + tracked_masternodes_callbacks: extensions, declared_capabilities, pending: RwLock::new(BTreeMap::new()), round_lock: Mutex::new(RoundGuardState::default()), @@ -1053,6 +1157,21 @@ impl FFIPersister { if self.callbacks.on_persist_asset_locks_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); } + if self + .tracked_masternodes_callbacks + .persist_tracked_masternodes + .is_some() + && self + .tracked_masternodes_callbacks + .load_tracked_masternodes + .is_some() + && self + .tracked_masternodes_callbacks + .load_tracked_masternodes_free + .is_some() + { + capabilities = capabilities.union(PersistenceCapabilities::TRACKED_MASTERNODES); + } if self.callbacks.on_persist_wallet_changeset_fn.is_some() && wallet_restore && capabilities.contains(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) @@ -1092,6 +1211,136 @@ impl PlatformWalletPersistence for FFIPersister { .intersection(self.callback_capabilities()) } + fn persist_tracked_masternodes( + &self, + network: dashcore::Network, + records: &[platform_wallet::masternode::TrackedMasternode], + ) -> Result<(), PersistenceError> { + // No callback ⇒ the honest default: a session-scoped no-op. The + // TRACKED_MASTERNODES capability bit is not attested in that case, + // so callers know the difference. + let Some(persist) = self + .tracked_masternodes_callbacks + .persist_tracked_masternodes + else { + return Ok(()); + }; + let network_c = + CString::new(network.to_string()).expect("network names contain no interior NUL"); + // Own every string for the duration of the call. + let storage: Vec<(Option, CString)> = records + .iter() + .map(|record| { + let label = record.label.as_deref().and_then(|l| CString::new(l).ok()); + let snapshot = CString::new(platform_wallet::masternode::snapshot_to_json( + &record.snapshot, + )) + .expect("snapshot JSON contains no interior NUL"); + (label, snapshot) + }) + .collect(); + let rows: Vec = records + .iter() + .zip(storage.iter()) + .map(|(record, (label, snapshot))| TrackedMasternodeFFI { + pro_tx_hash: record.pro_tx_hash, + label: label + .as_ref() + .map(|l| l.as_ptr()) + .unwrap_or(std::ptr::null()), + added_at: record.added_at, + snapshot_json: snapshot.as_ptr(), + }) + .collect(); + let rc = unsafe { + persist( + self.callbacks.context, + network_c.as_ptr(), + if rows.is_empty() { + std::ptr::null() + } else { + rows.as_ptr() + }, + rows.len(), + ) + }; + if rc != 0 { + return Err(PersistenceError::backend(format!( + "on_persist_tracked_masternodes_fn returned error code {rc}" + ))); + } + Ok(()) + } + + fn load_tracked_masternodes( + &self, + network: dashcore::Network, + ) -> Result, PersistenceError> { + let Some(load) = self.tracked_masternodes_callbacks.load_tracked_masternodes else { + return Ok(Vec::new()); + }; + // Fail closed on a half-wired pair: without the free callback the + // host-allocated rows could never be returned, so every load would + // leak. Same rule as the shielded load/free arms. + let Some(free) = self + .tracked_masternodes_callbacks + .load_tracked_masternodes_free + else { + return Err(PersistenceError::backend( + "on_load_tracked_masternodes_fn requires on_load_tracked_masternodes_free_fn; \ + wire both or neither", + )); + }; + let network_c = + CString::new(network.to_string()).expect("network names contain no interior NUL"); + let mut rows_ptr: *const TrackedMasternodeFFI = std::ptr::null(); + let mut count: usize = 0; + let rc = unsafe { + load( + self.callbacks.context, + network_c.as_ptr(), + &mut rows_ptr, + &mut count, + ) + }; + if rc != 0 { + return Err(PersistenceError::backend(format!( + "on_load_tracked_masternodes_fn returned error code {rc}" + ))); + } + let mut out = Vec::with_capacity(count); + if !rows_ptr.is_null() && count > 0 { + let rows = unsafe { slice::from_raw_parts(rows_ptr, count) }; + for row in rows { + let label = if row.label.is_null() { + None + } else { + unsafe { CStr::from_ptr(row.label) } + .to_str() + .ok() + .map(str::to_string) + }; + let snapshot = if row.snapshot_json.is_null() { + platform_wallet::masternode::TrackedMasternodeSnapshot::default() + } else { + platform_wallet::masternode::snapshot_from_json( + unsafe { CStr::from_ptr(row.snapshot_json) } + .to_str() + .unwrap_or(""), + ) + }; + out.push(platform_wallet::masternode::TrackedMasternode { + pro_tx_hash: row.pro_tx_hash, + label, + added_at: row.added_at, + snapshot, + }); + } + } + unsafe { free(self.callbacks.context, rows_ptr, count) }; + Ok(out) + } + fn store_commits_inline(&self) -> bool { // The end callback commits (or rolls back) the host transaction before // `store` returns. `flush` is only a later general-purpose notification. @@ -6042,6 +6291,189 @@ mod tests { ) -> FFIPersister { FFIPersister::new_with_persistence_capabilities(cb, capabilities) } + + // --- tracked masternodes: host-callback round trip --------------------- + + /// In-memory "host store" for the tracked-masternode callbacks: rows + /// are copied into host-owned allocations on persist and handed back + /// (host-owned again) on load, exercising the same alloc/free contract + /// Swift and Kotlin implement. + mod tracked_host { + use super::*; + use std::sync::Mutex; + + /// (network, proTxHash, label, added_at, snapshot_json). + pub type StoredRow = (String, [u8; 32], Option, u64, String); + + pub struct Store { + pub rows: Mutex>, + pub loaned: Mutex, Vec)>>, + } + + pub unsafe extern "C" fn persist( + ctx: *mut c_void, + network: *const c_char, + rows: *const TrackedMasternodeFFI, + count: usize, + ) -> i32 { + let store = &*(ctx as *const Store); + let network = CStr::from_ptr(network).to_str().unwrap().to_string(); + let mut guard = store.rows.lock().unwrap(); + guard.retain(|(n, ..)| n != &network); + if !rows.is_null() { + for row in slice::from_raw_parts(rows, count) { + let label = if row.label.is_null() { + None + } else { + Some(CStr::from_ptr(row.label).to_str().unwrap().to_string()) + }; + let snapshot = CStr::from_ptr(row.snapshot_json) + .to_str() + .unwrap() + .to_string(); + guard.push(( + network.clone(), + row.pro_tx_hash, + label, + row.added_at, + snapshot, + )); + } + } + 0 + } + + pub unsafe extern "C" fn load( + ctx: *mut c_void, + network: *const c_char, + out_rows: *mut *const TrackedMasternodeFFI, + out_count: *mut usize, + ) -> i32 { + let store = &*(ctx as *const Store); + let network = CStr::from_ptr(network).to_str().unwrap().to_string(); + let mut strings = Vec::new(); + let rows: Vec = store + .rows + .lock() + .unwrap() + .iter() + .filter(|(n, ..)| n == &network) + .map(|(_, hash, label, added_at, snapshot)| { + let label_ptr = match label { + Some(l) => { + let c = CString::new(l.as_str()).unwrap(); + let ptr = c.as_ptr(); + strings.push(c); + ptr + } + None => std::ptr::null(), + }; + let snapshot_c = CString::new(snapshot.as_str()).unwrap(); + let snapshot_ptr = snapshot_c.as_ptr(); + strings.push(snapshot_c); + TrackedMasternodeFFI { + pro_tx_hash: *hash, + label: label_ptr, + added_at: *added_at, + snapshot_json: snapshot_ptr, + } + }) + .collect(); + *out_count = rows.len(); + *out_rows = if rows.is_empty() { + std::ptr::null() + } else { + rows.as_ptr() + }; + // Loan the allocations to Rust until the free callback. + store.loaned.lock().unwrap().push((rows, strings)); + 0 + } + + pub unsafe extern "C" fn free( + ctx: *mut c_void, + rows: *const TrackedMasternodeFFI, + _count: usize, + ) { + let store = &*(ctx as *const Store); + let mut loaned = store.loaned.lock().unwrap(); + loaned.retain(|(vec, _)| !(vec.is_empty() && rows.is_null()) && vec.as_ptr() != rows); + } + } + + #[test] + fn tracked_masternodes_round_trip_through_host_callbacks() { + use platform_wallet::masternode::{TrackedMasternode, TrackedMasternodeSnapshot}; + + let store = Box::leak(Box::new(tracked_host::Store { + rows: std::sync::Mutex::new(Vec::new()), + loaned: std::sync::Mutex::new(Vec::new()), + })); + let cb = PersistenceCallbacks { + context: store as *mut tracked_host::Store as *mut c_void, + release_fn: Some(noop_release), + ..Default::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + cb, + PersistenceCapabilities::from_bits_retain( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES, + ), + PersistenceExtensionCallbacks { + persist_tracked_masternodes: Some(tracked_host::persist), + load_tracked_masternodes: Some(tracked_host::load), + load_tracked_masternodes_free: Some(tracked_host::free), + ..Default::default() + }, + ); + + // Structural + declared ⇒ attested. + assert!(persister + .persistence_capabilities() + .contains(PersistenceCapabilities::TRACKED_MASTERNODES)); + + let record = TrackedMasternode { + pro_tx_hash: [7u8; 32], + label: Some("home node".to_string()), + added_at: 1_700_000_000, + snapshot: TrackedMasternodeSnapshot { + ever_listed: true, + ..Default::default() + }, + }; + let unnamed = TrackedMasternode { + pro_tx_hash: [8u8; 32], + label: None, + added_at: 1_700_000_001, + snapshot: TrackedMasternodeSnapshot::default(), + }; + persister + .persist_tracked_masternodes( + dashcore::Network::Mainnet, + &[record.clone(), unnamed.clone()], + ) + .expect("persist"); + // Other-network rows are untouched by a mainnet replace. + persister + .persist_tracked_masternodes(dashcore::Network::Testnet, std::slice::from_ref(&record)) + .expect("persist testnet"); + persister + .persist_tracked_masternodes(dashcore::Network::Mainnet, std::slice::from_ref(&record)) + .expect("replace mainnet"); + + let loaded = persister + .load_tracked_masternodes(dashcore::Network::Mainnet) + .expect("load"); + assert_eq!(loaded, vec![record.clone()]); + let testnet = persister + .load_tracked_masternodes(dashcore::Network::Testnet) + .expect("load testnet"); + assert_eq!(testnet.len(), 1); + // Every loan was returned through the free callback. + assert!(store.loaned.lock().unwrap().is_empty()); + } + + unsafe extern "C" fn noop_release(_ctx: *mut c_void) {} #[cfg(feature = "shielded")] unsafe extern "C" fn noop_persist_viewing_keys( _ctx: *mut c_void, @@ -6321,11 +6753,24 @@ mod tests { std::mem::size_of::() ); assert_eq!(PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, 1); - assert_eq!( + // The extension grows ADDITIVELY under version 1 (size-gated + // reads); pin the current field order and terminal slot so an + // accidental reorder — which would silently misread every older + // host's callbacks — fails here. + assert!( std::mem::offset_of!( PersistenceCallbacksExtension, on_persist_dpns_name_states_fn - ) + std::mem::size_of::>(), + ) < std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_tracked_masternodes_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_tracked_masternodes_free_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( diff --git a/packages/rs-platform-wallet-ffi/src/tracked_masternode.rs b/packages/rs-platform-wallet-ffi/src/tracked_masternode.rs new file mode 100644 index 00000000000..2d93ab52d67 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/tracked_masternode.rs @@ -0,0 +1,420 @@ +//! FFI for tracked (wallet-independent) masternodes: track / untrack / +//! rename, list, refresh, capabilities, and withdraw with a host-supplied +//! key. Thin marshalling over `platform_wallet::masternode::tracked`; the +//! records reuse [`MasternodeEntryFFI`] (`source == 1`) so hosts render +//! wallet and tracked masternodes with the same code. + +use std::ffi::{c_char, CStr}; + +use platform_wallet::masternode::locator::parse_secret_for_role; +use platform_wallet::masternode::{ + capabilities_for_roles, LocatorSecret, MasternodeKeyRole, MasternodeRecord, +}; + +use crate::core_wallet_types::{masternode_entry_ffi, MasternodeEntryFFI}; +use crate::error::*; +use crate::handle::*; +use crate::runtime::block_on_worker; +use crate::{check_ptr, unwrap_result_or_return}; + +fn invalid_handle() -> PlatformWalletFFIResult { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "invalid platform wallet manager handle", + ) +} + +unsafe fn optional_string(ptr: *const c_char) -> Result, PlatformWalletFFIResult> { + if ptr.is_null() { + return Ok(None); + } + match CStr::from_ptr(ptr).to_str() { + Ok(text) => Ok(Some(text.to_string())), + Err(e) => Err(e.into()), + } +} + +unsafe fn write_records( + records: Vec, + network: dashcore::Network, + out_entries: *mut *const MasternodeEntryFFI, + out_count: *mut usize, +) { + let entries: Vec = records + .iter() + .map(|record| masternode_entry_ffi(record, network)) + .collect(); + let count = entries.len(); + if count == 0 { + *out_entries = std::ptr::null(); + *out_count = 0; + } else { + *out_entries = Box::into_raw(entries.into_boxed_slice()) as *const _; + *out_count = count; + } +} + +/// Track the masternode `pro_tx_hash` (32 wire bytes) independently of any +/// wallet. `label` is optional (null / blank = none). Seeds the record from +/// the current masternode list when available — local, no network; call +/// [`platform_wallet_manager_refresh_tracked_masternode`] afterwards for the +/// Platform / registration details. Returns the new record as a one-entry +/// array (free with `platform_wallet_manager_free_masternodes`). +/// +/// Whether the row survives a restart depends on the configured persister — +/// see `PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES`. +/// +/// Errors: `ErrorInvalidParameter` when already tracked. +/// +/// # Safety +/// `pro_tx_hash` must point at 32 readable bytes; `label` may be null; +/// `out_entry` / `out_count` must be writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_track_masternode( + manager_handle: Handle, + pro_tx_hash: *const u8, + label: *const c_char, + out_entry: *mut *const MasternodeEntryFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(pro_tx_hash); + check_ptr!(out_entry); + check_ptr!(out_count); + *out_entry = std::ptr::null(); + *out_count = 0; + + let target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); + let label = match optional_string(label) { + Ok(label) => label, + Err(e) => return e, + }; + + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { + (manager.tracked_masternodes_service(), manager.sdk().network) + }); + let Some((service, network)) = option else { + return invalid_handle(); + }; + let record = unwrap_result_or_return!(service.track_blocking(target, label)); + write_records(vec![record], network, out_entry, out_count); + PlatformWalletFFIResult::ok() +} + +/// Stop tracking `pro_tx_hash`. `out_removed` reports whether a row +/// existed. The host owns any keys it stored for this node (secure +/// storage) and deletes them itself. +/// +/// # Safety +/// `pro_tx_hash` must point at 32 readable bytes; `out_removed` must be +/// writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_untrack_masternode( + manager_handle: Handle, + pro_tx_hash: *const u8, + out_removed: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(pro_tx_hash); + check_ptr!(out_removed); + *out_removed = false; + let target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { + manager.tracked_masternodes_service() + }); + let Some(service) = option else { + return invalid_handle(); + }; + *out_removed = unwrap_result_or_return!(service.untrack_blocking(&target)); + PlatformWalletFFIResult::ok() +} + +/// Rename a tracked masternode (`label` null / blank clears it). +/// +/// # Safety +/// `pro_tx_hash` must point at 32 readable bytes; `label` may be null. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_set_tracked_masternode_label( + manager_handle: Handle, + pro_tx_hash: *const u8, + label: *const c_char, +) -> PlatformWalletFFIResult { + check_ptr!(pro_tx_hash); + let target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); + let label = match optional_string(label) { + Ok(label) => label, + Err(e) => return e, + }; + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { + manager.tracked_masternodes_service() + }); + let Some(service) = option else { + return invalid_handle(); + }; + unwrap_result_or_return!(service.set_label_blocking(&target, label)); + PlatformWalletFFIResult::ok() +} + +/// Every tracked masternode as a [`MasternodeEntryFFI`] (`source == 1`, +/// `label` set when named), with its status resolved against the CURRENT +/// masternode list (Active / Inactive / Retired, `Unknown` while the list +/// is unavailable). Sorted by when they were tracked. Free with +/// [`crate::wallet::platform_wallet_manager_free_masternodes`]. +/// +/// # Safety +/// `out_entries` / `out_count` must be writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_list_tracked_masternodes( + manager_handle: Handle, + out_entries: *mut *const MasternodeEntryFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(out_entries); + check_ptr!(out_count); + *out_entries = std::ptr::null(); + *out_count = 0; + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { + (manager.tracked_masternodes_service(), manager.sdk().network) + }); + let Some((service, network)) = option else { + return invalid_handle(); + }; + write_records(service.list_blocking(), network, out_entries, out_count); + PlatformWalletFFIResult::ok() +} + +/// Refresh everything the wallet layer can learn about a tracked +/// masternode: its list entry (local), its Platform owner / operator +/// identities (owner + payout key hashes, claimable balance), and — once — +/// its ProRegTx via DAPI Core (registration height, collateral, original +/// keys). Blocks on the network round-trips. Partial results are kept and +/// persisted even when a step fails (the error is still returned). On +/// success returns the refreshed record as a one-entry array (free with +/// `platform_wallet_manager_free_masternodes`). +/// +/// # Safety +/// `pro_tx_hash` must point at 32 readable bytes; `out_entry` / `out_count` +/// must be writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_refresh_tracked_masternode( + manager_handle: Handle, + pro_tx_hash: *const u8, + out_entry: *mut *const MasternodeEntryFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(pro_tx_hash); + check_ptr!(out_entry); + check_ptr!(out_count); + *out_entry = std::ptr::null(); + *out_count = 0; + let target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); + + // The refresh awaits Platform / DAPI, so snapshot the service handle + // under the guard and run the future on a worker without holding it. + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { + (manager.tracked_masternodes_service(), manager.sdk().network) + }); + let Some((service, network)) = option else { + return invalid_handle(); + }; + let record = + unwrap_result_or_return!(block_on_worker( + async move { service.refresh(&target).await } + )); + write_records(vec![record], network, out_entry, out_count); + PlatformWalletFFIResult::ok() +} + +/// What a host can do with a masternode given the key roles it holds for +/// it, as a mask over `MasternodeKeyRole` (bit = role discriminant). +/// `out_capabilities` bits: 0 withdraw, 1 vote, 2 update service, +/// 3 identifies the platform node. Pure policy — shared with Android so +/// action gating never diverges. +/// +/// # Safety +/// `out_capabilities` must be writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_masternode_capabilities( + roles_mask: u8, + out_capabilities: *mut u8, +) -> PlatformWalletFFIResult { + check_ptr!(out_capabilities); + let roles = MasternodeKeyRole::ALL + .into_iter() + .filter(|role| roles_mask & (1 << role.as_u8()) != 0); + let caps = capabilities_for_roles(roles); + let mut bits = 0u8; + if caps.can_withdraw { + bits |= 1; + } + if caps.can_vote { + bits |= 1 << 1; + } + if caps.can_update_service { + bits |= 1 << 2; + } + if caps.identifies_platform_node { + bits |= 1 << 3; + } + *out_capabilities = bits; + PlatformWalletFFIResult::ok() +} + +/// Withdraw from a TRACKED masternode's owner identity with a +/// host-supplied key. `role` is 0 (owner key; pays the registered payout +/// address, `destination` must be null) or 4 (payout-address key; +/// `destination` optional, defaults to the payout address itself). +/// `key_text` is the private key as the user holds it — WIF +/// (network-checked) or 64-char hex. The key is used for this call only. +/// Returns the identity's new balance in credits. +/// +/// Ambiguous outcomes surface as `ErrorMasternodeWithdrawalUnconfirmed` +/// with the same do-not-retry contract as the wallet-scoped withdraw. +/// +/// # Safety +/// `pro_tx_hash` must point at 32 readable bytes; `key_text` must be a +/// valid NUL-terminated UTF-8 string; `destination` may be null; +/// `out_new_balance` must be writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_withdraw( + manager_handle: Handle, + pro_tx_hash: *const u8, + amount_credits: u64, + role: u8, + key_text: *const c_char, + destination: *const c_char, + out_new_balance: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(pro_tx_hash); + check_ptr!(key_text); + check_ptr!(out_new_balance); + *out_new_balance = 0; + + let target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); + let Some(role) = MasternodeKeyRole::from_u8(role) else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("unknown masternode key role {role}"), + ); + }; + let key_text = unwrap_result_or_return!(CStr::from_ptr(key_text).to_str()).to_string(); + let destination = match optional_string(destination) { + Ok(destination) => destination, + Err(e) => return e, + }; + + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { + (manager.tracked_masternodes_service(), manager.sdk().network) + }); + let Some((service, network)) = option else { + return invalid_handle(); + }; + + // Decode the key host-side of the await so parse errors return typed + // messages without touching the network. + let secret = match parse_secret_for_role(&key_text, role, network) { + Ok(LocatorSecret::Ecdsa { secret, .. }) => secret, + Ok(_) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "a withdrawal key is a secp256k1 key (WIF or 64-char hex)", + ) + } + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e.to_string(), + ) + } + }; + + let new_balance = unwrap_result_or_return!(block_on_worker(async move { + service + .withdraw(&target, amount_credits, role, &secret, destination) + .await + })); + *out_new_balance = new_balance; + PlatformWalletFFIResult::ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_handles_are_invalid_handles() { + let hash = [0u8; 32]; + let mut entries: *const MasternodeEntryFFI = std::ptr::null(); + let mut count = 5usize; + let mut r = unsafe { + platform_wallet_manager_track_masternode( + 0, + hash.as_ptr(), + std::ptr::null(), + &mut entries, + &mut count, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert!(entries.is_null()); + assert_eq!(count, 0); + unsafe { platform_wallet_ffi_result_free(&mut r) }; + + let mut removed = true; + let mut r = + unsafe { platform_wallet_manager_untrack_masternode(0, hash.as_ptr(), &mut removed) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert!(!removed); + unsafe { platform_wallet_ffi_result_free(&mut r) }; + + let mut r = unsafe { + platform_wallet_manager_list_tracked_masternodes(0, &mut entries, &mut count) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + unsafe { platform_wallet_ffi_result_free(&mut r) }; + } + + #[test] + fn capabilities_mask_round_trips() { + let mut out = 0xFFu8; + let mut r = unsafe { platform_wallet_masternode_capabilities(0, &mut out) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out, 0, "no roles ⇒ no capabilities"); + unsafe { platform_wallet_ffi_result_free(&mut r) }; + + // Owner (bit 0) + voting (bit 1) ⇒ withdraw + vote. + let mut r = unsafe { platform_wallet_masternode_capabilities(0b11, &mut out) }; + assert_eq!(out, 0b11); + unsafe { platform_wallet_ffi_result_free(&mut r) }; + + // Owner payout (bit 4) alone still withdraws. + let mut r = unsafe { platform_wallet_masternode_capabilities(1 << 4, &mut out) }; + assert_eq!(out, 1); + unsafe { platform_wallet_ffi_result_free(&mut r) }; + + // Operator (bit 2) + platform node (bit 3). + let mut r = unsafe { platform_wallet_masternode_capabilities(0b1100, &mut out) }; + assert_eq!(out, 0b1100); + unsafe { platform_wallet_ffi_result_free(&mut r) }; + } + + #[test] + fn withdraw_rejects_bad_roles_and_keys_before_handles() { + let hash = [0u8; 32]; + let key = std::ffi::CString::new("xyz").unwrap(); + let mut balance = 7u64; + // Unknown role. + let mut r = unsafe { + platform_wallet_manager_tracked_masternode_withdraw( + 0, + hash.as_ptr(), + 1, + 42, + key.as_ptr(), + std::ptr::null(), + &mut balance, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + assert_eq!(balance, 0); + unsafe { platform_wallet_ffi_result_free(&mut r) }; + } +} diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 7bc83a750f7..49639627fe2 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -205,8 +205,10 @@ pub unsafe extern "C" fn platform_wallet_manager_free_account_balances( /// /// 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. +/// `PlatformWalletManager::wallet_masternodes_blocking`, which also resolves +/// status and operator / platform key ownership — this function only +/// marshals. `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. @@ -229,43 +231,16 @@ pub unsafe extern "C" fn platform_wallet_manager_list_masternodes( 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) + manager.wallet_masternodes_blocking(&wid) }); // Outer Option: handle resolved. Inner Option: wallet found. let inner = unwrap_option_or_return!(option); - 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). - 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, p, tx)| (*h, *p, tx)), - membership, - ); + let masternodes = unwrap_option_or_return!(inner); - let entries: Vec = aggregates + let entries: Vec = masternodes + .records .iter() - .enumerate() - .map(|(idx, mn)| { - crate::core_wallet_types::masternode_entry_ffi( - mn, - idx as u32, - network, - &operator_index, - &platform_index, - ) - }) + .map(|mn| crate::core_wallet_types::masternode_entry_ffi(mn, masternodes.network)) .collect(); let count = entries.len(); @@ -300,6 +275,7 @@ pub unsafe extern "C" fn platform_wallet_manager_free_masternodes( entry.payout_address, entry.operator_pseudo_address, entry.platform_node_address, + entry.label, ] { if !ptr.is_null() { let _ = std::ffi::CString::from_raw(ptr); diff --git a/packages/rs-platform-wallet-storage/migrations/V006__tracked_masternodes.rs b/packages/rs-platform-wallet-storage/migrations/V006__tracked_masternodes.rs new file mode 100644 index 00000000000..7dc644492f8 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V006__tracked_masternodes.rs @@ -0,0 +1,23 @@ +//! Add the `tracked_masternodes` table (wallet-independent masternodes the +//! user follows). +//! +//! One row per (network, proTxHash). NOT wallet-scoped on purpose: a +//! tracked masternode belongs to no wallet, survives deleting any single +//! wallet, and is keyed by the network it lives on. `snapshot_json` is the +//! versioned cache of what the wallet layer has learned about the node +//! (its DML entry, Platform identity key hashes, registration details) — +//! PUBLIC material only, re-fetchable, decoded by +//! `platform_wallet::masternode::snapshot_from_json`. Keys a user attaches +//! to a tracked node live in the host's secure storage, never here. + +pub fn migration() -> String { + "CREATE TABLE tracked_masternodes ( + network TEXT NOT NULL CHECK (network IN ('mainnet', 'testnet', 'devnet', 'regtest')), + pro_tx_hash BLOB NOT NULL CHECK (length(pro_tx_hash) = 32), + label TEXT, + added_at INTEGER NOT NULL, + snapshot_json TEXT NOT NULL, + PRIMARY KEY (network, pro_tx_hash) + );" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index d331530e9b2..0a5906ae24f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -836,6 +836,30 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::PENDING_CONTACT_CRYPTO) .union(PersistenceCapabilities::DPNS_NAME_STATES) .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) + .union(PersistenceCapabilities::TRACKED_MASTERNODES) + } + + fn persist_tracked_masternodes( + &self, + network: dashcore::Network, + records: &[platform_wallet::masternode::TrackedMasternode], + ) -> Result<(), PersistenceError> { + let mut conn = self.conn().map_err(PersistenceError::from)?; + let tx = conn + .transaction() + .map_err(|e| PersistenceError::from(WalletStorageError::from(e)))?; + schema::tracked_masternodes::replace_all(&tx, network, records) + .map_err(PersistenceError::from)?; + tx.commit() + .map_err(|e| PersistenceError::from(WalletStorageError::from(e))) + } + + fn load_tracked_masternodes( + &self, + network: dashcore::Network, + ) -> Result, PersistenceError> { + let conn = self.conn().map_err(PersistenceError::from)?; + schema::tracked_masternodes::load_all(&conn, network).map_err(PersistenceError::from) } /// Merge `changeset` into the per-wallet buffer. diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index 5335bde9943..b545be7a9af 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -26,6 +26,7 @@ pub mod invitations; pub mod pending_contact_crypto; pub mod platform_addrs; pub mod token_balances; +pub mod tracked_masternodes; pub mod wallet_meta; /// Defensive check that every `identity_id` in `touched` exists in diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/tracked_masternodes.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/tracked_masternodes.rs new file mode 100644 index 00000000000..edd4c9a4888 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/tracked_masternodes.rs @@ -0,0 +1,84 @@ +//! `tracked_masternodes` table writer + reader (wallet-independent +//! masternodes the user follows). +//! +//! Whole-set semantics per network: `replace_all` deletes the network's +//! rows and re-inserts the supplied set inside the caller's transaction — +//! the set is user-curated and small, and the trait contract +//! (`PlatformWalletPersistence::persist_tracked_masternodes`) is a +//! whole-set write. `snapshot_json` is an opaque versioned document of +//! PUBLIC material only (see the V006 migration doc); this module never +//! interprets it beyond storing and returning it. + +use rusqlite::{params, Connection, Transaction}; + +use platform_wallet::masternode::{snapshot_from_json, snapshot_to_json, TrackedMasternode}; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::util::safe_cast; + +/// Replace every row for `network` with `records`. +pub fn replace_all( + tx: &Transaction<'_>, + network: dashcore::Network, + records: &[TrackedMasternode], +) -> Result<(), WalletStorageError> { + let network = network.to_string(); + tx.execute( + "DELETE FROM tracked_masternodes WHERE network = ?1", + params![network], + )?; + if records.is_empty() { + return Ok(()); + } + let mut stmt = tx.prepare_cached( + "INSERT INTO tracked_masternodes \ + (network, pro_tx_hash, label, added_at, snapshot_json) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + )?; + for record in records { + let added_at = safe_cast::u64_to_i64("tracked_masternodes.added_at", record.added_at)?; + stmt.execute(params![ + network, + record.pro_tx_hash.as_slice(), + record.label, + added_at, + snapshot_to_json(&record.snapshot), + ])?; + } + Ok(()) +} + +/// Every row for `network`, oldest-tracked first. +pub fn load_all( + conn: &Connection, + network: dashcore::Network, +) -> Result, WalletStorageError> { + let mut stmt = conn.prepare_cached( + "SELECT pro_tx_hash, label, added_at, snapshot_json \ + FROM tracked_masternodes WHERE network = ?1 \ + ORDER BY added_at, pro_tx_hash", + )?; + let rows = stmt.query_map(params![network.to_string()], |row| { + let hash: Vec = row.get(0)?; + let label: Option = row.get(1)?; + let added_at: i64 = row.get(2)?; + let snapshot: String = row.get(3)?; + Ok((hash, label, added_at, snapshot)) + })?; + let mut out = Vec::new(); + for row in rows { + let (hash, label, added_at, snapshot) = row?; + let Ok(pro_tx_hash) = <[u8; 32]>::try_from(hash.as_slice()) else { + // Length is CHECK-constrained; a mismatch means external + // tampering — skip rather than fail the whole load. + continue; + }; + out.push(TrackedMasternode { + pro_tx_hash, + label, + added_at: added_at.max(0) as u64, + snapshot: snapshot_from_json(&snapshot), + }); + } + Ok(out) +} diff --git a/packages/rs-platform-wallet-storage/tests/tracked_masternodes_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/tracked_masternodes_roundtrip.rs new file mode 100644 index 00000000000..fd4706a98b1 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/tracked_masternodes_roundtrip.rs @@ -0,0 +1,96 @@ +//! Tracked-masternode rows: whole-set replace + per-network scoping + +//! restart survival through the `PlatformWalletPersistence` trait methods. + +mod common; + +use common::fresh_persister; +use platform_wallet::changeset::{PersistenceCapabilities, PlatformWalletPersistence}; +use platform_wallet::masternode::{ + PlatformKeySnapshot, TrackedMasternode, TrackedMasternodeSnapshot, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +fn tracked(byte: u8, label: Option<&str>) -> TrackedMasternode { + TrackedMasternode { + pro_tx_hash: [byte; 32], + label: label.map(str::to_string), + added_at: byte as u64, + snapshot: TrackedMasternodeSnapshot { + platform: Some(PlatformKeySnapshot { + owner_key_hash: Some([byte; 20]), + payout_key_hash: Some([byte ^ 0xFF; 20]), + operator_payout_key_hash: None, + owner_identity_balance: Some(1_000 + byte as u64), + }), + ever_listed: true, + ..Default::default() + }, + } +} + +#[test] +fn capability_is_attested() { + let (p, _tmp, _path) = fresh_persister(); + assert!(p + .persistence_capabilities() + .contains(PersistenceCapabilities::TRACKED_MASTERNODES)); +} + +#[test] +fn whole_set_replace_and_network_scoping() { + let (p, _tmp, path) = fresh_persister(); + let mainnet = dashcore::Network::Mainnet; + let testnet = dashcore::Network::Testnet; + + p.persist_tracked_masternodes(mainnet, &[tracked(1, Some("alpha")), tracked(2, None)]) + .expect("persist mainnet"); + p.persist_tracked_masternodes(testnet, &[tracked(9, Some("testnode"))]) + .expect("persist testnet"); + + // Whole-set replace: dropping node 2 and renaming node 1 must not + // resurrect anything. + p.persist_tracked_masternodes(mainnet, &[tracked(1, Some("renamed"))]) + .expect("replace mainnet"); + + let mainnet_rows = p.load_tracked_masternodes(mainnet).expect("load mainnet"); + assert_eq!(mainnet_rows.len(), 1); + assert_eq!(mainnet_rows[0].pro_tx_hash, [1u8; 32]); + assert_eq!(mainnet_rows[0].label.as_deref(), Some("renamed")); + assert_eq!( + mainnet_rows[0] + .snapshot + .platform + .as_ref() + .and_then(|pl| pl.owner_identity_balance), + Some(1_001), + "snapshot JSON round-trips through the row" + ); + + // The other network's rows are untouched. + let testnet_rows = p.load_tracked_masternodes(testnet).expect("load testnet"); + assert_eq!(testnet_rows.len(), 1); + assert_eq!(testnet_rows[0].pro_tx_hash, [9u8; 32]); + + // Restart: a fresh persister over the same file sees the same rows. + drop(p); + let reopened = + SqlitePersister::open(SqlitePersisterConfig::new(&path)).expect("reopen persister"); + let rows = reopened + .load_tracked_masternodes(mainnet) + .expect("load after reopen"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].label.as_deref(), Some("renamed")); +} + +#[test] +fn empty_set_clears_the_network() { + let (p, _tmp, _path) = fresh_persister(); + let network = dashcore::Network::Mainnet; + p.persist_tracked_masternodes(network, &[tracked(3, None)]) + .expect("persist"); + p.persist_tracked_masternodes(network, &[]).expect("clear"); + assert!(p + .load_tracked_masternodes(network) + .expect("load") + .is_empty()); +} diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index 0364200492f..cd260cc5fae 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -52,6 +52,13 @@ impl PersistenceCapabilities { /// persisted. Restart hydration is the separate `WALLET_RESTORE` contract. pub const TRACKED_ASSET_LOCKS: Self = Self(1 << 9); + /// Tracked (wallet-independent) masternodes are persisted AND restored + /// across restarts + /// ([`persist_tracked_masternodes`](super::PlatformWalletPersistence::persist_tracked_masternodes) + /// / [`load_tracked_masternodes`](super::PlatformWalletPersistence::load_tracked_masternodes)). + /// Without this bit, tracking is session-scoped. + pub const TRACKED_MASTERNODES: Self = Self(1 << 10); + /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( Self::ATOMIC_CHANGESETS.0 @@ -131,6 +138,10 @@ impl PersistenceCapabilities { PersistenceCapabilities::TRACKED_ASSET_LOCKS, "tracked_asset_locks", ), + ( + PersistenceCapabilities::TRACKED_MASTERNODES, + "tracked_masternodes", + ), ]; KNOWN @@ -160,6 +171,7 @@ mod tests { assert_eq!(PersistenceCapabilities::WALLET_RESTORE.bits(), 0x80); assert_eq!(PersistenceCapabilities::DPNS_NAME_STATES.bits(), 0x100); assert_eq!(PersistenceCapabilities::TRACKED_ASSET_LOCKS.bits(), 0x200); + assert_eq!(PersistenceCapabilities::TRACKED_MASTERNODES.bits(), 0x400); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), 0x281 diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 7dcd3bee816..660774a51ca 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -309,6 +309,34 @@ pub trait PlatformWalletPersistence: Send + Sync { // instead; a sentinel-scope flush path is still to be designed. fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError>; + /// Replace the persisted tracked-masternode set for `network` with + /// `records` (whole-set write; the set is user-curated and small). + /// + /// Default: a successful no-op — tracking then works but is + /// session-scoped. Backends that persist AND restore the rows attest + /// [`PersistenceCapabilities::TRACKED_MASTERNODES`] so hosts can tell + /// the difference; the default deliberately does not. + /// + /// The same reentrancy contract as [`Self::store`] applies. + fn persist_tracked_masternodes( + &self, + network: dashcore::Network, + records: &[crate::masternode::TrackedMasternode], + ) -> Result<(), PersistenceError> { + let _ = (network, records); + Ok(()) + } + + /// Load the persisted tracked-masternode set for `network`. Default: + /// empty (see [`Self::persist_tracked_masternodes`]). + fn load_tracked_masternodes( + &self, + network: dashcore::Network, + ) -> Result, PersistenceError> { + let _ = network; + Ok(Vec::new()) + } + /// Load the full client state from storage. /// /// Returns a [`ClientStartState`] — a ready-to-boot snapshot covering diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index b78ec9838a9..2fedc74a1a8 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -19,6 +19,7 @@ pub mod changeset; pub mod error; pub mod events; pub mod manager; +pub mod masternode; pub mod spv; #[cfg(any(test, feature = "test-utils"))] pub mod test_support; @@ -67,6 +68,10 @@ pub use wallet::signed_payment_registry::{ // DashPay types + crypto helpers re-exported through the identity // domain (they live under `identity::types::dashpay::*` and // `identity::crypto::*` internally). +pub use masternode::{ + aggregate_masternodes, ListMembership, MasternodeKeyRole, MasternodeRecord, MasternodeSource, + MasternodeStatus, TrackedMasternode, TrackedMasternodeSnapshot, WalletMasternodes, +}; pub use wallet::core_address_key::CoreAddressPrivateKey; pub use wallet::identity::network::{ derive_identity_auth_keypair, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index b7f71284e9b..af4c76dfc5f 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -301,6 +301,12 @@ impl PlatformWalletManager

{ /// Clone the `Arc` so callers (e.g. FFI) can invoke /// [`SpvRuntime::spawn_run_loop`] which takes `&Arc`. + /// Shared handle to the Platform SDK, for work that outlives a borrow + /// of the manager (e.g. a locate run on a worker thread). + pub fn sdk_arc(&self) -> Arc { + Arc::clone(&self.sdk) + } + pub fn spv_arc(&self) -> Arc { Arc::clone(&self.spv_manager) } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 4a4d8a9d9ce..ce44a55d0e7 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -44,6 +44,11 @@ impl PlatformWalletManager

{ )) })?; + // Tracked (wallet-independent) masternodes ride the same startup + // hydration; a failure logs and starts empty rather than failing + // wallet restore. + self.load_tracked_masternodes_from_persistence(); + let persister_dyn: Arc = Arc::clone(&self.persister) as _; // Track every wallet successfully inserted into diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 1e64401db2b..e6f946f136a 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -391,6 +391,12 @@ pub struct PlatformWalletManager { #[cfg(feature = "shielded")] pub(super) event_manager: Arc, pub(super) persister: Arc

, + /// Tracked (wallet-independent) masternodes for this manager's + /// network, keyed by wire proTxHash. Hydrated from the persister at + /// `load_from_persistor`; every mutation writes the whole set back + /// (see `masternode::tracked`). + pub(crate) tracked_masternodes: + std::sync::Arc>, /// Cancellation token + join handle for the wallet-event adapter /// task. Held so [`shutdown`] can stop it cleanly when the manager /// is torn down. @@ -538,6 +544,7 @@ impl PlatformWalletManager

{ #[cfg(feature = "shielded")] event_manager, persister, + tracked_masternodes: std::sync::Arc::new(std::sync::RwLock::new(Default::default())), event_adapter_cancel, event_adapter_join: tokio::sync::Mutex::new(Some(event_adapter_join)), registry, diff --git a/packages/rs-platform-wallet/src/masternode/list.rs b/packages/rs-platform-wallet/src/masternode/list.rs new file mode 100644 index 00000000000..d49590c8bb0 --- /dev/null +++ b/packages/rs-platform-wallet/src/masternode/list.rs @@ -0,0 +1,328 @@ +//! Typed access to the deterministic masternode list (DML) for lookups. +//! +//! The SML entry dash-spv holds for each masternode carries its proTxHash, +//! service address, operator BLS key, voting key id, validity and (for +//! evonodes) the platform node id and HTTP port — but **not** the owner key +//! hash, payout script, collateral or registration height; those live only +//! in the provider transactions and on Platform's masternode identities. +//! [`MasternodeListSummary`] is exactly what the list knows, typed +//! (`SocketAddr`, not `"ip:port"`), and [`MasternodeListQuery`] is every way +//! a host can ask the list for a masternode from a user-supplied locator. +//! +//! Lookups are pure over a snapshot (`Vec`, ~4 k +//! entries on mainnet) so they are unit-testable without a live engine and +//! never hold the engine lock while a host iterates results. + +use std::net::{IpAddr, SocketAddr}; + +use dashcore::sml::masternode_list::MasternodeList; +use dashcore::sml::masternode_list_entry::{EntryMasternodeType, MasternodeListEntry}; + +/// What the deterministic masternode list knows about one masternode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MasternodeListSummary { + /// proTxHash in **wire order** — the same orientation as + /// `MasternodeRecord::pro_tx_hash` and `MasternodeEntryFFI.pro_tx_hash` + /// (a `Txid`'s bytes; explorers, Tenderdash and Platform identity ids + /// show the reversal). + pub pro_tx_hash: [u8; 32], + /// Primary routable Core P2P endpoint. `None` for Tor / I2P / CJDNS / + /// domain-only entries, which have no `SocketAddr` form. + pub service_address: Option, + /// Platform HTTP (DAPI gRPC) port — evonodes only. + pub platform_http_port: Option, + /// Operator BLS public key (48 bytes, as serialized in the list — the + /// basic scheme for v2+ entries, legacy for v1). + pub operator_public_key: [u8; 48], + /// Voting key id (hash160 of the voting public key). + pub voting_key_id: [u8; 20], + /// Tenderdash node id (`SHA256(ed25519 pk)[..20]`, canonical order) — + /// evonodes only. + pub platform_node_id: Option<[u8; 20]>, + /// `false` when the entry is PoSe-banned. + pub is_valid: bool, + /// High-performance (evonode) entry. + pub is_evonode: bool, +} + +impl MasternodeListSummary { + /// Lift the list entry into the typed summary. + pub fn from_entry(entry: &MasternodeListEntry) -> Self { + let mut pro_tx_hash = [0u8; 32]; + // `pro_reg_tx_hash` on a consensus-decoded entry is the wire + // orientation (the DML map keys by the reversed/display form, so + // read it off the entry, never the map key). + pro_tx_hash.copy_from_slice(entry.pro_reg_tx_hash.as_ref()); + let mut operator_public_key = [0u8; 48]; + operator_public_key.copy_from_slice(entry.operator_public_key.as_ref()); + let mut voting_key_id = [0u8; 20]; + voting_key_id.copy_from_slice(entry.key_id_voting.as_ref()); + let (platform_http_port, platform_node_id, is_evonode) = match &entry.mn_type { + EntryMasternodeType::Regular => (None, None, false), + EntryMasternodeType::HighPerformance { + platform_http_port, + platform_node_id, + } => ( + Some(*platform_http_port), + Some(platform_node_id.to_byte_array()), + true, + ), + }; + Self { + pro_tx_hash, + service_address: entry.service_address.primary_service_address(), + platform_http_port, + operator_public_key, + voting_key_id, + platform_node_id, + is_valid: entry.is_valid, + is_evonode, + } + } + + /// Every entry of `list` as a summary, in the list's (proTxHash map) + /// order. + pub fn all_from_list(list: &MasternodeList) -> Vec { + list.masternodes + .values() + .map(|qualified| Self::from_entry(&qualified.masternode_list_entry)) + .collect() + } + + /// proTxHash in display (explorer / Tenderdash / Platform identity id) + /// orientation. + pub fn pro_tx_hash_display(&self) -> [u8; 32] { + let mut out = self.pro_tx_hash; + out.reverse(); + out + } +} + +/// One way of asking the list for a masternode. Every variant is matched +/// against the list's own fields — nothing here needs the wallet or the +/// network. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MasternodeListQuery { + /// proTxHash in wire order. + ProTxHash([u8; 32]), + /// Service IP, optionally pinned to a port. Without a port any port + /// matches (a host that pastes a bare IP rarely knows the P2P port). + ServiceAddress { ip: IpAddr, port: Option }, + /// hash160 of a voting public key. + VotingKeyId([u8; 20]), + /// Operator BLS public key, in whichever serialization the caller + /// has — callers that derive from a secret should query both the basic + /// and the legacy form. + OperatorPublicKey([u8; 48]), + /// Tenderdash node id (`SHA256(ed25519 pk)[..20]`). + PlatformNodeId([u8; 20]), +} + +impl MasternodeListQuery { + /// Whether `summary` satisfies this query. + pub fn matches(&self, summary: &MasternodeListSummary) -> bool { + match self { + Self::ProTxHash(hash) => &summary.pro_tx_hash == hash, + Self::ServiceAddress { ip, port } => match summary.service_address { + Some(addr) => addr.ip() == *ip && port.map(|p| p == addr.port()).unwrap_or(true), + None => false, + }, + Self::VotingKeyId(id) => &summary.voting_key_id == id, + Self::OperatorPublicKey(key) => &summary.operator_public_key == key, + Self::PlatformNodeId(id) => summary.platform_node_id.as_ref() == Some(id), + } + } +} + +/// Every summary in `summaries` matching `query`, in input order. +pub fn find_in_summaries<'a>( + summaries: &'a [MasternodeListSummary], + query: &MasternodeListQuery, +) -> Vec<&'a MasternodeListSummary> { + summaries.iter().filter(|s| query.matches(s)).collect() +} + +#[cfg(test)] +pub(crate) mod test_support { + //! Synthetic list summaries for locator tests. Kept `pub(crate)` so the + //! locator tests build lists the same way. + use super::MasternodeListSummary; + use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; + + /// A regular masternode at `10.0.0.:9999` whose proTxHash, + /// operator key and voting key id are all derived from `seed` so every + /// entry is distinct and recognizable. + pub(crate) fn masternode(seed: u8) -> MasternodeListSummary { + MasternodeListSummary { + pro_tx_hash: [seed; 32], + service_address: Some(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::new(10, 0, 0, seed), + 9999, + ))), + platform_http_port: None, + operator_public_key: [seed; 48], + voting_key_id: [seed; 20], + platform_node_id: None, + is_valid: true, + is_evonode: false, + } + } + + /// An evonode variant of [`masternode`] with a platform node id and + /// HTTP port. + pub(crate) fn evonode(seed: u8) -> MasternodeListSummary { + MasternodeListSummary { + platform_http_port: Some(443), + platform_node_id: Some([seed ^ 0xFF; 20]), + is_evonode: true, + ..masternode(seed) + } + } + + pub(crate) fn ip(seed: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(10, 0, 0, seed)) + } +} + +#[cfg(test)] +mod tests { + use super::test_support::{evonode, ip, masternode}; + use super::*; + use dashcore::bls_sig_utils::BLSPublicKey; + use dashcore::hashes::Hash; + use dashcore::sml::masternode_list_entry::MasternodeNetInfo; + use dashcore::{BlockHash, PlatformNodeId, ProTxHash, PubkeyHash}; + use std::net::{Ipv4Addr, SocketAddrV4}; + + #[test] + fn summary_lifts_every_field_from_a_list_entry() { + let pro_tx = ProTxHash::from_byte_array([7u8; 32]); + let entry = MasternodeListEntry { + version: 2, + pro_reg_tx_hash: pro_tx, + confirmed_hash: None, + service_address: MasternodeNetInfo::Legacy(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::new(1, 2, 3, 4), + 19999, + ))), + operator_public_key: BLSPublicKey::from([9u8; 48]), + key_id_voting: PubkeyHash::from_byte_array([5u8; 20]), + is_valid: false, + mn_type: EntryMasternodeType::HighPerformance { + platform_http_port: 1443, + platform_node_id: PlatformNodeId::from_byte_array([3u8; 20]), + }, + }; + let list = MasternodeList::build( + [(pro_tx, entry.into())].into_iter().collect(), + Default::default(), + BlockHash::from_byte_array([0u8; 32]), + 0, + ) + .build(); + + let summaries = MasternodeListSummary::all_from_list(&list); + assert_eq!(summaries.len(), 1); + let s = &summaries[0]; + assert_eq!(s.pro_tx_hash, [7u8; 32]); + assert_eq!( + s.service_address, + Some("1.2.3.4:19999".parse::().unwrap()) + ); + assert_eq!(s.platform_http_port, Some(1443)); + assert_eq!(s.operator_public_key, [9u8; 48]); + assert_eq!(s.voting_key_id, [5u8; 20]); + assert_eq!(s.platform_node_id, Some([3u8; 20])); + assert!(!s.is_valid, "PoSe-banned entry stays invalid"); + assert!(s.is_evonode); + let mut display = [7u8; 32]; + display.reverse(); + assert_eq!(s.pro_tx_hash_display(), display); + } + + #[test] + fn regular_entry_has_no_platform_fields() { + let s = masternode(1); + assert!(!s.is_evonode); + assert_eq!(s.platform_node_id, None); + assert_eq!(s.platform_http_port, None); + } + + #[test] + fn finds_by_pro_tx_hash() { + let list = vec![masternode(1), masternode(2), evonode(3)]; + let hits = find_in_summaries(&list, &MasternodeListQuery::ProTxHash([2u8; 32])); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].pro_tx_hash, [2u8; 32]); + assert!(find_in_summaries(&list, &MasternodeListQuery::ProTxHash([9u8; 32])).is_empty()); + } + + #[test] + fn finds_by_ip_with_or_without_port() { + let list = vec![masternode(1), masternode(2)]; + let any_port = MasternodeListQuery::ServiceAddress { + ip: ip(2), + port: None, + }; + assert_eq!(find_in_summaries(&list, &any_port).len(), 1); + let right_port = MasternodeListQuery::ServiceAddress { + ip: ip(2), + port: Some(9999), + }; + assert_eq!(find_in_summaries(&list, &right_port).len(), 1); + let wrong_port = MasternodeListQuery::ServiceAddress { + ip: ip(2), + port: Some(19999), + }; + assert!( + find_in_summaries(&list, &wrong_port).is_empty(), + "a pinned port must match exactly" + ); + let unknown_ip = MasternodeListQuery::ServiceAddress { + ip: ip(200), + port: None, + }; + assert!(find_in_summaries(&list, &unknown_ip).is_empty()); + } + + #[test] + fn ip_query_skips_entries_without_a_socket_address() { + let mut tor_only = masternode(4); + tor_only.service_address = None; + let list = vec![tor_only]; + let q = MasternodeListQuery::ServiceAddress { + ip: ip(4), + port: None, + }; + assert!(find_in_summaries(&list, &q).is_empty()); + } + + #[test] + fn finds_every_masternode_sharing_a_voting_key() { + let mut shared_a = masternode(1); + shared_a.voting_key_id = [0xAA; 20]; + let mut shared_b = masternode(2); + shared_b.voting_key_id = [0xAA; 20]; + let list = vec![shared_a, shared_b, masternode(3)]; + let hits = find_in_summaries(&list, &MasternodeListQuery::VotingKeyId([0xAA; 20])); + assert_eq!(hits.len(), 2, "shared voting keys return every node"); + } + + #[test] + fn finds_by_operator_key_and_platform_node_id() { + let list = vec![masternode(1), evonode(2), evonode(3)]; + let by_op = find_in_summaries(&list, &MasternodeListQuery::OperatorPublicKey([2u8; 48])); + assert_eq!(by_op.len(), 1); + assert!(by_op[0].is_evonode); + let by_node = find_in_summaries( + &list, + &MasternodeListQuery::PlatformNodeId([3u8 ^ 0xFF; 20]), + ); + assert_eq!(by_node.len(), 1); + assert_eq!(by_node[0].pro_tx_hash, [3u8; 32]); + // A regular masternode never matches a node-id query. + assert!( + find_in_summaries(&list, &MasternodeListQuery::PlatformNodeId([1u8; 20])).is_empty() + ); + } +} diff --git a/packages/rs-platform-wallet/src/masternode/locator.rs b/packages/rs-platform-wallet/src/masternode/locator.rs new file mode 100644 index 00000000000..faf0513f8f8 --- /dev/null +++ b/packages/rs-platform-wallet/src/masternode/locator.rs @@ -0,0 +1,1458 @@ +//! Find a masternode from whatever the user has in hand. +//! +//! A host pastes one string — an IP (`1.2.3.4`, `1.2.3.4:9999`, a DAPI URL), +//! a proTxHash, or a private key (owner / voting WIF or hex, operator BLS hex, +//! Tenderdash node key in dashmate's base64 or hex) — and gets back the +//! masternode(s) it names, plus, for a key, the role that key fills on each. +//! +//! Three layers, each pure and testable on its own: +//! +//! 1. [`parse_locator_input`] turns the text into *candidates*. A 64-hex +//! string is ambiguous (proTxHash, secp256k1 secret, BLS secret, ed25519 +//! seed), so it yields every reading and the list decides. +//! 2. [`locate_in_summaries`] resolves candidates against a DML snapshot: +//! proTxHash / IP directly; secrets by deriving the public side and +//! matching the list's voting key id, operator key (basic **and** legacy +//! serialization) or platform node id. +//! 3. [`MasternodeLocator::locate`] adds the opt-in Platform step for secp256k1 +//! keys: owner and payout keys are not on the list, but Platform's +//! masternode identities carry them (owner identity = proTxHash: key 0 = +//! payout address TRANSFER, key 1 = owner OWNER; operator identity: +//! operator payout TRANSFER), all registered non-unique, so +//! `getIdentityByNonUniquePublicKeyHash` finds them. +//! +//! [`verify_masternode_key`] is the same derive-and-compare used when a host +//! attaches a key to a role: it answers `Matches` / `DoesNotMatch` or +//! `Unverifiable` when the reference (owner key hash, payout hash) isn't known +//! yet — never a false pass. + +use std::collections::{BTreeSet, HashMap}; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; + +use dash_sdk::platform::types::identity::NonUniquePublicKeyHashQuery; +use dash_sdk::platform::Fetch; +use dashcore::blsful::{ + Bls12381G2Impl, PublicKey as BlsPublicKey, SecretKey as BlsSecretKey, SerializationFormat, +}; +use dashcore::ed25519_dalek::SigningKey; +use dashcore::hashes::{hash160, Hash}; +use dashcore::secp256k1::{PublicKey as SecpPublicKey, Secp256k1, SecretKey as SecpSecretKey}; +use dashcore::{Network, PlatformNodeId, PrivateKey}; +use dpp::identifier::MasternodeIdentifiers; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{Identity, Purpose}; +use dpp::prelude::Identifier; +use zeroize::Zeroizing; + +use super::list::{find_in_summaries, MasternodeListQuery, MasternodeListSummary}; +use super::record::MasternodeRecord; +use crate::spv::SpvRuntime; +use crate::wallet::platform_wallet::WalletId; + +// --------------------------------------------------------------------------- +// Roles +// --------------------------------------------------------------------------- + +/// A private key's job on a masternode. The discriminants are the FFI wire +/// values and line up with the Android wallet's `MasternodeKeyType` for the +/// first four. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MasternodeKeyRole { + /// secp256k1; signs ProUpRegTx, and is the OWNER key of the Platform + /// owner identity (can sign withdrawals). + Owner = 0, + /// secp256k1; governance / contested-resource voting. + Voting = 1, + /// BLS12-381; signs ProUpServTx, SYSTEM key of the operator identity. + Operator = 2, + /// ed25519; the Tenderdash node key. Identifies an evonode, signs + /// nothing a wallet does. + PlatformNode = 3, + /// secp256k1 key of the owner payout address — the TRANSFER key of the + /// owner identity, i.e. what withdraws owner rewards. + OwnerPayout = 4, + /// secp256k1 key of the operator payout address — the TRANSFER key of + /// the operator identity. + OperatorPayout = 5, +} + +impl MasternodeKeyRole { + pub const ALL: [MasternodeKeyRole; 6] = [ + Self::Owner, + Self::Voting, + Self::Operator, + Self::PlatformNode, + Self::OwnerPayout, + Self::OperatorPayout, + ]; + + pub fn as_u8(self) -> u8 { + self as u8 + } + + pub fn from_u8(value: u8) -> Option { + Self::ALL.into_iter().find(|r| r.as_u8() == value) + } + + /// Whether the role's key is a secp256k1 key (WIF / hex input). + pub fn is_ecdsa(self) -> bool { + matches!( + self, + Self::Owner | Self::Voting | Self::OwnerPayout | Self::OperatorPayout + ) + } +} + +// --------------------------------------------------------------------------- +// Parsed input +// --------------------------------------------------------------------------- + +/// Decoded secret material from the locator text. Zeroized on drop. +#[derive(Clone)] +pub enum LocatorSecret { + /// secp256k1 secret; `compressed` follows the WIF flag (hex input is + /// taken as compressed, which is what every Dash tool emits). + Ecdsa { + secret: Zeroizing<[u8; 32]>, + compressed: bool, + }, + /// BLS12-381 secret scalar (32 bytes, big-endian). + Bls(Zeroizing<[u8; 32]>), + /// ed25519 seed (32 bytes). + Ed25519(Zeroizing<[u8; 32]>), +} + +impl std::fmt::Debug for LocatorSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ecdsa { compressed, .. } => f + .debug_struct("Ecdsa") + .field("compressed", compressed) + .finish_non_exhaustive(), + Self::Bls(_) => f.write_str("Bls(..)"), + Self::Ed25519(_) => f.write_str("Ed25519(..)"), + } + } +} + +/// One reading of the locator text. +#[derive(Debug, Clone)] +pub enum MasternodeLocatorInput { + /// proTxHash in wire order. + ProTxHash([u8; 32]), + /// Service IP, optionally with the Core P2P port. + ServiceAddress { ip: IpAddr, port: Option }, + /// A private key of some role. + Secret(LocatorSecret), +} + +/// Every plausible reading of the text. Ambiguous input (64 hex chars) +/// yields several candidates; the list disambiguates. +#[derive(Debug, Clone, Default)] +pub struct ParsedLocatorInput { + pub candidates: Vec, +} + +impl ParsedLocatorInput { + pub fn has_secret(&self) -> bool { + self.candidates + .iter() + .any(|c| matches!(c, MasternodeLocatorInput::Secret(_))) + } +} + +/// Why the locator text couldn't be read at all. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum LocatorParseError { + #[error("nothing to look up")] + Empty, + #[error("not an IP address, proTxHash or private key")] + Unrecognized, + /// A WIF key for another network (mainnet key on testnet or the reverse). + #[error("this key is for {key_network}, the wallet is on {expected}")] + WrongNetworkKey { + key_network: Network, + expected: Network, + }, + /// A 64-byte node key whose public half does not match its seed — the + /// pasted value is corrupt or not a Tenderdash node key. + #[error("the node key's public half does not match its seed")] + NodeKeyMismatch, + /// Hex / base64 that decodes to 32 bytes but is not a valid secret on + /// any of the three curves (and isn't a proTxHash either). + #[error("not a valid private key")] + InvalidSecret, +} + +fn strip_url(text: &str) -> &str { + let lower = text.to_ascii_lowercase(); + let rest = if let Some(stripped) = lower.strip_prefix("https://") { + &text[text.len() - stripped.len()..] + } else if let Some(stripped) = lower.strip_prefix("http://") { + &text[text.len() - stripped.len()..] + } else { + text + }; + // Cut at the first path / query separator, drop a trailing slash. + let end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + rest[..end].trim_end_matches('/') +} + +fn parse_service_address(text: &str) -> Option { + if let Ok(addr) = text.parse::() { + return Some(MasternodeLocatorInput::ServiceAddress { + ip: addr.ip(), + port: Some(addr.port()), + }); + } + let bare = text.trim_start_matches('[').trim_end_matches(']'); + if let Ok(ip) = bare.parse::() { + return Some(MasternodeLocatorInput::ServiceAddress { ip, port: None }); + } + None +} + +fn is_mainnet(network: Network) -> bool { + matches!(network, Network::Mainnet) +} + +/// The 32-byte secret candidates a raw 32-byte blob can be, on every curve +/// it is a valid secret for. `Ecdsa` only when the scalar is in range, `Bls` +/// only when below the group order; ed25519 accepts any 32 bytes as a seed. +fn secret_candidates(bytes: &[u8; 32]) -> Vec { + let mut out = Vec::with_capacity(3); + if SecpSecretKey::from_slice(bytes).is_ok() { + out.push(MasternodeLocatorInput::Secret(LocatorSecret::Ecdsa { + secret: Zeroizing::new(*bytes), + compressed: true, + })); + } + if bls_public_keys(bytes).is_some() { + out.push(MasternodeLocatorInput::Secret(LocatorSecret::Bls( + Zeroizing::new(*bytes), + ))); + } + out.push(MasternodeLocatorInput::Secret(LocatorSecret::Ed25519( + Zeroizing::new(*bytes), + ))); + out +} + +/// Split a 64-byte `seed ‖ public key` node key (dashmate / Tenderdash +/// `node_key.json`) and check the public half against the seed. +fn ed25519_seed_from_node_key(bytes: &[u8]) -> Result<[u8; 32], LocatorParseError> { + let seed: [u8; 32] = bytes[..32] + .try_into() + .map_err(|_| LocatorParseError::Unrecognized)?; + let declared_pub = &bytes[32..]; + let derived_pub = SigningKey::from_bytes(&seed).verifying_key().to_bytes(); + if declared_pub != derived_pub { + return Err(LocatorParseError::NodeKeyMismatch); + } + Ok(seed) +} + +/// Read the locator text into candidates. `network` is the wallet's +/// network, used to reject a WIF key for the other network. +pub fn parse_locator_input( + text: &str, + network: Network, +) -> Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Err(LocatorParseError::Empty); + } + let host = strip_url(trimmed); + + if let Some(addr) = parse_service_address(host) { + return Ok(ParsedLocatorInput { + candidates: vec![addr], + }); + } + + // Hex: 64 chars is ambiguous, 128 is a seed‖pub node key. + if trimmed.len().is_multiple_of(2) && trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + let bytes = hex::decode(trimmed).map_err(|_| LocatorParseError::Unrecognized)?; + match bytes.len() { + 32 => { + let raw: [u8; 32] = bytes.as_slice().try_into().expect("len checked"); + let mut candidates = Vec::with_capacity(5); + // Display-order proTxHash (explorers, dashmate, Tenderdash, + // `protx list`) — reverse to wire order. + let mut wire = raw; + wire.reverse(); + candidates.push(MasternodeLocatorInput::ProTxHash(wire)); + // The wire orientation itself, should a tool ever print it + // that way; harmless when the two coincide. + if wire != raw { + candidates.push(MasternodeLocatorInput::ProTxHash(raw)); + } + candidates.extend(secret_candidates(&raw)); + return Ok(ParsedLocatorInput { candidates }); + } + 64 => { + let seed = ed25519_seed_from_node_key(&bytes)?; + return Ok(ParsedLocatorInput { + candidates: vec![MasternodeLocatorInput::Secret(LocatorSecret::Ed25519( + Zeroizing::new(seed), + ))], + }); + } + _ => return Err(LocatorParseError::Unrecognized), + } + } + + // WIF (owner / voting / payout keys as Core's `dumpprivkey` prints them). + if let Ok(key) = PrivateKey::from_wif(trimmed) { + if is_mainnet(key.network) != is_mainnet(network) { + return Err(LocatorParseError::WrongNetworkKey { + key_network: key.network, + expected: network, + }); + } + return Ok(ParsedLocatorInput { + candidates: vec![MasternodeLocatorInput::Secret(LocatorSecret::Ecdsa { + secret: Zeroizing::new(key.inner.secret_bytes()), + compressed: key.compressed, + })], + }); + } + + // base64: dashmate's 64-byte node key, or a bare 32-byte secret. + { + if let Ok(bytes) = dashcore::base64::decode(trimmed) { + match bytes.len() { + 64 => { + let seed = ed25519_seed_from_node_key(&bytes)?; + return Ok(ParsedLocatorInput { + candidates: vec![MasternodeLocatorInput::Secret(LocatorSecret::Ed25519( + Zeroizing::new(seed), + ))], + }); + } + 32 => { + let raw: [u8; 32] = bytes.as_slice().try_into().expect("len checked"); + return Ok(ParsedLocatorInput { + candidates: secret_candidates(&raw), + }); + } + _ => {} + } + } + } + + Err(LocatorParseError::Unrecognized) +} + +// --------------------------------------------------------------------------- +// Public-side derivations +// --------------------------------------------------------------------------- + +/// hash160 of the secp256k1 public key for `secret`, or `None` when the +/// scalar is out of range. +pub fn ecdsa_key_id(secret: &[u8; 32], compressed: bool) -> Option<[u8; 20]> { + let sk = SecpSecretKey::from_slice(secret).ok()?; + let pk = SecpPublicKey::from_secret_key(&Secp256k1::signing_only(), &sk); + let bytes: Vec = if compressed { + pk.serialize().to_vec() + } else { + pk.serialize_uncompressed().to_vec() + }; + Some(hash160::Hash::hash(&bytes).to_byte_array()) +} + +/// `(basic, legacy)` 48-byte serializations of the BLS public key for +/// `secret`, or `None` when the scalar is not below the group order. +pub fn bls_public_keys(secret: &[u8; 32]) -> Option<([u8; 48], [u8; 48])> { + let sk: BlsSecretKey = + Option::from(BlsSecretKey::::from_be_bytes(secret))?; + let pk = BlsPublicKey::from(&sk); + let basic: [u8; 48] = pk.to_bytes().as_slice().try_into().ok()?; + let legacy: [u8; 48] = pk + .to_bytes_with_mode(SerializationFormat::Legacy) + .as_slice() + .try_into() + .ok()?; + Some((basic, legacy)) +} + +/// Tenderdash node id for an ed25519 `seed`. +pub fn ed25519_node_id(seed: &[u8; 32]) -> [u8; 20] { + let public = SigningKey::from_bytes(seed).verifying_key().to_bytes(); + PlatformNodeId::from_ed25519_public_key(&public).to_byte_array() +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +/// How a match was found. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LocatorMatchKind { + ProTxHash = 0, + ServiceAddress = 1, + /// A pasted private key — `matched_keys` says which role(s). + Key = 2, +} + +impl LocatorMatchKind { + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +/// One masternode the locator text names. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MasternodeLocateMatch { + pub summary: MasternodeListSummary, + pub matched_by: LocatorMatchKind, + /// Roles the pasted key fills on this masternode (empty unless + /// `matched_by == Key`). Sorted, no duplicates. Usually one; a key used + /// as both owner and voting key yields two. + pub matched_keys: Vec, + /// This masternode is already one of a loaded wallet's own (registered + /// with that wallet's keys) — hosts show "already in wallet" instead of + /// offering to track it. + pub in_wallet: Option, + /// Already in the tracked-masternode registry — hosts jump to it + /// instead of tracking twice. + pub already_tracked: bool, +} + +/// Outcome of the optional Platform step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlatformLookup { + /// The input had no secp256k1 key, so Platform had nothing to add. + NotNeeded = 0, + /// A secp256k1 key was given but the host didn't opt in. + NotRequested = 1, + /// Ran to completion. + Done = 2, + /// Attempted and failed (network / DAPI); the local matches stand, the + /// owner / payout roles simply weren't checked. + Unavailable = 3, +} + +impl PlatformLookup { + pub fn as_u8(&self) -> u8 { + match self { + Self::NotNeeded => 0, + Self::NotRequested => 1, + Self::Done => 2, + Self::Unavailable => 3, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MasternodeLocateResult { + /// In the list's order; one entry per distinct proTxHash. + pub matches: Vec, + pub platform_lookup: PlatformLookup, + /// Human-readable reason when `platform_lookup == Unavailable`. + pub platform_error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct LocateOptions { + /// Also ask Platform for owner / payout roles of a pasted secp256k1 + /// key (one `getIdentityByNonUniquePublicKeyHash` per key). Off by + /// default: it tells DAPI which key hash the user is interested in. + pub search_platform: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum MasternodeLocateError { + #[error(transparent)] + Parse(#[from] LocatorParseError), + /// The deterministic masternode list isn't available (SPV not running + /// or masternode sync incomplete) — there is nothing to search yet. + #[error("the masternode list is not available yet")] + ListUnavailable, +} + +/// secp256k1 key ids derived from the input, remembered for the Platform +/// step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EcdsaKeyCandidate { + pub key_id: [u8; 20], +} + +/// Resolve `parsed` against a DML snapshot. Pure. Returns the matches and +/// the secp256k1 key ids the input produced (for the optional Platform +/// step). A secret's *voting* role is matched here; owner / payout roles +/// are not on the list. +pub fn locate_in_summaries( + parsed: &ParsedLocatorInput, + summaries: &[MasternodeListSummary], + in_wallet: &HashMap<[u8; 32], WalletId>, + tracked: &BTreeSet<[u8; 32]>, +) -> (Vec, Vec) { + let mut matches: Vec = Vec::new(); + let mut ecdsa = Vec::new(); + + let mut add = |summary: &MasternodeListSummary, + kind: LocatorMatchKind, + role: Option| { + if let Some(existing) = matches + .iter_mut() + .find(|m| m.summary.pro_tx_hash == summary.pro_tx_hash) + { + if let Some(role) = role { + if !existing.matched_keys.contains(&role) { + existing.matched_keys.push(role); + existing.matched_keys.sort(); + } + } + return; + } + matches.push(MasternodeLocateMatch { + summary: summary.clone(), + matched_by: kind, + matched_keys: role.into_iter().collect(), + in_wallet: in_wallet.get(&summary.pro_tx_hash).copied(), + already_tracked: tracked.contains(&summary.pro_tx_hash), + }); + }; + + for candidate in &parsed.candidates { + match candidate { + MasternodeLocatorInput::ProTxHash(hash) => { + for s in find_in_summaries(summaries, &MasternodeListQuery::ProTxHash(*hash)) { + add(s, LocatorMatchKind::ProTxHash, None); + } + } + MasternodeLocatorInput::ServiceAddress { ip, port } => { + let q = MasternodeListQuery::ServiceAddress { + ip: *ip, + port: *port, + }; + for s in find_in_summaries(summaries, &q) { + add(s, LocatorMatchKind::ServiceAddress, None); + } + } + MasternodeLocatorInput::Secret(LocatorSecret::Ecdsa { secret, compressed }) => { + if let Some(key_id) = ecdsa_key_id(secret, *compressed) { + ecdsa.push(EcdsaKeyCandidate { key_id }); + for s in find_in_summaries(summaries, &MasternodeListQuery::VotingKeyId(key_id)) + { + add(s, LocatorMatchKind::Key, Some(MasternodeKeyRole::Voting)); + } + } + } + MasternodeLocatorInput::Secret(LocatorSecret::Bls(secret)) => { + if let Some((basic, legacy)) = bls_public_keys(secret) { + for key in [basic, legacy] { + for s in find_in_summaries( + summaries, + &MasternodeListQuery::OperatorPublicKey(key), + ) { + add(s, LocatorMatchKind::Key, Some(MasternodeKeyRole::Operator)); + } + } + } + } + MasternodeLocatorInput::Secret(LocatorSecret::Ed25519(seed)) => { + let node_id = ed25519_node_id(seed); + for s in find_in_summaries(summaries, &MasternodeListQuery::PlatformNodeId(node_id)) + { + add( + s, + LocatorMatchKind::Key, + Some(MasternodeKeyRole::PlatformNode), + ); + } + } + } + } + + // Keep the list's order so repeated lookups render stably. + let order: HashMap<[u8; 32], usize> = summaries + .iter() + .enumerate() + .map(|(i, s)| (s.pro_tx_hash, i)) + .collect(); + matches.sort_by_key(|m| { + order + .get(&m.summary.pro_tx_hash) + .copied() + .unwrap_or(usize::MAX) + }); + (matches, ecdsa) +} + +/// Which masternode (wire proTxHash) and role a Platform masternode identity +/// says `key_id` fills. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformKeyRole { + pub pro_tx_hash: [u8; 32], + pub role: MasternodeKeyRole, +} + +/// Interpret one identity returned by the non-unique-key-hash query for +/// `key_id`: an owner identity (id == display proTxHash of a listed node) +/// yields `Owner` / `OwnerPayout` by the matching key's purpose; an operator +/// identity (id == `create_operator_identifier(proTxHash, operator key)` of a +/// listed node) yields `OperatorPayout`. Anything else — a voter identity +/// (voting keys are matched from the list), a non-masternode identity that +/// happens to hold the key — yields nothing. Pure; the network step is the +/// caller's. +pub fn platform_roles_from_identity( + identity: &Identity, + key_id: &[u8; 20], + summaries: &[MasternodeListSummary], +) -> Vec { + let id_bytes: [u8; 32] = identity.id().to_buffer(); + let mut wire = id_bytes; + wire.reverse(); + + if let Some(summary) = summaries.iter().find(|s| s.pro_tx_hash == wire) { + // Owner identity. Which of its keys is ours decides the role. + let mut roles = BTreeSet::new(); + for key in identity.public_keys().values() { + if key.data().as_slice() != key_id { + continue; + } + match key.purpose() { + Purpose::OWNER => { + roles.insert(MasternodeKeyRole::Owner); + } + Purpose::TRANSFER => { + roles.insert(MasternodeKeyRole::OwnerPayout); + } + _ => {} + } + } + return roles + .into_iter() + .map(|role| PlatformKeyRole { + pro_tx_hash: summary.pro_tx_hash, + role, + }) + .collect(); + } + + // Operator identity of some listed node? + let id = identity.id(); + summaries + .iter() + .filter(|s| { + Identifier::create_operator_identifier(&s.pro_tx_hash_display(), &s.operator_public_key) + == id + }) + .filter(|_| { + identity + .public_keys() + .values() + .any(|k| k.data().as_slice() == key_id && k.purpose() == Purpose::TRANSFER) + }) + .map(|s| PlatformKeyRole { + pro_tx_hash: s.pro_tx_hash, + role: MasternodeKeyRole::OperatorPayout, + }) + .collect() +} + +/// Upper bound on identities paged through per key hash. Masternode keys +/// are rarely shared by more than a handful of identities; this caps the +/// round trips for a key that happens to be widely reused. +const PLATFORM_LOOKUP_MAX_PAGES: usize = 16; + +/// The snapshot a locate runs against: SPV for the list, the SDK for the +/// Platform step, the network for WIF checks, and the wallets' own +/// masternodes so matches can say "already in wallet". Built by +/// [`crate::PlatformWalletManager::masternode_locator_blocking`]; `Send + +/// Sync`, so hosts can run [`Self::locate`] on a worker without holding the +/// manager. +#[derive(Clone)] +pub struct MasternodeLocator { + pub spv: Arc, + pub sdk: Arc, + pub network: Network, + /// proTxHash (wire) ⇒ wallet id, for every loaded wallet's masternodes. + pub in_wallet: HashMap<[u8; 32], WalletId>, + /// Wire proTxHashes already in the tracked-masternode registry. + pub tracked: BTreeSet<[u8; 32]>, +} + +impl MasternodeLocator { + /// Find the masternode(s) `text` names. See the module docs for the + /// three layers. `Err(ListUnavailable)` when the DML isn't synced yet; + /// parse errors surface as `Err(Parse(..))`. + pub async fn locate( + &self, + text: &str, + options: LocateOptions, + ) -> Result { + let parsed = parse_locator_input(text, self.network)?; + let summaries = self + .spv + .masternode_list_summaries() + .await + .ok_or(MasternodeLocateError::ListUnavailable)?; + let (mut matches, ecdsa) = + locate_in_summaries(&parsed, &summaries, &self.in_wallet, &self.tracked); + + let (platform_lookup, platform_error) = if ecdsa.is_empty() { + (PlatformLookup::NotNeeded, None) + } else if !options.search_platform { + (PlatformLookup::NotRequested, None) + } else { + match self.platform_roles(&ecdsa, &summaries).await { + Ok(roles) => { + for found in roles { + let Some(summary) = summaries + .iter() + .find(|s| s.pro_tx_hash == found.pro_tx_hash) + else { + continue; + }; + if let Some(existing) = matches + .iter_mut() + .find(|m| m.summary.pro_tx_hash == found.pro_tx_hash) + { + if !existing.matched_keys.contains(&found.role) { + existing.matched_keys.push(found.role); + existing.matched_keys.sort(); + } + } else { + matches.push(MasternodeLocateMatch { + summary: summary.clone(), + matched_by: LocatorMatchKind::Key, + matched_keys: vec![found.role], + in_wallet: self.in_wallet.get(&found.pro_tx_hash).copied(), + already_tracked: self.tracked.contains(&found.pro_tx_hash), + }); + } + } + (PlatformLookup::Done, None) + } + Err(message) => (PlatformLookup::Unavailable, Some(message)), + } + }; + + Ok(MasternodeLocateResult { + matches, + platform_lookup, + platform_error, + }) + } + + async fn platform_roles( + &self, + ecdsa: &[EcdsaKeyCandidate], + summaries: &[MasternodeListSummary], + ) -> Result, String> { + let mut out = Vec::new(); + for candidate in ecdsa { + let mut after: Option<[u8; 32]> = None; + for _ in 0..PLATFORM_LOOKUP_MAX_PAGES { + let query = NonUniquePublicKeyHashQuery { + key_hash: candidate.key_id, + after, + }; + let identity = Identity::fetch(self.sdk.as_ref(), query) + .await + .map_err(|e| e.to_string())?; + let Some(identity) = identity else { + break; + }; + after = Some(identity.id().to_buffer()); + out.extend(platform_roles_from_identity( + &identity, + &candidate.key_id, + summaries, + )); + } + } + Ok(out) + } +} + +// --------------------------------------------------------------------------- +// Verification +// --------------------------------------------------------------------------- + +/// The on-chain / on-Platform references a key is checked against. Built +/// from whatever the caller knows: the DML summary (voting / operator / +/// platform node), the wallet record or a tracked node's enrichment (owner +/// / payout hashes). Missing references make a role `Unverifiable`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MasternodeKeyReference { + pub owner_key_hash: Option<[u8; 20]>, + pub voting_key_id: Option<[u8; 20]>, + pub operator_public_key: Option<[u8; 48]>, + pub platform_node_id: Option<[u8; 20]>, + /// hash160 behind the owner payout address (P2PKH payout script). + pub payout_key_hash: Option<[u8; 20]>, + /// hash160 behind the operator payout address. + pub operator_payout_key_hash: Option<[u8; 20]>, +} + +impl MasternodeKeyReference { + /// What the list knows: voting, operator, platform node. + pub fn from_summary(summary: &MasternodeListSummary) -> Self { + Self { + voting_key_id: Some(summary.voting_key_id), + operator_public_key: Some(summary.operator_public_key), + platform_node_id: summary.platform_node_id, + ..Default::default() + } + } + + /// What a wallet record knows (everything the provider transactions + /// carry). The payout hash is extracted only from a P2PKH payout + /// script; other script kinds leave it `None`. + pub fn from_record(record: &MasternodeRecord) -> Self { + Self { + owner_key_hash: record.owner_key_hash, + voting_key_id: record.voting_key_hash, + operator_public_key: record.operator_public_key, + platform_node_id: record.platform_node_id, + payout_key_hash: record.payout_script.as_deref().and_then(p2pkh_script_hash), + operator_payout_key_hash: None, + } + } + + /// Fill `None`s of `self` from `other` (the summary refreshes the + /// list-known fields, a record / enrichment supplies the rest). + pub fn merged_with(mut self, other: &Self) -> Self { + self.owner_key_hash = self.owner_key_hash.or(other.owner_key_hash); + self.voting_key_id = self.voting_key_id.or(other.voting_key_id); + self.operator_public_key = self.operator_public_key.or(other.operator_public_key); + self.platform_node_id = self.platform_node_id.or(other.platform_node_id); + self.payout_key_hash = self.payout_key_hash.or(other.payout_key_hash); + self.operator_payout_key_hash = self + .operator_payout_key_hash + .or(other.operator_payout_key_hash); + self + } +} + +/// hash160 of a standard P2PKH script (`OP_DUP OP_HASH160 <20> OP_EQUALVERIFY +/// OP_CHECKSIG`), else `None`. +pub fn p2pkh_script_hash(script: &[u8]) -> Option<[u8; 20]> { + if script.len() == 25 + && script[0] == 0x76 + && script[1] == 0xa9 + && script[2] == 0x14 + && script[23] == 0x88 + && script[24] == 0xac + { + let mut out = [0u8; 20]; + out.copy_from_slice(&script[3..23]); + Some(out) + } else { + None + } +} + +/// Result of checking a key against a role. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyVerification { + Matches = 0, + DoesNotMatch = 1, + /// The reference for this role isn't known (e.g. owner / payout of a + /// node whose registration details haven't been fetched) — not a pass. + Unverifiable = 2, +} + +impl KeyVerification { + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +/// Parse `text` as a key of `role`'s curve: WIF / hex for secp256k1 roles, +/// hex for BLS, base64 or hex (32 or 64 bytes) for ed25519. +pub fn parse_secret_for_role( + text: &str, + role: MasternodeKeyRole, + network: Network, +) -> Result { + let parsed = parse_locator_input(text, network)?; + let wanted = parsed.candidates.into_iter().find_map(|c| match (c, role) { + ( + MasternodeLocatorInput::Secret(s @ LocatorSecret::Ecdsa { .. }), + MasternodeKeyRole::Owner + | MasternodeKeyRole::Voting + | MasternodeKeyRole::OwnerPayout + | MasternodeKeyRole::OperatorPayout, + ) => Some(s), + ( + MasternodeLocatorInput::Secret(s @ LocatorSecret::Bls(_)), + MasternodeKeyRole::Operator, + ) => Some(s), + ( + MasternodeLocatorInput::Secret(s @ LocatorSecret::Ed25519(_)), + MasternodeKeyRole::PlatformNode, + ) => Some(s), + _ => None, + }); + wanted.ok_or(LocatorParseError::InvalidSecret) +} + +/// Derive-and-compare `secret` against `reference` for `role`. +pub fn verify_masternode_key( + reference: &MasternodeKeyReference, + role: MasternodeKeyRole, + secret: &LocatorSecret, +) -> KeyVerification { + fn compare(expected: Option, actual: Option) -> KeyVerification { + match (expected, actual) { + (Some(e), Some(a)) if e == a => KeyVerification::Matches, + (Some(_), Some(_)) => KeyVerification::DoesNotMatch, + (Some(_), None) => KeyVerification::DoesNotMatch, + (None, _) => KeyVerification::Unverifiable, + } + } + + match (role, secret) { + (MasternodeKeyRole::Owner, LocatorSecret::Ecdsa { secret, compressed }) => { + compare(reference.owner_key_hash, ecdsa_key_id(secret, *compressed)) + } + (MasternodeKeyRole::Voting, LocatorSecret::Ecdsa { secret, compressed }) => { + compare(reference.voting_key_id, ecdsa_key_id(secret, *compressed)) + } + (MasternodeKeyRole::OwnerPayout, LocatorSecret::Ecdsa { secret, compressed }) => { + compare(reference.payout_key_hash, ecdsa_key_id(secret, *compressed)) + } + (MasternodeKeyRole::OperatorPayout, LocatorSecret::Ecdsa { secret, compressed }) => { + compare( + reference.operator_payout_key_hash, + ecdsa_key_id(secret, *compressed), + ) + } + (MasternodeKeyRole::Operator, LocatorSecret::Bls(secret)) => { + match (reference.operator_public_key, bls_public_keys(secret)) { + (Some(expected), Some((basic, legacy))) => { + if expected == basic || expected == legacy { + KeyVerification::Matches + } else { + KeyVerification::DoesNotMatch + } + } + (Some(_), None) => KeyVerification::DoesNotMatch, + (None, _) => KeyVerification::Unverifiable, + } + } + (MasternodeKeyRole::PlatformNode, LocatorSecret::Ed25519(seed)) => { + compare(reference.platform_node_id, Some(ed25519_node_id(seed))) + } + // Curve mismatch: the caller parsed with `parse_secret_for_role`, so + // this only happens on misuse; it is simply not a match. + _ => KeyVerification::DoesNotMatch, + } +} + +/// Convenience: parse + verify in one step. +pub fn verify_masternode_key_text( + reference: &MasternodeKeyReference, + role: MasternodeKeyRole, + text: &str, + network: Network, +) -> Result { + let secret = parse_secret_for_role(text, role, network)?; + Ok(verify_masternode_key(reference, role, &secret)) +} + +#[cfg(test)] +mod tests { + use super::super::list::test_support::{evonode, ip, masternode}; + use super::*; + + // A fixed secp256k1 secret and its derived ids. + const SECP_SECRET: [u8; 32] = [0x11u8; 32]; + + fn secp_key_id() -> [u8; 20] { + ecdsa_key_id(&SECP_SECRET, true).unwrap() + } + + fn wif(network: Network) -> String { + PrivateKey::from_byte_array(&SECP_SECRET, network) + .unwrap() + .to_wif() + } + + // --- parsing ----------------------------------------------------------- + + #[test] + fn empty_and_garbage_are_rejected() { + assert_eq!( + parse_locator_input(" ", Network::Mainnet).unwrap_err(), + LocatorParseError::Empty + ); + assert_eq!( + parse_locator_input("not a thing", Network::Mainnet).unwrap_err(), + LocatorParseError::Unrecognized + ); + assert_eq!( + parse_locator_input("abcd", Network::Mainnet).unwrap_err(), + LocatorParseError::Unrecognized, + "short hex is not a proTxHash or a key" + ); + } + + #[test] + fn ip_forms_parse_to_a_service_address() { + let cases: [(&str, Option); 6] = [ + ("1.2.3.4", None), + ("1.2.3.4:9999", Some(9999)), + (" https://1.2.3.4:443/ ", Some(443)), + ("http://1.2.3.4", None), + ("[2001:db8::1]", None), + ("[2001:db8::1]:9999", Some(9999)), + ]; + for (text, port) in cases { + let parsed = parse_locator_input(text, Network::Mainnet).unwrap(); + assert_eq!(parsed.candidates.len(), 1, "{text}"); + match &parsed.candidates[0] { + MasternodeLocatorInput::ServiceAddress { port: p, .. } => { + assert_eq!(*p, port, "{text}") + } + other => panic!("{text}: {other:?}"), + } + } + } + + #[test] + fn sixty_four_hex_yields_every_reading() { + // Not a palindrome, so the two proTxHash orientations differ. + let mut raw = SECP_SECRET; + raw[0] = 0x01; + let hex = hex::encode(raw); + let parsed = parse_locator_input(&hex, Network::Mainnet).unwrap(); + let kinds: Vec<&str> = parsed + .candidates + .iter() + .map(|c| match c { + MasternodeLocatorInput::ProTxHash(_) => "protx", + MasternodeLocatorInput::Secret(LocatorSecret::Ecdsa { .. }) => "ecdsa", + MasternodeLocatorInput::Secret(LocatorSecret::Bls(_)) => "bls", + MasternodeLocatorInput::Secret(LocatorSecret::Ed25519(_)) => "ed25519", + MasternodeLocatorInput::ServiceAddress { .. } => "ip", + }) + .collect(); + assert_eq!(kinds, ["protx", "protx", "ecdsa", "bls", "ed25519"]); + // First proTxHash reading is the reversal (display → wire), the + // second the bytes as given. + match (&parsed.candidates[0], &parsed.candidates[1]) { + (MasternodeLocatorInput::ProTxHash(a), MasternodeLocatorInput::ProTxHash(b)) => { + let mut expected = raw; + expected.reverse(); + assert_eq!(*a, expected); + assert_eq!(*b, raw); + } + _ => unreachable!(), + } + // A palindromic hash yields the orientation once. + let parsed = parse_locator_input(&hex::encode(SECP_SECRET), Network::Mainnet).unwrap(); + assert_eq!( + parsed + .candidates + .iter() + .filter(|c| matches!(c, MasternodeLocatorInput::ProTxHash(_))) + .count(), + 1 + ); + } + + #[test] + fn out_of_range_secp_scalar_drops_the_ecdsa_reading() { + // 0xFF.. is above the secp256k1 group order, so it cannot be an + // owner / voting / payout key; it is still a proTxHash reading and + // an ed25519 seed (any 32 bytes). + let hex = "ff".repeat(32); + let parsed = parse_locator_input(&hex, Network::Mainnet).unwrap(); + assert!(parsed.candidates.iter().all(|c| !matches!( + c, + MasternodeLocatorInput::Secret(LocatorSecret::Ecdsa { .. }) + ))); + assert!(parsed.has_secret(), "ed25519 still accepts any 32 bytes"); + assert!(parsed + .candidates + .iter() + .any(|c| matches!(c, MasternodeLocatorInput::ProTxHash(_)))); + } + + #[test] + fn wif_parses_and_checks_the_network() { + let parsed = parse_locator_input(&wif(Network::Mainnet), Network::Mainnet).unwrap(); + assert_eq!(parsed.candidates.len(), 1); + match &parsed.candidates[0] { + MasternodeLocatorInput::Secret(LocatorSecret::Ecdsa { secret, compressed }) => { + assert_eq!(**secret, SECP_SECRET); + assert!(compressed); + } + other => panic!("{other:?}"), + } + assert_eq!( + parse_locator_input(&wif(Network::Testnet), Network::Mainnet).unwrap_err(), + LocatorParseError::WrongNetworkKey { + key_network: Network::Testnet, + expected: Network::Mainnet + } + ); + // Devnet / regtest share the testnet WIF prefix, so a testnet WIF is + // fine there. + assert!(parse_locator_input(&wif(Network::Testnet), Network::Devnet).is_ok()); + } + + #[test] + fn dashmate_node_key_parses_when_consistent() { + let seed = [0x42u8; 32]; + let public = SigningKey::from_bytes(&seed).verifying_key().to_bytes(); + let mut node_key = seed.to_vec(); + node_key.extend_from_slice(&public); + let b64 = dashcore::base64::encode(&node_key); + + let parsed = parse_locator_input(&b64, Network::Mainnet).unwrap(); + assert_eq!(parsed.candidates.len(), 1); + assert!(matches!( + &parsed.candidates[0], + MasternodeLocatorInput::Secret(LocatorSecret::Ed25519(s)) if **s == seed + )); + // Same key as 128 hex chars. + let parsed = parse_locator_input(&hex::encode(&node_key), Network::Mainnet).unwrap(); + assert!(matches!( + &parsed.candidates[0], + MasternodeLocatorInput::Secret(LocatorSecret::Ed25519(_)) + )); + + // A corrupted public half is rejected, not silently accepted. + node_key[40] ^= 0x01; + assert_eq!( + parse_locator_input(&dashcore::base64::encode(&node_key), Network::Mainnet) + .unwrap_err(), + LocatorParseError::NodeKeyMismatch + ); + } + + // --- derivations --------------------------------------------------------- + + #[test] + fn ecdsa_key_id_matches_dashcore_address_hash() { + let key = PrivateKey::from_byte_array(&SECP_SECRET, Network::Mainnet).unwrap(); + let expected: [u8; 20] = key + .public_key(&Secp256k1::new()) + .pubkey_hash() + .to_byte_array(); + assert_eq!(secp_key_id(), expected); + assert_ne!( + ecdsa_key_id(&SECP_SECRET, false).unwrap(), + expected, + "uncompressed keys hash differently" + ); + } + + #[test] + fn bls_keys_have_distinct_basic_and_legacy_forms() { + let (basic, legacy) = bls_public_keys(&[0x33u8; 32]).unwrap(); + assert_ne!(basic, legacy); + assert_eq!(basic[0] & 0x80, 0x80, "compressed flag set in both"); + assert_eq!(legacy[0] & 0x80, 0x80); + } + + #[test] + fn node_id_is_sha256_prefix_of_the_public_key() { + use dashcore::hashes::sha256; + let seed = [0x42u8; 32]; + let public = SigningKey::from_bytes(&seed).verifying_key().to_bytes(); + let digest = sha256::Hash::hash(&public).to_byte_array(); + assert_eq!(ed25519_node_id(&seed), digest[..20]); + } + + // --- local resolution ------------------------------------------------- + + fn list() -> Vec { + let mut voting = masternode(1); + voting.voting_key_id = secp_key_id(); + let mut operator = evonode(2); + operator.operator_public_key = bls_public_keys(&[0x33u8; 32]).unwrap().0; + let mut legacy_operator = masternode(3); + legacy_operator.operator_public_key = bls_public_keys(&[0x33u8; 32]).unwrap().1; + let mut node = evonode(4); + node.platform_node_id = Some(ed25519_node_id(&[0x42u8; 32])); + vec![voting, operator, legacy_operator, node, masternode(5)] + } + + #[test] + fn locates_by_pro_tx_hash_in_display_order() { + let mut display = [5u8; 32]; + display.reverse(); + let parsed = parse_locator_input(&hex::encode(display), Network::Mainnet).unwrap(); + let (matches, _) = locate_in_summaries(&parsed, &list(), &HashMap::new(), &BTreeSet::new()); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].summary.pro_tx_hash, [5u8; 32]); + assert_eq!(matches[0].matched_by, LocatorMatchKind::ProTxHash); + assert!(matches[0].matched_keys.is_empty()); + } + + #[test] + fn locates_by_ip_and_marks_wallet_membership() { + let parsed = parse_locator_input("10.0.0.5", Network::Mainnet).unwrap(); + let mut in_wallet = HashMap::new(); + in_wallet.insert([5u8; 32], [9u8; 32]); + let (matches, _) = locate_in_summaries(&parsed, &list(), &in_wallet, &BTreeSet::new()); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].matched_by, LocatorMatchKind::ServiceAddress); + assert_eq!(matches[0].in_wallet, Some([9u8; 32])); + } + + #[test] + fn voting_key_resolves_locally_and_is_remembered_for_platform() { + let parsed = parse_locator_input(&wif(Network::Mainnet), Network::Mainnet).unwrap(); + let (matches, ecdsa) = + locate_in_summaries(&parsed, &list(), &HashMap::new(), &BTreeSet::new()); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].summary.pro_tx_hash, [1u8; 32]); + assert_eq!(matches[0].matched_by, LocatorMatchKind::Key); + assert_eq!(matches[0].matched_keys, vec![MasternodeKeyRole::Voting]); + assert_eq!( + ecdsa, + vec![EcdsaKeyCandidate { + key_id: secp_key_id() + }] + ); + } + + #[test] + fn operator_secret_matches_basic_and_legacy_entries() { + let parsed = parse_locator_input(&hex::encode([0x33u8; 32]), Network::Mainnet).unwrap(); + let (matches, _) = locate_in_summaries(&parsed, &list(), &HashMap::new(), &BTreeSet::new()); + let hashes: Vec<[u8; 32]> = matches.iter().map(|m| m.summary.pro_tx_hash).collect(); + assert_eq!(hashes, vec![[2u8; 32], [3u8; 32]]); + for m in &matches { + assert_eq!(m.matched_keys, vec![MasternodeKeyRole::Operator]); + } + } + + #[test] + fn platform_node_seed_matches_the_evonode() { + let parsed = parse_locator_input(&hex::encode([0x42u8; 32]), Network::Mainnet).unwrap(); + let (matches, _) = locate_in_summaries(&parsed, &list(), &HashMap::new(), &BTreeSet::new()); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].summary.pro_tx_hash, [4u8; 32]); + assert_eq!( + matches[0].matched_keys, + vec![MasternodeKeyRole::PlatformNode] + ); + } + + #[test] + fn shared_key_across_roles_merges_into_one_match() { + // A node whose voting key id equals its platform node id bytes is + // contrived, but exercises the merge: two candidates, one match. + let mut both = evonode(7); + both.voting_key_id = secp_key_id(); + both.platform_node_id = Some(ed25519_node_id(&SECP_SECRET)); + let parsed = parse_locator_input(&hex::encode(SECP_SECRET), Network::Mainnet).unwrap(); + let (matches, _) = locate_in_summaries(&parsed, &[both], &HashMap::new(), &BTreeSet::new()); + assert_eq!(matches.len(), 1); + assert_eq!( + matches[0].matched_keys, + vec![MasternodeKeyRole::Voting, MasternodeKeyRole::PlatformNode] + ); + } + + #[test] + fn nothing_found_is_an_empty_match_list() { + let parsed = parse_locator_input("192.168.9.9", Network::Mainnet).unwrap(); + let (matches, ecdsa) = + locate_in_summaries(&parsed, &list(), &HashMap::new(), &BTreeSet::new()); + assert!(matches.is_empty()); + assert!(ecdsa.is_empty()); + let _ = ip(1); + } + + // --- platform role interpretation --------------------------------------- + + fn identity_with(id: [u8; 32], keys: Vec<(Purpose, [u8; 20])>) -> Identity { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{IdentityPublicKey, KeyType, SecurityLevel}; + use dpp::platform_value::BinaryData; + let public_keys = keys + .into_iter() + .enumerate() + .map(|(i, (purpose, data))| { + let key: IdentityPublicKey = IdentityPublicKeyV0 { + id: i as u32, + purpose, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: true, + data: BinaryData::new(data.to_vec()), + disabled_at: None, + } + .into(); + (i as u32, key) + }) + .collect(); + IdentityV0 { + id: Identifier::from(id), + public_keys, + balance: 0, + revision: 0, + } + .into() + } + + #[test] + fn owner_identity_yields_owner_or_payout_role_by_purpose() { + let summaries = list(); + let owner_hash = secp_key_id(); + let payout_hash = [0x77u8; 20]; + // Owner identity id = display-order proTxHash of node 5. + let mut display = [5u8; 32]; + display.reverse(); + let identity = identity_with( + display, + vec![ + (Purpose::TRANSFER, payout_hash), + (Purpose::OWNER, owner_hash), + ], + ); + assert_eq!( + platform_roles_from_identity(&identity, &owner_hash, &summaries), + vec![PlatformKeyRole { + pro_tx_hash: [5u8; 32], + role: MasternodeKeyRole::Owner + }] + ); + assert_eq!( + platform_roles_from_identity(&identity, &payout_hash, &summaries), + vec![PlatformKeyRole { + pro_tx_hash: [5u8; 32], + role: MasternodeKeyRole::OwnerPayout + }] + ); + // A key the identity doesn't hold yields nothing. + assert!(platform_roles_from_identity(&identity, &[0x01u8; 20], &summaries).is_empty()); + } + + #[test] + fn operator_identity_yields_operator_payout_role() { + let summaries = list(); + let node = &summaries[1]; // evonode 2 + let payout_hash = [0x66u8; 20]; + let id = Identifier::create_operator_identifier( + &node.pro_tx_hash_display(), + &node.operator_public_key, + ); + let identity = identity_with(id.to_buffer(), vec![(Purpose::TRANSFER, payout_hash)]); + assert_eq!( + platform_roles_from_identity(&identity, &payout_hash, &summaries), + vec![PlatformKeyRole { + pro_tx_hash: node.pro_tx_hash, + role: MasternodeKeyRole::OperatorPayout + }] + ); + } + + #[test] + fn unrelated_identity_yields_nothing() { + let identity = identity_with([0xEEu8; 32], vec![(Purpose::TRANSFER, secp_key_id())]); + assert!(platform_roles_from_identity(&identity, &secp_key_id(), &list()).is_empty()); + } + + // --- verification ------------------------------------------------------ + + #[test] + fn verifies_each_role_against_its_reference() { + let reference = MasternodeKeyReference { + owner_key_hash: Some(secp_key_id()), + voting_key_id: Some([0xABu8; 20]), + operator_public_key: Some(bls_public_keys(&[0x33u8; 32]).unwrap().1), + platform_node_id: Some(ed25519_node_id(&[0x42u8; 32])), + payout_key_hash: None, + operator_payout_key_hash: None, + }; + let net = Network::Mainnet; + let owner = wif(net); + assert_eq!( + verify_masternode_key_text(&reference, MasternodeKeyRole::Owner, &owner, net).unwrap(), + KeyVerification::Matches + ); + assert_eq!( + verify_masternode_key_text(&reference, MasternodeKeyRole::Voting, &owner, net).unwrap(), + KeyVerification::DoesNotMatch + ); + assert_eq!( + verify_masternode_key_text(&reference, MasternodeKeyRole::OwnerPayout, &owner, net) + .unwrap(), + KeyVerification::Unverifiable, + "no payout reference ⇒ unverifiable, never a pass" + ); + // Legacy-serialized operator key still matches from the secret. + assert_eq!( + verify_masternode_key_text( + &reference, + MasternodeKeyRole::Operator, + &hex::encode([0x33u8; 32]), + net + ) + .unwrap(), + KeyVerification::Matches + ); + assert_eq!( + verify_masternode_key_text( + &reference, + MasternodeKeyRole::Operator, + &hex::encode([0x34u8; 32]), + net + ) + .unwrap(), + KeyVerification::DoesNotMatch + ); + assert_eq!( + verify_masternode_key_text( + &reference, + MasternodeKeyRole::PlatformNode, + &hex::encode([0x42u8; 32]), + net + ) + .unwrap(), + KeyVerification::Matches + ); + // A WIF in the operator field is not a BLS key. + assert_eq!( + verify_masternode_key_text(&reference, MasternodeKeyRole::Operator, &owner, net) + .unwrap_err(), + LocatorParseError::InvalidSecret + ); + } + + #[test] + fn reference_from_record_extracts_the_p2pkh_payout_hash() { + let mut record = MasternodeRecord::default(); + let mut script = vec![0x76, 0xa9, 0x14]; + script.extend_from_slice(&[0x55u8; 20]); + script.extend_from_slice(&[0x88, 0xac]); + record.payout_script = Some(script); + record.owner_key_hash = Some([1u8; 20]); + let reference = MasternodeKeyReference::from_record(&record); + assert_eq!(reference.payout_key_hash, Some([0x55u8; 20])); + assert_eq!(reference.owner_key_hash, Some([1u8; 20])); + // Merge: the summary supplies list fields, the record the rest. + let merged = MasternodeKeyReference::from_summary(&masternode(1)).merged_with(&reference); + assert_eq!(merged.voting_key_id, Some([1u8; 20])); + assert_eq!(merged.payout_key_hash, Some([0x55u8; 20])); + assert_eq!(p2pkh_script_hash(&[0x00, 0x14]), None); + } + + #[test] + fn role_codes_round_trip_and_align_with_android() { + for role in MasternodeKeyRole::ALL { + assert_eq!(MasternodeKeyRole::from_u8(role.as_u8()), Some(role)); + } + assert_eq!(MasternodeKeyRole::Owner.as_u8(), 0); + assert_eq!(MasternodeKeyRole::Voting.as_u8(), 1); + assert_eq!(MasternodeKeyRole::Operator.as_u8(), 2); + assert_eq!(MasternodeKeyRole::PlatformNode.as_u8(), 3); + assert_eq!(MasternodeKeyRole::from_u8(6), None); + } +} diff --git a/packages/rs-platform-wallet/src/masternode/mod.rs b/packages/rs-platform-wallet/src/masternode/mod.rs new file mode 100644 index 00000000000..ae7a23d30cf --- /dev/null +++ b/packages/rs-platform-wallet/src/masternode/mod.rs @@ -0,0 +1,171 @@ +//! Masternodes / evonodes as the wallet layer sees them. +//! +//! * [`record`] — the pure aggregation model ([`MasternodeRecord`]) and the +//! DML-membership → status mapping. +//! * [`PlatformWalletManager::wallet_masternodes_blocking`] — the one +//! library entry point that lists a wallet's masternodes: aggregation, +//! status against the DML snapshot, and derive-and-compare ownership of +//! the operator / platform-node keys. Both FFI crates and the withdrawal +//! path read through it, so every host renders the same records. + +pub mod list; +pub mod locator; +pub mod record; +pub mod tracked; + +pub use list::{find_in_summaries, MasternodeListQuery, MasternodeListSummary}; +pub use locator::{ + locate_in_summaries, parse_locator_input, parse_secret_for_role, verify_masternode_key, + verify_masternode_key_text, KeyVerification, LocateOptions, LocatorMatchKind, + LocatorParseError, LocatorSecret, MasternodeKeyReference, MasternodeKeyRole, + MasternodeLocateError, MasternodeLocateMatch, MasternodeLocateResult, MasternodeLocator, + MasternodeLocatorInput, ParsedLocatorInput, PlatformLookup, +}; +pub use record::{ + aggregate_masternodes, provider_payload_fields, ListMembership, MasternodeRecord, + MasternodeSource, MasternodeStatus, ProviderPayloadFields, +}; +pub use tracked::{ + capabilities_for_roles, snapshot_from_json, snapshot_to_json, MasternodeCapabilities, + PlatformKeySnapshot, RegistrationDetails, TrackedMasternode, TrackedMasternodeSnapshot, +}; + +use crate::changeset::PlatformWalletPersistence; +use crate::manager::PlatformWalletManager; +use crate::wallet::platform_wallet::WalletId; + +/// A wallet's masternodes plus the network they belong to (needed to +/// encode key hashes as base58 addresses at the host boundary). +#[derive(Debug, Clone)] +pub struct WalletMasternodes { + pub network: dashcore::Network, + /// Sorted by registration order; `order_index` is each record's + /// position in this vec. + pub records: Vec, +} + +impl WalletMasternodes { + /// The record for `pro_tx_hash` (wire order), if this wallet has one. + pub fn find(&self, pro_tx_hash: &[u8; 32]) -> Option<&MasternodeRecord> { + self.records + .iter() + .find(|mn| &mn.pro_tx_hash == pro_tx_hash) + } +} + +impl PlatformWalletManager

{ + /// List the wallet's masternodes: aggregate its retained provider + /// special transactions (see + /// [`Self::provider_masternode_txs_blocking`]), resolve each record's + /// status against the current DML snapshot (`None` ⇒ `Unknown`, so a + /// persister keeps its prior value), and resolve operator / platform + /// key ownership by derive-and-compare. Owner / voting ownership is + /// NOT resolved here — those keys are on-chain addresses and hosts + /// join them against their persisted address rows. + /// + /// Returns `None` when the wallet isn't loaded. Blocking (reads the + /// wallet-manager, SPV client and engine locks via `blocking_read`) — + /// call from a blocking thread, never from the async runtime. + pub fn wallet_masternodes_blocking(&self, wallet_id: &WalletId) -> Option { + let (network, txs, dml, operator_index, platform_index) = + self.provider_masternode_txs_blocking(wallet_id)?; + + 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 mut records = + aggregate_masternodes(txs.iter().map(|(h, p, tx)| (*h, *p, tx)), membership); + // The check was possible iff the wallet's derived platform-node index + // had entries to compare against. Empty index ⇒ no platform pool / not + // yet rehydrated ⇒ ownership is "unchecked", and a persister must + // retain any prior value rather than clobber it. + let platform_ownership_checked = !platform_index.is_empty(); + for (idx, mn) in records.iter_mut().enumerate() { + mn.order_index = idx as u32; + mn.source = MasternodeSource::Wallet; + mn.operator_key_index = mn + .operator_public_key + .and_then(|k| operator_index.get(&k).copied()); + mn.platform_key_index = mn + .platform_node_id + .and_then(|id| platform_index.get(&id).copied()); + mn.platform_ownership_checked = platform_ownership_checked; + } + + Some(WalletMasternodes { network, records }) + } + + /// proTxHash (wire) ⇒ wallet id for every loaded wallet's masternodes — + /// the "already in wallet" index the locator marks matches with. + /// Blocking (see [`Self::wallet_masternodes_blocking`]). + pub fn wallet_masternode_index_blocking( + &self, + ) -> std::collections::HashMap<[u8; 32], WalletId> { + let mut index = std::collections::HashMap::new(); + for wallet_id in self.list_wallet_ids_blocking() { + if let Some(masternodes) = self.wallet_masternodes_blocking(&wallet_id) { + for record in masternodes.records { + index.entry(record.pro_tx_hash).or_insert(wallet_id); + } + } + } + index + } + + /// Snapshot everything a locate needs (SPV, SDK, network, the wallets' + /// own masternodes) into a `Send + Sync` [`MasternodeLocator`] that can + /// run on a worker without holding the manager. Blocking. + pub fn masternode_locator_blocking(&self) -> MasternodeLocator { + MasternodeLocator { + spv: self.spv_arc(), + sdk: self.sdk_arc(), + network: self.sdk().network, + in_wallet: self.wallet_masternode_index_blocking(), + tracked: self.tracked_masternode_hashes(), + } + } + + /// The key references known for `pro_tx_hash` (wire order): the DML + /// summary (voting / operator / platform node) merged with the owning + /// wallet's record (owner / payout) when it is one of a loaded wallet's + /// masternodes, and with the tracked-registry snapshot (owner / payout + /// from Platform, registration keys) when it is tracked. `None` when + /// nobody knows it. Blocking. + pub fn masternode_key_reference_blocking( + &self, + pro_tx_hash: &[u8; 32], + ) -> Option { + let from_list = self + .spv() + .masternode_list_summaries_blocking() + .and_then(|summaries| { + summaries + .iter() + .find(|s| &s.pro_tx_hash == pro_tx_hash) + .map(MasternodeKeyReference::from_summary) + }); + let from_wallet = self + .list_wallet_ids_blocking() + .into_iter() + .find_map(|wallet_id| { + self.wallet_masternodes_blocking(&wallet_id)? + .find(pro_tx_hash) + .map(MasternodeKeyReference::from_record) + }); + let from_tracked = self + .tracked_masternode(pro_tx_hash) + .map(|tracked| tracked.key_reference()); + [from_list, from_wallet, from_tracked] + .into_iter() + .flatten() + .reduce(|merged, next| merged.merged_with(&next)) + } +} diff --git a/packages/rs-platform-wallet/src/masternode/record.rs b/packages/rs-platform-wallet/src/masternode/record.rs new file mode 100644 index 00000000000..aa183ed0da5 --- /dev/null +++ b/packages/rs-platform-wallet/src/masternode/record.rs @@ -0,0 +1,879 @@ +//! Masternode records: the wallet-side model of a masternode / evonode. +//! +//! [`aggregate_masternodes`] folds a wallet's retained provider special +//! transactions (ProRegTx / ProUpServTx / ProUpRegTx / ProUpRevTx) into one +//! [`MasternodeRecord`] per proTxHash, resolving the displayed +//! [`MasternodeStatus`] against the deterministic masternode list through an +//! injected [`ListMembership`] lookup. Everything here is pure and +//! host-agnostic; the FFI crates only marshal the results. + +/// 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. +pub(crate) fn provider_hash_to_32(bytes: &[u8]) -> [u8; 32] { + let mut out = [0u8; 32]; + out.copy_from_slice(bytes); + out +} + +pub(crate) 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"`. +pub 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 +/// host UIs. All optional / gated — only a ProRegTx or ProUpServTx +/// populates them. The single seam where the DIP-3 payload is decoded; +/// the FFI layers only marshal the flat results. +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub struct ProviderPayloadFields { + /// Service endpoint as `"ip:port"`. + pub service_address: Option, + /// ProUpServTx registration linkage. `None` for ProRegTx (its own + /// txid is the proTxHash). + pub pro_tx_hash: Option<[u8; 32]>, + /// ProRegTx collateral outpoint (`txid` wire bytes, `vout`). + pub collateral: Option<([u8; 32], u32)>, + /// ProRegTx owner / voting key hashes (hash160, 20 bytes). + pub owner_key_hash: Option<[u8; 20]>, + pub voting_key_hash: Option<[u8; 20]>, +} + +/// Extract provider-registration (ProRegTx) / provider-update-service +/// (ProUpServTx) payload fields from a transaction for display. Returns +/// all-`None` for any other transaction. Pure; the only allocation is +/// the returned service-address `String`. +pub fn provider_payload_fields(tx: &dashcore::Transaction) -> ProviderPayloadFields { + use dashcore::transaction::TransactionPayload; + + match &tx.special_transaction_payload { + Some(TransactionPayload::ProviderRegistrationPayloadType(p)) => ProviderPayloadFields { + service_address: Some(p.service_address.to_string()), + pro_tx_hash: None, + collateral: Some(( + provider_hash_to_32(p.collateral_outpoint.txid.as_ref()), + p.collateral_outpoint.vout, + )), + 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() + }, + _ => 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 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 enum MasternodeStatus { + Active, + Inactive, + Retired, + #[default] + Unknown, +} + +impl MasternodeStatus { + pub 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 fn as_u8(self) -> u8 { + match self { + Self::Active => 0, + Self::Inactive => 1, + Self::Retired => 2, + Self::Unknown => 3, + } + } +} + +/// One masternode as the wallet layer knows it: the aggregate of a +/// wallet's provider special transactions grouped by proTxHash +/// (`source == Wallet`). Pure/testable output of +/// [`aggregate_masternodes`]; the FFI layers flatten it into their wire +/// entry (`MasternodeEntryFFI`) and own nothing but the encoding. +#[derive(Default, Debug, Clone)] +pub struct MasternodeRecord { + /// 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, + /// Platform HTTP (DAPI gRPC) port from the same ProRegTx / ProUpServTx + /// that set `service_address` — evonodes only, `None` for a regular + /// masternode or a pre-v19 payload without platform fields. With the + /// service IP this addresses the node's DAPI (`https://:`). + pub platform_http_port: Option, + /// Height that set `service_address` / `platform_http_port` (drives + /// latest-wins). + pub(crate) 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). + pub(crate) voting_height: u32, + /// Operator BLS public key (48 bytes) — follows the latest ProRegTx / + /// ProUpReg. + pub operator_public_key: Option<[u8; 48]>, + pub(crate) operator_height: u32, + /// Platform node id (SHA256[..20] Tenderdash, #884, 20 bytes) for evonodes — follows the + /// latest ProRegTx / ProUpServ. + pub platform_node_id: Option<[u8; 20]>, + pub(crate) 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>, + pub(crate) 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 + /// 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, + /// Where this record came from — see [`MasternodeSource`]. + pub source: MasternodeSource, + /// Stable cross-type position in the caller's sorted record list + /// (the "Masternode N" ordering key); assigned by the lister, 0 from + /// [`aggregate_masternodes`] alone. + pub order_index: u32, + /// Derive-and-compare ownership of the operator BLS key: the wallet's + /// `ProviderOperatorKeys` index whose public key (modern or legacy + /// serialization) equals `operator_public_key`. `None` when not in the + /// wallet or unresolved. + pub operator_key_index: Option, + /// Derive-and-compare ownership of the platform node key: the wallet's + /// `ProviderPlatformKeys` index whose Tenderdash node id equals + /// `platform_node_id`. `None` when not in the wallet or unresolved. + pub platform_key_index: Option, + /// Host-facing display label. Only tracked records carry one; wallet + /// aggregation always leaves it `None`. + pub label: Option, + /// Whether the platform-node ownership check was actually *possible*: + /// `true` when the wallet's derived platform-node index had entries to + /// compare against, `false` when it was empty / unavailable (no platform + /// pool, or a seedless restore before the persisted key batch rehydrated + /// it). Lets a persister distinguish a definitive + /// `platform_key_index == None` (checked, not ours) from "couldn't check + /// yet", so it never clobbers stale ownership. + pub platform_ownership_checked: bool, +} + +/// Provenance of a [`MasternodeRecord`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MasternodeSource { + /// Aggregated from a wallet's own retained provider transactions — + /// the masternode is registered with (some of) that wallet's keys. + #[default] + Wallet, + /// Deliberately tracked by the user, independent of every wallet + /// (see [`super::tracked`]). + Tracked, +} + +impl MasternodeSource { + /// FFI wire value: 0 wallet, 1 tracked. + pub fn as_u8(self) -> u8 { + match self { + Self::Wallet => 0, + Self::Tracked => 1, + } + } +} + +/// 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 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; + + // Each input item is `(height, in_block_position, tx)`. Core's + // `RebuildListFromBlock` applies same-block provider updates in + // `block.vtx` order, so the per-field latest-wins below must resolve + // ties by `(height, position)`, not by the arbitrary txid order the + // caller's `BTreeMap` dedup produces. Process ascending + // `(height, position)` so the block-latest write for each field lands + // last and wins under the `>= *_height` guards. Stable so equal keys + // keep their incoming order. + // + // The position is stamped onto `BlockInfo` during block processing + // (rust-dashcore#891) and round-tripped through persistence; legacy + // rows confirmed before the field existed come back as 0 and fall + // back to feed order among themselves. + let mut ordered: Vec<(u32, u32, &'a dashcore::Transaction)> = txs.collect(); + ordered.sort_by_key(|(height, position, _)| (*height, *position)); + + let mut order: Vec<[u8; 32]> = Vec::new(); + let mut by_hash: std::collections::HashMap<[u8; 32], MasternodeRecord> = + std::collections::HashMap::new(); + + for (height, _position, tx) in ordered { + // 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); + MasternodeRecord { + 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.platform_http_port = p.platform_http_port; + 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; + } + 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. + // `platform_node_id` is a `PlatformNodeId` newtype + // (rust-dashcore #885) whose `consensus_decode` normalizes + // the wire's reversed uint160-internal bytes to the + // canonical Tenderdash `SHA256(pubkey)[..20]` order + // (rust-dashcore #887/#889), so `to_byte_array()` here is + // already canonical and matches the derived ownership + // index (`accessors.rs`) and dashmate display directly — + // do NOT reverse platform-side. + if let Some(node_id) = p.platform_node_id { + agg.platform_node_id = Some(node_id.to_byte_array()); + 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.platform_http_port = p.platform_http_port; + agg.service_height = height; + } + // ProUpServ's `platform_node_id` is now `Option` + // (rust-dashcore #885, was `Option<[u8; 20]>`); decoded bytes + // are canonical forward order (see the ProRegTx arm above). + 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.to_byte_array()); + 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; + 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; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + /// ProRegTx provider payload is lifted from the DIP-3 special-tx + /// body for the UI. Fixture is the testnet + /// collateral-provider-registration transaction from rust-dashcore's + /// own `provider_registration` tests + /// (`test_collateral_provider_registration_transaction`), whose + /// service address is `1.2.5.6:19999` and whose owner/voting key + /// hashes are asserted below. ProRegTx carries no explicit + /// `pro_tx_hash` (its own txid is the proTxHash), so that field + /// stays `None`. + #[test] + fn provider_registration_payload_fields_extracted() { + let raw = "0300010001ca9a43051750da7c5f858008f2ff7732d15691e48eb7f845c791e5dca78bab58010000006b483045022100fe8fec0b3880bcac29614348887769b0b589908e3f5ec55a6cf478a6652e736502202f30430806a6690524e4dd599ba498e5ff100dea6a872ebb89c2fd651caa71ed012103d85b25d6886f0b3b8ce1eef63b720b518fad0b8e103eba4e85b6980bfdda2dfdffffffff018e37807e090000001976a9144ee1d4e5d61ac40a13b357ac6e368997079678c888ac00000000fd1201010000000000ca9a43051750da7c5f858008f2ff7732d15691e48eb7f845c791e5dca78bab580000000000000000000000000000ffff010205064e1f3dd03f9ec192b5f275a433bfc90f468ee1a3eb4c157b10706659e25eb362b5d902d809f9160b1688e201ee6e94b40f9b5062d7074683ef05a2d5efb7793c47059c878dfad38a30fafe61575db40f05ab0a08d55119b0aad300001976a9144fbc8fb6e11e253d77e5a9c987418e89cf4a63d288ac3477990b757387cb0406168c2720acf55f83603736a314a37d01b135b873a27b411fb37e49c1ff2b8057713939a5513e6e711a71cff2e517e6224df724ed750aef1b7f9ad9ec612b4a7250232e1e400da718a9501e1d9a5565526e4b1ff68c028763"; + let bytes = hex::decode(raw).expect("valid fixture hex"); + let tx: dashcore::Transaction = + dashcore::consensus::encode::deserialize(&bytes).expect("decode ProRegTx"); + + let fields = provider_payload_fields(&tx); + + assert_eq!( + fields.service_address.as_deref(), + Some("1.2.5.6:19999"), + "service address must be lifted from the ProRegTx payload" + ); + assert!( + fields.collateral.is_some(), + "ProRegTx carries a collateral outpoint" + ); + assert_eq!( + hex::encode(fields.owner_key_hash.expect("owner key hash")), + "3dd03f9ec192b5f275a433bfc90f468ee1a3eb4c" + ); + assert_eq!( + hex::encode(fields.voting_key_hash.expect("voting key hash")), + "d38a30fafe61575db40f05ab0a08d55119b0aad3" + ); + assert!( + fields.pro_tx_hash.is_none(), + "ProRegTx has no explicit pro_tx_hash" + ); + } + + /// ProUpServTx (provider-update-service) also carries a service + /// address — reconstructed here from its little-endian IPv6-mapped + /// `ip_address` + `port` — plus an explicit `pro_tx_hash` linking it + /// to the registration. Fixture is rust-dashcore's own + /// `test_provider_update_service_transaction` vector, whose endpoint + /// is `52.36.64.148:19999`. The `pro_tx_hash` is asserted in raw + /// wire order (what `to_32(txid.as_ref())` stores) — the reverse of + /// the block-explorer display form. + #[test] + fn provider_update_service_payload_fields_extracted() { + let raw = "03000200018f3fe6683e36326669b6e34876fb2a2264e8327e822f6fec304b66f47d61b3e1010000006b48304502210082af6727408f0f2ec16c7da1c42ccf0a026abea6a3a422776272b03c8f4e262a022033b406e556f6de980b2d728e6812b3ae18ee1c863ae573ece1cbdf777ca3e56101210351036c1192eaf763cd8345b44137482ad24b12003f23e9022ce46752edf47e6effffffff0180220e43000000001976a914123cbc06289e768ca7d743c8174b1e6eeb610f1488ac00000000b501003a72099db84b1c1158568eec863bea1b64f90eccee3304209cebe1df5e7539fd00000000000000000000ffff342440944e1f00e6725f799ea20480f06fb105ebe27e7c4845ab84155e4c2adf2d6e5b73a998b1174f9621bbeda5009c5a6487bdf75edcf602b67fe0da15c275cc91777cb25f5fd4bb94e84fd42cb2bb547c83792e57c80d196acd47020e4054895a0640b7861b3729c41dd681d4996090d5750f65c4b649a5cd5b2bdf55c880459821e53d91c9"; + let bytes = hex::decode(raw).expect("valid fixture hex"); + let tx: dashcore::Transaction = + dashcore::consensus::encode::deserialize(&bytes).expect("decode ProUpServTx"); + + let fields = provider_payload_fields(&tx); + + assert_eq!( + fields.service_address.as_deref(), + Some("52.36.64.148:19999"), + "ProUpServTx endpoint must be rebuilt from ip_address + port" + ); + assert_eq!( + fields.pro_tx_hash.map(hex::encode).as_deref(), + Some("3a72099db84b1c1158568eec863bea1b64f90eccee3304209cebe1df5e7539fd"), + "ProUpServTx carries an explicit pro_tx_hash (wire order)" + ); + assert!( + fields.collateral.is_none(), + "ProUpServTx has no collateral outpoint" + ); + assert!(fields.owner_key_hash.is_none()); + assert!(fields.voting_key_hash.is_none()); + } + + /// A plain (non-provider) transaction yields no provider fields, so + /// the FFI record emits null/zeroed/`false` for all of them. + #[test] + fn non_provider_tx_has_no_provider_fields() { + let tx = dashcore::Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let fields = provider_payload_fields(&tx); + assert!(fields.service_address.is_none()); + assert!(fields.pro_tx_hash.is_none()); + assert!(fields.collateral.is_none()); + 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, 0u32, ®)].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()); + // #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().is_some_and(|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!( + mn.platform_http_port.is_none(), + "legacy regular-MN fixture has no platform HTTP port" + ); + 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, 0u32, &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, 0u32, ®), (200u32, 0u32, &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, 0u32, &rev), (100u32, 0u32, ®)].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, 0u32, ®)].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, 0u32, ®ular), (200u32, 0u32, &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"); + } + + /// Two provider updates for one masternode in the SAME block must resolve + /// the per-field latest-wins by in-block `position`, matching Core's + /// `block.vtx` order — NOT by the arbitrary txid order the caller's + /// `BTreeMap` dedup would otherwise impose. Feed the same pair in + /// both orders; the higher-positioned (block-latest) update wins each time, + /// proving position — not feed/txid order — decides the outcome. + #[test] + fn same_block_updates_resolve_by_position_not_txid() { + use dashcore::blockdata::transaction::special_transaction::provider_update_service::ProviderUpdateServicePayload; + use dashcore::transaction::TransactionPayload; + + // Shared registration linkage ⇒ both updates land in one bucket. + let pro_tx_hash = decode_tx(PROREG_HEX).txid(); + let group_key = provider_hash_to_32(pro_tx_hash.as_ref()); + + // Build a ProUpServTx directly (no raw-hex vector needed); `port` + // distinguishes the resulting service address, `inputs` perturbs the + // txid so the two txs are genuinely distinct. + let make_upserv = |port: u16, inputs: u8| -> dashcore::Transaction { + let payload = ProviderUpdateServicePayload { + version: 1, + mn_type: None, + pro_tx_hash, + ip_address: 42, + port, + script_payout: dashcore::ScriptBuf::new(), + inputs_hash: [inputs; 32].into(), + platform_node_id: None, + platform_p2p_port: None, + platform_http_port: None, + payload_sig: [0u8; 96].into(), + }; + dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some( + TransactionPayload::ProviderUpdateServicePayloadType(payload), + ), + } + }; + + let low = make_upserv(19000, 3); // in-block position 0 + let high = make_upserv(19999, 4); // in-block position 1 (block-latest) + + for feed in [ + [(500u32, 0u32, &low), (500u32, 1u32, &high)], + // Reversed feed order (block-latest fed first): position, not feed + // order, must still pick the winner. + [(500u32, 1u32, &high), (500u32, 0u32, &low)], + ] { + let mns = aggregate_masternodes(feed.into_iter(), unavailable_dml); + assert_eq!(mns.len(), 1, "same proTxHash ⇒ one bucket"); + assert_eq!(mns[0].pro_tx_hash, group_key); + assert!( + mns[0] + .service_address + .as_deref() + .unwrap_or_default() + .ends_with(":19999"), + "higher in-block position (block-latest) must win; got {:?}", + mns[0].service_address + ); + assert_eq!(mns[0].tx_count, 2, "both updates counted"); + } + } + + /// The platform HTTP port travels with the service endpoint: the ProRegTx + /// seeds it and a later ProUpServTx replaces it (latest-wins), so the + /// DAPI address the wallet builds follows the node's current config. + #[test] + fn platform_http_port_follows_the_service_update() { + use dashcore::blockdata::transaction::special_transaction::provider_update_service::ProviderUpdateServicePayload; + use dashcore::transaction::special_transaction::provider_registration::ProviderMasternodeType; + use dashcore::transaction::TransactionPayload; + + let mut reg = decode_tx(PROREG_HEX); + if let Some(TransactionPayload::ProviderRegistrationPayloadType(p)) = + &mut reg.special_transaction_payload + { + p.masternode_type = ProviderMasternodeType::HighPerformance; + p.platform_http_port = Some(443); + } + let pro_tx_hash = reg.txid(); + + let upserv = dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some( + TransactionPayload::ProviderUpdateServicePayloadType( + ProviderUpdateServicePayload { + version: 2, + mn_type: Some(1), // HighPerformance (evonode) + pro_tx_hash, + ip_address: 42, + port: 19999, + script_payout: dashcore::ScriptBuf::new(), + inputs_hash: [7u8; 32].into(), + platform_node_id: None, + platform_p2p_port: Some(36656), + platform_http_port: Some(1443), + payload_sig: [0u8; 96].into(), + }, + ), + ), + }; + + // Registration alone ⇒ the ProRegTx port. + let mns = aggregate_masternodes([(100u32, 0u32, ®)].into_iter(), unavailable_dml); + assert_eq!(mns.len(), 1); + assert_eq!(mns[0].platform_http_port, Some(443)); + + // A later ProUpServTx replaces it along with the service address. + let mns = aggregate_masternodes( + [(100u32, 0u32, ®), (200u32, 0u32, &upserv)].into_iter(), + unavailable_dml, + ); + assert_eq!(mns.len(), 1, "same proTxHash ⇒ one bucket"); + assert_eq!(mns[0].platform_http_port, Some(1443)); + assert!( + mns[0] + .service_address + .as_deref() + .unwrap_or_default() + .ends_with(":19999"), + "service address and platform port move together" + ); + } +} diff --git a/packages/rs-platform-wallet/src/masternode/tracked.rs b/packages/rs-platform-wallet/src/masternode/tracked.rs new file mode 100644 index 00000000000..475f9cfd44d --- /dev/null +++ b/packages/rs-platform-wallet/src/masternode/tracked.rs @@ -0,0 +1,1178 @@ +//! Tracked masternodes: nodes the user follows that belong to NO wallet. +//! +//! A tracked masternode is a registry row — proTxHash, optional label, and a +//! cached [`TrackedMasternodeSnapshot`] of everything the wallet layer has +//! learned about the node from three sources: +//! +//! * the deterministic masternode list (service address, operator key, +//! voting key id, platform node id, validity) — refreshed on every read; +//! * the node's Platform **owner identity** (id = display-order proTxHash; +//! key 0 = payout-address TRANSFER key, key 1 = owner OWNER key) and +//! **operator identity** (operator payout TRANSFER key) — the only +//! sources for the owner / payout key hashes an SPV client can't see; +//! * the ProRegTx itself via DAPI Core `getTransaction` (registration +//! height, collateral, original keys / payout script). +//! +//! Secrets never enter this module: keys a user attaches to a tracked node +//! live in the host's secure storage (Keychain / Keystore) and are passed +//! per call into [`PlatformWalletManager::tracked_masternode_withdraw`], +//! mirroring `dash_sdk_contested_resource_cast_vote`. +//! +//! Persistence goes through +//! [`PlatformWalletPersistence::persist_tracked_masternodes`] / +//! [`load_tracked_masternodes`](PlatformWalletPersistence::load_tracked_masternodes) +//! as a whole-set replace per network (the set is user-curated and small). +//! A backend that doesn't implement the pair simply keeps tracking +//! session-scoped; hosts read +//! [`PersistenceCapabilities::TRACKED_MASTERNODES`] to know which they got. + +use std::collections::BTreeMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use dash_sdk::platform::Fetch; +use dashcore::hashes::Hash; +use dashcore::transaction::special_transaction::provider_registration::ProviderMasternodeType; +use dashcore::transaction::TransactionPayload; +use dashcore::{Address as DashAddress, Network}; +use dpp::identifier::MasternodeIdentifiers; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{Identity, Purpose}; +use dpp::prelude::Identifier; +use serde_json::{json, Value}; + +use super::list::MasternodeListSummary; +use super::locator::{p2pkh_script_hash, MasternodeKeyReference, MasternodeKeyRole}; +use super::record::{ListMembership, MasternodeRecord, MasternodeSource, MasternodeStatus}; +use crate::changeset::{PersistenceCapabilities, PlatformWalletPersistence}; +use crate::error::PlatformWalletError; +use crate::manager::PlatformWalletManager; +use crate::wallet::masternode_withdrawal::{ + execute_masternode_withdrawal, MasternodeWithdrawalKey, RawSecretCoreSigner, +}; + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +/// What the ProRegTx said at registration. Fetched once (DAPI Core +/// `getTransaction`) and cached; the DML / Platform snapshots carry the +/// *current* values where they can change. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistrationDetails { + /// Confirmation height of the ProRegTx (0 = still unknown). + pub height: u32, + /// Collateral outpoint (txid wire bytes, vout). + pub collateral: ([u8; 32], u32), + pub owner_key_hash: [u8; 20], + pub voting_key_hash: [u8; 20], + pub operator_public_key: [u8; 48], + /// Raw payout script as registered. + pub payout_script: Vec, + /// `"ip:port"` as registered. + pub service_address: Option, + pub is_evonode: bool, + pub platform_node_id: Option<[u8; 20]>, + pub platform_http_port: Option, +} + +/// Key hashes learned from the node's Platform identities — the fields the +/// masternode list does not carry. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PlatformKeySnapshot { + /// OWNER key of the owner identity. + pub owner_key_hash: Option<[u8; 20]>, + /// TRANSFER key of the owner identity = hash160 behind the CURRENT + /// payout address (what a withdrawal is signed with / paid to). + pub payout_key_hash: Option<[u8; 20]>, + /// TRANSFER key of the operator identity. + pub operator_payout_key_hash: Option<[u8; 20]>, + /// Owner identity balance in credits at the last refresh (the + /// claimable amount). Display hint only — hosts re-read live before a + /// withdrawal. + pub owner_identity_balance: Option, +} + +/// Everything learned about a tracked masternode so far. Every field is +/// re-fetchable; missing pieces stay `None` and the record models them as +/// unknown rather than inventing defaults. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TrackedMasternodeSnapshot { + /// The node's DML entry as of the last refresh. + pub list: Option, + /// The node has been seen on the list at least once — distinguishes + /// "retired" (was listed, now gone) from "never confirmed". + pub ever_listed: bool, + pub registration: Option, + pub platform: Option, + /// Unix seconds of the last fully successful + /// [`PlatformWalletManager::refresh_tracked_masternode`]. + pub refreshed_at: Option, +} + +/// One tracked masternode — the persisted registry row. No secrets. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrackedMasternode { + /// proTxHash, wire order (like every other `[u8; 32]` proTxHash in + /// this crate). + pub pro_tx_hash: [u8; 32], + pub label: Option, + /// Unix seconds when the user tracked it. + pub added_at: u64, + pub snapshot: TrackedMasternodeSnapshot, +} + +impl TrackedMasternode { + /// The key references this node's snapshot can verify a key against. + /// Current values win over registration-time values. + pub fn key_reference(&self) -> MasternodeKeyReference { + let list = self.snapshot.list.as_ref(); + let reg = self.snapshot.registration.as_ref(); + let platform = self.snapshot.platform.as_ref(); + MasternodeKeyReference { + owner_key_hash: platform + .and_then(|p| p.owner_key_hash) + .or(reg.map(|r| r.owner_key_hash)), + voting_key_id: list + .map(|l| l.voting_key_id) + .or(reg.map(|r| r.voting_key_hash)), + operator_public_key: list + .map(|l| l.operator_public_key) + .or(reg.map(|r| r.operator_public_key)), + platform_node_id: list + .and_then(|l| l.platform_node_id) + .or(reg.and_then(|r| r.platform_node_id)), + payout_key_hash: platform + .and_then(|p| p.payout_key_hash) + .or(reg.and_then(|r| p2pkh_script_hash(&r.payout_script))), + operator_payout_key_hash: platform.and_then(|p| p.operator_payout_key_hash), + } + } + + /// Build the display record. `list_now` is the node's CURRENT list + /// entry: `None` = the DML isn't available, `Some(None)` = available + /// but the node is gone (retired), `Some(Some(_))` = present. + pub fn record(&self, list_now: Option>) -> MasternodeRecord { + let live = list_now.flatten(); + let cached = self.snapshot.list.as_ref(); + let list = live.or(cached); + let reg = self.snapshot.registration.as_ref(); + let platform = self.snapshot.platform.as_ref(); + + let membership = match list_now { + None => ListMembership::ListUnavailable, + Some(None) => ListMembership::Absent, + Some(Some(entry)) => { + if entry.is_valid { + ListMembership::ValidEntry + } else { + ListMembership::InvalidEntry + } + } + }; + + // Payout: the owner identity's CURRENT transfer key wins over the + // registered script; both may be absent before enrichment. + let payout_script = platform + .and_then(|p| p.payout_key_hash) + .map(|hash| p2pkh_script(&hash)) + .or_else(|| reg.map(|r| r.payout_script.clone())); + + MasternodeRecord { + pro_tx_hash: self.pro_tx_hash, + has_registration: reg.is_some(), + registration_height: reg.map(|r| r.height).unwrap_or(0), + service_address: list + .and_then(|l| l.service_address.map(|a| a.to_string())) + .or_else(|| reg.and_then(|r| r.service_address.clone())), + platform_http_port: list + .and_then(|l| l.platform_http_port) + .or(reg.and_then(|r| r.platform_http_port)), + is_evonode: list + .map(|l| l.is_evonode) + .unwrap_or_else(|| reg.map(|r| r.is_evonode).unwrap_or(false)), + owner_key_hash: platform + .and_then(|p| p.owner_key_hash) + .or(reg.map(|r| r.owner_key_hash)), + voting_key_hash: list + .map(|l| l.voting_key_id) + .or(reg.map(|r| r.voting_key_hash)), + operator_public_key: list + .map(|l| l.operator_public_key) + .or(reg.map(|r| r.operator_public_key)), + platform_node_id: list + .and_then(|l| l.platform_node_id) + .or(reg.and_then(|r| r.platform_node_id)), + payout_script, + collateral: reg.map(|r| r.collateral), + revoked: false, + revocation_reason: 0, + tx_count: 0, + type_index: 0, // assigned by the lister + status: MasternodeStatus::from_membership(membership), + source: MasternodeSource::Tracked, + order_index: 0, // assigned by the lister + operator_key_index: None, + platform_key_index: None, + platform_ownership_checked: false, + label: self.label.clone(), + service_height: 0, + voting_height: 0, + operator_height: 0, + platform_node_height: 0, + payout_height: 0, + } + } +} + +/// `OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG`. +fn p2pkh_script(hash: &[u8; 20]) -> Vec { + let mut script = Vec::with_capacity(25); + script.extend_from_slice(&[0x76, 0xa9, 0x14]); + script.extend_from_slice(hash); + script.extend_from_slice(&[0x88, 0xac]); + script +} + +// --------------------------------------------------------------------------- +// What a set of attached keys enables +// --------------------------------------------------------------------------- + +/// What a host can do with a masternode given the key roles it holds for +/// it. Pure policy shared by both mobile hosts, so the gating never +/// diverges. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct MasternodeCapabilities { + /// Withdraw the owner identity's claimable balance (owner key — the + /// identity's OWNER key — or the payout-address TRANSFER key; a + /// withdrawal transition accepts either purpose). + pub can_withdraw: bool, + /// Cast governance / contested-resource votes. + pub can_vote: bool, + /// Sign ProUpServTx (operator BLS key). + pub can_update_service: bool, + /// Prove which Tenderdash node this is (no wallet action uses it). + pub identifies_platform_node: bool, +} + +/// Capabilities for the roles a host holds keys for. +pub fn capabilities_for_roles( + roles: impl IntoIterator, +) -> MasternodeCapabilities { + let mut caps = MasternodeCapabilities::default(); + for role in roles { + match role { + MasternodeKeyRole::Owner | MasternodeKeyRole::OwnerPayout => caps.can_withdraw = true, + MasternodeKeyRole::Voting => caps.can_vote = true, + MasternodeKeyRole::Operator => caps.can_update_service = true, + MasternodeKeyRole::PlatformNode => caps.identifies_platform_node = true, + MasternodeKeyRole::OperatorPayout => {} + } + } + caps +} + +// --------------------------------------------------------------------------- +// Snapshot JSON codec (persistence wire format) +// --------------------------------------------------------------------------- +// +// Hand-rolled over `serde_json::Value` so the crate's optional `serde` +// derive feature stays optional. Versioned; readers are lenient (a missing +// or malformed section is simply "not learned yet"), so the format can grow +// fields without a migration. + +const SNAPSHOT_JSON_VERSION: u64 = 1; + +fn hex_opt(bytes: Option<[u8; N]>) -> Value { + bytes + .map(hex::encode) + .map(Value::String) + .unwrap_or(Value::Null) +} + +fn parse_hex(value: &Value) -> Option<[u8; N]> { + let bytes = hex::decode(value.as_str()?).ok()?; + bytes.as_slice().try_into().ok() +} + +fn list_to_json(list: &MasternodeListSummary) -> Value { + json!({ + "proTxHash": hex::encode(list.pro_tx_hash), + "serviceAddress": list.service_address.map(|a| a.to_string()), + "platformHttpPort": list.platform_http_port, + "operatorPubKey": hex::encode(list.operator_public_key), + "votingKeyId": hex::encode(list.voting_key_id), + "platformNodeId": hex_opt(list.platform_node_id), + "isValid": list.is_valid, + "isEvonode": list.is_evonode, + }) +} + +fn list_from_json(value: &Value) -> Option { + Some(MasternodeListSummary { + pro_tx_hash: parse_hex(&value["proTxHash"])?, + service_address: value["serviceAddress"] + .as_str() + .and_then(|s| s.parse().ok()), + platform_http_port: value["platformHttpPort"].as_u64().map(|p| p as u16), + operator_public_key: parse_hex(&value["operatorPubKey"])?, + voting_key_id: parse_hex(&value["votingKeyId"])?, + platform_node_id: parse_hex(&value["platformNodeId"]), + is_valid: value["isValid"].as_bool()?, + is_evonode: value["isEvonode"].as_bool()?, + }) +} + +fn registration_to_json(reg: &RegistrationDetails) -> Value { + json!({ + "height": reg.height, + "collateralTxid": hex::encode(reg.collateral.0), + "collateralVout": reg.collateral.1, + "ownerKeyHash": hex::encode(reg.owner_key_hash), + "votingKeyHash": hex::encode(reg.voting_key_hash), + "operatorPubKey": hex::encode(reg.operator_public_key), + "payoutScript": hex::encode(®.payout_script), + "serviceAddress": reg.service_address, + "isEvonode": reg.is_evonode, + "platformNodeId": hex_opt(reg.platform_node_id), + "platformHttpPort": reg.platform_http_port, + }) +} + +fn registration_from_json(value: &Value) -> Option { + Some(RegistrationDetails { + height: value["height"].as_u64()? as u32, + collateral: ( + parse_hex(&value["collateralTxid"])?, + value["collateralVout"].as_u64()? as u32, + ), + owner_key_hash: parse_hex(&value["ownerKeyHash"])?, + voting_key_hash: parse_hex(&value["votingKeyHash"])?, + operator_public_key: parse_hex(&value["operatorPubKey"])?, + payout_script: hex::decode(value["payoutScript"].as_str()?).ok()?, + service_address: value["serviceAddress"].as_str().map(str::to_string), + is_evonode: value["isEvonode"].as_bool()?, + platform_node_id: parse_hex(&value["platformNodeId"]), + platform_http_port: value["platformHttpPort"].as_u64().map(|p| p as u16), + }) +} + +fn platform_to_json(platform: &PlatformKeySnapshot) -> Value { + json!({ + "ownerKeyHash": hex_opt(platform.owner_key_hash), + "payoutKeyHash": hex_opt(platform.payout_key_hash), + "operatorPayoutKeyHash": hex_opt(platform.operator_payout_key_hash), + "ownerIdentityBalance": platform.owner_identity_balance, + }) +} + +fn platform_from_json(value: &Value) -> Option { + if !value.is_object() { + return None; + } + Some(PlatformKeySnapshot { + owner_key_hash: parse_hex(&value["ownerKeyHash"]), + payout_key_hash: parse_hex(&value["payoutKeyHash"]), + operator_payout_key_hash: parse_hex(&value["operatorPayoutKeyHash"]), + owner_identity_balance: value["ownerIdentityBalance"].as_u64(), + }) +} + +/// Serialize a snapshot for the persistence row. +pub fn snapshot_to_json(snapshot: &TrackedMasternodeSnapshot) -> String { + json!({ + "v": SNAPSHOT_JSON_VERSION, + "everListed": snapshot.ever_listed, + "refreshedAt": snapshot.refreshed_at, + "list": snapshot.list.as_ref().map(list_to_json), + "registration": snapshot.registration.as_ref().map(registration_to_json), + "platform": snapshot.platform.as_ref().map(platform_to_json), + }) + .to_string() +} + +/// Read a persisted snapshot. Lenient: an unreadable document yields the +/// empty snapshot ("nothing learned yet"), and each section is optional — +/// tracked rows are cache, every field is re-fetchable. +pub fn snapshot_from_json(text: &str) -> TrackedMasternodeSnapshot { + let Ok(value) = serde_json::from_str::(text) else { + return TrackedMasternodeSnapshot::default(); + }; + TrackedMasternodeSnapshot { + list: list_from_json(&value["list"]), + ever_listed: value["everListed"].as_bool().unwrap_or(false), + registration: registration_from_json(&value["registration"]), + platform: platform_from_json(&value["platform"]), + refreshed_at: value["refreshedAt"].as_u64(), + } +} + +// --------------------------------------------------------------------------- +// Registry (manager surface) +// --------------------------------------------------------------------------- + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn not_tracked(pro_tx_hash: &[u8; 32]) -> PlatformWalletError { + let mut display = *pro_tx_hash; + display.reverse(); + PlatformWalletError::InvalidParameter(format!( + "masternode {} is not tracked", + hex::encode(display) + )) +} + +/// Assign `order_index` / `type_index` over a sorted record list, the same +/// per-type numbering the wallet aggregation uses. +fn number_records(records: &mut [MasternodeRecord]) { + let (mut evonode_n, mut masternode_n) = (0u32, 0u32); + for (idx, record) in records.iter_mut().enumerate() { + record.order_index = idx as u32; + if record.is_evonode { + evonode_n += 1; + record.type_index = evonode_n; + } else { + masternode_n += 1; + record.type_index = masternode_n; + } + } +} + +/// Shared handle to the tracked-masternode registry and everything its +/// operations need (SPV for the list, the SDK for Platform / DAPI, the +/// persister for durability). Cloneable and `Send + Sync`, so hosts can run +/// the network operations ([`Self::refresh`], [`Self::withdraw`]) on a +/// worker without holding the manager. Built by +/// [`PlatformWalletManager::tracked_masternodes_service`]; every clone +/// shares one registry. +#[derive(Clone)] +pub struct TrackedMasternodes { + registry: std::sync::Arc>, + spv: std::sync::Arc, + sdk: std::sync::Arc, + persister: std::sync::Arc, + network: Network, +} + +impl std::fmt::Debug for TrackedMasternodes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TrackedMasternodes") + .field("network", &self.network) + .finish_non_exhaustive() + } +} + +impl TrackedMasternodes { + /// Whether the configured persister keeps tracked masternodes across + /// restarts. When `false`, tracking still works but is session-scoped — + /// hosts surface that rather than pretending durability. + pub fn durable(&self) -> bool { + self.persister + .persistence_capabilities() + .contains(PersistenceCapabilities::TRACKED_MASTERNODES) + } + + /// Write the whole registry through the persister (whole-set replace + /// for this network — the set is small and user-curated). + fn persist(&self) -> Result<(), PlatformWalletError> { + let records: Vec = { + let guard = self + .registry + .read() + .expect("tracked masternode registry lock poisoned"); + guard.values().cloned().collect() + }; + self.persister + .persist_tracked_masternodes(self.network, &records) + .map_err(|e| { + PlatformWalletError::WalletCreation(format!( + "failed to persist tracked masternodes: {e}" + )) + }) + } + + /// Hydrate the registry from the persister. A load failure logs and + /// leaves the registry empty rather than failing wallet hydration. + pub(crate) fn load_from_persistence(&self) { + match self.persister.load_tracked_masternodes(self.network) { + Ok(rows) => { + let mut guard = self + .registry + .write() + .expect("tracked masternode registry lock poisoned"); + for row in rows { + guard.insert(row.pro_tx_hash, row); + } + } + Err(e) => { + tracing::warn!(error = %e, "failed to load tracked masternodes; starting empty"); + } + } + } + + /// The wire proTxHashes currently tracked (for locate's + /// `already_tracked` mark). + pub fn hashes(&self) -> std::collections::BTreeSet<[u8; 32]> { + self.registry + .read() + .expect("tracked masternode registry lock poisoned") + .keys() + .copied() + .collect() + } + + /// The tracked row itself (snapshot included), when present. + pub fn get(&self, pro_tx_hash: &[u8; 32]) -> Option { + self.registry + .read() + .expect("tracked masternode registry lock poisoned") + .get(pro_tx_hash) + .cloned() + } + + /// Track `pro_tx_hash` (wire order). Seeds the snapshot from the + /// current DML entry when the list is available (local; no network — + /// call [`Self::refresh`] afterwards for the Platform / registration + /// details). Errors when already tracked. Blocking. + pub fn track_blocking( + &self, + pro_tx_hash: [u8; 32], + label: Option, + ) -> Result { + let list_now = self.spv.masternode_list_summaries_blocking(); + let entry = list_now + .as_ref() + .map(|summaries| summaries.iter().find(|s| s.pro_tx_hash == pro_tx_hash)); + + let tracked = { + let mut guard = self + .registry + .write() + .expect("tracked masternode registry lock poisoned"); + if guard.contains_key(&pro_tx_hash) { + return Err(PlatformWalletError::InvalidParameter( + "this masternode is already tracked".to_string(), + )); + } + let entry = entry.flatten(); + let tracked = TrackedMasternode { + pro_tx_hash, + label: label.filter(|l| !l.trim().is_empty()), + added_at: now_unix(), + snapshot: TrackedMasternodeSnapshot { + list: entry.cloned(), + ever_listed: entry.is_some(), + ..Default::default() + }, + }; + guard.insert(pro_tx_hash, tracked.clone()); + tracked + }; + + self.persist()?; + Ok(tracked.record(entry)) + } + + /// Stop tracking. Returns `Ok(true)` when a row was removed. The + /// host owns any attached keys (secure storage) and deletes them + /// itself. Blocking. + pub fn untrack_blocking(&self, pro_tx_hash: &[u8; 32]) -> Result { + let removed = { + let mut guard = self + .registry + .write() + .expect("tracked masternode registry lock poisoned"); + guard.remove(pro_tx_hash).is_some() + }; + // Persist unconditionally: an earlier call may have removed the row + // from memory and then failed the write, so a retry arrives with + // `removed == false` while the stale row still sits on disk — a + // skipped persist would resurrect the node on the next start. + self.persist()?; + Ok(removed) + } + + /// Rename a tracked masternode (`None` / blank clears the label). + /// Blocking. + pub fn set_label_blocking( + &self, + pro_tx_hash: &[u8; 32], + label: Option, + ) -> Result<(), PlatformWalletError> { + { + let mut guard = self + .registry + .write() + .expect("tracked masternode registry lock poisoned"); + let tracked = guard + .get_mut(pro_tx_hash) + .ok_or_else(|| not_tracked(pro_tx_hash))?; + tracked.label = label.filter(|l| !l.trim().is_empty()); + } + self.persist() + } + + /// Every tracked masternode as a display record, with the CURRENT list + /// entry overlaid (status Active / Inactive / Retired, or Unknown when + /// the list isn't available). Sorted by when they were tracked. + /// Blocking. + pub fn list_blocking(&self) -> Vec { + let list_now = self.spv.masternode_list_summaries_blocking(); + self.records_with(list_now) + } + + fn records_with(&self, list_now: Option>) -> Vec { + let mut rows: Vec = { + let guard = self + .registry + .read() + .expect("tracked masternode registry lock poisoned"); + guard.values().cloned().collect() + }; + rows.sort_by_key(|t| (t.added_at, t.pro_tx_hash)); + let mut records: Vec = rows + .iter() + .map(|tracked| { + let entry = list_now + .as_ref() + .map(|s| s.iter().find(|e| e.pro_tx_hash == tracked.pro_tx_hash)); + tracked.record(entry) + }) + .collect(); + number_records(&mut records); + records + } + + /// Refresh everything the wallet layer can learn about a tracked + /// masternode: its DML entry (local), its owner / operator identities + /// on Platform (owner + payout key hashes, claimable balance), and — + /// once — its ProRegTx via DAPI Core (registration height, collateral, + /// original keys). Partial results are kept and persisted before an + /// error is returned, so a flaky step never discards what an earlier + /// step learned; `refreshed_at` advances only on a fully successful + /// pass. + pub async fn refresh( + &self, + pro_tx_hash: &[u8; 32], + ) -> Result { + let mut tracked = self + .get(pro_tx_hash) + .ok_or_else(|| not_tracked(pro_tx_hash))?; + + // 1. Current list entry (local). + let list_now = self.spv.masternode_list_summaries().await; + let entry = list_now + .as_ref() + .map(|s| s.iter().find(|e| e.pro_tx_hash == *pro_tx_hash)); + if let Some(Some(entry)) = &entry { + tracked.snapshot.list = Some((*entry).clone()); + tracked.snapshot.ever_listed = true; + } + + let mut display = *pro_tx_hash; + display.reverse(); + let mut first_error: Option = None; + + // 2. Owner identity: owner + payout key hashes and the claimable + // balance. + match Identity::fetch(self.sdk.as_ref(), Identifier::from(display)).await { + Ok(Some(identity)) => { + let mut platform = tracked.snapshot.platform.clone().unwrap_or_default(); + for key in identity.public_keys().values() { + let data: Option<[u8; 20]> = key.data().as_slice().try_into().ok(); + match key.purpose() { + Purpose::OWNER => { + platform.owner_key_hash = data.or(platform.owner_key_hash) + } + Purpose::TRANSFER => { + platform.payout_key_hash = data.or(platform.payout_key_hash) + } + _ => {} + } + } + platform.owner_identity_balance = Some(identity.balance()); + tracked.snapshot.platform = Some(platform); + } + Ok(None) => { + // No owner identity (node registered before Platform, or a + // lagging replica) — leave the platform snapshot as-is. + } + Err(e) => { + first_error.get_or_insert(PlatformWalletError::InvalidIdentityData(format!( + "failed to fetch the masternode's owner identity: {e}" + ))); + } + } + + // 3. Operator identity (needs the operator key — list, else + // registration). + let operator_key = tracked + .snapshot + .list + .as_ref() + .map(|l| l.operator_public_key) + .or(tracked + .snapshot + .registration + .as_ref() + .map(|r| r.operator_public_key)); + if let Some(operator_key) = operator_key { + let operator_id = Identifier::create_operator_identifier(&display, &operator_key); + match Identity::fetch(self.sdk.as_ref(), operator_id).await { + Ok(Some(identity)) => { + let mut platform = tracked.snapshot.platform.clone().unwrap_or_default(); + platform.operator_payout_key_hash = identity + .public_keys() + .values() + .find(|k| k.purpose() == Purpose::TRANSFER) + .and_then(|k| k.data().as_slice().try_into().ok()) + .or(platform.operator_payout_key_hash); + tracked.snapshot.platform = Some(platform); + } + Ok(None) => {} + Err(e) => { + first_error.get_or_insert(PlatformWalletError::InvalidIdentityData(format!( + "failed to fetch the masternode's operator identity: {e}" + ))); + } + } + } + + // 4. ProRegTx (once): registration height, collateral, original + // keys / payout script. + if tracked.snapshot.registration.is_none() { + match self.sdk.get_transaction(&hex::encode(display)).await { + Ok(Some(fetched)) => { + if let Some(details) = + registration_from_transaction(&fetched.transaction, fetched.height) + { + tracked.snapshot.registration = Some(details); + } + } + Ok(None) => {} + Err(e) => { + first_error.get_or_insert(PlatformWalletError::InvalidIdentityData(format!( + "failed to fetch the registration transaction: {e}" + ))); + } + } + } + + if first_error.is_none() { + tracked.snapshot.refreshed_at = Some(now_unix()); + } + + // Keep + persist whatever was learned, even on a partial failure — + // but only while the node is STILL tracked: an untrack that raced + // the network calls must win (no resurrection), and a concurrent + // relabel keeps its label (only the snapshot is refreshed here). + let still_tracked = { + let mut guard = self + .registry + .write() + .expect("tracked masternode registry lock poisoned"); + match guard.get_mut(pro_tx_hash) { + Some(live) => { + live.snapshot = tracked.snapshot.clone(); + tracked.label = live.label.clone(); + true + } + None => false, + } + }; + if !still_tracked { + return Err(not_tracked(pro_tx_hash)); + } + self.persist()?; + + match first_error { + Some(e) => Err(e), + None => Ok(tracked.record(entry)), + } + } + + /// Withdraw from a TRACKED masternode's owner identity with a + /// host-supplied key: the owner key (`role == Owner`; pays the + /// registered payout address, no destination allowed) or the + /// payout-address key (`role == OwnerPayout`; destination optional, + /// defaults to the payout address itself). The key is used for this + /// call only — nothing is retained. Returns the identity's new + /// balance in credits. + pub async fn withdraw( + &self, + pro_tx_hash: &[u8; 32], + amount_credits: u64, + role: MasternodeKeyRole, + secret: &[u8; 32], + destination: Option, + ) -> Result { + let tracked = self + .get(pro_tx_hash) + .ok_or_else(|| not_tracked(pro_tx_hash))?; + let reference = tracked.key_reference(); + let network = self.network; + + let signer = RawSecretCoreSigner::from_bytes(secret)?; + let key_hash = signer.public_key_hash160(); + + let (signing_key, expected, destination) = match role { + MasternodeKeyRole::Owner => { + if destination.is_some() { + return Err(PlatformWalletError::InvalidParameter( + "an owner-key withdrawal pays the registered payout address; a \ + destination cannot be chosen" + .to_string(), + )); + } + let expected = reference.owner_key_hash.ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "the masternode's owner key isn't known yet — refresh the node first" + .to_string(), + ) + })?; + (MasternodeWithdrawalKey::Owner, expected, None) + } + MasternodeKeyRole::OwnerPayout => { + let expected = reference.payout_key_hash.ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "the masternode's payout address isn't known yet — refresh the node first" + .to_string(), + ) + })?; + // Default destination: the payout address itself. + let destination_text = match destination { + Some(text) => text, + None => p2pkh_address(&expected, network), + }; + let destination = destination_text + .parse::>() + .map_err(|e| { + PlatformWalletError::InvalidParameter(format!( + "destination is not a valid Dash address: {e}" + )) + })? + .require_network(network) + .map_err(|e| { + PlatformWalletError::InvalidParameter(format!( + "destination is for another network: {e}" + )) + })?; + ( + MasternodeWithdrawalKey::Transfer, + expected, + Some(destination), + ) + } + _ => { + return Err(PlatformWalletError::InvalidParameter( + "a withdrawal signs with the owner key or the payout-address key".to_string(), + )) + } + }; + + // Refuse a mismatched key BEFORE any network work — same + // derive-and-compare the identity signer re-checks later. + if key_hash != expected { + return Err(PlatformWalletError::InvalidParameter(format!( + "this key does not match the masternode's {} (hash160 {} vs {})", + match role { + MasternodeKeyRole::Owner => "owner key", + _ => "payout address key", + }, + hex::encode(key_hash), + hex::encode(expected), + ))); + } + + // The signer ignores the path; pass the DIP-3 owner base so logs + // stay meaningful. + let path = crate::wallet::masternode_withdrawal::provider_owner_key_path(network, 0)?; + execute_masternode_withdrawal( + self.sdk.as_ref(), + *pro_tx_hash, + amount_credits, + signing_key, + expected, + path, + destination, + &signer, + ) + .await + } +} + +impl PlatformWalletManager

{ + /// The tracked-masternode registry as a cloneable, manager-independent + /// service handle. Every handle shares this manager's registry. + pub fn tracked_masternodes_service(&self) -> TrackedMasternodes { + TrackedMasternodes { + registry: std::sync::Arc::clone(&self.tracked_masternodes), + spv: self.spv_arc(), + sdk: self.sdk_arc(), + persister: std::sync::Arc::clone(&self.persister) as _, + network: self.sdk().network, + } + } + + /// Hydrate the tracked registry from the persister (startup). + pub(crate) fn load_tracked_masternodes_from_persistence(&self) { + self.tracked_masternodes_service().load_from_persistence(); + } + + /// See [`TrackedMasternodes::hashes`]. + pub fn tracked_masternode_hashes(&self) -> std::collections::BTreeSet<[u8; 32]> { + self.tracked_masternodes_service().hashes() + } + + /// See [`TrackedMasternodes::get`]. + pub fn tracked_masternode(&self, pro_tx_hash: &[u8; 32]) -> Option { + self.tracked_masternodes_service().get(pro_tx_hash) + } +} + +/// Base58 P2PKH address for `hash` on `network`. +fn p2pkh_address(hash: &[u8; 20], network: Network) -> String { + use dashcore::address::Payload; + use dashcore::PubkeyHash; + DashAddress::new( + network, + Payload::PubkeyHash(PubkeyHash::from_byte_array(*hash)), + ) + .to_string() +} + +/// Lift a ProRegTx into [`RegistrationDetails`]; `None` for any other +/// transaction. +pub fn registration_from_transaction( + tx: &dashcore::Transaction, + height: u32, +) -> Option { + let Some(TransactionPayload::ProviderRegistrationPayloadType(p)) = + &tx.special_transaction_payload + else { + return None; + }; + let mut collateral_txid = [0u8; 32]; + collateral_txid.copy_from_slice(p.collateral_outpoint.txid.as_ref()); + let mut owner_key_hash = [0u8; 20]; + owner_key_hash.copy_from_slice(p.owner_key_hash.as_ref()); + let mut voting_key_hash = [0u8; 20]; + voting_key_hash.copy_from_slice(p.voting_key_hash.as_ref()); + let operator: &[u8; 48] = p.operator_public_key.as_ref(); + Some(RegistrationDetails { + height, + collateral: (collateral_txid, p.collateral_outpoint.vout), + owner_key_hash, + voting_key_hash, + operator_public_key: *operator, + payout_script: p.script_payout.as_bytes().to_vec(), + service_address: Some(p.service_address.to_string()), + is_evonode: p.masternode_type == ProviderMasternodeType::HighPerformance, + platform_node_id: p.platform_node_id.map(|id| id.to_byte_array()), + platform_http_port: p.platform_http_port, + }) +} + +/// Whole-registry map type held by the manager. +pub(crate) type TrackedMasternodeMap = BTreeMap<[u8; 32], TrackedMasternode>; + +#[cfg(test)] +mod tests { + use super::super::list::test_support::{evonode, masternode}; + use super::*; + + fn snapshot_full() -> TrackedMasternodeSnapshot { + TrackedMasternodeSnapshot { + list: Some(evonode(7)), + ever_listed: true, + registration: Some(RegistrationDetails { + height: 1000, + collateral: ([9u8; 32], 1), + owner_key_hash: [1u8; 20], + voting_key_hash: [2u8; 20], + operator_public_key: [3u8; 48], + payout_script: p2pkh_script(&[4u8; 20]), + service_address: Some("1.2.3.4:9999".to_string()), + is_evonode: true, + platform_node_id: Some([5u8; 20]), + platform_http_port: Some(443), + }), + platform: Some(PlatformKeySnapshot { + owner_key_hash: Some([1u8; 20]), + payout_key_hash: Some([6u8; 20]), + operator_payout_key_hash: Some([7u8; 20]), + owner_identity_balance: Some(123_456_789_000), + }), + refreshed_at: Some(1_700_000_000), + } + } + + #[test] + fn snapshot_json_round_trips() { + for snapshot in [ + TrackedMasternodeSnapshot::default(), + snapshot_full(), + TrackedMasternodeSnapshot { + list: Some(masternode(3)), + ever_listed: true, + ..Default::default() + }, + ] { + let encoded = snapshot_to_json(&snapshot); + assert_eq!(snapshot_from_json(&encoded), snapshot, "{encoded}"); + } + } + + #[test] + fn unreadable_snapshot_json_degrades_to_empty() { + assert_eq!( + snapshot_from_json("not json"), + TrackedMasternodeSnapshot::default() + ); + assert_eq!( + snapshot_from_json("{\"v\":999}"), + TrackedMasternodeSnapshot::default() + ); + } + + fn tracked() -> TrackedMasternode { + TrackedMasternode { + pro_tx_hash: [7u8; 32], + label: Some("my node".to_string()), + added_at: 1, + snapshot: snapshot_full(), + } + } + + #[test] + fn record_prefers_live_list_then_snapshot_then_registration() { + let t = tracked(); + // Live entry present and valid ⇒ Active, live fields win. + let mut live = evonode(7); + live.platform_http_port = Some(1443); + let record = t.record(Some(Some(&live))); + assert_eq!(record.status, MasternodeStatus::Active); + assert_eq!(record.platform_http_port, Some(1443)); + assert_eq!(record.source, MasternodeSource::Tracked); + assert_eq!(record.label.as_deref(), Some("my node")); + // Platform payout key wins over the registered script. + assert_eq!(record.payout_script, Some(p2pkh_script(&[6u8; 20]))); + assert_eq!(record.owner_key_hash, Some([1u8; 20])); + assert!(record.has_registration); + assert_eq!(record.registration_height, 1000); + + // List available but node gone ⇒ Retired (snapshot fields still + // render). + let record = t.record(Some(None)); + assert_eq!(record.status, MasternodeStatus::Retired); + assert!(record.service_address.is_some()); + + // List unavailable ⇒ Unknown, never a fabricated Active. + let record = t.record(None); + assert_eq!(record.status, MasternodeStatus::Unknown); + + // PoSe-banned live entry ⇒ Inactive. + let mut banned = evonode(7); + banned.is_valid = false; + assert_eq!( + t.record(Some(Some(&banned))).status, + MasternodeStatus::Inactive + ); + } + + #[test] + fn bare_tracked_row_models_everything_unknown() { + let bare = TrackedMasternode { + pro_tx_hash: [1u8; 32], + label: None, + added_at: 0, + snapshot: TrackedMasternodeSnapshot::default(), + }; + let record = bare.record(None); + assert_eq!(record.status, MasternodeStatus::Unknown); + assert!(!record.has_registration); + assert_eq!(record.owner_key_hash, None); + assert_eq!(record.payout_script, None); + assert_eq!(record.service_address, None); + let reference = bare.key_reference(); + assert_eq!(reference, MasternodeKeyReference::default()); + } + + #[test] + fn key_reference_prefers_current_values() { + let reference = tracked().key_reference(); + // Voting / operator / node id come from the (current) list entry of + // `evonode(7)`, not the registration. + assert_eq!(reference.voting_key_id, Some([7u8; 20])); + assert_eq!(reference.operator_public_key, Some([7u8; 48])); + assert_eq!(reference.platform_node_id, Some([7u8 ^ 0xFF; 20])); + // Owner / payout come from Platform. + assert_eq!(reference.owner_key_hash, Some([1u8; 20])); + assert_eq!(reference.payout_key_hash, Some([6u8; 20])); + assert_eq!(reference.operator_payout_key_hash, Some([7u8; 20])); + } + + #[test] + fn capabilities_follow_roles() { + use MasternodeKeyRole::*; + assert_eq!( + capabilities_for_roles([]), + MasternodeCapabilities::default() + ); + assert!(capabilities_for_roles([Owner]).can_withdraw); + assert!(capabilities_for_roles([OwnerPayout]).can_withdraw); + assert!(!capabilities_for_roles([Voting]).can_withdraw); + assert!(capabilities_for_roles([Voting]).can_vote); + assert!(capabilities_for_roles([Operator]).can_update_service); + assert!(capabilities_for_roles([PlatformNode]).identifies_platform_node); + let all = capabilities_for_roles(MasternodeKeyRole::ALL); + assert!(all.can_withdraw && all.can_vote && all.can_update_service); + } + + #[test] + fn numbering_is_per_type_in_order() { + let t = tracked(); + let mut records = vec![ + { + let mut r = t.record(None); + r.is_evonode = false; + r + }, + t.record(None), + t.record(None), + ]; + number_records(&mut records); + assert_eq!(records[0].order_index, 0); + assert_eq!(records[0].type_index, 1, "Masternode 1"); + assert_eq!(records[1].type_index, 1, "Evonode 1"); + assert_eq!(records[2].type_index, 2, "Evonode 2"); + } + + #[test] + fn registration_details_lift_from_a_proregtx() { + // Reuse the aggregation fixture: rust-dashcore's testnet ProRegTx. + let raw = "0300010001ca9a43051750da7c5f858008f2ff7732d15691e48eb7f845c791e5dca78bab58010000006b483045022100fe8fec0b3880bcac29614348887769b0b589908e3f5ec55a6cf478a6652e736502202f30430806a6690524e4dd599ba498e5ff100dea6a872ebb89c2fd651caa71ed012103d85b25d6886f0b3b8ce1eef63b720b518fad0b8e103eba4e85b6980bfdda2dfdffffffff018e37807e090000001976a9144ee1d4e5d61ac40a13b357ac6e368997079678c888ac00000000fd1201010000000000ca9a43051750da7c5f858008f2ff7732d15691e48eb7f845c791e5dca78bab580000000000000000000000000000ffff010205064e1f3dd03f9ec192b5f275a433bfc90f468ee1a3eb4c157b10706659e25eb362b5d902d809f9160b1688e201ee6e94b40f9b5062d7074683ef05a2d5efb7793c47059c878dfad38a30fafe61575db40f05ab0a08d55119b0aad300001976a9144fbc8fb6e11e253d77e5a9c987418e89cf4a63d288ac3477990b757387cb0406168c2720acf55f83603736a314a37d01b135b873a27b411fb37e49c1ff2b8057713939a5513e6e711a71cff2e517e6224df724ed750aef1b7f9ad9ec612b4a7250232e1e400da718a9501e1d9a5565526e4b1ff68c028763"; + let bytes = hex::decode(raw).unwrap(); + let tx: dashcore::Transaction = dashcore::consensus::encode::deserialize(&bytes).unwrap(); + let details = registration_from_transaction(&tx, 4242).expect("ProRegTx lifts"); + assert_eq!(details.height, 4242); + assert_eq!(details.service_address.as_deref(), Some("1.2.5.6:19999")); + assert!(!details.is_evonode); + assert_eq!(details.platform_node_id, None); + assert!(p2pkh_script_hash(&details.payout_script).is_some()); + // A non-provider tx lifts nothing. + let plain = dashcore::Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + assert_eq!(registration_from_transaction(&plain, 1), None); + } +} diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index 38a65512744..4839b891e7b 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -20,6 +20,7 @@ use key_wallet_manager::WalletManager; use crate::broadcaster::BroadcastError; use crate::error::PlatformWalletError; use crate::events::PlatformEventManager; +use crate::masternode::list::MasternodeListSummary; use crate::spv::peers::{classify_peers, PeerTracker, SpvPeerInfo}; use crate::wallet::platform_wallet::PlatformWalletInfo; @@ -478,6 +479,36 @@ impl SpvRuntime { masternodes_by_voting_key(list, voting_key_id) } + /// Snapshot of the current-tip deterministic masternode list as typed + /// summaries. `None` when the list isn't available (SPV client not + /// running, engine not initialized, or masternode sync not complete). + /// Clones the engine `Arc` out under the client lock and reads the + /// engine without it — the two never nest, same as + /// [`Self::masternode_validity_snapshot_blocking`]. + pub async fn masternode_list_summaries(&self) -> Option> { + let engine = { + let client_guard = self.client.read().await; + let client = client_guard.as_ref()?; + client.masternode_list_engine().ok()? + }; + let engine_guard = engine.read().await; + let list = engine_guard.latest_masternode_list()?; + Some(MasternodeListSummary::all_from_list(list)) + } + + /// Blocking twin of [`Self::masternode_list_summaries`] for FFI threads + /// (`blocking_read`; never call from the async runtime). + pub fn masternode_list_summaries_blocking(&self) -> Option> { + 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()?; + Some(MasternodeListSummary::all_from_list(list)) + } + /// Get the current sync progress. /// /// Returns `None` if the SPV client is not running. diff --git a/packages/rs-platform-wallet/src/wallet/masternode_withdrawal.rs b/packages/rs-platform-wallet/src/wallet/masternode_withdrawal.rs index 19fa4871f90..f510e104d61 100644 --- a/packages/rs-platform-wallet/src/wallet/masternode_withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/masternode_withdrawal.rs @@ -304,20 +304,6 @@ impl PlatformWallet { keys: &MasternodeWithdrawalKeys, signer: &S, ) -> Result { - if request.amount_credits == 0 { - return Err(PlatformWalletError::InvalidParameter( - "masternode withdrawal amount must be greater than zero".to_string(), - )); - } - if !signer.supports(SignerMethod::Digest) { - return Err(PlatformWalletError::InvalidParameter(format!( - "signer backend cannot sign digests: it advertises {:?}, but an identity \ - credit withdrawal requires {:?}", - signer.supported_methods(), - SignerMethod::Digest, - ))); - } - // Resolve (path, expected hash160, destination) per signing key. let (path, expected_hash160, destination) = match request.signing_key { MasternodeWithdrawalKey::Owner => { @@ -381,118 +367,235 @@ impl PlatformWallet { } }; - // Owner identity id = display-order proTxHash. - let mut id_bytes = request.pro_tx_hash; - id_bytes.reverse(); - let identity_id = Identifier::from(id_bytes); + execute_masternode_withdrawal( + self.sdk(), + request.pro_tx_hash, + request.amount_credits, + request.signing_key, + expected_hash160, + path, + destination, + signer, + ) + .await + } +} - let identity = Identity::fetch(self.sdk(), identity_id) - .await? - .ok_or(PlatformWalletError::IdentityNotFound(identity_id))?; +/// The network half of a masternode withdrawal, shared by the wallet path +/// ([`PlatformWallet::masternode_withdraw`], key resolved by derivation) and +/// the tracked path (`PlatformWalletManager::tracked_masternode_withdraw`, +/// key supplied by the host): fetch the owner identity (id = display-order +/// proTxHash), select the OWNER / TRANSFER identity key matching +/// `expected_hash160`, sign with `signer` at `path` through +/// [`DerivedKeyIdentitySigner`] (which re-checks the derived key against +/// `expected_hash160` before emitting a signature), broadcast, and wait for +/// the proved balance. Error semantics are unchanged from #4451: definitive +/// rejections stay retryable, ambiguous outcomes are +/// [`PlatformWalletError::MasternodeWithdrawalUnconfirmed`]. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn execute_masternode_withdrawal( + sdk: &dash_sdk::Sdk, + pro_tx_hash: [u8; 32], + amount_credits: u64, + signing_key: MasternodeWithdrawalKey, + expected_hash160: [u8; 20], + path: DerivationPath, + destination: Option, + signer: &S, +) -> Result { + if amount_credits == 0 { + return Err(PlatformWalletError::InvalidParameter( + "masternode withdrawal amount must be greater than zero".to_string(), + )); + } + if !signer.supports(SignerMethod::Digest) { + return Err(PlatformWalletError::InvalidParameter(format!( + "signer backend cannot sign digests: it advertises {:?}, but an identity \ + credit withdrawal requires {:?}", + signer.supported_methods(), + SignerMethod::Digest, + ))); + } - let identity_key = match request.signing_key { - MasternodeWithdrawalKey::Owner => { - select_owner_withdrawal_key(identity.public_keys().values(), &expected_hash160) - } - MasternodeWithdrawalKey::Transfer => { - select_transfer_withdrawal_key(identity.public_keys().values(), &expected_hash160) - } + // Owner identity id = display-order proTxHash. + let mut id_bytes = pro_tx_hash; + id_bytes.reverse(); + let identity_id = Identifier::from(id_bytes); + + let identity = Identity::fetch(sdk, identity_id) + .await? + .ok_or(PlatformWalletError::IdentityNotFound(identity_id))?; + + let identity_key = match signing_key { + MasternodeWithdrawalKey::Owner => { + select_owner_withdrawal_key(identity.public_keys().values(), &expected_hash160) } - .cloned() - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData(format!( - "the masternode identity {identity_id} has no {} key matching this wallet's key \ - (hash160 {}); not broadcasting", - match request.signing_key { - MasternodeWithdrawalKey::Owner => "OWNER", - MasternodeWithdrawalKey::Transfer => "TRANSFER", - }, - hex::encode(expected_hash160), - )) - })?; + MasternodeWithdrawalKey::Transfer => { + select_transfer_withdrawal_key(identity.public_keys().values(), &expected_hash160) + } + } + .cloned() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "the masternode identity {identity_id} has no {} key matching this wallet's key \ + (hash160 {}); not broadcasting", + match signing_key { + MasternodeWithdrawalKey::Owner => "OWNER", + MasternodeWithdrawalKey::Transfer => "TRANSFER", + }, + hex::encode(expected_hash160), + )) + })?; - let identity_signer = DerivedKeyIdentitySigner { - signer, - path, - expected_key_hash160: expected_hash160, - }; + let identity_signer = DerivedKeyIdentitySigner { + signer, + path, + expected_key_hash160: expected_hash160, + }; - let sdk = self.sdk(); - let definitive = |e: dash_sdk::Error| { - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::InvalidIdentityData(format!( - "masternode withdrawal failed: {e}" - )) - }) - }; + let definitive = |e: dash_sdk::Error| { + crate::error::preserve_signer_key_unavailable_or(e, |e| { + PlatformWalletError::InvalidIdentityData(format!("masternode withdrawal failed: {e}")) + }) + }; - // Build + sign locally. Nothing has left the wallet yet, so every - // error here is definitive and retryable. - let nonce = sdk - .get_identity_nonce(identity_id, true, None) - .await - .map_err(definitive)?; - let output_script = destination.map(|address| CoreScript::new(address.script_pubkey())); - let state_transition = IdentityCreditWithdrawalTransition::try_from_identity( - &identity, - output_script, - request.amount_credits, - Pooling::Never, - MIN_CORE_FEE_PER_BYTE, - 0, - identity_signer, - Some(&identity_key), - PreferredKeyPurposeForSigningWithdrawal::TransferPreferred, - nonce, - sdk.version(), - None, - ) + // Build + sign locally. Nothing has left the wallet yet, so every + // error here is definitive and retryable. + let nonce = sdk + .get_identity_nonce(identity_id, true, None) .await - .map_err(|e| definitive(dash_sdk::Error::Protocol(e)))?; - - // Broadcast, then wait — split so an ambiguous outcome stays typed. - let unconfirmed = |reason: String| PlatformWalletError::MasternodeWithdrawalUnconfirmed { - identity_id, - amount_credits: request.amount_credits, - reason, - }; - match state_transition.broadcast(sdk, None).await { - Ok(()) => {} - Err(e) if broadcast_definitely_failed(&e) => return Err(definitive(e)), - Err(e) => { - tracing::warn!( - identity = %identity_id, - error = %e, - "masternode withdrawal broadcast returned no verdict; the transition may \ - have been admitted — falling through to the result wait" - ); - } + .map_err(definitive)?; + let output_script = destination.map(|address| CoreScript::new(address.script_pubkey())); + let state_transition = IdentityCreditWithdrawalTransition::try_from_identity( + &identity, + output_script, + amount_credits, + Pooling::Never, + MIN_CORE_FEE_PER_BYTE, + 0, + identity_signer, + Some(&identity_key), + PreferredKeyPurposeForSigningWithdrawal::TransferPreferred, + nonce, + sdk.version(), + None, + ) + .await + .map_err(|e| definitive(dash_sdk::Error::Protocol(e)))?; + + // Broadcast, then wait — split so an ambiguous outcome stays typed. + let unconfirmed = |reason: String| PlatformWalletError::MasternodeWithdrawalUnconfirmed { + identity_id, + amount_credits, + reason, + }; + match state_transition.broadcast(sdk, None).await { + Ok(()) => {} + Err(e) if broadcast_definitely_failed(&e) => return Err(definitive(e)), + Err(e) => { + tracing::warn!( + identity = %identity_id, + error = %e, + "masternode withdrawal broadcast returned no verdict; the transition may \ + have been admitted — falling through to the result wait" + ); } + } - match state_transition - .wait_for_affected_state::(sdk, None) - .await - { - Ok(StateTransitionProofResult::VerifiedPartialIdentity(partial)) => { - partial.balance.ok_or_else(|| { - unconfirmed("the result proof carried no identity balance".to_string()) - }) - } - // Proved, but not the shape a withdrawal produces — the transition - // landed; only the balance read-back is missing. - Ok(_) => Err(unconfirmed( - "the result proof did not carry the identity's balance".to_string(), - )), - Err(e) if carries_consensus_rejection(&e) => Err(definitive(e)), - Err(e) => Err(unconfirmed(e.to_string())), - } + match state_transition + .wait_for_affected_state::(sdk, None) + .await + { + Ok(StateTransitionProofResult::VerifiedPartialIdentity(partial)) => partial + .balance + .ok_or_else(|| unconfirmed("the result proof carried no identity balance".to_string())), + // Proved, but not the shape a withdrawal produces — the transition + // landed; only the balance read-back is missing. + Ok(_) => Err(unconfirmed( + "the result proof did not carry the identity's balance".to_string(), + )), + Err(e) if carries_consensus_rejection(&e) => Err(definitive(e)), + Err(e) => Err(unconfirmed(e.to_string())), + } +} + +/// A [`CoreSigner`] over ONE raw secp256k1 secret, path-agnostic — the +/// tracked-masternode withdrawal signer, where the key was supplied by the +/// host (keychain / Keystore) instead of derived from a wallet seed. Every +/// `sign_ecdsa` call answers with this key regardless of the requested +/// path; [`DerivedKeyIdentitySigner`] then re-checks the produced public +/// key against the masternode identity key's hash160, so a wrong key still +/// cannot produce a broadcastable signature. +pub struct RawSecretCoreSigner { + secret: dashcore::secp256k1::SecretKey, +} + +impl RawSecretCoreSigner { + /// `secret` must be a valid secp256k1 scalar (32 bytes). + pub fn from_bytes(secret: &[u8; 32]) -> Result { + let secret = dashcore::secp256k1::SecretKey::from_slice(secret).map_err(|_| { + PlatformWalletError::InvalidParameter("not a valid secp256k1 private key".to_string()) + })?; + Ok(Self { secret }) + } + + /// hash160 of this key's compressed public key. + pub fn public_key_hash160(&self) -> [u8; 20] { + let secp = Secp256k1::signing_only(); + let public = dashcore::secp256k1::PublicKey::from_secret_key(&secp, &self.secret); + hash160::Hash::hash(&public.serialize()).to_byte_array() + } +} + +impl fmt::Debug for RawSecretCoreSigner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RawSecretCoreSigner") + .finish_non_exhaustive() + } +} + +#[async_trait] +impl CoreSigner for RawSecretCoreSigner { + type Error = String; + + fn supported_methods(&self) -> &[SignerMethod] { + &[SignerMethod::Digest] + } + + async fn sign_ecdsa( + &self, + _path: &DerivationPath, + sighash: [u8; 32], + ) -> Result< + ( + dashcore::secp256k1::ecdsa::Signature, + dashcore::secp256k1::PublicKey, + ), + Self::Error, + > { + let secp = Secp256k1::new(); + let msg = Message::from_digest(sighash); + Ok(( + secp.sign_ecdsa(&msg, &self.secret), + dashcore::secp256k1::PublicKey::from_secret_key(&secp, &self.secret), + )) + } + + async fn public_key( + &self, + _path: &DerivationPath, + ) -> Result { + Ok(dashcore::secp256k1::PublicKey::from_secret_key( + &Secp256k1::new(), + &self.secret, + )) } } /// `m/9'/coin'/3'/2'/index` — the `ProviderOwnerKeys` account base path plus /// the (non-hardened) key index, exactly as the account's address pool /// derives it. -fn provider_owner_key_path( +pub(crate) fn provider_owner_key_path( network: Network, index: u32, ) -> Result { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 47d3e29fbe4..6d17cd115e1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -39,7 +39,8 @@ public enum DashModelContainer { PersistentShieldedViewingKey.self, PersistentAssetLock.self, PersistentInvitation.self, - PersistentMasternode.self + PersistentMasternode.self, + PersistentTrackedMasternode.self ] } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTrackedMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTrackedMasternode.swift new file mode 100644 index 00000000000..07945f69e11 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTrackedMasternode.swift @@ -0,0 +1,57 @@ +import Foundation +import SwiftData + +/// SwiftData row for a TRACKED (wallet-independent) masternode — a node the +/// user follows that belongs to no wallet. +/// +/// Deliberately NOT a `PersistentMasternode`: that model is keyed and +/// network-scoped by its owning `walletId`, which a tracked node doesn't +/// have. This row is keyed by `(networkRaw, proTxHash)` directly, survives +/// deleting any single wallet, and is removed only by untracking (or a +/// reset-all). +/// +/// The row is pure storage for the Rust tracked-masternode registry +/// (`platform_wallet::masternode::tracked`): `snapshotJSON` is the +/// versioned, Rust-produced cache of everything learned about the node +/// (its list entry, Platform identity key hashes, registration details) — +/// PUBLIC material only, decoded exclusively by Rust. Keys the user +/// attaches to a tracked node live in the host's secure storage +/// (Keychain), never here. +@Model +public final class PersistentTrackedMasternode { + #Unique([\.networkRaw, \.proTxHash]) + #Index([\.networkRaw]) + + /// `Network.rawValue` of the network the node lives on. + public var networkRaw: UInt32 + /// proTxHash (32 raw wire bytes) — same orientation as + /// `PersistentMasternode.proTxHash`. + public var proTxHash: Data + /// Optional user label. + public var label: String? + /// Unix seconds when the user tracked it. + public var addedAt: UInt64 + /// Versioned snapshot document produced by Rust + /// (`snapshot_to_json`); stored opaquely and handed back verbatim on + /// restore. + public var snapshotJSON: String + + public var network: Network? { + get { Network(rawValue: networkRaw) } + set { networkRaw = newValue?.rawValue ?? networkRaw } + } + + public init( + networkRaw: UInt32, + proTxHash: Data, + label: String?, + addedAt: UInt64, + snapshotJSON: String + ) { + self.networkRaw = networkRaw + self.proTxHash = proTxHash + self.label = label + self.addedAt = addedAt + self.snapshotJSON = snapshotJSON + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index c3e33764823..33621de6756 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -65,6 +65,10 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// Tracked asset-lock rows, including status and proof updates, can be /// persisted. Restart hydration is separately attested by `walletRestore`. public static let trackedAssetLocks: UInt64 = 1 << 9 + /// Tracked (wallet-independent) masternodes are persisted and restored + /// across restarts. Mirrors + /// `PersistenceCapabilities::TRACKED_MASTERNODES`. + public static let trackedMasternodes: UInt64 = 1 << 10 public let version: UInt32 public let bits: UInt64 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeLocator.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeLocator.swift new file mode 100644 index 00000000000..552ba299612 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeLocator.swift @@ -0,0 +1,248 @@ +import DashSDKFFI +import Foundation + +/// A private key's job on a masternode. Raw values are the FFI wire values; +/// the first four line up with the Android wallet's `MasternodeKeyType`. +public enum MasternodeKeyRole: UInt8, CaseIterable, Sendable, Hashable { + /// secp256k1; signs ProUpRegTx, OWNER key of the Platform owner identity + /// (can sign withdrawals). + case owner = 0 + /// secp256k1; governance / contested-resource voting. + case voting = 1 + /// BLS12-381; signs ProUpServTx. + case `operator` = 2 + /// ed25519; the Tenderdash node key — identifies an evonode, signs + /// nothing a wallet does. + case platformNode = 3 + /// secp256k1 key of the owner payout address — the TRANSFER key of the + /// owner identity, i.e. what withdraws owner rewards. + case ownerPayout = 4 + /// secp256k1 key of the operator payout address. + case operatorPayout = 5 + + /// Roles encoded in an FFI bit mask (bit = raw value). + static func roles(fromMask mask: UInt8) -> [MasternodeKeyRole] { + allCases.filter { mask & (1 << $0.rawValue) != 0 } + } +} + +/// How the locator found a masternode. +public enum MasternodeLocatorMatchKind: UInt8, Sendable { + case proTxHash = 0 + case serviceAddress = 1 + /// A pasted private key — see `MasternodeLocateMatch.matchedKeys`. + case key = 2 +} + +/// Outcome of the optional Platform step of a locate. +public enum MasternodePlatformLookup: UInt8, Sendable { + /// The input had no secp256k1 key, so Platform had nothing to add. + case notNeeded = 0 + /// A secp256k1 key was given but `searchPlatform` was off. + case notRequested = 1 + /// Ran to completion. + case done = 2 + /// Attempted and failed; the local matches stand, owner / payout roles + /// were not checked. `MasternodeLocateResult.platformError` says why. + case unavailable = 3 +} + +/// One masternode the locator text names: what the deterministic masternode +/// list knows about it, plus how it was matched. +public struct MasternodeLocateMatch: Sendable, Hashable { + /// proTxHash, 32 WIRE-order bytes — same orientation as + /// `PlatformMasternode.proTxHash`. + public let proTxHash: Data + /// `"ip:port"` of the Core P2P endpoint; `nil` for Tor / I2P-only entries. + public let serviceAddress: String? + /// Platform HTTP (DAPI) port — evonodes only. + public let platformHTTPPort: UInt16? + /// Operator BLS public key as serialized in the list (48 bytes). + public let operatorPublicKey: Data + /// Voting key id (hash160, 20 bytes). + public let votingKeyId: Data + /// Tenderdash node id (20 bytes) — evonodes only. + public let platformNodeId: Data? + /// `false` when PoSe-banned. + public let isValid: Bool + public let isEvonode: Bool + public let matchedBy: MasternodeLocatorMatchKind + /// Roles the pasted key fills on this masternode (empty unless + /// `matchedBy == .key`). Usually one; a key used as both owner and + /// voting key yields two. + public let matchedKeys: [MasternodeKeyRole] + /// Set when this masternode is already one of a loaded wallet's own — + /// show "already in wallet" instead of offering to track it. + public let inWalletId: Data? + /// Already in the tracked-masternode registry — jump to it instead of + /// tracking twice. + public let alreadyTracked: Bool + + /// proTxHash in display (explorer / Tenderdash / identity id) order. + public var proTxHashHex: String { + Data(proTxHash.reversed()).map { String(format: "%02x", $0) }.joined() + } + + /// `https://host:port` of the node's DAPI, when it is an evonode with a + /// routable service address. + public var platformDAPIAddress: String? { + guard let port = platformHTTPPort, let host = serviceHost else { return nil } + // An IPv6 literal must be bracketed in a URI authority. Locator + // matches carry Rust `SocketAddr` strings (already bracketed), but + // mirror `PlatformMasternode.platformDAPIAddress` so the two can + // never diverge on a bare literal. + let authorityHost = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host + return "https://\(authorityHost):\(port)" + } + + /// Host part of `serviceAddress` (IPv6 keeps its brackets). + public var serviceHost: String? { + guard let address = serviceAddress else { return nil } + if address.hasPrefix("[") { + guard let close = address.firstIndex(of: "]") else { return nil } + return String(address[...close]) + } + guard let colon = address.lastIndex(of: ":") else { return address } + return String(address[.. MasternodeLocateResult { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidParameter("Manager not configured") + } + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { () -> MasternodeLocateResult in + var outMatches: UnsafePointer? + var outCount: UInt = 0 + var outLookup: UInt8 = 0 + var outError: UnsafeMutablePointer? + let ffiResult = text.withCString { cText in + platform_wallet_manager_locate_masternode( + handle, cText, searchPlatform, + &outMatches, &outCount, &outLookup, &outError + ) + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + defer { + if let entries = outMatches, outCount > 0 { + platform_wallet_manager_free_masternode_matches( + UnsafeMutablePointer(mutating: entries), outCount) + } + if let error = outError { + platform_wallet_string_free(error) + } + } + let platformError = outError.map { String(cString: $0) } + let lookup = MasternodePlatformLookup(rawValue: outLookup) ?? .unavailable + guard let entries = outMatches, outCount > 0 else { + return MasternodeLocateResult( + matches: [], platformLookup: lookup, platformError: platformError) + } + let matches = (0.. MasternodeLocateMatch in + var entry = entries[i] + return MasternodeLocateMatch( + proTxHash: withUnsafeBytes(of: &entry.pro_tx_hash) { Data($0) }, + serviceAddress: entry.service_address.map { String(cString: $0) }, + platformHTTPPort: entry.has_platform_http_port ? entry.platform_http_port : nil, + operatorPublicKey: withUnsafeBytes(of: &entry.operator_public_key) { Data($0) }, + votingKeyId: withUnsafeBytes(of: &entry.voting_key_id) { Data($0) }, + platformNodeId: entry.has_platform_node_id + ? withUnsafeBytes(of: &entry.platform_node_id) { Data($0) } + : nil, + isValid: entry.is_valid, + isEvonode: entry.is_evonode, + matchedBy: MasternodeLocatorMatchKind(rawValue: entry.matched_by) ?? .proTxHash, + matchedKeys: MasternodeKeyRole.roles(fromMask: entry.matched_key_roles), + inWalletId: entry.in_wallet + ? withUnsafeBytes(of: &entry.wallet_id) { Data($0) } + : nil, + alreadyTracked: entry.already_tracked + ) + } + return MasternodeLocateResult( + matches: matches, platformLookup: lookup, platformError: platformError) + }.value + } + + /// Check `key` against the `role` key of masternode `proTxHash` (32 + /// WIRE-order bytes). The reference is the list entry (voting / operator + /// / platform node) merged with the owning wallet's record (owner / + /// payout) when the node is one of a loaded wallet's masternodes. + /// `.unverifiable` means the reference for that role isn't known — it is + /// NOT a pass. Throws `.invalidParameter` when `key` isn't a key of the + /// role's curve (or is a WIF for the other network) and `.notFound` when + /// neither the list nor any wallet knows the masternode. Local; no + /// network. + public func verifyMasternodeKey( + proTxHash: Data, + role: MasternodeKeyRole, + key: String + ) throws -> MasternodeKeyVerification { + guard isConfigured, handle != NULL_HANDLE, proTxHash.count == 32 else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, or proTxHash not 32 bytes") + } + var out: UInt8 = MasternodeKeyVerification.unverifiable.rawValue + let ffiResult = proTxHash.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + key.withCString { cKey in + platform_wallet_manager_masternode_verify_key( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + role.rawValue, + cKey, + &out + ) + } + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return MasternodeKeyVerification(rawValue: out) ?? .unverifiable + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift index f9483027a6b..1902c28b56f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift @@ -8,6 +8,15 @@ import DashSDKFFI /// /// All aggregation / DIP-3 decoding happens in Rust — this is pure /// bridging. +/// Provenance of a [`PlatformMasternode`] record. Raw values are the FFI +/// wire values. +public enum MasternodeSource: UInt8, Sendable, Hashable { + /// Aggregated from a wallet's own retained provider transactions. + case wallet = 0 + /// Deliberately tracked by the user, independent of every wallet. + case tracked = 1 +} + public struct PlatformMasternode: Sendable { /// proTxHash (32 raw wire bytes) — the group key / registration txid. public let proTxHash: Data @@ -68,6 +77,12 @@ public struct PlatformMasternode: Sendable { /// clobbering it. When true, `platformInWallet` is definitive (true OR /// false), so an on-chain rotation to an external key correctly clears it. public let platformOwnershipChecked: Bool + /// Where this record came from: one of the wallet's own masternodes, + /// or a node the user tracks independently of every wallet. + public let source: MasternodeSource + /// User label of a tracked masternode (`nil` for wallet records and + /// unnamed tracked ones). + public let label: String? } extension PlatformMasternode { @@ -138,7 +153,18 @@ extension PlatformWalletManager { ) } - return (0.., + count: Int + ) -> [PlatformMasternode] { + (0..) { + let mask = roles.reduce(UInt8(0)) { $0 | (1 << $1.rawValue) } + var bits: UInt8 = 0 + var result = platform_wallet_masternode_capabilities(mask, &bits) + platform_wallet_ffi_result_free(&result) + canWithdraw = bits & 1 != 0 + canVote = bits & (1 << 1) != 0 + canUpdateService = bits & (1 << 2) != 0 + identifiesPlatformNode = bits & (1 << 3) != 0 + } +} + +extension PlatformWalletManager { + /// Whether tracked masternodes survive an app restart with the + /// configured persistence backend. When `false` (a host that didn't + /// wire the tracked-masternode persistence callbacks), tracking still + /// works but is session-scoped. + public var trackedMasternodesAreDurable: Bool { + persistenceCapabilities.contains( + PlatformWalletPersistenceCapabilities.trackedMasternodes) + } + + /// Track the masternode `proTxHash` (32 WIRE-order bytes, as + /// `MasternodeLocateMatch.proTxHash` carries it) independently of any + /// wallet, with an optional label. Local — seeds the record from the + /// current masternode list; call `refreshTrackedMasternode` afterwards + /// for the Platform / registration details. Returns the new record + /// (`source == .tracked`). Throws `.invalidParameter` when already + /// tracked. + @discardableResult + public func trackMasternode( + proTxHash: Data, + label: String? = nil + ) throws -> PlatformMasternode { + try trackedEntryCall(proTxHash: proTxHash) { handle, hashPtr, out, count in + if let label, !label.isEmpty { + return label.withCString { cLabel in + platform_wallet_manager_track_masternode(handle, hashPtr, cLabel, out, count) + } + } + return platform_wallet_manager_track_masternode(handle, hashPtr, nil, out, count) + } + } + + /// Stop tracking. Returns whether a row existed. Keys the app stored + /// for this node live in ITS secure storage and are the app's to + /// delete. + @discardableResult + public func untrackMasternode(proTxHash: Data) throws -> Bool { + guard isConfigured, handle != NULL_HANDLE, proTxHash.count == 32 else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, or proTxHash not 32 bytes") + } + var removed = false + let ffiResult = proTxHash.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_manager_untrack_masternode( + handle, raw.baseAddress?.assumingMemoryBound(to: UInt8.self), &removed) + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return removed + } + + /// Rename a tracked masternode (`nil` / blank clears the label). + public func setTrackedMasternodeLabel(proTxHash: Data, label: String?) throws { + guard isConfigured, handle != NULL_HANDLE, proTxHash.count == 32 else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, or proTxHash not 32 bytes") + } + let ffiResult = proTxHash.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + let hashPtr = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) + if let label, !label.isEmpty { + return label.withCString { cLabel in + platform_wallet_manager_set_tracked_masternode_label(handle, hashPtr, cLabel) + } + } + return platform_wallet_manager_set_tracked_masternode_label(handle, hashPtr, nil) + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + } + + /// Every tracked masternode (`source == .tracked`), status resolved + /// against the CURRENT masternode list, sorted by when they were + /// tracked. Empty when nothing is tracked or the manager isn't + /// configured. Same record shape as `masternodes(for:)`, so list UIs + /// render both with one code path. + public func trackedMasternodes() -> [PlatformMasternode] { + guard isConfigured, handle != NULL_HANDLE else { return [] } + var outEntries: UnsafePointer? + var outCount: UInt = 0 + let ffiResult = platform_wallet_manager_list_tracked_masternodes( + handle, &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 Self.masternodeModels(from: entries, count: Int(outCount)) + } + + /// Refresh everything the wallet layer can learn about a tracked + /// masternode — its list entry, its Platform owner / operator + /// identities (owner + payout key hashes, claimable balance) and, once, + /// its ProRegTx (registration height, collateral). Network; runs on a + /// detached task. Partial results are kept even when a step fails (the + /// error is still thrown). + @discardableResult + public func refreshTrackedMasternode(proTxHash: Data) async throws -> PlatformMasternode { + guard isConfigured, handle != NULL_HANDLE, proTxHash.count == 32 else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, or proTxHash not 32 bytes") + } + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { () -> PlatformMasternode in + var outEntries: UnsafePointer? + var outCount: UInt = 0 + let ffiResult = proTxHash.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_manager_refresh_tracked_masternode( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &outEntries, + &outCount) + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + guard let entries = outEntries, outCount > 0 else { + throw PlatformWalletError.notFound("refresh returned no record") + } + defer { + platform_wallet_manager_free_masternodes( + UnsafeMutablePointer(mutating: entries), outCount) + } + guard let record = Self.masternodeModels(from: entries, count: Int(outCount)).first + else { + throw PlatformWalletError.notFound("refresh returned no record") + } + return record + }.value + } + + /// Withdraw from a TRACKED masternode's owner identity with a + /// host-supplied key. `role` is `.owner` (pays the registered payout + /// address; `destinationAddress` must be nil) or `.ownerPayout` + /// (destination optional, defaults to the payout address itself). + /// `key` is the private key text as the user holds it — WIF or 64-char + /// hex; it is passed through for this one signing call and never + /// retained. Returns the identity's new balance in credits. + /// + /// `.masternodeWithdrawalUnconfirmed` carries the same do-NOT-retry + /// contract as the wallet-scoped withdraw: re-read the claimable + /// balance before anything else. + public func trackedMasternodeWithdraw( + proTxHash: Data, + amountCredits: UInt64, + role: MasternodeKeyRole, + key: String, + destinationAddress: String? = nil + ) async throws -> UInt64 { + guard isConfigured, handle != NULL_HANDLE, proTxHash.count == 32 else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, or proTxHash not 32 bytes") + } + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { () -> UInt64 in + var newBalance: UInt64 = 0 + let ffiResult = proTxHash.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + let hashPtr = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) + return key.withCString { cKey in + if let destination = destinationAddress { + return destination.withCString { cDest in + platform_wallet_manager_tracked_masternode_withdraw( + handle, hashPtr, amountCredits, role.rawValue, cKey, cDest, + &newBalance) + } + } + return platform_wallet_manager_tracked_masternode_withdraw( + handle, hashPtr, amountCredits, role.rawValue, cKey, nil, &newBalance) + } + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return newBalance + }.value + } + + // MARK: - Shared marshalling + + /// One-entry-call helper for FFI functions returning a masternode + /// entry array. + private func trackedEntryCall( + proTxHash: Data, + _ call: ( + Handle, + UnsafePointer?, + UnsafeMutablePointer?>, + UnsafeMutablePointer + ) -> PlatformWalletFFIResult + ) throws -> PlatformMasternode { + guard isConfigured, handle != NULL_HANDLE, proTxHash.count == 32 else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, or proTxHash not 32 bytes") + } + var outEntries: UnsafePointer? + var outCount: UInt = 0 + let ffiResult = proTxHash.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + call( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &outEntries, + &outCount) + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + guard let entries = outEntries, outCount > 0 else { + throw PlatformWalletError.notFound("no record returned") + } + defer { + platform_wallet_manager_free_masternodes( + UnsafeMutablePointer(mutating: entries), outCount) + } + guard let record = Self.masternodeModels(from: entries, count: Int(outCount)).first else { + throw PlatformWalletError.notFound("no record returned") + } + return record + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index b8b1bcde6cb..1347ce4d42d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1477,6 +1477,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.walletRestore | PlatformWalletPersistenceCapabilities.dpnsNameStates | PlatformWalletPersistenceCapabilities.trackedAssetLocks + | PlatformWalletPersistenceCapabilities.trackedMasternodes ) } @@ -1489,6 +1490,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.version = UInt32(PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION) extensionCallbacks.reserved = 0 extensionCallbacks.on_persist_dpns_name_states_fn = persistDpnsNameStatesCallback + extensionCallbacks.on_persist_tracked_masternodes_fn = persistTrackedMasternodesCallback + extensionCallbacks.on_load_tracked_masternodes_fn = loadTrackedMasternodesCallback + extensionCallbacks.on_load_tracked_masternodes_free_fn = loadTrackedMasternodesFreeCallback return extensionCallbacks } @@ -6094,6 +6098,146 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// we handed to Rust. Drained by `loadWalletListFree`. private var loadAllocations: [UnsafeRawPointer: LoadAllocation] = [:] + // MARK: - Tracked (wallet-independent) masternodes + + /// One tracked-masternode row crossing the persistence boundary. + struct TrackedMasternodeRow { + let proTxHash: Data + let label: String? + let addedAt: UInt64 + let snapshotJSON: String + } + + /// Replace the stored tracked-masternode set for `networkRaw` with + /// `rows` (whole-set semantics, mirroring the Rust trait contract). + /// + /// Registry writes arrive OUTSIDE Rust `store()` rounds, so this + /// method saves immediately — unless a changeset round is open on the + /// shared context, in which case the round's `endChangeset` commits + /// (or rolls back) these rows together with the round. A rolled-back + /// registry write is re-issued by the next registry mutation (the + /// Rust side always writes the whole set). + func persistTrackedMasternodes(networkRaw: UInt32, rows: [TrackedMasternodeRow]) -> Bool { + onQueue { + do { + let existing = try backgroundContext.fetch( + FetchDescriptor( + predicate: #Predicate { $0.networkRaw == networkRaw } + ) + ) + var stale: [Data: PersistentTrackedMasternode] = [:] + for row in existing { + stale[row.proTxHash] = row + } + for row in rows { + if let found = stale.removeValue(forKey: row.proTxHash) { + found.label = row.label + found.addedAt = row.addedAt + found.snapshotJSON = row.snapshotJSON + } else { + backgroundContext.insert(PersistentTrackedMasternode( + networkRaw: networkRaw, + proTxHash: row.proTxHash, + label: row.label, + addedAt: row.addedAt, + snapshotJSON: row.snapshotJSON + )) + } + } + for removed in stale.values { + backgroundContext.delete(removed) + } + if !inChangeset { + try backgroundContext.save() + } + return true + } catch { + print("⚠️ persistTrackedMasternodes: \(error)") + return false + } + } + } + + /// Load every tracked-masternode row for `networkRaw` into + /// Rust-readable C rows. The allocation is loaned to Rust and released + /// by `loadTrackedMasternodesFree`. + func loadTrackedMasternodes( + networkRaw: UInt32 + ) -> (entries: UnsafePointer?, count: Int, errored: Bool) { + onQueue { + let rows: [PersistentTrackedMasternode] + do { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.networkRaw == networkRaw } + ) + descriptor.sortBy = [ + SortDescriptor(\.addedAt, order: .forward) + ] + rows = try backgroundContext.fetch(descriptor) + } catch { + print("⚠️ loadTrackedMasternodes: \(error)") + return (nil, 0, true) + } + guard !rows.isEmpty else { + return (nil, 0, false) + } + let allocation = TrackedMasternodeLoadAllocation() + let buf = UnsafeMutablePointer.allocate(capacity: rows.count) + allocation.entries = buf + var written = 0 + for row in rows { + // A proTxHash that is not 32 bytes has no usable identity — + // skip the row (same convention as the shielded loaders) + // rather than keying a phantom masternode on zeros. + guard row.proTxHash.count == 32 else { + print("⚠️ loadTrackedMasternodes: skipping a row with a \(row.proTxHash.count)-byte proTxHash") + continue + } + var entry = TrackedMasternodeFFI() + withUnsafeMutableBytes(of: &entry.pro_tx_hash) { dst in + row.proTxHash.withUnsafeBytes { src in + dst.copyMemory(from: src) + } + } + if let label = row.label, let dup = strdup(label) { + allocation.strings.append(dup) + entry.label = UnsafePointer(dup) + } + entry.added_at = row.addedAt + if let dup = strdup(row.snapshotJSON) { + allocation.strings.append(dup) + entry.snapshot_json = UnsafePointer(dup) + } + buf[written] = entry + written += 1 + } + allocation.count = written + guard written > 0 else { + allocation.release() + return (nil, 0, false) + } + trackedMasternodeLoadAllocations[UnsafeRawPointer(buf)] = allocation + return (UnsafePointer(buf), written, false) + } + } + + /// Release a loan handed out by `loadTrackedMasternodes`. + func loadTrackedMasternodesFree(entries: UnsafeRawPointer?) { + onQueue { + guard let entries = entries, + let allocation = trackedMasternodeLoadAllocations.removeValue(forKey: entries) + else { + return + } + allocation.release() + } + } + + /// Outstanding tracked-masternode load allocations keyed by the + /// entries pointer we handed to Rust. + private var trackedMasternodeLoadAllocations: + [UnsafeRawPointer: TrackedMasternodeLoadAllocation] = [:] + /// Human-readable name for a persisted account, mirroring the /// top-level `AccountTypeTagFFI` discriminant plus — for tag 0 /// (Standard) — the `StandardAccountTypeTagFFI` sub-discriminant. @@ -6327,6 +6471,25 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Retains all heap allocations produced by a single /// `loadWalletList` call. Released wholesale by `loadWalletListFree`. +/// Owned allocations behind one `loadTrackedMasternodes` answer: the +/// entries buffer plus every strdup'd label / snapshot string. Trivial C +/// structs — deallocate only. +private final class TrackedMasternodeLoadAllocation { + var entries: UnsafeMutablePointer? + var count: Int = 0 + var strings: [UnsafeMutablePointer] = [] + + func release() { + for string in strings { + free(string) + } + strings.removeAll() + entries?.deallocate() + entries = nil + count = 0 + } +} + private final class LoadAllocation { var entries: UnsafeMutablePointer? /// Allocated capacity — equal to `restorable.count`. Used for @@ -6818,6 +6981,93 @@ private func loadWalletListFreeCallback( handler.loadWalletListFree(entries: entries.map(UnsafeRawPointer.init)) } +/// Map the `network` C string Rust passes to the tracked-masternode +/// persistence callbacks onto `Network.rawValue`. Unknown names return +/// `nil` and the callback reports failure rather than filing rows under +/// the wrong network. +private func networkRawFromCString(_ ptr: UnsafePointer?) -> UInt32? { + guard let ptr = ptr else { return nil } + switch String(cString: ptr) { + case "mainnet": return Network.mainnet.rawValue + case "testnet": return Network.testnet.rawValue + case "devnet": return Network.devnet.rawValue + case "regtest": return Network.regtest.rawValue + default: return nil + } +} + +/// C shim for `on_persist_tracked_masternodes_fn`. Deep-copies every row +/// (label + snapshot strings included) before invoking the handler, so +/// Rust can drop its allocations the moment we return. +private func persistTrackedMasternodesCallback( + context: UnsafeMutableRawPointer?, + networkPtr: UnsafePointer?, + rowsPtr: UnsafePointer?, + rowsCount: UInt +) -> Int32 { + guard let context = context, + let networkRaw = networkRawFromCString(networkPtr) else { + return 1 + } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + var rows: [PlatformWalletPersistenceHandler.TrackedMasternodeRow] = [] + if rowsCount > 0, let rowsPtr = rowsPtr { + rows.reserveCapacity(Int(rowsCount)) + for i in 0..?, + outRows: UnsafeMutablePointer?>?, + outCount: UnsafeMutablePointer? +) -> Int32 { + guard let context = context, + let outRows = outRows, + let outCount = outCount else { + return 1 + } + outRows.pointee = nil + outCount.pointee = 0 + guard let networkRaw = networkRawFromCString(networkPtr) else { + return 1 + } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let (entries, count, errored) = handler.loadTrackedMasternodes(networkRaw: networkRaw) + outRows.pointee = entries + outCount.pointee = UInt(count) + return errored ? 1 : 0 +} + +/// C shim for `on_load_tracked_masternodes_free_fn`. +private func loadTrackedMasternodesFreeCallback( + context: UnsafeMutableRawPointer?, + rows: UnsafePointer?, + _ count: UInt +) { + guard let context = context else { return } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + handler.loadTrackedMasternodesFree(entries: rows.map(UnsafeRawPointer.init)) +} + /// C shim for `on_persist_account_address_pools_fn`. Walks the /// Rust-owned `[AccountAddressPoolFFI]` slice and dispatches one /// `persistAccountAddresses` call per pool. Replaces the legacy diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 8e0036c3e5e..d494475cb50 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -60,6 +60,12 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// the claimable balance and reconcile first. Definitive rejections keep /// their ordinary codes and stay retryable. case errorMasternodeWithdrawalUnconfirmed = 42 + /// The deterministic masternode list isn't available yet (SPV not + /// running or masternode sync incomplete), so a list-backed query such + /// as `locateMasternode` has nothing to search. Transient: retry once + /// `spvProgress.masternodes` reports the list synced. + /// (46 — 43/44/45 are held by the shielded-invite error trio, #4313.) + case errorMasternodeListUnavailable = 46 /// Definitively-failed address-nonce race: Platform rejected an /// address-funds transition (shield, or identity top-up-from-addresses) /// because the submitted address nonce raced Platform's expected value @@ -215,6 +221,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorTransactionBroadcastUnconfirmed case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_MASTERNODE_WITHDRAWAL_UNCONFIRMED: self = .errorMasternodeWithdrawalUnconfirmed + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_MASTERNODE_LIST_UNAVAILABLE: + self = .errorMasternodeListUnavailable case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ADDRESS_NONCE_MISMATCH: self = .errorAddressNonceMismatch case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_CORE_INSUFFICIENT_FUNDS: @@ -372,6 +380,10 @@ public enum PlatformWalletError: LocalizedError { /// and the identity nonce was consumed — do NOT retry; re-read the /// claimable balance first (`.errorMasternodeWithdrawalUnconfirmed`). case masternodeWithdrawalUnconfirmed(String) + /// The masternode list hasn't synced yet, so there is nothing to look a + /// masternode up in (`.errorMasternodeListUnavailable`). Retry after + /// masternode sync completes. + case masternodeListUnavailable(String) /// Core definitively rejected the transaction and its input reservation /// was released. Unlike `transactionBroadcastUnconfirmed`, retry is safe. case transactionBroadcastRejected(String) @@ -460,6 +472,7 @@ public enum PlatformWalletError: LocalizedError { .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), .transactionBroadcastUnconfirmed(let m), .masternodeWithdrawalUnconfirmed(let m), + .masternodeListUnavailable(let m), .transactionBroadcastRejected(let m), .addressNonceMismatch(let m), .shutdownIncomplete(let m), @@ -530,6 +543,8 @@ public enum PlatformWalletError: LocalizedError { self = .transactionBroadcastUnconfirmed(detail) case .errorMasternodeWithdrawalUnconfirmed: self = .masternodeWithdrawalUnconfirmed(detail) + case .errorMasternodeListUnavailable: + self = .masternodeListUnavailable(detail) case .errorTransactionBroadcastRejected: self = .transactionBroadcastRejected(detail) case .errorAddressNonceMismatch: diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index d60303feca4..64bd42c8867 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -144,6 +144,9 @@ struct StorageExplorerView: View { modelRow("Masternodes", icon: "server.rack", type: PersistentMasternode.self) { MasternodeStorageListView(network: network) } + modelRow("Tracked Masternodes", icon: "eye", type: PersistentTrackedMasternode.self) { + TrackedMasternodeStorageListView(network: network) + } modelRow("Manager Metadata", icon: "gearshape.2", type: PersistentWalletManagerMetadata.self) { WalletManagerMetadataStorageListView(network: network) } @@ -341,6 +344,11 @@ struct StorageExplorerView: View { filteredCount(PersistentMasternode.self) { walletsOnNetwork.contains($0.walletId) } + // Tracked masternodes belong to no wallet — they carry their own + // network column. + filteredCount(PersistentTrackedMasternode.self) { + $0.networkRaw == raw + } // 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 d6bc70e7c65..6a735390481 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1920,6 +1920,53 @@ struct MasternodeStorageListView: View { } } +// MARK: - PersistentTrackedMasternode + +/// Tracked (wallet-independent) masternodes — the user-curated registry +/// rows. Network-scoped by their own `networkRaw` column (no wallet join: +/// they belong to no wallet), oldest-tracked first. +struct TrackedMasternodeStorageListView: View { + let network: Network + @Query(sort: [SortDescriptor(\PersistentTrackedMasternode.addedAt)]) + private var records: [PersistentTrackedMasternode] + + private var scopedRecords: [PersistentTrackedMasternode] { + records.filter { $0.networkRaw == network.rawValue } + } + + var body: some View { + let visible = scopedRecords + List(visible) { record in + NavigationLink(destination: TrackedMasternodeStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(record.label ?? "Unnamed") + .font(.body) + Spacer() + Text(Date(timeIntervalSince1970: TimeInterval(record.addedAt)), + style: .date) + .font(.caption2) + .foregroundColor(.secondary) + } + Text(record.proTxHash.map { String(format: "%02x", $0) }.joined()) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1).truncationMode(.middle) + } + } + } + .navigationTitle("Tracked Masternodes (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView( + "No Tracked Masternodes", + systemImage: "eye" + ) + } + } + } +} + // 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 8b276e1cff6..5d4d05ce373 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -2217,6 +2217,34 @@ struct MasternodeStorageDetailView: View { } } +// MARK: - PersistentTrackedMasternode + +struct TrackedMasternodeStorageDetailView: View { + let record: PersistentTrackedMasternode + + var body: some View { + Form { + Section("Identity") { + FieldRow(label: "Network", value: record.network?.displayName ?? "raw \(record.networkRaw)") + FieldRow(label: "proTxHash (wire)", value: hexString(record.proTxHash)) + FieldRow(label: "Label", value: record.label ?? "—") + FieldRow( + label: "Added", + value: dateString(Date(timeIntervalSince1970: TimeInterval(record.addedAt)))) + } + Section("Snapshot") { + // Opaque, Rust-owned document (PUBLIC material only) — + // shown verbatim; only Rust interprets it. + Text(record.snapshotJSON) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + } + } + .navigationTitle(record.label ?? "Tracked Masternode") + .navigationBarTitleDisplayMode(.inline) + } +} + // MARK: - PersistentShieldedNote struct ShieldedNoteStorageDetailView: View { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/EvonodeStatusTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/EvonodeStatusTests.swift index 5e88734edca..765b7fe8bb9 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/EvonodeStatusTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/EvonodeStatusTests.swift @@ -150,7 +150,9 @@ final class EvonodeStatusTests: XCTestCase { platformInWallet: false, platformAccountType: 0, platformKeyIndex: 0, - platformOwnershipChecked: false) + platformOwnershipChecked: false, + source: .wallet, + label: nil) } func testPlatformDAPIAddressDropsTheCorePort() { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index d9ffd747989..478c114d0ca 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -57,6 +57,10 @@ final class InvitationPersistenceTests: XCTestCase { // `PersistentDPNSName`, so this bit is genuinely attested. | PlatformWalletPersistenceCapabilities.dpnsNameStates | PlatformWalletPersistenceCapabilities.trackedAssetLocks + // Tracked (wallet-independent) masternodes: the handler wires + // the persist/load/free trio onto `PersistentTrackedMasternode`, + // so restart survival is genuinely attested. + | PlatformWalletPersistenceCapabilities.trackedMasternodes XCTAssertEqual( capabilities.version, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/MasternodeLocatorTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/MasternodeLocatorTests.swift new file mode 100644 index 00000000000..723f84f0b22 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/MasternodeLocatorTests.swift @@ -0,0 +1,72 @@ +import XCTest +@testable import SwiftDashSDK + +/// Pure marshalling helpers of the masternode locator wrapper: role masks, +/// proTxHash display order, and the DAPI address a match renders. +final class MasternodeLocatorTests: XCTestCase { + private func match( + serviceAddress: String?, + platformHTTPPort: UInt16?, + matchedKeys: [MasternodeKeyRole] = [] + ) -> MasternodeLocateMatch { + MasternodeLocateMatch( + proTxHash: Data((0..<32).map { UInt8($0) }), + serviceAddress: serviceAddress, + platformHTTPPort: platformHTTPPort, + operatorPublicKey: Data(repeating: 0x02, count: 48), + votingKeyId: Data(repeating: 0x03, count: 20), + platformNodeId: nil, + isValid: true, + isEvonode: platformHTTPPort != nil, + matchedBy: matchedKeys.isEmpty ? .serviceAddress : .key, + matchedKeys: matchedKeys, + inWalletId: nil, + alreadyTracked: false + ) + } + + func testRoleMaskDecodesEveryBitInRoleOrder() { + XCTAssertEqual(MasternodeKeyRole.roles(fromMask: 0), []) + XCTAssertEqual(MasternodeKeyRole.roles(fromMask: 0b0000_0001), [.owner]) + XCTAssertEqual(MasternodeKeyRole.roles(fromMask: 0b0000_0011), [.owner, .voting]) + XCTAssertEqual(MasternodeKeyRole.roles(fromMask: 0b0011_0100), [.operator, .ownerPayout, .operatorPayout]) + XCTAssertEqual(MasternodeKeyRole.roles(fromMask: 0b0000_1000), [.platformNode]) + } + + func testRoleRawValuesLineUpWithAndroid() { + XCTAssertEqual(MasternodeKeyRole.owner.rawValue, 0) + XCTAssertEqual(MasternodeKeyRole.voting.rawValue, 1) + XCTAssertEqual(MasternodeKeyRole.operator.rawValue, 2) + XCTAssertEqual(MasternodeKeyRole.platformNode.rawValue, 3) + } + + func testProTxHashHexIsTheReversedWireBytes() { + let m = match(serviceAddress: nil, platformHTTPPort: nil) + XCTAssertTrue(m.proTxHashHex.hasPrefix("1f1e1d1c")) + XCTAssertTrue(m.proTxHashHex.hasSuffix("03020100")) + XCTAssertEqual(m.proTxHashHex.count, 64) + } + + func testServiceHostAndDAPIAddress() { + let v4 = match(serviceAddress: "1.2.3.4:9999", platformHTTPPort: 443) + XCTAssertEqual(v4.serviceHost, "1.2.3.4") + XCTAssertEqual(v4.platformDAPIAddress, "https://1.2.3.4:443") + + let v6 = match(serviceAddress: "[2001:db8::1]:9999", platformHTTPPort: 1443) + XCTAssertEqual(v6.serviceHost, "[2001:db8::1]") + XCTAssertEqual(v6.platformDAPIAddress, "https://[2001:db8::1]:1443") + + let regular = match(serviceAddress: "1.2.3.4:9999", platformHTTPPort: nil) + XCTAssertNil(regular.platformDAPIAddress, "a regular masternode has no DAPI") + + let torOnly = match(serviceAddress: nil, platformHTTPPort: 443) + XCTAssertNil(torOnly.serviceHost) + XCTAssertNil(torOnly.platformDAPIAddress) + } + + func testKeyMatchCarriesItsRoles() { + let m = match(serviceAddress: "1.2.3.4:9999", platformHTTPPort: nil, matchedKeys: [.voting]) + XCTAssertEqual(m.matchedBy, .key) + XCTAssertEqual(m.matchedKeys, [.voting]) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TrackedMasternodeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TrackedMasternodeTests.swift new file mode 100644 index 00000000000..b9b93d6cbbe --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TrackedMasternodeTests.swift @@ -0,0 +1,52 @@ +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +/// Tracked-masternode marshalling: the Rust-computed capability gating and +/// the SwiftData row the persistence callbacks write. +final class TrackedMasternodeTests: XCTestCase { + func testCapabilitiesFollowHeldRoles() { + XCTAssertEqual( + MasternodeCapabilities(holding: []), + MasternodeCapabilities(holding: [])) + let none = MasternodeCapabilities(holding: []) + XCTAssertFalse(none.canWithdraw) + XCTAssertFalse(none.canVote) + + let owner = MasternodeCapabilities(holding: [.owner]) + XCTAssertTrue(owner.canWithdraw) + XCTAssertFalse(owner.canVote) + + let payout = MasternodeCapabilities(holding: [.ownerPayout]) + XCTAssertTrue(payout.canWithdraw, "the payout-address key also withdraws") + + let voting = MasternodeCapabilities(holding: [.voting]) + XCTAssertTrue(voting.canVote) + XCTAssertFalse(voting.canWithdraw) + + let op = MasternodeCapabilities(holding: [.operator, .platformNode]) + XCTAssertTrue(op.canUpdateService) + XCTAssertTrue(op.identifiesPlatformNode) + XCTAssertFalse(op.canWithdraw) + } + + @MainActor + func testPersistentTrackedMasternodeUniquenessIsPerNetwork() throws { + let container = try ModelContainer( + for: PersistentTrackedMasternode.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + let context = container.mainContext + let hash = Data(repeating: 7, count: 32) + context.insert(PersistentTrackedMasternode( + networkRaw: Network.mainnet.rawValue, proTxHash: hash, + label: "main", addedAt: 1, snapshotJSON: "{}")) + context.insert(PersistentTrackedMasternode( + networkRaw: Network.testnet.rawValue, proTxHash: hash, + label: "test", addedAt: 2, snapshotJSON: "{}")) + try context.save() + let rows = try context.fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 2, "the same proTxHash may be tracked on both networks") + XCTAssertEqual(Set(rows.compactMap(\.network)), [.mainnet, .testnet]) + } +}