From ad8bcba5262886ae0d9922446dd0fa37ff41f2e9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 14 Jul 2026 21:19:59 +0700 Subject: [PATCH 1/2] feat(platform-wallet): persist typed BLS/EdDSA provider keys as core address rows Widen CoreAddressEntryFFI from an ECDSA-only 33-byte slot to a typed 48-byte key (key_type_tag ECDSA/BLS/EdDSA + public_key_len), so BLS operator and Ed25519 platform-node public keys survive the SwiftData persist/restore round-trip in the row itself. Populate the managed platform-node pool at registration so its keys ride the normal address-pool pipeline, and retire the account-level derived-platform-node-keys batch plumbing plus the typed-key merge workaround in restore_address_pool. The storage explorer labels keys by curve, and the Node Keys screen reads the typed address rows. Co-Authored-By: Claude Fable 5 --- .../src/core_address_types.rs | 49 +- .../rs-platform-wallet-ffi/src/persistence.rs | 467 ++++++++---------- .../src/provider_key_at_index.rs | 32 ++ .../src/wallet_registration_persistence.rs | 19 +- .../src/wallet_restore_types.rs | 39 -- .../src/changeset/changeset.rs | 14 +- .../src/manager/wallet_lifecycle.rs | 30 +- .../src/wallet/provider_key_at_index.rs | 177 +++++++ .../Models/PersistentAccount.swift | 42 -- .../Models/PersistentCoreAddress.swift | 14 +- .../ManagedPlatformWallet.swift | 38 +- .../PlatformWalletPersistenceHandler.swift | 124 ++--- .../Core/Views/AccountDetailView.swift | 34 +- .../Views/StorageRecordDetailViews.swift | 16 +- 14 files changed, 608 insertions(+), 487 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_address_types.rs b/packages/rs-platform-wallet-ffi/src/core_address_types.rs index 48eb5903279..c82827b67d1 100644 --- a/packages/rs-platform-wallet-ffi/src/core_address_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_address_types.rs @@ -22,17 +22,56 @@ pub enum AddressPoolTypeTagFFI { AbsentHardened = 3, } +/// Curve discriminant for [`CoreAddressEntryFFI::key_type_tag`], +/// selecting which `key_wallet::managed_account::address_pool::PublicKeyType` +/// variant [`CoreAddressEntryFFI::public_key`] holds. Meaningful only +/// when `public_key_len > 0`. Kept stable across releases — it lands in +/// SwiftData rows next to the key bytes so a BLS operator or Ed25519 +/// platform-node key survives the persist/restore round-trip that the +/// ECDSA-only 33-byte slot used to drop. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyTypeTagFFI { + /// 33-byte compressed secp256k1 public key. + ECDSA = 0, + /// 48-byte BLS masternode-operator public key. + BLS = 1, + /// 32-byte Ed25519 platform-node public key. + EdDSA = 2, +} + +impl KeyTypeTagFFI { + /// Validate a foreign byte into a `KeyTypeTagFFI` before use — reading + /// an out-of-range value directly into the `repr(u8)` field would be UB. + pub fn try_from_u8(b: u8) -> Option { + Some(match b { + 0 => Self::ECDSA, + 1 => Self::BLS, + 2 => Self::EdDSA, + _ => return None, + }) + } +} + /// A single on-chain address entry. /// /// `*const c_char` strings are Rust-owned and valid only for the /// duration of the callback; Swift must copy them before returning. #[repr(C)] pub struct CoreAddressEntryFFI { - /// 33-byte compressed secp256k1 public key. Valid iff - /// `has_public_key == true`; zero-filled otherwise. Watch-only - /// reconstructed accounts currently always carry the pubkey. - pub public_key: [u8; 33], - pub has_public_key: bool, + /// Typed public key, left-aligned in a fixed 48-byte slot sized for + /// the widest supported key (BLS-48). Exactly `public_key_len` + /// leading bytes are meaningful; the remainder is zero-filled. The + /// whole slot is zero when `public_key_len == 0` (no key). + pub public_key: [u8; 48], + /// Count of valid leading bytes in `public_key`: `0` = no key, + /// `33` = ECDSA, `48` = BLS, `32` = EdDSA. The `(public_key_len, + /// key_type_tag)` pair must agree with those widths; a mismatch is + /// treated as "no key" on decode. + pub public_key_len: u8, + /// [`KeyTypeTagFFI`] raw value. Meaningful only when + /// `public_key_len > 0`; ignored (and conventionally `0`) otherwise. + pub key_type_tag: u8, /// `AddressPoolTypeTagFFI` raw value. pub pool_type_tag: u8, /// Derivation index within this pool. diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 7d9cca48bdd..77a994b9222 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -39,7 +39,7 @@ use crate::asset_lock_persistence::{ use crate::contact_persistence::{ free_contact_requests_ffi, ContactIgnoredSenderFFI, ContactRequestFFI, ContactRequestRemovalFFI, }; -use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI}; +use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; use crate::core_wallet_types::{free_wallet_changeset_ffi, WalletChangeSetFFI}; use crate::identity_persistence::{ free_identity_entry_ffi, free_identity_key_entry_ffi, IdentityEntryFFI, IdentityKeyEntryFFI, @@ -51,8 +51,8 @@ use crate::wallet_registration_persistence::AccountAddressPoolFFI; use crate::wallet_restore_types::{ AccountSpecFFI, AccountTypeTagFFI, ContactProfileRestoreEntryFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, LoadWalletListFreeFn, PaymentRestoreEntryFFI, - ProviderPlatformNodeKeyFFI, ProviderSpecialTxRestoreEntryFFI, StandardAccountTypeTagFFI, - UnresolvedAssetLockTxRecordFFI, UtxoRestoreEntryFFI, WalletRestoreEntryFFI, + ProviderSpecialTxRestoreEntryFFI, StandardAccountTypeTagFFI, UnresolvedAssetLockTxRecordFFI, + UtxoRestoreEntryFFI, WalletRestoreEntryFFI, }; use dpp::address_funds::PlatformAddress; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -687,7 +687,7 @@ impl PlatformWalletPersistence for FFIPersister { &changeset.account_registrations, &changeset.provider_key_account_registrations, ) { - Ok((specs, _xpub_bytes_storage, _derived_keys_storage)) => { + Ok((specs, _xpub_bytes_storage)) => { let result = unsafe { cb( self.callbacks.context, @@ -696,12 +696,11 @@ impl PlatformWalletPersistence for FFIPersister { specs.len(), ) }; - // Force the spec / byte buffers / derived-key - // buffers to live until after the callback even - // though their drop happens on scope exit anyway. + // Force the spec / byte buffers to live until after + // the callback even though their drop happens on + // scope exit anyway. drop(specs); drop(_xpub_bytes_storage); - drop(_derived_keys_storage); if result != 0 { eprintln!( "Account registrations persistence callback returned error code {}", @@ -2341,10 +2340,6 @@ fn build_account_spec_ffi(account_type: &AccountType, xpub_bytes: &[u8]) -> Acco friend_identity_id: [0u8; 32], account_xpub_bytes: xpub_bytes.as_ptr(), account_xpub_bytes_len: xpub_bytes.len(), - // Set by `build_account_specs_for_callback` for the - // `ProviderPlatformKeys` entry; null/0 for every other account. - derived_platform_node_keys: std::ptr::null(), - derived_platform_node_keys_count: 0, }; // The producer side casts each `AccountTypeTagFFI` / // `StandardAccountTypeTagFFI` variant to `u8` because both fields @@ -2438,29 +2433,16 @@ fn build_account_spec_ffi(account_type: &AccountType, xpub_bytes: &[u8]) -> Acco } /// Build the `Vec` array for -/// `on_persist_account_registrations_fn` plus the parallel storage each -/// spec borrows into: -/// 1. `Vec>` — bincoded xpub byte buffers -/// (`account_xpub_bytes`). -/// 2. `Vec>` — one inner Vec per -/// provider entry holding its pre-derived platform-node keys -/// (`derived_platform_node_keys`); empty for the BLS operator entry -/// and for every ECDSA account. +/// `on_persist_account_registrations_fn` plus the parallel `Vec>` +/// of bincoded xpub byte buffers each spec's `account_xpub_bytes` borrows +/// into. /// -/// All three share lifetime — the caller must keep them alive until -/// after the callback returns. -#[allow(clippy::type_complexity)] +/// Both share lifetime — the caller must keep them alive until after the +/// callback returns. fn build_account_specs_for_callback( entries: &[AccountRegistrationEntry], provider_entries: &[ProviderKeyAccountEntry], -) -> Result< - ( - Vec, - Vec>, - Vec>, - ), - String, -> { +) -> Result<(Vec, Vec>), String> { // Pre-encode every extended public key once so each spec slot can // borrow the pointer + length without a self-referential lifetime // trick. ECDSA accounts encode their secp256k1 `ExtendedPubKey`; @@ -2485,24 +2467,6 @@ fn build_account_specs_for_callback( xpub_buffers.push(bytes); } - // Pre-derived platform-node key storage, index-aligned to - // `provider_entries`. Built to completion BEFORE any spec borrows a - // pointer into it so the inner Vecs never move under a live pointer. - let derived_storage: Vec> = provider_entries - .iter() - .map(|entry| { - entry - .derived_platform_node_keys - .iter() - .map(|k| ProviderPlatformNodeKeyFFI { - index: k.index, - public_key: k.public_key, - node_id: k.node_id, - }) - .collect() - }) - .collect(); - let mut specs: Vec = Vec::with_capacity(xpub_buffers.len()); let mut idx = 0; for entry in entries { @@ -2512,20 +2476,14 @@ fn build_account_specs_for_callback( )); idx += 1; } - for (p_idx, entry) in provider_entries.iter().enumerate() { - let mut spec = build_account_spec_ffi(&entry.account_type, &xpub_buffers[idx]); - // Point at the pre-built (stable) derived-key storage for this - // provider entry. Empty for the BLS operator account, so its - // spec keeps the null/0 default from `build_account_spec_ffi`. - let rows = &derived_storage[p_idx]; - if !rows.is_empty() { - spec.derived_platform_node_keys = rows.as_ptr(); - spec.derived_platform_node_keys_count = rows.len(); - } - specs.push(spec); + for entry in provider_entries { + specs.push(build_account_spec_ffi( + &entry.account_type, + &xpub_buffers[idx], + )); idx += 1; } - Ok((specs, xpub_buffers, derived_storage)) + Ok((specs, xpub_buffers)) } /// Build the `Vec` array for @@ -2657,18 +2615,42 @@ fn build_core_address_entry_ffi( owned_strings.push(address_c); owned_strings.push(path_c); - let mut public_key = [0u8; 33]; - let has_public_key = match &info.public_key { + // Marshal whichever typed key the pool entry carries into the fixed + // 48-byte slot. Each variant is length-validated against its curve's + // fixed width (ECDSA 33 / BLS 48 / EdDSA 32); a wrong-length key is + // emitted as "no key" (`public_key_len == 0`) rather than aborting the + // row — the address + derivation-path still surface for the Storage + // Explorer, and a malformed key would only mislead the provider-key + // matcher on restore. + let mut public_key = [0u8; 48]; + let (public_key_len, key_type_tag) = match &info.public_key { + None => (0u8, 0u8), Some(PublicKeyType::ECDSA(bytes)) if bytes.len() == 33 => { - public_key.copy_from_slice(bytes); - true + public_key[..33].copy_from_slice(bytes); + (33u8, KeyTypeTagFFI::ECDSA as u8) + } + Some(PublicKeyType::BLS(bytes)) if bytes.len() == 48 => { + public_key[..48].copy_from_slice(bytes); + (48u8, KeyTypeTagFFI::BLS as u8) + } + Some(PublicKeyType::EdDSA(bytes)) if bytes.len() == 32 => { + public_key[..32].copy_from_slice(bytes); + (32u8, KeyTypeTagFFI::EdDSA as u8) + } + Some(_) => { + tracing::warn!( + index = info.index, + "persist: address pool entry carries a typed public key with an \ + unexpected length for its curve; emitting the row with no key" + ); + (0u8, 0u8) } - _ => false, }; Ok(CoreAddressEntryFFI { public_key, - has_public_key, + public_key_len, + key_type_tag, pool_type_tag, address_index: info.index, is_used: info.used, @@ -2727,10 +2709,39 @@ unsafe fn address_info_from_ffi( .map_err(|e| format!("derivation_path not UTF-8: {}", e))?; let path = DerivationPath::from_str(path_str) .map_err(|e| format!("failed to parse derivation path '{}': {}", path_str, e))?; - let public_key = if entry.has_public_key { - Some(PublicKeyType::ECDSA(entry.public_key.to_vec())) - } else { + // Rebuild the typed key from the (len, tag) pair. A tag that doesn't + // validate, or a len that disagrees with its curve's fixed width (or + // overruns the 48-byte slot), yields `None` + a warn rather than an + // error — forgiving, matching the rest of this row's decode posture: + // the address still restores, only its provider-key match is lost. + let public_key = if entry.public_key_len == 0 { None + } else { + let len = entry.public_key_len as usize; + if len > entry.public_key.len() { + tracing::warn!( + len, + "load: persisted address row public_key_len exceeds the key slot; \ + dropping the key" + ); + None + } else { + let bytes = entry.public_key[..len].to_vec(); + match (KeyTypeTagFFI::try_from_u8(entry.key_type_tag), len) { + (Some(KeyTypeTagFFI::ECDSA), 33) => Some(PublicKeyType::ECDSA(bytes)), + (Some(KeyTypeTagFFI::BLS), 48) => Some(PublicKeyType::BLS(bytes)), + (Some(KeyTypeTagFFI::EdDSA), 32) => Some(PublicKeyType::EdDSA(bytes)), + _ => { + tracing::warn!( + key_type_tag = entry.key_type_tag, + len, + "load: persisted address row has an invalid key-type/length \ + combination; dropping the key" + ); + None + } + } + } }; Ok(AddressInfo { address, @@ -2750,34 +2761,21 @@ unsafe fn address_info_from_ffi( }) } -/// Merge persisted `AddressInfo` rows into a managed account's -/// `AddressPool`. Upsert semantics: a persisted row overwrites the -/// gap-limit default `ManagedWalletInfo::from_wallet` pre-derived at -/// the same index, and the reverse-lookup maps + `highest_*` -/// watermarks are extended to cover indices past that default -/// gap window. +/// Restore persisted `AddressInfo` rows into a managed account's +/// `AddressPool`. Plain upsert: a persisted row overwrites the gap-limit +/// default `ManagedWalletInfo::from_wallet` pre-derived at the same +/// index, and the reverse-lookup maps + `highest_*` watermarks are +/// extended to cover indices past that default gap window. /// -/// **Typed-key preservation:** a persisted row can lose its typed public -/// key on the FFI round-trip — the [`CoreAddressEntryFFI`] key field is a -/// bare 33-byte slot that only fits a compressed ECDSA key and carries no -/// key-type discriminator, so BLS (48B) operator and Ed25519 (32B) -/// platform-node entries come back with `public_key: None`. Overwriting a -/// prederived typed entry (which `from_wallet` filled with -/// `PublicKeyType::BLS`/`EdDSA`) with such a `None` row would break -/// key-wallet's provider-key matching. So this MERGES: when the incoming -/// row has no typed key but the existing entry at that index does, the -/// existing typed key is kept. (Funds/ECDSA rows always carry their key, -/// so the merge is a no-op for them.) +/// The persisted row is authoritative for its typed public key — the +/// [`CoreAddressEntryFFI`] row now carries the full typed key (ECDSA-33 / +/// BLS-48 / EdDSA-32) with a [`KeyTypeTagFFI`] discriminator, so BLS +/// operator and Ed25519 platform-node keys survive the round-trip in the +/// row itself. No merge against the pre-derived entry is needed or +/// wanted. fn restore_address_pool(pool: &mut AddressPool, infos: Vec) { - for mut info in infos { + for info in infos { let idx = info.index; - if info.public_key.is_none() { - if let Some(existing) = pool.addresses.get(&idx) { - if existing.public_key.is_some() { - info.public_key = existing.public_key.clone(); - } - } - } pool.address_index.insert(info.address.clone(), idx); pool.script_pubkey_index .insert(info.script_pubkey.clone(), idx); @@ -3001,79 +2999,6 @@ unsafe fn restore_core_address_pools( }) } -/// Rehydrate the persisted platform-node (Ed25519) key `batch` into the -/// managed `ProviderPlatformKeys` pool. -/// -/// SLIP-10 Ed25519 is hardened-only, so `ManagedWalletInfo::from_wallet` -/// can't re-derive these keys from the account xpub on an external-signable -/// restore — the batch (persisted at registration, supplied by the host on -/// load) is the only seedless source. Each entry is inserted as a typed -/// [`PublicKeyType::EdDSA`] pool address, keyed by its hardened index, with -/// the platform node id recomputed as `SHA256(pubkey)[..20]` (rust-dashcore -/// #884) — **never** trusting any persisted hash160-era id. This lets the -/// masternode-ownership scan match a ProRegTx `platform_node_id` to the -/// wallet's own derivation index on a seedless wallet. -fn restore_platform_node_pool( - wallet_info: &mut ManagedWalletInfo, - batch: &[(u32, [u8; 32])], - network: Network, -) -> Result<(), PersistenceError> { - use dashcore::hashes::Hash; - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - if batch.is_empty() { - return Ok(()); - } - let Some(account) = wallet_info.accounts.provider_platform_keys.as_mut() else { - return Ok(()); - }; - let account_path = AccountType::ProviderPlatformKeys - .derivation_path(network) - .map_err(|e| PersistenceError::backend(format!("platform-node account path: {:?}", e)))?; - let base_children: Vec = account_path.as_ref().to_vec(); - - let mut infos: Vec = Vec::with_capacity(batch.len()); - for (index, pubkey) in batch { - // Recompute the node id from the pubkey (SHA256[..20], #884). - let node_id = dashcore::PlatformNodeId::from_ed25519_public_key(pubkey).to_byte_array(); - let payload = - dashcore::address::Payload::PubkeyHash(dashcore::PubkeyHash::from_byte_array(node_id)); - let address = dashcore::Address::new(network, payload); - let script_pubkey = address.script_pubkey(); - let child = key_wallet::bip32::ChildNumber::from_hardened_idx(*index).map_err(|e| { - PersistenceError::backend(format!("platform-node child index {}: {:?}", index, e)) - })?; - let mut children = base_children.clone(); - children.push(child); - let path = DerivationPath::from(children); - infos.push(AddressInfo { - address, - script_pubkey, - public_key: Some(PublicKeyType::EdDSA(pubkey.to_vec())), - index: *index, - path, - used: false, - generated_at: 0, - used_at: None, - tx_count: 0, - total_received: 0, - total_sent: 0, - balance: 0, - label: None, - metadata: std::collections::BTreeMap::new(), - }); - } - // The platform-node pool is `AbsentHardened` (SLIP-10 hardened-only). - if let Some(pool) = account - .managed_account_type_mut() - .address_pools_mut() - .into_iter() - .find(|p| p.pool_type == AddressPoolType::AbsentHardened) - { - restore_address_pool(pool, infos); - } - Ok(()) -} - /// Bucket a slice of upstream-emitted `DerivedAddress` entries into the /// same `AccountAddressPoolFFI` shape `build_address_pools_for_callback` /// produces, so the event-driven gap-limit-extension flow can fan out @@ -3185,12 +3110,15 @@ fn build_address_pools_from_derived( owned_strings.push(address_c); owned_strings.push(path_c); + // Upstream `DerivedAddress::public_key` is a + // `dashcore::PublicKey`; its compressed serialization is the + // 33-byte ECDSA form, left-aligned in the 48-byte slot. + let mut public_key = [0u8; 48]; + public_key[..33].copy_from_slice(&d.public_key.inner.serialize()); pool_entries.push(CoreAddressEntryFFI { - // Upstream `DerivedAddress::public_key` is now a - // `dashcore::PublicKey`; compressed serialization - // is the 33-byte form our FFI expects. - public_key: d.public_key.inner.serialize(), - has_public_key: true, + public_key, + public_key_len: 33, + key_type_tag: KeyTypeTagFFI::ECDSA as u8, pool_type_tag: pool_tag, address_index: d.derivation_index, // Newly-derived addresses haven't been seen in any @@ -3297,13 +3225,6 @@ fn build_wallet_start_state( // Build the per-account collection from the typed spec array. let mut accounts = AccountCollection::new(); - // The persisted platform-node (Ed25519) key batch, captured from the - // `ProviderPlatformKeys` spec so it can be rehydrated into the managed - // pool once `ManagedWalletInfo::from_wallet` has created it. SLIP-10 is - // hardened-only, so from_wallet can't re-derive these seedlessly on the - // external-signable restore — the batch is the only source. Stored as - // (index, pubkey); node ids are recomputed SHA256[..20] at insert. - let mut platform_node_batch: Vec<(u32, [u8; 32])> = Vec::new(); let specs: &[AccountSpecFFI] = if entry.accounts.is_null() || entry.accounts_count == 0 { &[] } else { @@ -3404,21 +3325,11 @@ fn build_wallet_start_state( e )) })?; - // Capture the persisted platform-node key batch (if the host - // supplied it on this load) for rehydration into the managed - // pool below. Only the pubkeys are trusted; node ids are - // recomputed under the #884 convention at insert time. - if !spec.derived_platform_node_keys.is_null() - && spec.derived_platform_node_keys_count > 0 - { - let rows = unsafe { - slice::from_raw_parts( - spec.derived_platform_node_keys, - spec.derived_platform_node_keys_count, - ) - }; - platform_node_batch = rows.iter().map(|r| (r.index, r.public_key)).collect(); - } + // The platform-node (Ed25519) pool is rehydrated from the + // persisted core-address rows like every other pool — see + // `restore_core_address_pools`. Those rows now carry the + // typed EdDSA key + `KeyTypeTagFFI::EdDSA`, so no dedicated + // batch side-channel is needed here. continue; } _ => {} @@ -3529,13 +3440,11 @@ fn build_wallet_start_state( } } - // Rehydrate the platform-node (Ed25519) key batch into its managed pool. - // Unlike the ECDSA/BLS pools this can't come from `core_address_pools` - // (SLIP-10 is hardened-only and the 33-byte pool ABI can't carry a typed - // 32-byte Ed25519 key), so it flows through the dedicated batch captured - // from the `ProviderPlatformKeys` spec above. Without it a seedless - // restore has no platform-node keys to match masternode ownership. - restore_platform_node_pool(&mut wallet_info, &platform_node_batch, network)?; + // The platform-node (Ed25519) pool rehydrates through the same + // `restore_core_address_pools` path above as every other pool: its + // rows now carry the typed 32-byte Ed25519 key + `KeyTypeTagFFI::EdDSA` + // in the widened `CoreAddressEntryFFI`, so the masternode-ownership + // scan finds the wallet's platform-node keys with no dedicated batch. // Persisted unspent UTXOs → funds-bearing accounts. Keys-only and // PlatformPayment variants are skipped: the former never carry @@ -3578,9 +3487,6 @@ fn build_wallet_start_state( friend_identity_id: u.friend_identity_id, account_xpub_bytes: std::ptr::null(), account_xpub_bytes_len: 0, - // Irrelevant to `account_type_from_spec` routing. - derived_platform_node_keys: std::ptr::null(), - derived_platform_node_keys_count: 0, }; // Skip-and-continue is correct ONLY for the legacy // `IdentityAuthentication{Ecdsa,Bls}` tag bytes (15 / 16) @@ -5095,8 +5001,9 @@ mod tests { let addr_c = CString::new(addr).unwrap(); let path_c = CString::new("m/44'/1'/0'/1/0").unwrap(); let entry = CoreAddressEntryFFI { - public_key: [0u8; 33], - has_public_key: false, + public_key: [0u8; 48], + public_key_len: 0, + key_type_tag: 0, pool_type_tag: AddressPoolTypeTagFFI::Internal as u8, address_index: 0, is_used: false, @@ -5307,8 +5214,9 @@ mod tests { let addr_c = CString::new("yMqShkrgjTRuReBGFpQr7FozEF1QcNBBYA").unwrap(); let path_c = CString::new("m/9'/1'/2'/50").unwrap(); let row = CoreAddressEntryFFI { - public_key: [0u8; 33], - has_public_key: false, + public_key: [0u8; 48], + public_key_len: 0, + key_type_tag: 0, pool_type_tag, address_index: RESTORED_INDEX, is_used: true, @@ -5443,13 +5351,17 @@ mod tests { } } - /// Build a minimal `AddressInfo` for merge tests. The address/script/ - /// path content is irrelevant — `restore_address_pool`'s merge only - /// inspects `public_key` — so a P2PKH keyed off `index` suffices. - fn merge_test_address_info(index: u32, public_key: Option) -> AddressInfo { + /// Build a minimal P2PKH `AddressInfo` carrying `public_key`, keyed + /// off `index`. The address is the P2PKH payload of a 20-byte hash + /// seeded from `index` (exactly how the pools build platform-node / + /// provider-key entries) so it base58-round-trips through + /// [`address_info_from_ffi`], which re-parses the rendered string and + /// rebuilds the address from its script. + fn typed_key_test_address_info(index: u32, public_key: Option) -> AddressInfo { use dashcore::hashes::Hash; let mut h = [0u8; 20]; h[0] = index as u8; + h[1] = (index >> 8) as u8; let payload = dashcore::address::Payload::PubkeyHash(dashcore::PubkeyHash::from_byte_array(h)); let address = dashcore::Address::new(Network::Testnet, payload); @@ -5459,7 +5371,8 @@ mod tests { script_pubkey, public_key, index, - path: DerivationPath::from(Vec::::new()), + path: DerivationPath::from_str(&format!("m/9'/1'/2'/{}", index)) + .expect("static derivation path must parse"), used: false, generated_at: 0, used_at: None, @@ -5472,17 +5385,52 @@ mod tests { } } - /// Blocker #1: a prederived operator entry carrying a typed BLS (48B) - /// public key must NOT be clobbered by a persisted row that lost its - /// typed key on the 33-byte-slot FFI round-trip (`public_key: None`). - /// Pins the merge in `restore_address_pool` so key-wallet's provider-key - /// matching keeps a usable typed key after restore. + /// Push `key` at `index` through the full FFI row round-trip + /// (`build_core_address_entry_ffi` → `address_info_from_ffi` → + /// `restore_address_pool`) into `pool`, returning the restored entry's + /// typed key. No pre-seeded entry is needed — the widened row carries + /// the typed key itself. + fn round_trip_typed_key_into_pool( + pool: &mut AddressPool, + index: u32, + key: PublicKeyType, + ) -> Option { + let info = typed_key_test_address_info(index, Some(key)); + let mut owned: Vec = Vec::new(); + let entry = build_core_address_entry_ffi( + &info, + AddressPoolTypeTagFFI::AbsentHardened as u8, + false, + &mut owned, + ) + .expect("build_core_address_entry_ffi must succeed"); + // SAFETY: the address / path c-strings live in `owned`, kept alive + // until after this decode. + let restored = unsafe { address_info_from_ffi(&entry, Network::Testnet) } + .expect("address_info_from_ffi must decode the row"); + restore_address_pool(pool, vec![restored]); + drop(owned); + pool.addresses + .get(&index) + .expect("restored entry must be present") + .public_key + .clone() + } + + /// A BLS (48B) operator key, an Ed25519 (32B) platform-node key, and an + /// ECDSA (33B) control must each survive the widened + /// [`CoreAddressEntryFFI`] round-trip byte-for-byte and land in the + /// managed pool typed correctly — no pre-seeded entry and no merge. + /// This is what lets the seedless masternode-ownership scan match a + /// ProRegTx `platform_node_id` after restore (replacing the old + /// 33-byte-slot merge that only preserved a pre-derived key). #[test] - fn restore_address_pool_merge_preserves_typed_bls_operator_key() { + fn typed_public_key_survives_ffi_round_trip_into_fresh_pool() { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - // Any managed keys account gives a real `AddressPool` to exercise - // the (pool-type-agnostic) merge against. + // Any managed keys account gives a real `AddressPool`; we restore at + // indices well beyond any pre-derived gap window so the pool has no + // pre-seeded entry at them. let mut wallet_info = test_managed_wallet_info_with_provider_owner(); let owner = wallet_info .accounts @@ -5496,42 +5444,55 @@ mod tests { .next() .expect("the account must have at least one pool"); + const BLS_IDX: u32 = 500; + const EDDSA_IDX: u32 = 501; + const ECDSA_IDX: u32 = 502; + for idx in [BLS_IDX, EDDSA_IDX, ECDSA_IDX] { + assert!( + pool.addresses.get(&idx).is_none(), + "index {idx} must start with no pre-seeded entry" + ); + } + let bls = vec![0xABu8; 48]; - // Seed a prederived typed BLS entry at index 0. - restore_address_pool( - pool, - vec![merge_test_address_info( - 0, - Some(PublicKeyType::BLS(bls.clone())), - )], - ); - // A persisted row that lost its typed key (None) at the SAME index - // must keep the existing BLS key, not overwrite it with None. - restore_address_pool(pool, vec![merge_test_address_info(0, None)]); - match pool - .addresses - .get(&0) - .expect("entry at index 0") - .public_key - .as_ref() - { + let eddsa = vec![0xCDu8; 32]; + let ecdsa = vec![0x02u8; 33]; + + let out_bls = + round_trip_typed_key_into_pool(pool, BLS_IDX, PublicKeyType::BLS(bls.clone())); + match out_bls { Some(PublicKeyType::BLS(bytes)) => { - assert_eq!(bytes, &bls, "the prederived BLS key must be preserved") + assert_eq!(bytes, bls, "BLS operator key must survive byte-for-byte") } - other => panic!("expected a preserved BLS key at index 0, got {:?}", other), + other => panic!("expected a typed BLS key after round-trip, got {:?}", other), } - // Control: a `None` row at a fresh index with no existing entry - // stays `None` (merge only fills from an existing typed entry). - restore_address_pool(pool, vec![merge_test_address_info(7, None)]); - assert!( - pool.addresses - .get(&7) - .expect("entry at index 7") - .public_key - .is_none(), - "a None row with no prederived entry must remain None" - ); + let out_ed = + round_trip_typed_key_into_pool(pool, EDDSA_IDX, PublicKeyType::EdDSA(eddsa.clone())); + match out_ed { + Some(PublicKeyType::EdDSA(bytes)) => { + assert_eq!( + bytes, eddsa, + "Ed25519 platform-node key must survive byte-for-byte" + ) + } + other => panic!( + "expected a typed EdDSA key after round-trip, got {:?}", + other + ), + } + + let out_ec = + round_trip_typed_key_into_pool(pool, ECDSA_IDX, PublicKeyType::ECDSA(ecdsa.clone())); + match out_ec { + Some(PublicKeyType::ECDSA(bytes)) => { + assert_eq!(bytes, ecdsa, "ECDSA control key must survive byte-for-byte") + } + other => panic!( + "expected a typed ECDSA key after round-trip, got {:?}", + other + ), + } } /// `account_xpub` must survive the persist→restore byte round-trip — it is diff --git a/packages/rs-platform-wallet-ffi/src/provider_key_at_index.rs b/packages/rs-platform-wallet-ffi/src/provider_key_at_index.rs index 81895c079c7..75c5265efe6 100644 --- a/packages/rs-platform-wallet-ffi/src/provider_key_at_index.rs +++ b/packages/rs-platform-wallet-ffi/src/provider_key_at_index.rs @@ -304,3 +304,35 @@ pub unsafe extern "C" fn platform_wallet_provider_key_at_index_free( out.private_key_hex = std::ptr::null_mut(); } } + +/// Compute the 20-byte Tenderdash platform node id +/// (`SHA256(ed25519 pubkey)[..20]`, rust-dashcore #884) for a raw 32-byte +/// Ed25519 public key. Pure helper — no wallet handle, no key material +/// beyond the public key. +/// +/// Wraps `dashcore::PlatformNodeId::from_ed25519_public_key` so the host +/// can render the node id of a persisted platform-node public key (which +/// carries only the pubkey) without re-implementing the SHA-256 digest. +/// +/// Returns `true` on success, having written 20 bytes into +/// `out_node_id_20`; `false` (and no write) when `pubkey_ptr` is null, +/// `pubkey_len != 32`, or `out_node_id_20` is null. +/// +/// # Safety +/// `pubkey_ptr` must point to `pubkey_len` readable bytes; `out_node_id_20`, +/// when non-null, must point to at least 20 writable bytes. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_platform_node_id_from_ed25519_pubkey( + pubkey_ptr: *const u8, + pubkey_len: usize, + out_node_id_20: *mut u8, +) -> bool { + if pubkey_ptr.is_null() || out_node_id_20.is_null() || pubkey_len != 32 { + return false; + } + let mut pk32 = [0u8; 32]; + pk32.copy_from_slice(unsafe { std::slice::from_raw_parts(pubkey_ptr, 32) }); + let node_id = dashcore::PlatformNodeId::from_ed25519_public_key(&pk32).to_byte_array(); + unsafe { std::ptr::copy_nonoverlapping(node_id.as_ptr(), out_node_id_20, 20) }; + true +} diff --git a/packages/rs-platform-wallet-ffi/src/wallet_registration_persistence.rs b/packages/rs-platform-wallet-ffi/src/wallet_registration_persistence.rs index 527be165e6e..1fd75f969ae 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_registration_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_registration_persistence.rs @@ -67,22 +67,13 @@ unsafe impl Sync for AccountAddressPoolFFI {} // Expected layout on 64-bit targets (all fields in declaration // order under `#[repr(C)]`): // -// 0..=111 account AccountSpecFFI (108 bytes -// + 4 bytes inline pad to align -// the trailing 8-byte pointers -// — includes the appended -// derived-platform-node-keys -// ptr/len pair; see the layout -// note on AccountSpecFFI) +// 0..=95 account AccountSpecFFI (96 bytes) // ... // -// The exact internal padding inside `AccountSpecFFI` is fixed by the -// upstream layout guard in `wallet_restore_types`; we only pin the -// outer struct size here. On 64-bit targets the trailing pool fields -// add `1 + 7 (pad) + 8 (ptr) + 8 (len) = 24` bytes after a 112-byte -// account (96 + the derived-platform-node-keys ptr/len pair), for a -// total of 136. +// We only pin the outer struct size here. On 64-bit targets the +// trailing pool fields add `1 + 7 (pad) + 8 (ptr) + 8 (len) = 24` +// bytes after the 96-byte account spec, for a total of 120. // // Recompute via `std::mem::size_of` if the spec layout changes. -const _: [u8; 136] = [0u8; std::mem::size_of::()]; +const _: [u8; 120] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index 6c86b192731..9f8a8d27065 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -117,30 +117,6 @@ impl StandardAccountTypeTagFFI { } } -/// One pre-derived platform-node (Ed25519) key carried on -/// [`AccountSpecFFI::derived_platform_node_keys`] for the -/// `ProviderPlatformKeys` account (`type_tag == 11`). -/// -/// Ed25519/SLIP-10 is hardened-only, so the wallet can never extend -/// its platform-node pool without the seed — the batch is pre-derived -/// at registration (while the seed is in hand) and surfaced here so -/// the host can persist + display it with no keychain prompt. Plain -/// POD (no pointers): the Tenderdash node id (SHA256[..20], #884) is precomputed on the Rust -/// side so the host needs no RIPEMD-160 of its own. The private scalar -/// is never carried — a per-index reveal still routes through -/// `platform_wallet_provider_key_at_index` with the resolver. -#[repr(C)] -#[derive(Debug, Clone, Copy)] -pub struct ProviderPlatformNodeKeyFFI { - /// Hardened key index within the platform-node pool (`#0..`). - pub index: u32, - /// Raw 32-byte Ed25519 public key at this index. - pub public_key: [u8; 32], - /// 20-byte platform node id — `SHA256(ed25519 pubkey)[..20]` (#884) - /// (the ProRegTx `platform_node_id`). - pub node_id: [u8; 20], -} - /// Flat account spec carried in `WalletRestoreEntryFFI.accounts`. /// /// Field relevance per `type_tag`: @@ -188,21 +164,6 @@ pub struct AccountSpecFFI { /// callback duration only; Swift owns the allocation. pub account_xpub_bytes: *const u8, pub account_xpub_bytes_len: usize, - /// Pre-derived platform-node (Ed25519) public keys — only populated - /// on the **write** callback for the `ProviderPlatformKeys` account - /// (`type_tag == 11`); `null` / `0` for every other account type. - /// - /// On the write callback (`on_persist_account_registrations_fn`) - /// this is Rust-owned and valid for the callback window only — the - /// host copies the rows into its account row so the Node Keys - /// screen can list them from persistence without re-deriving. On - /// the **load** callback the host leaves this `null` / `0`: the - /// Rust load path does not consume it (it is display data the host - /// is the sole source of truth for), and the persisted account row - /// is never rewritten after registration, so the batch survives the - /// SwiftData → restore → re-persist cycle untouched. - pub derived_platform_node_keys: *const ProviderPlatformNodeKeyFFI, - pub derived_platform_node_keys_count: usize, } /// Per-identity public-key row carried on diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 67f839c4e18..f5f5229d675 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1095,6 +1095,11 @@ pub enum ProviderKeyExtendedPubKey { /// list these keys later from an external-signable / watch-only /// wallet without re-prompting for the mnemonic. Only the public parts /// are carried — the private scalar stays resolver-gated per index. +/// +/// Produced by [`derive_platform_node_public_keys`](crate::wallet::provider_key_at_index::derive_platform_node_public_keys) +/// and fed straight into the managed platform-node pool at registration +/// via [`populate_platform_node_pool`](crate::wallet::provider_key_at_index::populate_platform_node_pool), +/// from which the keys persist as ordinary typed core-address rows. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ProviderPlatformNodePubKey { @@ -1133,15 +1138,6 @@ pub struct ProviderKeyAccountEntry { pub account_type: AccountType, /// The account's extended public key. pub extended_public_key: ProviderKeyExtendedPubKey, - /// Pre-derived platform-node (Ed25519) public keys, captured at - /// registration while the seed was in hand. Only populated for the - /// `ProviderPlatformKeys` (EdDSA) entry — always empty for the BLS - /// operator entry, whose pool the wallet can re-derive on demand - /// from the account xpub (non-hardened `ckd_pub`, no seed). The FFI - /// layer surfaces these to the host as a flat display array so the - /// Node Keys screen can list them from persistence with no keychain - /// prompt. See [`ProviderPlatformNodePubKey`]. - pub derived_platform_node_keys: Vec, } /// Address-pool snapshot for one `(account_type, pool_type)` pair. diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index dc5025871cd..947121519be 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -179,7 +179,9 @@ impl PlatformWalletManager

{ } }; - let wallet_info = ManagedWalletInfo::from_wallet(&wallet, birth_height); + // `mut` so the platform-node (Ed25519) pool can be populated in + // place below, BEFORE the address-pool snapshot is taken. + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, birth_height); let balance = Arc::new(WalletBalance::new()); @@ -213,10 +215,6 @@ impl PlatformWalletManager

{ provider_key_account_registrations.push(ProviderKeyAccountEntry { account_type: key_wallet::account::AccountType::ProviderOperatorKeys, extended_public_key: ProviderKeyExtendedPubKey::Bls(bls.bls_public_key.clone()), - // The BLS operator pool extends on demand from the - // account xpub (non-hardened `ckd_pub`, no seed), so it - // needs no pre-derived batch. - derived_platform_node_keys: Vec::new(), }); } #[cfg(feature = "eddsa")] @@ -228,7 +226,10 @@ impl PlatformWalletManager

{ // the wallet is still seed-bearing (`downgrade_to_external_signable` // hasn't run yet). Ed25519/SLIP-10 is hardened-only, so this // pool can never be extended later from the watch-only restore — - // capturing the public parts now lets the Node Keys screen list + // populating the managed pool now lets those keys ride the + // normal typed-address persistence pipeline (persisted as + // `PublicKeyType::EdDSA` core-address rows, rehydrated on load + // by `restore_core_address_pools`) so the Node Keys screen lists // them from persistence with no keychain prompt. A derivation // failure here is non-fatal: fall back to an empty batch (the UI // then uses its resolver-based "Load Keys" path) rather than @@ -247,12 +248,27 @@ impl PlatformWalletManager

{ ); Vec::new() }); + // Populate the managed platform-node pool in place, BEFORE the + // address-pool snapshot below reads `all_managed_accounts()`, so + // the EdDSA keys are captured as typed core-address rows. A + // population failure is non-fatal for the same reason the + // derivation failure above is. + if let Err(e) = crate::wallet::provider_key_at_index::populate_platform_node_pool( + &mut wallet_info, + &derived_platform_node_keys, + wallet.network, + ) { + tracing::warn!( + error = %e, + "failed to populate the managed platform-node pool at registration; \ + the Node Keys screen will fall back to the resolver path" + ); + } provider_key_account_registrations.push(ProviderKeyAccountEntry { account_type: key_wallet::account::AccountType::ProviderPlatformKeys, extended_public_key: ProviderKeyExtendedPubKey::EdDSA( eddsa.ed25519_public_key.clone(), ), - derived_platform_node_keys, }); } // Snapshot core (BIP44/CoinJoin/identity/provider/DashPay) diff --git a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs index e75a9259412..1584f7eb73e 100644 --- a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs +++ b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs @@ -166,6 +166,118 @@ pub fn derive_platform_node_public_keys( Ok(out) } +/// Insert a pre-derived platform-node (Ed25519) key batch into the +/// managed `ProviderPlatformKeys` pool of a `ManagedWalletInfo` so those +/// keys flow out as ordinary typed [`PublicKeyType::EdDSA`] address rows. +/// +/// Called at registration (`PlatformWalletManager::register_wallet`), +/// AFTER `ManagedWalletInfo::from_wallet` has created the (empty) +/// platform-node account but BEFORE the address-pool snapshot is taken, +/// so the EdDSA keys ride the normal address-persistence pipeline (they +/// are persisted as typed core-address rows, restored by +/// `restore_core_address_pools`) and the in-memory pool matches what a +/// restore reconstructs. SLIP-10 Ed25519 is hardened-only, so this pool +/// can never be extended later from the account xpub — this one-time +/// registration-side population is the only source of these keys. +/// +/// Each entry becomes a pool address keyed by its hardened index: the +/// address is the P2PKH payload of the entry's 20-byte platform node id +/// (`SHA256(pubkey)[..20]`, rust-dashcore #884), the path is the +/// `ProviderPlatformKeys` account path plus the hardened index, and the +/// typed key is `PublicKeyType::EdDSA(pubkey)`. The `node_id` field is +/// trusted here — it was just computed by +/// [`derive_platform_node_public_keys`] from the same pubkey. Mirrors the +/// `AddressInfo` shape key-wallet's own EdDSA pool derivation produces, so +/// a freshly-registered wallet and a restored one carry byte-identical +/// pool entries. +/// +/// A no-op when the wallet has no managed platform-node account or no +/// `AbsentHardened` pool on it. +/// +/// # Errors +/// [`PlatformWalletError::KeyDerivation`] if the `ProviderPlatformKeys` +/// account derivation path or a hardened child index cannot be built. +#[cfg(feature = "eddsa")] +pub fn populate_platform_node_pool( + wallet_info: &mut key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, + keys: &[ProviderPlatformNodePubKey], + network: key_wallet::Network, +) -> Result<(), PlatformWalletError> { + use dashcore::hashes::Hash; + use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::AddressInfo; + + if keys.is_empty() { + return Ok(()); + } + let Some(account) = wallet_info.accounts.provider_platform_keys.as_mut() else { + return Ok(()); + }; + + let account_path = AccountType::ProviderPlatformKeys + .derivation_path(network) + .map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to build ProviderPlatformKeys account path: {e:?}" + )) + })?; + let base_children: Vec = account_path.as_ref().to_vec(); + + let mut infos: Vec = Vec::with_capacity(keys.len()); + for key in keys { + // Trust the entry's node id — it was just derived from `public_key`. + let payload = dashcore::address::Payload::PubkeyHash( + dashcore::PubkeyHash::from_byte_array(key.node_id), + ); + let address = dashcore::Address::new(network, payload); + let script_pubkey = address.script_pubkey(); + let child = key_wallet::bip32::ChildNumber::from_hardened_idx(key.index).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to build hardened child index {}: {e:?}", + key.index + )) + })?; + let mut children = base_children.clone(); + children.push(child); + let path = key_wallet::bip32::DerivationPath::from(children); + infos.push(AddressInfo { + address, + script_pubkey, + public_key: Some(PublicKeyType::EdDSA(key.public_key.to_vec())), + index: key.index, + path, + used: false, + generated_at: 0, + used_at: None, + tx_count: 0, + total_received: 0, + total_sent: 0, + balance: 0, + label: None, + metadata: std::collections::BTreeMap::new(), + }); + } + + // The platform-node pool is `AbsentHardened` (SLIP-10 hardened-only). + if let Some(pool) = account + .managed_account_type_mut() + .address_pools_mut() + .into_iter() + .find(|p| p.pool_type == AddressPoolType::AbsentHardened) + { + for info in infos { + let idx = info.index; + pool.address_index.insert(info.address.clone(), idx); + pool.script_pubkey_index + .insert(info.script_pubkey.clone(), idx); + pool.highest_generated = Some(pool.highest_generated.map_or(idx, |h| h.max(idx))); + pool.addresses.insert(idx, info); + } + } + Ok(()) +} + /// Which provider key-material account to derive from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderKeyKind { @@ -709,4 +821,69 @@ mod tests { "same mnemonic must yield different platform-node keys per network" ); } + + /// Registration-side: populating the managed platform-node pool from a + /// derived batch must land typed [`PublicKeyType::EdDSA`] rows in the + /// account's `AbsentHardened` pool — the source the address-persistence + /// pipeline snapshots and the masternode-ownership scan reads. Pins the + /// pool shape (typed key + node-id-derived address + advancing + /// watermark) independent of any FFI plumbing. + #[cfg(feature = "eddsa")] + #[test] + fn populate_platform_node_pool_lands_typed_eddsa_rows() { + use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let wallet = seed_bearing_wallet(Network::Mainnet); + let keys = derive_platform_node_public_keys(&wallet, Network::Mainnet, 5) + .expect("platform-node derivation"); + + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + populate_platform_node_pool(&mut info, &keys, Network::Mainnet) + .expect("populate must succeed"); + + let account = info + .accounts + .provider_platform_keys + .as_ref() + .expect("managed platform-node account must exist"); + let pool = account + .managed_account_type() + .address_pools() + .iter() + .find(|p| p.pool_type == AddressPoolType::AbsentHardened) + .cloned() + .expect("AbsentHardened pool must exist"); + + assert_eq!(pool.addresses.len(), keys.len(), "one row per derived key"); + for k in &keys { + let entry = pool + .addresses + .get(&k.index) + .expect("row present at derived index"); + match &entry.public_key { + Some(PublicKeyType::EdDSA(bytes)) => assert_eq!( + bytes.as_slice(), + &k.public_key, + "typed Ed25519 key must be stored byte-for-byte" + ), + other => panic!("expected a typed EdDSA row, got {other:?}"), + } + // The row address is the P2PKH payload of the node id, so a + // scan recomputing SHA256[..20] from the pubkey maps back to + // this index. + let expected_node_id = + dashcore::PlatformNodeId::from_ed25519_public_key(&k.public_key).to_byte_array(); + assert_eq!( + k.node_id, expected_node_id, + "node id must be the Tenderdash SHA256[..20]" + ); + } + assert_eq!( + pool.highest_generated, + Some(keys.len() as u32 - 1), + "highest_generated must advance to the last populated index" + ); + } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift index 3939467dced..32e01382277 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift @@ -1,31 +1,6 @@ import Foundation import SwiftData -/// One pre-derived platform-node (Ed25519) public key persisted on a -/// `ProviderPlatformKeys` account (`accountType == 11`). -/// -/// Ed25519/SLIP-10 is hardened-only, so the wallet can never extend -/// its platform-node pool without the seed — the batch is derived once -/// at wallet registration (while the seed is available) and stored here -/// so the Node Keys detail screen lists it with no keychain prompt. The -/// 20-byte `nodeId` is `hash160(publicKey)`, precomputed on the Rust -/// side (the host needs no RIPEMD-160). The private scalar is never -/// stored — a per-index reveal re-derives it through the resolver. -public struct DerivedPlatformNodeKey: Codable, Equatable, Hashable, Sendable { - /// Hardened key index within the platform-node pool (`#0..`). - public var index: UInt32 - /// Raw 32-byte Ed25519 public key. - public var publicKey: Data - /// 20-byte platform node id (`hash160` of `publicKey`). - public var nodeId: Data - - public init(index: UInt32, publicKey: Data, nodeId: Data) { - self.index = index - self.publicKey = publicKey - self.nodeId = nodeId - } -} - /// SwiftData model for persisting a wallet account. /// /// Each account represents an HD derivation path (BIP44, CoinJoin, @@ -119,22 +94,6 @@ public final class PersistentAccount { /// `nil` values, so freshly-inserted unhydrated rows don't /// conflict. @Attribute(.unique) public var accountExtendedPubKeyBytes: Data? - /// Pre-derived platform-node (Ed25519) public keys for the - /// `ProviderPlatformKeys` account (`accountType == 11`), captured - /// at wallet registration while the seed was available. Empty for - /// every other account type, and for wallets created before this - /// field existed (the Node Keys screen then falls back to the - /// resolver-based "Load Keys" path). Populated once by - /// `on_persist_account_registrations_fn` and read directly by the - /// UI — never rewritten on the restore / re-persist cycle, so the - /// batch is durable across relaunches with no keychain prompt. See - /// [`DerivedPlatformNodeKey`]. - /// - /// Declared with an inline default so SwiftData's lightweight - /// migration can add the column to stores created before this - /// field existed — without it, `ModelContainer` creation fatals - /// with `loadIssueModelContainer` on first launch after upgrade. - public var derivedPlatformNodeKeys: [DerivedPlatformNodeKey] = [] /// Record timestamps. public var createdAt: Date public var lastUpdated: Date @@ -216,7 +175,6 @@ public final class PersistentAccount { self.userIdentityId = Data() self.friendIdentityId = Data() self.accountExtendedPubKeyBytes = nil - self.derivedPlatformNodeKeys = [] self.createdAt = Date() self.lastUpdated = Date() self.coreAddresses = [] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift index 8734ce1d9be..06a7dd25524 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift @@ -16,11 +16,15 @@ public final class PersistentCoreAddress { /// because the same address can't validly exist under two accounts /// (collision would imply a wallet-id hash collision). @Attribute(.unique) public var address: String - /// 33-byte compressed secp256k1 public key, or empty Data when the - /// Rust side couldn't produce one (e.g. BLS accounts, or pool - /// entries that stored only a script). Watch-only-restored - /// accounts currently always populate this. + /// Typed public key bytes, or empty Data when the Rust side couldn't + /// produce one (e.g. a pool entry that stored only a script). The + /// curve is given by `keyType`: 33-byte compressed secp256k1 (ECDSA), + /// 48-byte BLS operator key, or 32-byte Ed25519 platform-node key. public var publicKey: Data + /// `KeyTypeTagFFI` raw value identifying the curve of `publicKey`: + /// 0 ECDSA / 1 BLS / 2 EdDSA. Meaningful only when `publicKey` is + /// non-empty. + public var keyType: UInt8 /// `AddressPoolTypeTagFFI` raw value — 0 External, 1 Internal, /// 2 Absent, 3 AbsentHardened. public var poolTypeTag: UInt8 @@ -59,6 +63,7 @@ public final class PersistentCoreAddress { public init( address: String, publicKey: Data = Data(), + keyType: UInt8 = 0, poolTypeTag: UInt8, addressIndex: UInt32, derivationPath: String, @@ -67,6 +72,7 @@ public final class PersistentCoreAddress { ) { self.address = address self.publicKey = publicKey + self.keyType = keyType self.poolTypeTag = poolTypeTag self.addressIndex = addressIndex self.derivationPath = derivationPath diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index a43ec4891c4..94d84652c1b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -958,11 +958,11 @@ extension ManagedPlatformWallet { /// so this is the only private form. public let privateKeyHex: String? - /// Public memberwise init so hosts can build display rows from a - /// persisted platform-node batch (see - /// `PersistentAccount.derivedPlatformNodeKeys`) without a fresh - /// FFI derivation — the synthesized memberwise init is internal - /// and unreachable from the app module. + /// Public memberwise init so hosts can build display rows from the + /// persisted platform-node core-address rows (typed + /// `PersistentCoreAddress` entries with `keyType == 2`) without a + /// fresh FFI derivation — the synthesized memberwise init is + /// internal and unreachable from the app module. public init( index: UInt32, publicKeyHex: String, @@ -1045,6 +1045,34 @@ extension ManagedPlatformWallet { } } + /// Compute the 20-byte Tenderdash platform node id + /// (`SHA256(ed25519 pubkey)[..20]`, rust-dashcore #884) for a raw + /// 32-byte Ed25519 public key, via the pure Rust helper + /// `platform_wallet_platform_node_id_from_ed25519_pubkey`. + /// + /// The node id is exactly what a ProRegTx `platform_node_id` field + /// carries; hosts use this to render the node id of a persisted + /// platform-node public key (which stores only the pubkey) without + /// re-implementing the SHA-256 digest. Pure bridge — no wallet handle, + /// no key material beyond the public key. + /// + /// - Returns: the 20-byte node id, or `nil` when `publicKey` is not + /// exactly 32 bytes or the FFI rejects it. + public static func platformNodeId(fromEd25519PublicKey publicKey: Data) -> Data? { + guard publicKey.count == 32 else { return nil } + var out = Data(count: 20) + let ok = out.withUnsafeMutableBytes { outRaw -> Bool in + publicKey.withUnsafeBytes { pkRaw -> Bool in + platform_wallet_platform_node_id_from_ed25519_pubkey( + pkRaw.bindMemory(to: UInt8.self).baseAddress, + UInt(pkRaw.count), + outRaw.bindMemory(to: UInt8.self).baseAddress + ) + } + } + return ok ? out : nil + } + /// Derive a single ECDSA identity-authentication keypair at an /// arbitrary `(identityIndex, keyId)` slot — the building block /// the "add key to existing identity" flow runs on. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 54917b5f99e..c1b17981e50 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2618,6 +2618,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { row = PersistentCoreAddress( address: entry.address, publicKey: entry.publicKey, + keyType: entry.keyType, poolTypeTag: entry.poolTypeTag, addressIndex: entry.addressIndex, derivationPath: entry.derivationPath, @@ -2628,6 +2629,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } // Mutation path for both insert + update. row.publicKey = entry.publicKey + row.keyType = entry.keyType row.poolTypeTag = entry.poolTypeTag row.addressIndex = entry.addressIndex row.derivationPath = entry.derivationPath @@ -2773,6 +2775,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { struct CoreAddressEntrySnapshot { let address: String let publicKey: Data + /// `KeyTypeTagFFI` raw value (0 ECDSA / 1 BLS / 2 EdDSA); + /// meaningful only when `publicKey` is non-empty. + let keyType: UInt8 let poolTypeTag: UInt8 let addressIndex: UInt32 let isUsed: Bool @@ -3866,31 +3871,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { xpubBytes = Data() } - // Pre-derived platform-node (Ed25519) keys for the - // ProviderPlatformKeys account. Rust-owned + valid only for - // the callback window, so copy each row's bytes out now. - var derivedPlatformNodeKeys: [DerivedPlatformNodeKey] = [] - if let dkPtr = spec.derived_platform_node_keys, - spec.derived_platform_node_keys_count > 0 { - let rows = UnsafeBufferPointer( - start: dkPtr, - count: Int(spec.derived_platform_node_keys_count) - ) - for row in rows { - var pub = Data(count: 32) - withUnsafeBytes(of: row.public_key) { src in - pub.withUnsafeMutableBytes { dst in dst.copyMemory(from: src) } - } - var node = Data(count: 20) - withUnsafeBytes(of: row.node_id) { src in - node.withUnsafeMutableBytes { dst in dst.copyMemory(from: src) } - } - derivedPlatformNodeKeys.append( - DerivedPlatformNodeKey(index: row.index, publicKey: pub, nodeId: node) - ) - } - } - // Upsert keyed by the full account identity. We can't easily // express the identity tuple in a #Predicate with local `Data` // captures, so fetch by (walletId, accountType, accountIndex) @@ -3936,15 +3916,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { account.userIdentityId = userIdentityId account.friendIdentityId = friendIdentityId account.accountExtendedPubKeyBytes = xpubBytes - // Only overwrite the batch when this callback actually - // carries one (i.e. the registration-time ProviderPlatformKeys - // spec). Any other emitter passes an empty array, so a - // balance-only re-persist never wipes the registration batch — - // Swift is the sole source of truth for it (Rust never echoes - // it back on the load path). - if !derivedPlatformNodeKeys.isEmpty { - account.derivedPlatformNodeKeys = derivedPlatformNodeKeys - } account.lastUpdated = Date() if !self.inChangeset { try? backgroundContext.save() } } @@ -4128,50 +4099,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { copyBytes(acc.friendIdentityId, into: &spec.friend_identity_id) spec.account_xpub_bytes = UnsafePointer(xpubBuffer) spec.account_xpub_bytes_len = UInt(xpub.count) - // Rehydrate the platform-node (Ed25519) key batch on the - // ProviderPlatformKeys account so the Rust restore can - // repopulate its managed pool. SLIP-10 is hardened-only, - // so these keys can't be re-derived seedlessly, and the - // 33-byte core-address-pool ABI can't carry a typed 32B - // Ed25519 key — the batch is the only path. Only the - // platform account has a non-empty batch, so that alone - // gates this. Pure load-side marshalling of persisted - // rows (the Rust side recomputes node ids under #884). - if !acc.derivedPlatformNodeKeys.isEmpty { - let batch = acc.derivedPlatformNodeKeys - let nkBuf = UnsafeMutablePointer.allocate( - capacity: batch.count - ) - var nkWritten = 0 - for dk in batch where dk.publicKey.count == 32 && dk.nodeId.count == 20 { - var row = ProviderPlatformNodeKeyFFI() - row.index = dk.index - dk.publicKey.withUnsafeBytes { src in - withUnsafeMutableBytes(of: &row.public_key) { dst in - dst.copyMemory(from: src) - } - } - dk.nodeId.withUnsafeBytes { src in - withUnsafeMutableBytes(of: &row.node_id) { dst in - dst.copyMemory(from: src) - } - } - nkBuf[nkWritten] = row - nkWritten += 1 - } - if nkWritten > 0 { - spec.derived_platform_node_keys = UnsafePointer(nkBuf) - spec.derived_platform_node_keys_count = UInt(nkWritten) - allocation.providerPlatformNodeKeyArrays.append((nkBuf, nkWritten)) - } else { - nkBuf.deallocate() - spec.derived_platform_node_keys = nil - spec.derived_platform_node_keys_count = 0 - } - } else { - spec.derived_platform_node_keys = nil - spec.derived_platform_node_keys_count = 0 - } + // The platform-node (Ed25519) pool now rehydrates from + // this account's persisted typed core-address rows like + // every other pool — no dedicated batch on the spec. buf[written] = spec written += 1 } @@ -4582,8 +4512,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) for (j, row) in group.rows.enumerated() { var e = CoreAddressEntryFFI() - copyBytes(row.publicKey, into: &e.public_key) - e.has_public_key = (row.publicKey.count == 33) + // Copy the typed key bytes (<= 48) into the fixed slot and + // record their length + curve tag. A row whose stored key + // somehow exceeds the slot is emitted with no key rather + // than truncated. Pure marshalling — the Rust side decides. + if row.publicKey.count <= 48 { + copyBytes(row.publicKey, into: &e.public_key) + e.public_key_len = UInt8(row.publicKey.count) + e.key_type_tag = row.keyType + } else { + e.public_key_len = 0 + e.key_type_tag = 0 + } e.pool_type_tag = group.poolTypeTag e.address_index = row.addressIndex e.is_used = row.isUsed @@ -4608,8 +4548,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { copyBytes(account.friendIdentityId, into: &spec.friend_identity_id) spec.account_xpub_bytes = nil spec.account_xpub_bytes_len = 0 - spec.derived_platform_node_keys = nil - spec.derived_platform_node_keys_count = 0 var pool = AccountAddressPoolFFI() pool.account = spec @@ -5605,11 +5543,6 @@ private final class LoadAllocation { var coreAddressPoolArrays: [(UnsafeMutablePointer, Int)] = [] /// Inner `CoreAddressEntryFFI` arrays, one per pool entry above. var coreAddressEntryArrays: [(UnsafeMutablePointer, Int)] = [] - /// Per-`ProviderPlatformKeys`-account `ProviderPlatformNodeKeyFFI` - /// arrays — the persisted platform-node key batch marshalled back so - /// the Rust restore can repopulate the managed pool. Flat POD (no owned - /// pointers), so nothing extra rides `scalarBuffers`. - var providerPlatformNodeKeyArrays: [(UnsafeMutablePointer, Int)] = [] func release() { if let entries = entries { @@ -5692,10 +5625,6 @@ private final class LoadAllocation { ptr.deinitialize(count: count) ptr.deallocate() } - for (ptr, count) in providerPlatformNodeKeyArrays { - ptr.deinitialize(count: count) - ptr.deallocate() - } } } @@ -6050,11 +5979,17 @@ private func persistAccountAddressPoolsCallback( let entry = addressesPtr[j] let address = entry.address_base58.map { String(cString: $0) } ?? "" let derivationPath = entry.derivation_path.map { String(cString: $0) } ?? "" + // Copy exactly `public_key_len` leading bytes out of the + // 48-byte slot; `key_type_tag` records the curve. Pure + // marshalling — the Rust side already validated the pair. + let keyLen = Int(entry.public_key_len) let publicKey: Data - if entry.has_public_key { - var pk = Data(count: 33) + if keyLen > 0 { + var pk = Data(count: keyLen) withUnsafeBytes(of: entry.public_key) { src in - pk.withUnsafeMutableBytes { dst in dst.copyMemory(from: src) } + pk.withUnsafeMutableBytes { dst in + dst.copyMemory(from: UnsafeRawBufferPointer(rebasing: src[0.. String { data.map { String(format: "%02x", $0) }.joined() } +/// Human label for a stored public key, keyed on its byte length — the +/// curve is fixed by the width (ECDSA 33 / BLS 48 / Ed25519 32), +/// matching the Rust-side `KeyTypeTagFFI` discriminant. +private func publicKeyTypeLabel(byteCount: Int) -> String { + switch byteCount { + case 33: return "ECDSA Public Key" + case 48: return "BLS Public Key" + case 32: return "Ed25519 Public Key" + default: return "Public Key" + } +} + /// Render an owning `PersistentWallet` for one-line display on /// the storage-record detail screens. Priority: explicit wallet /// name → `"…"` of the `walletId` → "None" for @@ -1309,7 +1321,7 @@ struct PlatformAddressDetailView: View { } Section("Public Key") { FieldRow( - label: "Bytes (hex)", + label: publicKeyTypeLabel(byteCount: record.publicKey.count), value: record.publicKey.isEmpty ? "—" : record.publicKey.map { String(format: "%02x", $0) }.joined() @@ -1581,7 +1593,7 @@ struct CoreAddressDetailView: View { } Section("Public Key") { FieldRow( - label: "Bytes (hex)", + label: publicKeyTypeLabel(byteCount: record.publicKey.count), value: record.publicKey.isEmpty ? "—" : record.publicKey.map { String(format: "%02x", $0) }.joined() From e6fd9b51d9d720bd2b56de7b254efa63eeeb20ce Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 14 Jul 2026 23:19:38 +0700 Subject: [PATCH 2/2] refactor(platform-wallet): size key-slot check via MemoryLayout and simplify key copy Address CodeRabbit review: derive the 48-byte slot bound from the FFI field itself and build the persisted key Data via a bounds-safe prefix. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.swift | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 1fe3d39628d..b8916c2c7a4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -4679,7 +4679,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // record their length + curve tag. A row whose stored key // somehow exceeds the slot is emitted with no key rather // than truncated. Pure marshalling — the Rust side decides. - if row.publicKey.count <= 48 { + if row.publicKey.count <= MemoryLayout.size(ofValue: e.public_key) { copyBytes(row.publicKey, into: &e.public_key) e.public_key_len = UInt8(row.publicKey.count) e.key_type_tag = row.keyType @@ -6165,18 +6165,9 @@ private func persistAccountAddressPoolsCallback( // 48-byte slot; `key_type_tag` records the curve. Pure // marshalling — the Rust side already validated the pair. let keyLen = Int(entry.public_key_len) - let publicKey: Data - if keyLen > 0 { - var pk = Data(count: keyLen) - withUnsafeBytes(of: entry.public_key) { src in - pk.withUnsafeMutableBytes { dst in - dst.copyMemory(from: UnsafeRawBufferPointer(rebasing: src[0.. 0 + ? withUnsafeBytes(of: entry.public_key) { Data($0.prefix(keyLen)) } + : Data() if address.isEmpty { continue } snapshots.append(.init( address: address,