diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index ad98bfbdceb..62ca83e7f9a 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -2861,6 +2861,16 @@ fn build_wallet_start_state( ..Default::default() }; + // `contacts` / `identity_keys` are the PR-3 keyless feed the + // manager layers onto the managed identities via + // `apply_contacts_and_keys`. The iOS path does NOT use them: + // identity PUBLIC keys are already reconstructed straight into + // `Identity.public_keys` by `build_wallet_identity_bucket` (feeding + // the slot too would double-apply), and `WalletRestoreEntryFFI` + // carries no contacts back from Swift on load — surfacing them + // would need a new cross-boundary struct field + Swift wiring, + // tracked as a follow-up. Empty slots make `apply_contacts_and_keys` + // a no-op for this path, preserving the established iOS behaviour. let wallet_state = ClientWalletStartState { network, birth_height: entry.birth_height, @@ -2868,6 +2878,8 @@ fn build_wallet_start_state( core_state, identity_manager, unused_asset_locks, + contacts: Default::default(), + identity_keys: Default::default(), }; let platform_address_state = if per_account.is_empty() diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index b7cd7230176..090edc3c809 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -19,13 +19,11 @@ use crate::sqlite::schema::{self, PER_WALLET_TABLES}; use crate::sqlite::util::permissions::apply_secure_permissions; use crate::sqlite::util::safe_cast; -/// Sub-areas still deferred after full signing-wallet rehydration -/// landed. `contacts` + `identity_keys` need a changeset-shape change -/// (PR-3); `last_applied_chain_lock` re-warms on the first post-load -/// SPV chainlock (no V001 column). Surfaced via the structured -/// `tracing::info!` summary on every `load()`. -pub(crate) const LOAD_UNIMPLEMENTED: &[&str] = - &["contacts", "identity_keys", "core::last_applied_chain_lock"]; +/// Sub-areas still deferred after contacts + identity-keys rehydration +/// landed (PR-3). Only `last_applied_chain_lock` remains — it re-warms +/// on the first post-load SPV chainlock (no V001 column). Surfaced via +/// the structured `tracing::info!` summary on every `load()`. +pub(crate) const LOAD_UNIMPLEMENTED: &[&str] = &["core::last_applied_chain_lock"]; /// Outcome of a `prune_backups` call. #[derive(Debug, Clone)] @@ -712,6 +710,10 @@ impl PlatformWalletPersistence for SqlitePersister { .map_err(PersistenceError::from)?; let unused_asset_locks = schema::asset_locks::load_unconsumed(&conn, &wallet_id) .map_err(PersistenceError::from)?; + let contacts = schema::contacts::load_changeset(&conn, &wallet_id) + .map_err(PersistenceError::from)?; + let identity_keys = schema::identity_keys::load_state(&conn, &wallet_id) + .map_err(PersistenceError::from)?; state.wallets.insert( wallet_id, @@ -722,6 +724,8 @@ impl PlatformWalletPersistence for SqlitePersister { core_state, identity_manager, unused_asset_locks, + contacts, + identity_keys, }, ); } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs index 57156a0fefc..486152f470c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs @@ -191,6 +191,25 @@ pub(crate) fn load_state( Ok(state) } +/// Build a keyless [`ContactChangeSet`] for one wallet — the +/// rehydration feed the manager layers onto the restored managed +/// identities. PUBLIC material only; `removed_*` are always empty +/// (deletes never reach storage as rows). Fail-hard on a corrupt row, +/// inherited from [`load_state`]. +pub fn load_changeset( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + let records = load_state(conn, wallet_id)?; + Ok(ContactChangeSet { + sent_requests: records.sent_requests, + incoming_requests: records.incoming_requests, + established: records.established, + removed_sent: Default::default(), + removed_incoming: Default::default(), + }) +} + fn decode_pair_key(a: &[u8], b: &[u8]) -> Result<(Identifier, Identifier), WalletStorageError> { let a32 = <[u8; 32]>::try_from(a) .map_err(|_| WalletStorageError::blob_decode("contacts.id column is not 32 bytes"))?; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index e8c93c5a66a..f2a158324e3 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -10,7 +10,7 @@ //! the bincode-serde encoder. The shape is documented on the //! `IdentityKeyWire` struct below. -use rusqlite::{params, Transaction}; +use rusqlite::{params, Connection, Transaction}; use serde::{Deserialize, Serialize}; use dpp::identity::{IdentityPublicKey, KeyID}; @@ -112,3 +112,39 @@ pub fn decode_entry(payload: &[u8]) -> Result Result { + let mut cs = IdentityKeysChangeSet::default(); + let mut stmt = conn.prepare( + "SELECT identity_id, key_id, public_key_blob FROM identity_keys WHERE wallet_id = ?1", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + let identity_id_bytes: Vec = row.get(0)?; + let key_id: i64 = row.get(1)?; + let payload: Vec = row.get(2)?; + let id32 = <[u8; 32]>::try_from(identity_id_bytes.as_slice()).map_err(|_| { + WalletStorageError::blob_decode("identity_keys.identity_id is not 32 bytes") + })?; + let identity_id = Identifier::from(id32); + let key_id = KeyID::try_from(key_id).map_err(|_| WalletStorageError::IntegerOverflow { + field: "identity_keys.key_id", + value: key_id as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?; + let entry = decode_entry(&payload)?; + cs.upserts.insert((identity_id, key_id), entry); + } + Ok(cs) +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index ce590c83d55..21cdade60b1 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -57,6 +57,10 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "core_state.rs", "SELECT last_processed_height, synced_height FROM core_sync_state WHERE wallet_id", ), + ( + "identity_keys.rs", + "SELECT identity_id, key_id, public_key_blob FROM identity_keys WHERE wallet_id", + ), // P4 readers — `load_state` per area uses one-shot SELECTs. ( "identities.rs", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs new file mode 100644 index 00000000000..cc3148f95e7 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs @@ -0,0 +1,187 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Item G (PR-3) — contacts + identity-keys rehydrate through the +//! keyless `load()` path: store → drop → reopen → load → assert the +//! `ClientWalletStartState.contacts` / `.identity_keys` slots carry the +//! persisted PUBLIC material bit-exact. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + ContactChangeSet, ContactRequestEntry, IdentityKeyEntry, IdentityKeysChangeSet, + PlatformWalletChangeSet, PlatformWalletPersistence, ReceivedContactRequestKey, + SentContactRequestKey, +}; +use platform_wallet::wallet::identity::ContactRequest; + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister") +} + +fn req(sender: u8, recipient: u8) -> ContactRequestEntry { + ContactRequestEntry { + request: ContactRequest { + sender_id: Identifier::from([sender; 32]), + recipient_id: Identifier::from([recipient; 32]), + sender_key_index: 1, + recipient_key_index: 2, + account_reference: 3, + encrypted_account_label: None, + encrypted_public_key: vec![9, 9, 9], + auto_accept_proof: None, + core_height_created_at: 42, + created_at: 7, + }, + } +} + +fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { + IdentityKeyEntry { + identity_id: identity, + key_id, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![byte; 33]), + disabled_at: None, + }), + public_key_hash: [byte; 20], + wallet_id: None, + derivation_indices: None, + } +} + +/// G-RT1: contacts (sent + received) rehydrate bit-exact into the +/// keyless `ClientWalletStartState.contacts` slot. +#[test] +fn g_rt1_contacts_rehydrate_into_keyless_payload() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC0); + ensure_wallet_meta(&persister, &w); + + let sent_key = SentContactRequestKey { + owner_id: Identifier::from([0x11; 32]), + recipient_id: Identifier::from([0x22; 32]), + }; + let recv_key = ReceivedContactRequestKey { + owner_id: Identifier::from([0x11; 32]), + sender_id: Identifier::from([0x33; 32]), + }; + let sent_entry = req(0x11, 0x22); + let recv_entry = req(0x33, 0x11); + let mut sent = std::collections::BTreeMap::new(); + sent.insert(sent_key, sent_entry.clone()); + let mut recv = std::collections::BTreeMap::new(); + recv.insert(recv_key, recv_entry.clone()); + + persister + .store( + w, + PlatformWalletChangeSet { + contacts: Some(ContactChangeSet { + sent_requests: sent, + incoming_requests: recv, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let state = p2.load().expect("load"); + let slice = state.wallets.get(&w).expect("wallet rehydrated"); + + let got_sent = slice + .contacts + .sent_requests + .get(&sent_key) + .expect("sent request rehydrated"); + assert_eq!( + got_sent.request.core_height_created_at, + sent_entry.request.core_height_created_at + ); + assert_eq!( + got_sent.request.encrypted_public_key, + sent_entry.request.encrypted_public_key + ); + let got_recv = slice + .contacts + .incoming_requests + .get(&recv_key) + .expect("incoming request rehydrated"); + assert_eq!(got_recv.request.sender_id, recv_entry.request.sender_id); + // The rehydration feed never carries deletes. + assert!(slice.contacts.removed_sent.is_empty()); + assert!(slice.contacts.removed_incoming.is_empty()); +} + +/// G-RT2: identity-key entries rehydrate bit-exact into the keyless +/// `ClientWalletStartState.identity_keys` slot. +#[test] +fn g_rt2_identity_keys_rehydrate_into_keyless_payload() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC1); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x44; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + + let e0 = key_entry(id, 0, 0xAA); + let e1 = key_entry(id, 1, 0xBB); + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((id, 0), e0.clone()); + keys.upserts.insert((id, 1), e1.clone()); + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let state = p2.load().expect("load"); + let slice = state.wallets.get(&w).expect("wallet rehydrated"); + assert_eq!(slice.identity_keys.upserts.len(), 2); + assert_eq!(slice.identity_keys.upserts.get(&(id, 0)), Some(&e0)); + assert_eq!(slice.identity_keys.upserts.get(&(id, 1)), Some(&e1)); + assert!(slice.identity_keys.removed.is_empty()); +} + +/// G-RT3: a metadata-only wallet has empty (not error) contacts / +/// identity-keys slots. +#[test] +fn g_rt3_empty_slots_for_bare_wallet() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC2); + ensure_wallet_meta(&persister, &w); + drop(persister); + let p2 = reopen(&path); + let state = p2.load().expect("load"); + let slice = state.wallets.get(&w).expect("wallet present"); + assert!(slice.contacts.sent_requests.is_empty()); + assert!(slice.contacts.incoming_requests.is_empty()); + assert!(slice.contacts.established.is_empty()); + assert!(slice.identity_keys.upserts.is_empty()); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_identity_keys_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_identity_keys_reader.rs new file mode 100644 index 00000000000..09727e31799 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_identity_keys_reader.rs @@ -0,0 +1,150 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Item G (PR-3) — `schema::identity_keys::load_state` reads the +//! `identity_keys` rows back into a keyless `IdentityKeysChangeSet`, +//! bit-exact, fail-hard on a corrupt blob. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, PlatformWalletChangeSet, + PlatformWalletPersistence, +}; +use platform_wallet_storage::sqlite::schema::identity_keys; +use platform_wallet_storage::WalletStorageError; + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister") +} + +fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { + IdentityKeyEntry { + identity_id: identity, + key_id, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![byte; 33]), + disabled_at: None, + }), + public_key_hash: [byte; 20], + wallet_id: None, + derivation_indices: Some(IdentityKeyDerivationIndices { + identity_index: 0, + key_index: u32::from(byte), + }), + } +} + +/// G-K1: identity-key rows round-trip bit-exact into the keyless +/// `IdentityKeysChangeSet`. +#[test] +fn gk1_identity_keys_roundtrip() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD1); + ensure_wallet_meta(&persister, &w); + let id_a = Identifier::from([0x0A; 32]); + let id_b = Identifier::from([0x0B; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id_a.as_slice().try_into().unwrap(), + ) + .unwrap(); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id_b.as_slice().try_into().unwrap(), + ) + .unwrap(); + + let e1 = key_entry(id_a, 0, 0x11); + let e2 = key_entry(id_a, 1, 0x22); + let e3 = key_entry(id_b, 0, 0x33); + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((id_a, 0), e1.clone()); + keys.upserts.insert((id_a, 1), e2.clone()); + keys.upserts.insert((id_b, 0), e3.clone()); + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let cs = identity_keys::load_state(&conn, &w).expect("load_state"); + drop(conn); + + assert_eq!(cs.upserts.len(), 3); + assert_eq!(cs.upserts.get(&(id_a, 0)), Some(&e1)); + assert_eq!(cs.upserts.get(&(id_a, 1)), Some(&e2)); + assert_eq!(cs.upserts.get(&(id_b, 0)), Some(&e3)); + assert!(cs.removed.is_empty()); +} + +/// G-K2: an empty wallet yields an empty changeset, not an error. +#[test] +fn gk2_empty_identity_keys_is_ok() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD2); + ensure_wallet_meta(&persister, &w); + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let cs = identity_keys::load_state(&conn, &w).expect("load_state"); + drop(conn); + assert!(cs.upserts.is_empty()); +} + +/// G-K3: a corrupt `public_key_blob` is a typed hard error, never a +/// silent skip. +#[test] +fn gk3_corrupt_blob_is_hard_error() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD3); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x0C; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO identity_keys \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, 0, X'00', ?3, NULL)", + rusqlite::params![w.as_slice(), id.as_slice(), &[0u8; 20][..]], + ) + .unwrap(); + } + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let result = identity_keys::load_state(&conn, &w); + drop(conn); + assert!( + matches!(result, Err(WalletStorageError::BincodeDecode { .. })), + "corrupt public_key_blob must be a typed BincodeDecode; got {result:?}" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs index 355d669b35d..77793914b82 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs @@ -10,9 +10,10 @@ //! (`tc_p4_006`/`tc_p4_007`) and in `sqlite_load_wiring.rs` / //! `sqlite_core_state_reader.rs` / `sqlite_asset_locks_filter.rs`; the //! end-to-end manager path is covered by `platform-wallet`'s -//! `rehydration_load.rs`. `persister::LOAD_UNIMPLEMENTED` now lists -//! only the genuinely-deferred areas (contacts / identity_keys / -//! `last_applied_chain_lock`). +//! `rehydration_load.rs`. Contacts + identity-keys now rehydrate too +//! (PR-3, see `sqlite_contacts_keys_rehydration.rs`), so +//! `persister::LOAD_UNIMPLEMENTED` lists only the single remaining +//! deferred area (`core::last_applied_chain_lock`). mod common; @@ -78,15 +79,13 @@ fn tc040_load_platform_addresses() { assert_eq!(state.platform_addresses[&b].sync_height, 20); } -/// TC-043: non-wired-up sub-areas are written to disk (verified by -/// direct SQL probes) but do not surface in the load result. -/// -/// Constructs non-empty `ContactChangeSet` and `TokenBalanceChangeSet` -/// payloads — `is_empty()` returns false on either, so the buffer -/// flushes them — then asserts both `contacts_sent` and -/// `token_balances` rows are present in SQLite after a reopen, while -/// `ClientStartState.platform_addresses` stays empty for the wallet -/// (no platform-address activity was stored). +/// TC-043: `token_balances` is still persisted-but-not-rehydrated +/// (genuinely deferred); contacts now DO rehydrate (PR-3) and surface +/// in `state.wallets[w].contacts`. Asserts both `contacts_sent` and +/// `token_balances` rows are durable on disk after a reopen, the +/// contact round-trips into the keyless payload, and +/// `state.platform_addresses` stays empty (no platform-address +/// activity was stored). #[test] fn tc043_non_wired_up_persisted_but_not_returned() { use dpp::prelude::Identifier; @@ -154,6 +153,16 @@ fn tc043_non_wired_up_persisted_but_not_returned() { !state.platform_addresses.contains_key(&w), "no platform-address activity was stored — wallet must be absent" ); + // Contacts now rehydrate into the keyless payload (PR-3). + let slice = state.wallets.get(&w).expect("wallet rehydrated"); + let key = SentContactRequestKey { + owner_id: owner, + recipient_id: recipient, + }; + assert!( + slice.contacts.sent_requests.contains_key(&key), + "the persisted sent contact request must rehydrate" + ); drop(p2); let conn = common::ro_conn(&path); diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index 7f11c394b90..f8900ffa3a1 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -11,7 +11,9 @@ use std::collections::BTreeMap; use crate::changeset::identity_manager_start_state::IdentityManagerStartState; -use crate::changeset::{AccountRegistrationEntry, CoreChangeSet}; +use crate::changeset::{ + AccountRegistrationEntry, ContactChangeSet, CoreChangeSet, IdentityKeysChangeSet, +}; use crate::wallet::asset_lock::tracked::TrackedAssetLock; use dashcore::OutPoint; use key_wallet::Network; @@ -46,4 +48,15 @@ pub struct ClientWalletStartState { /// top-up, keyed by account index → outpoint. Terminal `Consumed` /// rows are already filtered out by the asset-lock reader. pub unused_asset_locks: BTreeMap>, + /// Persisted DashPay contact state (sent/received requests + + /// established contacts) to layer onto the rehydrated managed + /// identities. PUBLIC material — `removed_*` are always empty + /// (deletes never reach storage as rows). Routed by the manager + /// after `IdentityManager::from`, mirroring the runtime apply path. + pub contacts: ContactChangeSet, + /// Persisted per-identity PUBLIC key entries (no private key + /// material) to layer onto the rehydrated managed identities so + /// `Identity.public_keys` is populated at load time instead of + /// only after the next sync. `removed` is always empty. + pub identity_keys: IdentityKeysChangeSet, } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 85e8117ac97..48f3f88f00e 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -87,6 +87,8 @@ impl PlatformWalletManager

{ core_state, identity_manager, unused_asset_locks, + contacts, + identity_keys, } = wallet_state; // Resolve the runtime secret. Seed unavailable ⇒ skip @@ -152,10 +154,16 @@ impl PlatformWalletManager

{ core_balance.immature(), core_balance.locked(), ); + // Build the identity manager from the (id, balance, + // revision) skeleton, then layer the persisted PUBLIC + // contacts + identity keys onto it — the same routing the + // runtime changeset-replay path uses. + let mut identity_manager = IdentityManager::from(identity_manager); + identity_manager.apply_contacts_and_keys(contacts, identity_keys, network); let platform_info = PlatformWalletInfo { core_wallet: wallet_info, balance: Arc::clone(&balance), - identity_manager: IdentityManager::from(identity_manager), + identity_manager, tracked_asset_locks, }; diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index df1b437116e..f4ec1cbebd3 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -148,93 +148,17 @@ impl PlatformWalletInfo { } } - // 2b. Identity keys. Runs after the scalar identity pass so - // the owning ManagedIdentity is guaranteed to exist before - // we layer keys into it. Upserts land first, then removals, - // matching the discipline used across the rest of this - // function. Orphan entries (owner not in the wallet) are - // logged and skipped by the per-entry apply helpers. - if let Some(keys_cs) = identity_keys { - let crate::changeset::IdentityKeysChangeSet { upserts, removed } = keys_cs; - // Thread the wallet network through so the key-apply - // path can reproduce DIP-9 derivation paths for any - // entry that carries `(wallet_id, derivation_indices)`. - let network = wallet.network; - for (_key, entry) in upserts { - self.identity_manager - .apply_identity_key_entry(entry, network); - } - for (identity_id, key_id) in removed { - self.identity_manager - .apply_identity_key_removal(&identity_id, key_id); - } - } - - // 3. Contacts. Each entry routes to its owning ManagedIdentity by - // `(owner, contact)` key; orphans (owner not in the wallet) - // are logged and skipped. Trivial map ops (sent / incoming - // insert and remove) are inlined here — no helper earns its - // name for a single `insert` / `shift_remove` call. Only - // `apply_established_contact` is a method because it has - // real logic (drops both pending sides per the contract). - if let Some(contact_cs) = contacts { - let crate::changeset::ContactChangeSet { - sent_requests, - removed_sent, - incoming_requests, - removed_incoming, - established, - } = contact_cs; - - for (key, entry) in sent_requests { - match self.identity_manager.managed_identity_mut(&key.owner_id) { - Some(managed) => { - managed - .sent_contact_requests - .insert(entry.request.recipient_id, entry.request); - } - None => tracing::warn!( - owner = %key.owner_id, - "skipping sent contact request during apply: owner identity not in wallet" - ), - } - } - for (key, entry) in incoming_requests { - match self.identity_manager.managed_identity_mut(&key.owner_id) { - Some(managed) => { - managed - .incoming_contact_requests - .insert(entry.request.sender_id, entry.request); - } - None => tracing::warn!( - owner = %key.owner_id, - "skipping incoming contact request during apply: owner identity not in wallet" - ), - } - } - for key in removed_sent { - if let Some(managed) = self.identity_manager.managed_identity_mut(&key.owner_id) { - managed.sent_contact_requests.remove(&key.recipient_id); - } - } - for key in removed_incoming { - if let Some(managed) = self.identity_manager.managed_identity_mut(&key.owner_id) { - managed.incoming_contact_requests.remove(&key.sender_id); - } - } - // Established promotions — drop any matching pending - // entries on both sides per the auto-establishment contract. - for (key, established) in established { - match self.identity_manager.managed_identity_mut(&key.owner_id) { - Some(managed) => { - managed.apply_established_contact(established); - } - None => tracing::warn!( - owner = %key.owner_id, - "skipping established contact during apply: owner identity not in wallet" - ), - } - } + // 2b/3. Identity keys + contacts. Keys are layered before + // contacts so a contact entry never lands before its + // owner's keys; orphans are logged and skipped. Single + // source of truth shared with the persister rehydration + // path (`load_from_persistor`). + if identity_keys.is_some() || contacts.is_some() { + self.identity_manager.apply_contacts_and_keys( + contacts.unwrap_or_default(), + identity_keys.unwrap_or_default(), + wallet.network, + ); } // 3b. DashPay profile/payment overlays. Applied AFTER identities diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs index 7d04f29c538..09dd930c0cc 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs @@ -15,7 +15,7 @@ //! [`IdentityManager::apply_identity_key_entry`]. use super::{IdentityLocation, IdentityManager}; -use crate::changeset::{IdentityEntry, IdentityKeyEntry}; +use crate::changeset::{ContactChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet}; use crate::wallet::identity::state::managed_identity::ManagedIdentity; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -181,4 +181,85 @@ impl IdentityManager { managed.identity.public_keys_mut().remove(&key_id); } } + + /// Layer a [`ContactChangeSet`] + [`IdentityKeysChangeSet`] onto the + /// already-restored managed identities. + /// + /// Single source of truth for the contact / identity-key routing — + /// shared by the runtime changeset-replay path + /// ([`apply_changeset`](crate::wallet::PlatformWalletInfo::apply_changeset)) + /// and the persister rehydration path + /// ([`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor)). + /// Identity keys are applied first so a contact entry never lands + /// before its owner's keys; orphan entries (owner not in the + /// wallet) are logged and skipped, never fatal. `removed_*` are + /// honoured for the replay path; the rehydration feed leaves them + /// empty. + pub(crate) fn apply_contacts_and_keys( + &mut self, + contacts: ContactChangeSet, + identity_keys: IdentityKeysChangeSet, + network: Network, + ) { + let IdentityKeysChangeSet { upserts, removed } = identity_keys; + for (_key, entry) in upserts { + self.apply_identity_key_entry(entry, network); + } + for (identity_id, key_id) in removed { + self.apply_identity_key_removal(&identity_id, key_id); + } + + let ContactChangeSet { + sent_requests, + removed_sent, + incoming_requests, + removed_incoming, + established, + } = contacts; + for (key, entry) in sent_requests { + match self.managed_identity_mut(&key.owner_id) { + Some(managed) => { + managed + .sent_contact_requests + .insert(entry.request.recipient_id, entry.request); + } + None => tracing::warn!( + owner = %key.owner_id, + "skipping sent contact request: owner identity not in wallet" + ), + } + } + for (key, entry) in incoming_requests { + match self.managed_identity_mut(&key.owner_id) { + Some(managed) => { + managed + .incoming_contact_requests + .insert(entry.request.sender_id, entry.request); + } + None => tracing::warn!( + owner = %key.owner_id, + "skipping incoming contact request: owner identity not in wallet" + ), + } + } + for key in removed_sent { + if let Some(managed) = self.managed_identity_mut(&key.owner_id) { + managed.sent_contact_requests.remove(&key.recipient_id); + } + } + for key in removed_incoming { + if let Some(managed) = self.managed_identity_mut(&key.owner_id) { + managed.incoming_contact_requests.remove(&key.sender_id); + } + } + for (key, established) in established { + match self.managed_identity_mut(&key.owner_id) { + Some(managed) => managed.apply_established_contact(established), + None => tracing::warn!( + owner = %key.owner_id, + "skipping established contact: owner identity not in wallet" + ), + } + } + } } diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index be61c00384c..4b80519c772 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -60,6 +60,8 @@ impl PlatformWalletPersistence for FixedLoadPersister { core_state: w.core_state.clone(), identity_manager: Default::default(), unused_asset_locks: Default::default(), + contacts: Default::default(), + identity_keys: Default::default(), }, ); } @@ -156,6 +158,8 @@ fn slice(seed: [u8; 64]) -> (WalletId, ClientWalletStartState) { core_state: CoreChangeSet::default(), identity_manager: Default::default(), unused_asset_locks: Default::default(), + contacts: Default::default(), + identity_keys: Default::default(), }, ) }