diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs index ee3b509db3d..d33b1690c26 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs @@ -289,54 +289,22 @@ impl PlatformAddressWallet { // Persistence errors are logged inside the seam rather than // propagated: Platform already accepted the transition, and a // persistence hiccup shouldn't mask that. - let cs = self - .reconcile_address_infos(&address_infos, "fund from asset lock") - .await; - - // ADDR-09: force the next BLAST sync to full-scan-reconcile - // instead of applying an incremental delta. // - // `reconcile_address_infos` above set the provider's committed - // `found` seed to the proof-attested ABSOLUTE balance `X`, but the - // top-up is recorded on-chain as a DELTA (`AddBalanceToAddress` → - // `AddToCredits`) in Drive's recent-address-balance-changes tree, - // and we did NOT advance the incremental watermark. An incremental - // next pass would seed `result.found` from `current_balances()` - // (already `X`) and then re-apply that recent `AddToCredits(X)` - // delta from the stale watermark, landing at `X + X = 2X` — the - // ADDR-09 double-count. Zeroing the in-memory watermark makes - // `last_sync_timestamp()` return `None`, so the next pass full-scans - // (absolute seed from the tree, catch-up from the fresh checkpoint) - // and reconciles to the correct `X`. This is the automated + // ADDR-09: every recipient of an asset-lock top-up is credited via + // an on-chain `AddBalanceToAddress` DELTA, so the whole recipient + // set goes into the seam's `credited_outputs` gate. Committing + // their proof-attested ABSOLUTE balances while the incremental + // watermark stayed stale would let the next incremental BLAST pass + // re-apply the delta on top → `X + X = 2X`, the ADDR-09 + // double-count. The seam invalidates the watermark inside its own + // critical section (see `reconcile_address_infos` and + // `PlatformPaymentAddressProvider::invalidate_sync_watermark`), + // forcing the next pass to full-scan-reconcile — the automated // equivalent of the manual Sync-tab "Clear" + "Sync Now". - // - // Unlike transfer/withdrawal (which also route through the seam), - // an asset-lock top-up credits its recipient with a pure additive - // delta and no offsetting input for that same address, so updating - // the `found` seed alone does not neutralize the re-applied delta — - // hence this path-specific watermark invalidation. - // - // DURABILITY: in-memory only. The persisted sync watermark cannot - // be reset to 0 through the normal changeset — - // `PlatformAddressChangeSet::merge` combines `sync_height` with - // `.max()` and the persister only fires `on_persist_sync_state_fn` - // when a component is `> 0` — so a durable zero would have to fight - // both the merge and the `> 0` gate. Instead we rely on the - // in-session BLAST cadence (~15s): the next pass full-scans, - // reconciles to `X`, and persists a correct FORWARD watermark, so - // durable state self-corrects within ~15s. The only residual gap is - // an app kill inside that ~15s window; a restart then resumes - // incremental sync from the stale persisted watermark and the - // double-count could briefly reappear until the next full rescan - // (or a manual Clear). That narrow window is accepted here rather - // than over-engineering a durable invalidation against the - // `.max()` merge / `> 0` gate. - { - let mut guard = self.provider.write().await; - if let Some(provider) = guard.as_mut() { - provider.invalidate_sync_watermark(); - } - } + let credited_outputs = super::credited_outputs_set(addresses.keys()); + let cs = self + .reconcile_address_infos(&address_infos, &credited_outputs, "fund from asset lock") + .await; if let Some(out_point) = tracked_out_point { // Platform DID accept the top-up — propagating an Err diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs index e8ca3d92704..2910b38aff2 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs @@ -1,10 +1,11 @@ //! DIP-17 platform payment address wallet and provider. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; pub use dpp::prelude::AddressNonce; +use key_wallet::PlatformP2PKHAddress; #[cfg(doc)] use crate::PlatformWalletError; @@ -42,6 +43,27 @@ where .ok_or(crate::PlatformWalletError::InputSumOverflow) } +/// Collect the P2PKH members of an address iterator into the set shape +/// [`PlatformAddressWallet::reconcile_address_infos`] takes for its +/// `credited_outputs` parameter — the addresses a transition credits via +/// an on-chain `AddBalanceToAddress` DELTA (transfer outputs, asset-lock +/// top-up recipients, identity-registration change, identity→address +/// credit-transfer recipients), as opposed to inputs, which are recorded +/// as absolute `SetBalanceToAddress` ops. Non-P2PKH addresses are skipped: +/// wallet-owned platform-payment addresses are always P2PKH, so a non-P2PKH +/// output can never be an owned address the seam would reconcile. +pub(crate) fn credited_outputs_set<'a>( + addresses: impl IntoIterator, +) -> BTreeSet { + addresses + .into_iter() + .filter_map(|addr| match addr { + PlatformAddress::P2pkh(hash) => Some(PlatformP2PKHAddress::new(*hash)), + _ => None, + }) + .collect() +} + pub use provider::{ PerAccountPlatformAddressState, PerWalletPlatformAddressState, PlatformAddressTag, }; 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 fa43b7fcf3d..0b51ed5c2ff 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs @@ -525,17 +525,39 @@ impl PlatformPaymentAddressProvider { /// cached `found` seed (unlike [`reset_sync_state`](Self::reset_sync_state), /// which is the "Clear" flow). /// - /// WHY (ADDR-09): the asset-lock top-up path reconciles the + /// WHY (ADDR-09): every flow that credits a wallet-owned address via + /// an on-chain `AddBalanceToAddress` DELTA (`AddToCredits` in Drive's + /// recent-address-balance-changes tree) — asset-lock top-up recipients, + /// same-wallet transfer outputs, identity-registration change, + /// identity→address credit-transfer recipients — reconciles the /// proof-attested ABSOLUTE balance `X` into both the managed account - /// and the provider's committed `found` seed, but the on-chain credit - /// is recorded as a DELTA (`AddBalanceToAddress` → `AddToCredits`) in - /// Drive's recent-address-balance-changes tree. If the next pass ran + /// and the provider's committed `found` seed. If the next pass ran /// INCREMENTALLY it would seed `result.found` from `current_balances()` /// (already `X`) and then re-apply that recent `AddToCredits(X)` delta /// from the stale watermark, landing at `X + X = 2X` — the ADDR-09 /// double-count. An optimistic absolute write is fundamentally /// inconsistent with incremental delta re-application, so we force the - /// very next pass to full-scan-reconcile. + /// very next pass to full-scan-reconcile. The single call site is the + /// gate inside `PlatformAddressWallet::reconcile_address_infos`, which + /// fires when a committed entry matches the caller-declared + /// `credited_outputs` set — inside the seam's provider-lock critical + /// section, so no sync pass can interleave between the seed commit and + /// this invalidation with the stale watermark. + /// + /// DURABILITY: in-memory only. The persisted sync watermark cannot be + /// reset to 0 through the normal changeset — + /// `PlatformAddressChangeSet::merge` combines `sync_height` with + /// `.max()` and the FFI persister only fires `on_persist_sync_state_fn` + /// when a component is `> 0` — so a durable zero would have to fight + /// both the merge and the `> 0` gate. Instead we rely on the in-session + /// BLAST cadence (~15s): the next pass full-scans, reconciles to `X`, + /// and persists a correct FORWARD watermark, so durable state + /// self-corrects within ~15s. The only residual gap is an app kill + /// inside that window; a restart then resumes incremental sync from the + /// stale persisted watermark and the double-count could briefly + /// reappear until the next full rescan (or a manual Clear). That narrow + /// window is accepted rather than over-engineering a durable + /// invalidation against the `.max()` merge / `> 0` gate. /// /// With `sync_timestamp == 0`, [`last_sync_timestamp`](Self::last_sync_timestamp) /// returns `None`, which makes `sync_address_balances` choose the @@ -1521,33 +1543,27 @@ mod tests { } } - /// Integration regression for the reconciliation *contract* — not - /// just the pure entry builder. `reconcile_address_infos` 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 `reconcile_address_infos` stopped persisting. - #[tokio::test] - async fn reconcile_address_infos_persists_decremented_balance() { + /// Wallet wired to a capturing persister — the shared fixture for the + /// reconciliation-seam tests below. `reconcile_address_infos` only + /// touches provider / wallet_manager / persister; the rest mirrors the + /// short-circuit fixture. + async fn reconcile_seam_wallet( + recorder: Arc, + ) -> ( + crate::wallet::platform_addresses::PlatformAddressWallet, + Arc>>, + ) { 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 — `reconcile_address_infos` 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 persister = WalletPersister::new(WALLET, recorder); 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)); @@ -1566,6 +1582,23 @@ mod tests { asset_locks, persister, ); + (wallet, wallet_manager) + } + + /// Integration regression for the reconciliation *contract* — not + /// just the pure entry builder. `reconcile_address_infos` 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 `reconcile_address_infos` stopped persisting. + #[tokio::test] + async fn reconcile_address_infos_persists_decremented_balance() { + use dash_sdk::query_types::AddressInfo; + + let recorder = Arc::new(CapturingPersister::default()); + let (wallet, wallet_manager) = reconcile_seam_wallet(recorder.clone()).await; // The provider knows the spent address via its persisted bijection // (pre-spend balance 100). @@ -1587,7 +1620,7 @@ mod tests { ); wallet - .reconcile_address_infos(&address_infos, "test top-up") + .reconcile_address_infos(&address_infos, &BTreeSet::new(), "test top-up") .await; // The reconciliation must have PERSISTED the decremented entry — the @@ -1628,6 +1661,166 @@ mod tests { ); } + /// ADDR-09 watermark gate, credit side: when the seam COMMITS an entry + /// for an address the caller declared as a delta-credited output, it + /// must invalidate the incremental sync watermark INSIDE its critical + /// section (forcing the next BLAST pass to full-scan-reconcile instead + /// of re-applying the on-chain `AddToCredits` delta on top of the + /// just-committed absolute seed) — while KEEPING the reconciled `found` + /// seed visible for display / input budgeting. + #[tokio::test] + async fn reconcile_invalidates_watermark_when_credited_output_committed() { + use dash_sdk::query_types::AddressInfo; + + let recorder = Arc::new(CapturingPersister::default()); + let (wallet, wallet_manager) = reconcile_seam_wallet(recorder).await; + + // Mid-incremental-sync provider: non-zero watermark, pre-credit + // seed balance 100 at nonce 1. + let addr = p2pkh(0x11); + let mut provider = + provider_tracking_address(Arc::clone(&wallet_manager), WALLET, addr, funds(100, 1)); + provider.set_stored_sync_state(10, 20, 30); + *wallet.provider.write().await = Some(provider); + + // A transition credited the address (delta on-chain); the proof + // attests the post-credit ABSOLUTE balance 600. Output credits + // leave the address nonce untouched, so it stays at 1. + let credited = PlatformAddress::P2pkh([0x11; 20]); + let mut address_infos = AddressInfos::new(); + address_infos.insert( + credited, + Some(AddressInfo { + address: credited, + nonce: 1, + balance: 600, + }), + ); + let credited_outputs: BTreeSet = [addr].into_iter().collect(); + + let cs = wallet + .reconcile_address_infos(&address_infos, &credited_outputs, "test credit") + .await; + assert_eq!(cs.addresses.len(), 1, "the credit must be committed"); + + let guard = wallet.provider.read().await; + let provider = guard.as_ref().expect("provider present"); + assert_eq!( + provider.last_sync_timestamp(), + None, + "committed credited output must zero the watermark so the next \ + pass takes the full-scan branch (the ADDR-09 gate)" + ); + assert_eq!(provider.last_sync_height(), 0); + assert_eq!(provider.last_known_recent_block(), 0); + // The reconciled seed survives the invalidation. + let seed: Vec<_> = provider.current_balances().collect(); + assert_eq!(seed.len(), 1); + assert_eq!( + seed[0].2, + funds(600, 1), + "invalidation must not drop the just-reconciled found seed" + ); + } + + /// ADDR-09 watermark gate, drain side: a reconciliation that only + /// touches INPUT addresses (empty `credited_outputs` — e.g. an + /// external-recipient transfer or a withdrawal) must PRESERVE the + /// incremental watermark. Inputs are recorded on-chain as absolute + /// `SetBalanceToAddress` ops, idempotent under incremental re-apply, + /// so forcing a full scan would only burn the fast cadence. + #[tokio::test] + async fn reconcile_keeps_watermark_without_credited_outputs() { + use dash_sdk::query_types::AddressInfo; + + let recorder = Arc::new(CapturingPersister::default()); + let (wallet, wallet_manager) = reconcile_seam_wallet(recorder).await; + + let addr = p2pkh(0x11); + let mut provider = + provider_tracking_address(Arc::clone(&wallet_manager), WALLET, addr, funds(100, 1)); + provider.set_stored_sync_state(10, 20, 30); + *wallet.provider.write().await = Some(provider); + + // A spend drained the address: absolute post-spend balance 5, + // bumped input nonce 4. No credited outputs declared. + let spent = PlatformAddress::P2pkh([0x11; 20]); + let mut address_infos = AddressInfos::new(); + address_infos.insert( + spent, + Some(AddressInfo { + address: spent, + nonce: 4, + balance: 5, + }), + ); + + let cs = wallet + .reconcile_address_infos(&address_infos, &BTreeSet::new(), "test drain") + .await; + assert_eq!(cs.addresses.len(), 1, "the drain must be committed"); + + let guard = wallet.provider.read().await; + let provider = guard.as_ref().expect("provider present"); + assert_eq!( + provider.last_sync_timestamp(), + Some(20), + "input-only reconciliation must keep the incremental watermark" + ); + assert_eq!(provider.last_sync_height(), 10); + assert_eq!(provider.last_known_recent_block(), 30); + } + + /// ADDR-09 watermark gate keys on entries actually COMMITTED, not + /// merely requested: a credited output whose proof entry matches the + /// committed seed exactly (`unchanged_skipped` — a background sync + /// already applied this credit and advanced the watermark past it) + /// must NOT trigger invalidation. + #[tokio::test] + async fn reconcile_keeps_watermark_when_credited_output_not_committed() { + use dash_sdk::query_types::AddressInfo; + + let recorder = Arc::new(CapturingPersister::default()); + let (wallet, wallet_manager) = reconcile_seam_wallet(recorder).await; + + // Seed already carries the post-credit state (600, nonce 1) — the + // background sync applied the credit before this reconcile ran. + let addr = p2pkh(0x11); + let mut provider = + provider_tracking_address(Arc::clone(&wallet_manager), WALLET, addr, funds(600, 1)); + provider.set_stored_sync_state(10, 20, 30); + *wallet.provider.write().await = Some(provider); + + let credited = PlatformAddress::P2pkh([0x11; 20]); + let mut address_infos = AddressInfos::new(); + address_infos.insert( + credited, + Some(AddressInfo { + address: credited, + nonce: 1, + balance: 600, + }), + ); + let credited_outputs: BTreeSet = [addr].into_iter().collect(); + + let cs = wallet + .reconcile_address_infos(&address_infos, &credited_outputs, "test unchanged") + .await; + assert!( + cs.addresses.is_empty(), + "unchanged entry must be skipped, not re-committed" + ); + + let guard = wallet.provider.read().await; + let provider = guard.as_ref().expect("provider present"); + assert_eq!( + provider.last_sync_timestamp(), + Some(20), + "a credit the sync already applied (and advanced the watermark \ + past) must not force a full rescan" + ); + } + /// Freshness guard: an entry whose nonce is below the committed seed's /// (a background sync — or a later transition — already committed /// fresher state) must be dropped, not applied over the fresher value. diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs index b7cd0a2bffd..f04d17e3f11 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs @@ -212,6 +212,14 @@ impl PlatformAddressWallet { // gate and the spend path diverge on a non-latest-pinned SDK. let version = platform_version.unwrap_or_else(|| self.sdk.version()); + // Capture the credited output addresses BEFORE `outputs` is moved + // into the SDK call below. Transfer outputs are recorded on-chain + // as `AddBalanceToAddress` DELTAS (a same-wallet output — e.g. a + // change address — is the ADDR-09 double-count shape); the seam's + // watermark gate needs to know them. See + // `reconcile_address_infos` for the full mechanism. + let credited_outputs = super::credited_outputs_set(outputs.keys()); + let address_infos = match input_selection { InputSelection::Explicit(inputs) => { if inputs.is_empty() { @@ -265,9 +273,13 @@ impl PlatformAddressWallet { // `transfer_address_funds` returns address info for the full // `inputs ∪ outputs` set, including external recipients the wallet // does not own — the shared seam filters those out, applies the - // proof-attested balances, updates the sync seed, and persists. + // proof-attested balances, updates the sync seed, persists, and + // (when a wallet-owned output in `credited_outputs` was committed) + // invalidates the incremental sync watermark so the next BLAST + // pass full-scan-reconciles instead of re-applying the on-chain + // credit delta on top of the absolute seed (ADDR-09). Ok(self - .reconcile_address_infos(&address_infos, "address transfer") + .reconcile_address_infos(&address_infos, &credited_outputs, "address transfer") .await) } 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 f6d648422eb..dbff6ba6b2e 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -1,6 +1,6 @@ //! Platform address wallet for DIP-17 platform payment addresses. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use dpp::address_funds::PlatformAddress; @@ -199,14 +199,47 @@ impl PlatformAddressWallet { /// interleave between (or persist across) the seam's steps; the lock /// order (provider → wallet manager) matches the sync callbacks. /// + /// # `credited_outputs` — the ADDR-09 watermark gate + /// + /// `credited_outputs` names the addresses the transition credited via + /// an on-chain `AddBalanceToAddress` op — a DELTA (`AddToCredits`) in + /// Drive's recent-address-balance-changes tree — as opposed to inputs, + /// which are recorded as absolute `SetBalanceToAddress` ops. Build it + /// with [`super::credited_outputs_set`]; pass an empty set when the + /// transition credits nothing the wallet could own (e.g. withdrawal, + /// which never has a change output). + /// + /// When the seam commits an entry for one of these addresses, it has + /// just written an optimistic ABSOLUTE balance into the sync seed + /// while the on-chain record of the same credit is a delta. An + /// incremental next sync would seed from `current_balances()` (already + /// the absolute `X`) and re-apply the recent delta on top → `X + delta` + /// — the ADDR-09 double-count. So the seam invalidates the provider's + /// incremental sync watermark ([`PlatformPaymentAddressProvider::invalidate_sync_watermark`]) + /// INSIDE this same critical section, forcing the next pass to + /// full-scan-reconcile (absolute re-seed from the tree). Doing it + /// under the lock — rather than caller-side after the seam returns — + /// means no sync pass can slip in between the seed commit and the + /// invalidation with the stale watermark. Input-only reconciliations + /// (empty or non-matching `credited_outputs`) keep the fast + /// incremental cadence: re-applying an absolute op is idempotent. + /// + /// The gate keys on entries actually COMMITTED, not merely requested: + /// an entry skipped as stale/unchanged means a sync already applied + /// this credit (and advanced the watermark past it), so no forced + /// full scan is needed. + /// /// Persistence errors are logged rather than propagated — Platform /// already accepted the transition, and a later sync reconciles. /// /// [`PlatformPaymentAddressProvider::commit_reconciliation`]: /// super::provider::PlatformPaymentAddressProvider::commit_reconciliation + /// [`PlatformPaymentAddressProvider::invalidate_sync_watermark`]: + /// super::provider::PlatformPaymentAddressProvider::invalidate_sync_watermark pub async fn reconcile_address_infos( &self, address_infos: &AddressInfos, + credited_outputs: &BTreeSet, context: &'static str, ) -> crate::PlatformAddressChangeSet { if address_infos.is_empty() { @@ -336,6 +369,21 @@ impl PlatformAddressWallet { addresses: outcome.entries, ..Default::default() }; + + // ADDR-09 watermark gate (see the method docs): a committed entry + // for a delta-credited output means the absolute seed write above + // is inconsistent with incremental delta re-application — force + // the next pass to full-scan. Still under the provider write lock, + // so no sync pass can interleave between the seed commit and this + // invalidation with the stale watermark. + if cs + .addresses + .iter() + .any(|entry| credited_outputs.contains(&entry.address)) + { + provider.invalidate_sync_watermark(); + } + if let Err(e) = self.persister.store(cs.clone().into()) { tracing::error!( context, diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs index 7765f4b64cb..34ef776bf81 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs @@ -190,8 +190,20 @@ impl PlatformAddressWallet { // persisted index bijection (with live-pool fallback), so a // restored address that is no longer in a live derived pool keeps // its real derivation index instead of corrupting index 0. + // + // `credited_outputs` is empty (ADDR-09 watermark gate stays off): + // every owned address a withdrawal touches is an INPUT, recorded + // on-chain as `SetBalanceToAddress` (an absolute `SetCredits` op in + // the recent tree); re-applying an absolute op on the next + // incremental pass is idempotent, so there is no delta to + // double-count. The only op that *would* be a delta + // (`AddBalanceToAddress`) is the change output, and every + // `withdraw_address_funds` call above passes `None` for it — the + // account is drained in full with no change. If a future change + // output is ever wired up here, pass it via + // `super::credited_outputs_set` so the seam's gate covers it. Ok(self - .reconcile_address_infos(&address_infos, "address withdrawal") + .reconcile_address_infos(&address_infos, &BTreeSet::new(), "address withdrawal") .await) } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 3faae689637..71fdae34c21 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1,6 +1,6 @@ //! The main PlatformWallet struct combining core, identity (+DashPay), and platform sub-wallets. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::ops::{Deref, DerefMut}; use std::sync::Arc; @@ -258,8 +258,14 @@ impl PlatformWallet { .identity .top_up_from_addresses(identity_id, inputs, address_signer, settings) .await?; + // `credited_outputs` is empty: an identity top-up only DRAINS + // platform addresses (absolute `SetBalanceToAddress` ops, + // idempotent under incremental re-apply) — it has no change + // output. If one is ever added, pass it via + // `platform_addresses::credited_outputs_set` so the seam's + // ADDR-09 watermark gate covers it. self.platform - .reconcile_address_infos(&address_infos, "identity top-up") + .reconcile_address_infos(&address_infos, &BTreeSet::new(), "identity top-up") .await; Ok(new_balance) } @@ -291,6 +297,14 @@ impl PlatformWallet { IS: Signer, AS: Signer + Send + Sync, { + // The optional refund-style `output` is credited on-chain via an + // `AddBalanceToAddress` DELTA; when it lands on a wallet-owned + // address, the seam's ADDR-09 watermark gate must know about it or + // the next incremental BLAST pass would re-apply the delta on top + // of the reconciled absolute balance (double-count). Capture it + // before `output` moves into the identity call. + let credited_outputs = + super::platform_addresses::credited_outputs_set(output.iter().map(|(addr, _)| addr)); let (registered_identity, address_infos) = self .identity .register_from_addresses( @@ -304,7 +318,7 @@ impl PlatformWallet { ) .await?; self.platform - .reconcile_address_infos(&address_infos, "identity registration") + .reconcile_address_infos(&address_infos, &credited_outputs, "identity registration") .await; Ok(registered_identity) } @@ -330,6 +344,15 @@ impl PlatformWallet { where S: Signer + Send + Sync, { + // Every recipient is credited on-chain via an `AddBalanceToAddress` + // DELTA — and the primary use of this flow is consolidating identity + // credits into the wallet's OWN platform addresses, so the seam's + // ADDR-09 watermark gate must know the recipient set or the next + // incremental BLAST pass would re-apply the delta on top of the + // reconciled absolute balance (double-count). Capture it before + // `recipient_addresses` moves into the identity call. + let credited_outputs = + super::platform_addresses::credited_outputs_set(recipient_addresses.keys()); let (address_infos, new_balance) = self .identity .transfer_credits_to_addresses_with_external_signer( @@ -340,7 +363,11 @@ impl PlatformWallet { ) .await?; self.platform - .reconcile_address_infos(&address_infos, "credit transfer to addresses") + .reconcile_address_infos( + &address_infos, + &credited_outputs, + "credit transfer to addresses", + ) .await; Ok(new_balance) }