diff --git a/packages/rs-platform-wallet-ffi/src/identity_top_up.rs b/packages/rs-platform-wallet-ffi/src/identity_top_up.rs index 44d57d7ecd2..177da07164d 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_top_up.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_top_up.rs @@ -118,11 +118,21 @@ pub unsafe extern "C" fn platform_wallet_top_up_from_addresses_with_signer( let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let identity_wallet = wallet.identity().clone(); + let platform_wallet = wallet.platform().clone(); block_on_worker(async move { let address_signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; - identity_wallet + let (address_infos, new_balance) = identity_wallet .top_up_from_addresses(&identity_id, input_map, address_signer, None) - .await + .await?; + // Reconcile the spent platform-address balances from the proof so + // the wallet's displayed balance and next input selection reflect + // the spend. Resolves spent addresses via the address provider, so + // it covers addresses restored from disk that are no longer in a + // live derived pool. + platform_wallet + .apply_top_up_reconciliation(&address_infos) + .await; + Ok::<_, platform_wallet::PlatformWalletError>(new_balance) }) }); let result = unwrap_option_or_return!(option); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs index c6940328576..a689acbb493 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs @@ -12,6 +12,8 @@ use dash_sdk::platform::transition::top_up_identity_from_addresses::TopUpIdentit use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; +use dash_sdk::query_types::AddressInfos; + use crate::error::PlatformWalletError; use super::*; @@ -26,6 +28,15 @@ impl IdentityWallet { /// Uses the `TopUpIdentityFromAddresses` SDK trait. Address nonces are /// looked up automatically. /// + /// Returns the proof-attested post-spend `AddressInfos` alongside the new + /// identity balance. The caller MUST reconcile the spent platform-address + /// balances from the `AddressInfos` via + /// [`PlatformAddressWallet::apply_top_up_reconciliation`] — this method + /// only owns the identity-side balance update, because resolving a spent + /// address back to its derivation index needs the address provider, which + /// lives on the platform-address wallet (and covers addresses restored + /// from disk that are no longer in a live derived pool). + /// /// # Arguments /// /// * `identity_id` - The identity to top up. @@ -40,7 +51,7 @@ impl IdentityWallet { inputs: BTreeMap, address_signer: &S, settings: Option, - ) -> Result { + ) -> Result<(AddressInfos, Credits), PlatformWalletError> { let identity = { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { @@ -55,7 +66,7 @@ impl IdentityWallet { .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? }; - let (_address_infos, new_balance) = identity + let (address_infos, new_balance) = identity .top_up_from_addresses(&self.sdk, inputs, address_signer, settings) .await .map_err(|e| { @@ -89,6 +100,10 @@ impl IdentityWallet { } } - Ok(new_balance) + // The spent platform-address balances are reconciled by the caller via + // `PlatformAddressWallet::apply_top_up_reconciliation`, which has the + // address provider needed to map restored addresses back to their + // derivation index. + Ok((address_infos, new_balance)) } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs index b8d4cc653f7..797e603b6dc 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs @@ -32,9 +32,12 @@ use key_wallet_manager::WalletManager; use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; +use crate::PlatformAddressBalanceEntry; use dash_sdk::platform::address_sync::{ AddressFunds, AddressIndex, AddressProvider, AddressSyncResult, }; +use dash_sdk::query_types::AddressInfos; +use dpp::address_funds::PlatformAddress; use tokio::sync::RwLock; /// DIP-17 address coordinates used as both the pending-bimap key and @@ -202,6 +205,18 @@ pub(crate) struct PlatformPaymentAddressProvider { } impl PlatformPaymentAddressProvider { + /// The committed per-account index/balance state for one wallet, or + /// `None` if the provider doesn't cover it. Exposes the full persisted + /// `index <-> address` bijection — including addresses restored from + /// disk that are no longer in a live derived pool — so callers can map a + /// spent address back to its derivation index. + pub(crate) fn per_wallet_state( + &self, + wallet_id: &WalletId, + ) -> Option<&PerWalletPlatformAddressState> { + self.per_wallet.get(wallet_id) + } + /// Build a provider covering every platform payment account on /// each wallet in `wallet_ids`. /// @@ -811,6 +826,55 @@ impl AddressProvider for PlatformPaymentAddressProvider { } } +/// Resolve each spent platform address in a top-up's proof-attested +/// `address_infos` to its `(account_index, address_index)` using the +/// per-account index bijections, and pair it with the proof's post-spend +/// balance + nonce. Resolving against the bijections — rather than the live +/// derived address pool — means addresses restored from disk (present in the +/// persisted index map but not in `ManagedPlatformAccount::addresses`) are +/// still reconciled; without this, a top-up that spends a restored cached row +/// would leave its stale balance behind, preserving the phantom balance. +/// +/// Addresses the proof returns that the wallet doesn't own (no bijection +/// entry) are skipped. Pure and lock-free so the reconciliation is +/// unit-testable. +pub(crate) fn build_top_up_balance_entries( + wallet_id: WalletId, + per_account_addresses: &[(u32, &BiBTreeMap)], + address_infos: &AddressInfos, +) -> Vec { + let mut entries = Vec::new(); + for (addr, maybe_info) in address_infos.iter() { + let PlatformAddress::P2pkh(hash) = addr else { + continue; + }; + let p2pkh = PlatformP2PKHAddress::new(*hash); + let funds = match maybe_info { + Some(ai) => AddressFunds { + balance: ai.balance, + nonce: ai.nonce, + }, + None => AddressFunds { + balance: 0, + nonce: 0, + }, + }; + for &(account_index, bimap) in per_account_addresses { + if let Some(&address_index) = bimap.get_by_right(&p2pkh) { + entries.push(PlatformAddressBalanceEntry { + wallet_id, + account_index, + address_index, + address: p2pkh, + funds, + }); + break; + } + } + } + entries +} + #[cfg(test)] mod tests { use super::*; @@ -837,6 +901,58 @@ mod tests { AddressFunds { balance, nonce } } + /// Regression for the top-up reconciliation: a spent platform address + /// that exists only in the persisted index bijection (restored from + /// disk — not in any live derived pool) must still be resolved to its + /// derivation index and recorded with the proof's post-spend balance. + /// The original fix scanned only the live pool, so it left restored + /// rows stale, preserving the phantom Platform Balance the PR targets. + #[test] + fn build_top_up_entries_resolves_restored_address_outside_live_pool() { + use dash_sdk::query_types::AddressInfo; + + // Present in the persisted bijection at index 3, but NOT in a live + // derived address pool. + let restored = p2pkh(0x11); + let mut bimap: BiBTreeMap = BiBTreeMap::new(); + bimap.insert(3, restored); + + // The top-up spent it; the proof attests post-spend balance 5, + // nonce bumped to 4. + let restored_addr = PlatformAddress::P2pkh([0x11; 20]); + let mut address_infos = AddressInfos::new(); + address_infos.insert( + restored_addr, + Some(AddressInfo { + address: restored_addr, + nonce: 4, + balance: 5, + }), + ); + + let per_account: [(u32, &BiBTreeMap); 1] = + [(ACCOUNT, &bimap)]; + let entries = build_top_up_balance_entries(WALLET, &per_account, &address_infos); + + assert_eq!( + entries.len(), + 1, + "a restored spent address must still be reconciled" + ); + let e = &entries[0]; + assert_eq!(e.address, restored); + assert_eq!(e.account_index, ACCOUNT); + assert_eq!( + e.address_index, 3, + "index resolved from the persisted bijection, not the live pool" + ); + assert_eq!( + e.funds.balance, 5, + "records the proof's post-spend balance, not a stale value" + ); + assert_eq!(e.funds.nonce, 4, "records the bumped nonce"); + } + /// Build a provider whose committed `per_wallet` tracks a single /// account on `wallet_id` with one funded address (index 0), backed /// by the supplied wallet manager. @@ -1136,6 +1252,119 @@ mod tests { ); } + /// Records every changeset handed to `store`, so a test can assert what + /// the reconciliation actually persisted. + #[derive(Default)] + struct CapturingPersister { + stored: std::sync::Mutex>, + } + + impl crate::changeset::PlatformWalletPersistence for CapturingPersister { + fn store( + &self, + _wallet_id: WalletId, + changeset: crate::changeset::PlatformWalletChangeSet, + ) -> Result<(), crate::changeset::PersistenceError> { + self.stored.lock().expect("persister mutex").push(changeset); + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), crate::changeset::PersistenceError> { + Ok(()) + } + + fn load( + &self, + ) -> Result + { + Ok(crate::changeset::ClientStartState::default()) + } + } + + /// Integration regression for the top-up reconciliation *contract* — not + /// just the pure entry builder. `apply_top_up_reconciliation` must build + /// AND **persist** a `PlatformAddressChangeSet` carrying the proof's + /// post-spend balance for a spent address resolved via the provider's + /// persisted state. The reported bug was the *missing persist* (the SDK's + /// `address_infos` were discarded), so this pins that `store` actually + /// fires with the decremented entry — a helper-only test would still pass + /// if `apply_top_up_reconciliation` stopped persisting. + #[tokio::test] + async fn apply_top_up_reconciliation_persists_decremented_balance() { + use crate::broadcaster::SpvBroadcaster; + use crate::events::PlatformEventManager; + use crate::spv::SpvRuntime; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::persister::WalletPersister; + use crate::wallet::platform_addresses::PlatformAddressWallet; + use dash_sdk::query_types::AddressInfo; + use tokio::sync::Notify; + + let recorder = Arc::new(CapturingPersister::default()); + + // Wallet wired to the capturing persister. The rest mirrors the + // short-circuit fixture — `apply_top_up_reconciliation` only touches + // provider / wallet_manager / persister. + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let wallet_manager = Arc::new(RwLock::new(WalletManager::new(sdk.network))); + let persister = WalletPersister::new(WALLET, recorder.clone()); + let event_manager = Arc::new(PlatformEventManager::new(Vec::new())); + let spv = Arc::new(SpvRuntime::new(Arc::clone(&wallet_manager), event_manager)); + let broadcaster = Arc::new(SpvBroadcaster::new(spv)); + let asset_locks = Arc::new(AssetLockManager::new( + Arc::clone(&sdk), + Arc::clone(&wallet_manager), + WALLET, + Arc::new(Notify::new()), + broadcaster, + persister.clone(), + )); + let wallet = PlatformAddressWallet::new( + sdk, + Arc::clone(&wallet_manager), + WALLET, + asset_locks, + persister, + ); + + // The provider knows the spent address via its persisted bijection + // (pre-spend balance 100). + let addr = p2pkh(0x11); + let provider = + provider_tracking_address(Arc::clone(&wallet_manager), WALLET, addr, funds(100, 1)); + *wallet.provider.write().await = Some(provider); + + // The top-up spent it; the proof attests post-spend balance 5, nonce 4. + let spent = PlatformAddress::P2pkh([0x11; 20]); + let mut address_infos = AddressInfos::new(); + address_infos.insert( + spent, + Some(AddressInfo { + address: spent, + nonce: 4, + balance: 5, + }), + ); + + wallet.apply_top_up_reconciliation(&address_infos).await; + + // The reconciliation must have PERSISTED the decremented entry — the + // contract the original bug broke by discarding `address_infos`. + let stored = recorder.stored.lock().expect("persister mutex"); + let entry = stored + .iter() + .filter_map(|cs| cs.platform_addresses.as_ref()) + .flat_map(|pa| pa.addresses.iter()) + .find(|e| e.address == addr) + .expect("a persisted platform-address entry for the spent address"); + assert_eq!(entry.account_index, ACCOUNT); + assert_eq!( + entry.funds.balance, 5, + "persists the proof's post-spend balance, not the stale pre-spend value" + ); + assert_eq!(entry.funds.nonce, 4, "persists the bumped nonce"); + } + /// `reset_sync_state` must zero the incremental watermark AND drop /// the cached `found` seed, so the next pass is a full rescan rather /// than an incremental catch-up. This is the core of the platform diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs index a59cbe2f7ad..9a0531f84f7 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -16,6 +16,8 @@ use crate::wallet::persister::WalletPersister; use super::provider::PlatformPaymentAddressProvider; +use dash_sdk::query_types::AddressInfos; + /// Platform address wallet providing DIP-17 platform payment address functionality. #[derive(Clone)] pub struct PlatformAddressWallet { @@ -157,6 +159,100 @@ impl PlatformAddressWallet { Ok(()) } + /// Reconcile platform-address balances after an identity + /// top-up-from-addresses, from the proof-attested `address_infos` the SDK + /// returns. Each spent address's `(account_index, address_index)` is + /// resolved from the persisted provider state — covering addresses + /// restored from disk that are no longer in a live derived pool — its + /// in-memory balance is set to the proof's post-spend value, and a + /// `PlatformAddressChangeSet` is persisted so the displayed balance and + /// the next input selection both reflect on-chain reality. + /// + /// Without this, the local balances stay frozen at their pre-top-up + /// values: the wallet keeps displaying a stale "Platform Balance" and the + /// next top-up over-selects the now-drained addresses, which Drive + /// rejects with "Insufficient combined address balances". + /// + /// Locks are released before persisting (the persistence backend runs its + /// callbacks inline), and errors are logged rather than propagated — + /// Platform already accepted the top-up, and a later sync reconciles. + pub async fn apply_top_up_reconciliation(&self, address_infos: &AddressInfos) { + // Resolve spent addresses to balance entries under the provider read + // lock, then drop it. + let entries = { + let guard = self.provider.read().await; + match guard + .as_ref() + .and_then(|p| p.per_wallet_state(&self.wallet_id)) + { + Some(state) => { + let per_account: Vec<_> = state + .iter() + .map(|(idx, acct)| (*idx, acct.addresses())) + .collect(); + super::provider::build_top_up_balance_entries( + self.wallet_id, + &per_account, + address_infos, + ) + } + None => { + tracing::warn!( + wallet_id = ?self.wallet_id, + "Top-up reconciliation skipped: no platform-address \ + provider state for this wallet; local balances stay \ + stale until the next platform-address sync" + ); + return; + } + } + }; + if entries.is_empty() { + if !address_infos.is_empty() { + tracing::warn!( + wallet_id = ?self.wallet_id, + spent_addresses = address_infos.len(), + "Top-up reconciliation resolved none of the proof's spent \ + addresses through the persisted provider state; local \ + balances stay stale until the next platform-address sync" + ); + } + return; + } + // Apply the proof-attested post-spend balances in memory, then drop + // the write lock. + { + let mut wm = self.wallet_manager.write().await; + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + for entry in &entries { + if let Some(account) = info + .core_wallet + .platform_payment_managed_account_at_index_mut(entry.account_index) + { + account.set_address_credit_balance( + entry.address, + entry.funds.balance, + None, + ); + } + } + } + } + // Persist with no locks held. + let cs = crate::PlatformAddressChangeSet { + addresses: entries, + ..Default::default() + }; + if let Err(e) = self.persister.store(cs.into()) { + tracing::error!( + error = %e, + "Failed to persist top-up platform-address reconciliation; \ + in-memory balances are updated but durable rows stay stale \ + until the next platform-address sync" + ); + } + } + /// Get the network from the SDK. pub fn network(&self) -> key_wallet::Network { self.sdk.network