Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -2861,13 +2861,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, Clone)]
Expand Down Expand Up @@ -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,
Expand All @@ -722,6 +724,8 @@ impl PlatformWalletPersistence for SqlitePersister {
core_state,
identity_manager,
unused_asset_locks,
contacts,
identity_keys,
},
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 @@ -112,3 +112,39 @@ pub fn decode_entry(payload: &[u8]) -> Result<IdentityKeyEntry, WalletStorageErr
let wire: IdentityKeyWire = blob::decode(payload)?;
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)
}
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
Original file line number Diff line number Diff line change
@@ -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());
}
Loading
Loading