Skip to content
Draft
12 changes: 12 additions & 0 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2863,13 +2863,25 @@ 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,
account_manifest,
core_state,
identity_manager,
unused_asset_locks,
contacts: Default::default(),
identity_keys: Default::default(),
};

let platform_address_state = if per_account.is_empty()
Expand Down
18 changes: 11 additions & 7 deletions packages/rs-platform-wallet-storage/src/sqlite/persister.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -946,6 +944,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,
Expand All @@ -956,6 +958,8 @@ impl PlatformWalletPersistence for SqlitePersister {
core_state,
identity_manager,
unused_asset_locks,
contacts,
identity_keys,
},
);
}
Expand Down
61 changes: 40 additions & 21 deletions packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs
Original file line number Diff line number Diff line change
@@ -1,37 +1,39 @@
//! `contacts_sent` / `contacts_recv` / `contacts_established` writers
//! and per-wallet reader.

use rusqlite::{params, Transaction};
use std::collections::BTreeMap;

use platform_wallet::changeset::ContactChangeSet;
use platform_wallet::wallet::platform_wallet::WalletId;
use rusqlite::{params, Connection, Transaction};

use crate::sqlite::error::WalletStorageError;
use crate::sqlite::schema::blob;

#[cfg(feature = "__test-helpers")]
use dpp::prelude::Identifier;
#[cfg(feature = "__test-helpers")]
use platform_wallet::changeset::{
ContactRequestEntry, ReceivedContactRequestKey, SentContactRequestKey,
ContactChangeSet, ContactRequestEntry, ReceivedContactRequestKey, SentContactRequestKey,
};
#[cfg(feature = "__test-helpers")]
use platform_wallet::wallet::identity::EstablishedContact;
#[cfg(feature = "__test-helpers")]
use rusqlite::Connection;
#[cfg(feature = "__test-helpers")]
use std::collections::BTreeMap;
use platform_wallet::wallet::platform_wallet::WalletId;

use crate::sqlite::error::WalletStorageError;
use crate::sqlite::schema::blob;

/// Storage-internal snapshot of one wallet's `contacts_*` rows.
///
/// Mirrors the populated-only subset of
/// [`ContactChangeSet`](platform_wallet::changeset::ContactChangeSet);
/// `removed_*` are absent because deletes never reach storage as rows
/// (the writer applies them as `DELETE`s). Only built by the
/// `__test-helpers` reader path so this crate's own integration tests
/// can assert on the hardened (fail-hard) contacts reader; the
/// production `load()` reconstruction that consumes it lands with the
/// rehydration feature.
/// (the writer applies them as `DELETE`s). Crate-internal on purpose —
/// rs-platform-wallet's `ClientStartState` does not carry a contacts
/// slot, so this type is never re-exported across the crate boundary.
/// Promoted to `pub` only under `__test-helpers` so this crate's own
/// integration tests can assert on the hardened reader directly.
#[derive(Debug, Default, PartialEq)]
#[cfg(not(feature = "__test-helpers"))]
pub(crate) struct ContactsRecords {
pub sent_requests: BTreeMap<SentContactRequestKey, ContactRequestEntry>,
pub incoming_requests: BTreeMap<ReceivedContactRequestKey, ContactRequestEntry>,
pub established: BTreeMap<SentContactRequestKey, EstablishedContact>,
}

/// See the `not(__test-helpers)` definition for the canonical docs.
#[derive(Debug, Default, PartialEq)]
#[cfg(feature = "__test-helpers")]
pub struct ContactsRecords {
Expand Down Expand Up @@ -123,7 +125,6 @@ pub fn apply(
/// Build a [`ContactsRecords`] for one wallet from the three
/// `contacts_*` tables. Any row that fails to decode is a hard error —
/// corruption is never silently dropped.
#[cfg(feature = "__test-helpers")]
pub(crate) fn load_state(
conn: &Connection,
wallet_id: &WalletId,
Expand Down Expand Up @@ -190,7 +191,25 @@ pub(crate) fn load_state(
Ok(state)
}

#[cfg(feature = "__test-helpers")]
/// 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<ContactChangeSet, WalletStorageError> {
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"))?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -134,6 +134,42 @@ pub fn decode_entry(payload: &[u8]) -> Result<IdentityKeyEntry, WalletStorageErr
wire.into_entry()
}

/// Read every `identity_keys` row for `wallet_id` back into a keyless
/// [`IdentityKeysChangeSet`] (PUBLIC material only — the blob is an
/// `IdentityPublicKey`; private keys are NOT stored or read here).
///
/// Keyed by `(identity_id, key_id)`; `removed` is always empty (deletes
/// reach storage as `DELETE`s, never as rows). Any row whose blob fails
/// to decode is a hard, typed [`WalletStorageError`] — corruption is
/// never silently dropped.
pub fn load_state(
conn: &Connection,
wallet_id: &WalletId,
) -> Result<IdentityKeysChangeSet, WalletStorageError> {
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<u8> = row.get(0)?;
let key_id: i64 = row.get(1)?;
let payload: Vec<u8> = 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)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading