-
Notifications
You must be signed in to change notification settings - Fork 56
fix(platform-wallet): reconcile platform-address balances after top-up-from-addresses #3969
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
6d867c6
1bd68c3
c0d5ac7
e317d79
e9aff46
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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). | ||
|
Comment on lines
+31
to
+38
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: Two-call reconciliation contract enforced only by a doc comment, not the type system
Today there is exactly one in-tree caller ( source: ['claude'] |
||
| /// | ||
| /// # Arguments | ||
| /// | ||
| /// * `identity_id` - The identity to top up. | ||
|
|
@@ -40,7 +51,7 @@ impl IdentityWallet { | |
| inputs: BTreeMap<PlatformAddress, Credits>, | ||
| address_signer: &S, | ||
| settings: Option<PutSettings>, | ||
| ) -> Result<Credits, PlatformWalletError> { | ||
| ) -> 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)) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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`. | ||||||||||||||||||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -781,6 +796,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<AddressIndex, PlatformP2PKHAddress>)], | ||||||||||||||||||||||||||||||||||||||||||||||||
| address_infos: &AddressInfos, | ||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> Vec<PlatformAddressBalanceEntry> { | ||||||||||||||||||||||||||||||||||||||||||||||||
| let mut entries = Vec::new(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| for (addr, maybe_info) in address_infos.iter() { | ||||||||||||||||||||||||||||||||||||||||||||||||
| let PlatformAddress::P2pkh(hash) = addr else { | ||||||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+847
to
+850
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬 Nitpick: Non-P2PKH proof entries silently skipped without a diagnostic
source: ['claude'] |
||||||||||||||||||||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+852
to
+861
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion:
For a just-spent input address,
This reintroduces a narrower version of the exact 'local diverges from on-chain silently' bug class this PR exists to fix. The PR description explicitly says it mirrors
Suggested change
source: ['claude'] |
||||||||||||||||||||||||||||||||||||||||||||||||
| 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::*; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -807,6 +871,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<AddressIndex, PlatformP2PKHAddress> = 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<AddressIndex, PlatformP2PKHAddress>); 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. | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,83 @@ 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 => Vec::new(), | ||
| } | ||
| }; | ||
| if entries.is_empty() { | ||
| 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, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+222
to
+240
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: Reconciliation updates ManagedPlatformAccount but leaves the live provider's
However, the parallel copy of this data in Until the next full sync round rebuilds the provider's source: ['claude'] |
||
| // 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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.