Skip to content
Merged
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
31 changes: 31 additions & 0 deletions packages/rs-platform-wallet-ffi/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,34 @@ pub unsafe extern "C" fn platform_wallet_manager_destroy(
}
PlatformWalletFFIResult::ok()
}

/// Remove one wallet from the manager. Idempotent on missing wallets.
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_manager_remove_wallet(
manager_handle: Handle,
wallet_id: *const [u8; 32],
) -> PlatformWalletFFIResult {
check_ptr!(wallet_id);
let wallet_id_value = *wallet_id;

let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| {
runtime().block_on(manager.remove_wallet(&wallet_id_value))
});
let result = unwrap_option_or_return!(option);
match result {
Ok(_) => PlatformWalletFFIResult::ok(),
// Idempotency: a wallet that's already gone is the success
// state callers want. Everything else is a real failure.
Err(platform_wallet::PlatformWalletError::WalletNotFound(_)) => {
PlatformWalletFFIResult::ok()
}
Err(e) => PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!(
"Failed to remove wallet {}: {}",
hex::encode(wallet_id_value),
e
),
),
}
}
27 changes: 27 additions & 0 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,32 @@ pub struct PersistenceCallbacks {
unsafe impl Send for PersistenceCallbacks {}
unsafe impl Sync for PersistenceCallbacks {}

impl Default for PersistenceCallbacks {
fn default() -> Self {
Self {
context: std::ptr::null_mut(),
on_changeset_begin_fn: None,
on_changeset_end_fn: None,
on_store_fn: None,
on_flush_fn: None,
on_persist_address_balances_fn: None,
on_persist_wallet_changeset_fn: None,
on_persist_sync_state_fn: None,
on_persist_account_registrations_fn: None,
on_load_wallet_list_fn: None,
on_load_wallet_list_free_fn: None,
on_persist_wallet_metadata_fn: None,
on_persist_account_address_pools_fn: None,
on_persist_identities_fn: None,
on_persist_identity_keys_fn: None,
on_persist_token_balances_fn: None,
on_persist_contacts_fn: None,
on_get_core_tx_record_fn: None,
on_get_core_tx_record_free_fn: None,
}
}
}

/// In-memory persister that accumulates changesets and notifies via callbacks.
pub struct FFIPersister {
callbacks: PersistenceCallbacks,
Expand Down Expand Up @@ -2287,3 +2313,4 @@ unsafe fn slice_from_raw<'a>(ptr: *const u8, len: usize) -> &'a [u8] {
slice::from_raw_parts(ptr, len)
}
}

156 changes: 98 additions & 58 deletions packages/rs-platform-wallet/src/manager/identity_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,14 @@ where
return;
}

// Build the changeset and update our own cache in lockstep.
self.apply_fresh_balances(identity_id, fresh_balances).await;
}

async fn apply_fresh_balances(
&self,
identity_id: Identifier,
fresh_balances: BTreeMap<Identifier, Option<TokenAmount>>,
) {
let mut cs = TokenBalanceChangeSet::default();
for (token_id, maybe_balance) in &fresh_balances {
let key = (identity_id, *token_id);
Expand All @@ -555,6 +562,16 @@ where
}
}

let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);

let mut state = self.state.write().await;
let Some(existing_row) = state.get(&identity_id).cloned() else {
return;
};

// The persister API is wallet-scoped (`store(wallet_id, ..)`)
// but this manager is identity-scoped. Use the zero-byte
// sentinel — the FFI / SQLite token-balance write paths key
Expand All @@ -569,67 +586,31 @@ where
);
}

// TODO(identity-sync nonce): once token-id → contract-id
// resolution lands on the registry (currently keyed by token
// id only), fetch the per-(identity, contract) nonce here via
// `self.sdk.get_identity_contract_nonce(identity_id,
// contract_id, false, None).await` and replicate it onto
// every token row that shares the same contract. The
// `IdentityTokenSyncInfo::contract_id` field is plumbed
// through with a `Identifier::default()` placeholder so the
// FFI mirror shape doesn't have to change when this lands.

let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);

// Rewrite the per-identity cache row from the freshly fetched
// balances. Tokens that returned `None` (i.e. removed on
// Platform) drop out of the row; tokens that returned `Some`
// get the new balance. We rebuild rather than splice so that
// the row always reflects the latest watched-token set
// intersected with what Platform reports.
let mut state = self.state.write().await;
if let Some(existing_row) = state.get(&identity_id).cloned() {
// Rebuild from the *live* row (which may have been mutated
// by concurrent `update_watched_tokens` / `unregister_identity`
// while our network calls were in flight) rather than the
// stale `token_ids` snapshot. This way mid-sync registry
// changes are preserved: newly added tokens keep their
// initial state, and tokens removed during the pass stay
// removed.
let mut new_tokens: Vec<IdentityTokenSyncInfo> =
Vec::with_capacity(existing_row.tokens.len());
for prior in &existing_row.tokens {
match fresh_balances.get(&prior.token_id) {
Some(Some(amount)) => {
new_tokens.push(IdentityTokenSyncInfo {
balance: *amount,
..*prior
});
}
Some(None) => {
// Platform reported the token removed for
// this identity — drop the row.
}
None => {
// Batch didn't cover this token (added mid-
// sync, or batch failed) — keep prior state.
new_tokens.push(*prior);
}
let mut new_tokens: Vec<IdentityTokenSyncInfo> =
Vec::with_capacity(existing_row.tokens.len());
for prior in &existing_row.tokens {
match fresh_balances.get(&prior.token_id) {
Some(Some(amount)) => {
new_tokens.push(IdentityTokenSyncInfo {
balance: *amount,
..*prior
});
}
Some(None) => {}
None => {
new_tokens.push(*prior);
}
}
}

state.insert(
state.insert(
identity_id,
IdentityTokenSyncState {
identity_id,
IdentityTokenSyncState {
identity_id,
last_sync_unix: now,
tokens: new_tokens,
},
);
}
last_sync_unix: now,
tokens: new_tokens,
},
);
}
}

Expand All @@ -652,6 +633,7 @@ mod tests {
use super::*;

use crate::changeset::{ClientStartState, PersistenceError, PlatformWalletChangeSet};
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};

/// Test-only persister that swallows every `store` call and
/// records nothing. Lifecycle / registry tests don't need the
Expand All @@ -678,6 +660,37 @@ mod tests {
}
}

struct RecordingPersister {
stores: AtomicUsize,
}

impl RecordingPersister {
fn new() -> Self {
Self {
stores: AtomicUsize::new(0),
}
}
}

impl PlatformWalletPersistence for RecordingPersister {
fn store(
&self,
_wallet_id: WalletId,
_changeset: PlatformWalletChangeSet,
) -> Result<(), PersistenceError> {
self.stores.fetch_add(1, AtomicOrdering::SeqCst);
Ok(())
}

fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> {
Ok(())
}

fn load(&self) -> Result<ClientStartState, PersistenceError> {
Ok(ClientStartState::default())
}
}

/// Build a manager wired to a no-op persister. The SDK is
/// constructed via `SdkBuilder::new_mock` so we don't need a
/// running runtime for the registry/lifecycle tests below; none
Expand All @@ -688,6 +701,18 @@ mod tests {
Arc::new(IdentitySyncManager::new(sdk, persister))
}

fn make_recording_manager() -> (
Arc<IdentitySyncManager<RecordingPersister>>,
Arc<RecordingPersister>,
) {
let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"));
let persister = Arc::new(RecordingPersister::new());
(
Arc::new(IdentitySyncManager::new(sdk, Arc::clone(&persister))),
persister,
)
}

/// `register_identity` populates a row with zero-balance
/// placeholders for each token, and `state_for_identity` returns
/// the cloned row. Validates the read API the FFI snapshot path
Expand Down Expand Up @@ -770,6 +795,21 @@ mod tests {
mgr.unregister_identity(&Identifier::from([99u8; 32])).await;
}

#[tokio::test]
async fn unregistered_identity_does_not_persist_fresh_balances() {
let (mgr, persister) = make_recording_manager();
let id_a = Identifier::from([1u8; 32]);
let token_x = Identifier::from([10u8; 32]);
let mut fresh = BTreeMap::new();
fresh.insert(token_x, Some(5u64));

mgr.register_identity(id_a, [token_x]).await;
mgr.unregister_identity(&id_a).await;
mgr.apply_fresh_balances(id_a, fresh).await;

assert_eq!(persister.stores.load(AtomicOrdering::SeqCst), 0);
}

/// `set_interval` clamps to >=1s and is read back via `interval`.
/// Default interval matches the documented constant. Pinned so
/// future tuning surfaces in the test suite.
Expand Down
30 changes: 27 additions & 3 deletions packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,16 +340,40 @@ impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> {
&self,
wallet_id: &WalletId,
) -> Result<Arc<PlatformWallet>, PlatformWalletError> {
let owned_identity_ids: Vec<dpp::prelude::Identifier> = {
let mut wm = self.wallet_manager.write().await;
let ids = match wm.get_wallet_info(wallet_id) {
Some(info) => info
.identity_manager
.wallet_identities
.get(wallet_id)
.map(|inner| {
use dpp::identity::accessors::IdentityGettersV0;
inner
.values()
.map(|managed| managed.identity.id())
.collect()
})
.unwrap_or_default(),
None => Vec::new(),
};
let _ = wm.remove_wallet(wallet_id);
ids
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let removed = {
let mut wallets = self.wallets.write().await;
wallets
.remove(wallet_id)
.ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))?
};
{
let mut wm = self.wallet_manager.write().await;
let _ = wm.remove_wallet(wallet_id);

for identity_id in &owned_identity_ids {
self.identity_sync_manager
.unregister_identity(identity_id)
.await;
}

Ok(removed)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,57 @@ public class PlatformWalletManager: ObservableObject {
return restored
}

// MARK: - Wallet deletion

/// Fully wipe a wallet's Rust, SwiftData, and Keychain footprint.
///
/// Requires the manager to have been `configure`d with a
/// `ModelContainer` — the per-identity Keychain sweep needs the
/// wallet's identity ids, which only the persistence handler can
/// resolve. The no-persistence configuration mode is rejected
/// here rather than silently leaving identity key material behind.
///
/// Deleting an already-removed wallet succeeds unless an
/// operation fails.
public func deleteWallet(walletId: Data) throws {
try ensureConfigured()
guard walletId.count == 32 else {
throw PlatformWalletError.invalidParameter(
"walletId must be 32 bytes, got \(walletId.count)"
)
}
guard let persistenceHandler = persistenceHandler else {
throw PlatformWalletError.invalidHandle(
"deleteWallet requires a persistence handler — configure the manager with a ModelContainer"
)
}

let identityIds = try persistenceHandler.identityIdsForWallet(walletId: walletId)

try walletId.withUnsafeBytes { raw in
guard let base = raw.baseAddress?.assumingMemoryBound(to: FFIByteTuple32.self) else {
throw PlatformWalletError.nullPointer(
"wallet_id buffer base address was nil"
)
}
try platform_wallet_manager_remove_wallet(handle, base).check()
}

wallets.removeValue(forKey: walletId)

try persistenceHandler.deleteWalletData(walletId: walletId)

for identityId in identityIds {
try KeychainManager.shared.deleteAllKeychainItems(forIdentityId: identityId)
Comment thread
llbartekll marked this conversation as resolved.
}
try KeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId: walletId)

let storage = WalletStorage()
// Delete metadata first so the mnemonic remains available for retry.
try storage.deleteMetadata(for: walletId)
try storage.deleteMnemonic(for: walletId)
}

// MARK: - Per-wallet lookup

/// Return the managed wallet with the given 32-byte id, or `nil`
Expand Down
Loading
Loading