@@ -525,17 +525,39 @@ impl PlatformPaymentAddressProvider {
525525 /// cached `found` seed (unlike [`reset_sync_state`](Self::reset_sync_state),
526526 /// which is the "Clear" flow).
527527 ///
528- /// WHY (ADDR-09): the asset-lock top-up path reconciles the
528+ /// WHY (ADDR-09): every flow that credits a wallet-owned address via
529+ /// an on-chain `AddBalanceToAddress` DELTA (`AddToCredits` in Drive's
530+ /// recent-address-balance-changes tree) — asset-lock top-up recipients,
531+ /// same-wallet transfer outputs, identity-registration change,
532+ /// identity→address credit-transfer recipients — reconciles the
529533 /// proof-attested ABSOLUTE balance `X` into both the managed account
530- /// and the provider's committed `found` seed, but the on-chain credit
531- /// is recorded as a DELTA (`AddBalanceToAddress` → `AddToCredits`) in
532- /// Drive's recent-address-balance-changes tree. If the next pass ran
534+ /// and the provider's committed `found` seed. If the next pass ran
533535 /// INCREMENTALLY it would seed `result.found` from `current_balances()`
534536 /// (already `X`) and then re-apply that recent `AddToCredits(X)` delta
535537 /// from the stale watermark, landing at `X + X = 2X` — the ADDR-09
536538 /// double-count. An optimistic absolute write is fundamentally
537539 /// inconsistent with incremental delta re-application, so we force the
538- /// very next pass to full-scan-reconcile.
540+ /// very next pass to full-scan-reconcile. The single call site is the
541+ /// gate inside `PlatformAddressWallet::reconcile_address_infos`, which
542+ /// fires when a committed entry matches the caller-declared
543+ /// `credited_outputs` set — inside the seam's provider-lock critical
544+ /// section, so no sync pass can interleave between the seed commit and
545+ /// this invalidation with the stale watermark.
546+ ///
547+ /// DURABILITY: in-memory only. The persisted sync watermark cannot be
548+ /// reset to 0 through the normal changeset —
549+ /// `PlatformAddressChangeSet::merge` combines `sync_height` with
550+ /// `.max()` and the FFI persister only fires `on_persist_sync_state_fn`
551+ /// when a component is `> 0` — so a durable zero would have to fight
552+ /// both the merge and the `> 0` gate. Instead we rely on the in-session
553+ /// BLAST cadence (~15s): the next pass full-scans, reconciles to `X`,
554+ /// and persists a correct FORWARD watermark, so durable state
555+ /// self-corrects within ~15s. The only residual gap is an app kill
556+ /// inside that window; a restart then resumes incremental sync from the
557+ /// stale persisted watermark and the double-count could briefly
558+ /// reappear until the next full rescan (or a manual Clear). That narrow
559+ /// window is accepted rather than over-engineering a durable
560+ /// invalidation against the `.max()` merge / `> 0` gate.
539561 ///
540562 /// With `sync_timestamp == 0`, [`last_sync_timestamp`](Self::last_sync_timestamp)
541563 /// returns `None`, which makes `sync_address_balances` choose the
@@ -1521,33 +1543,27 @@ mod tests {
15211543 }
15221544 }
15231545
1524- /// Integration regression for the reconciliation *contract* — not
1525- /// just the pure entry builder . `reconcile_address_infos` must build
1526- /// AND **persist** a `PlatformAddressChangeSet` carrying the proof's
1527- /// post-spend balance for a spent address resolved via the provider's
1528- /// persisted state. The reported bug was the *missing persist* (the SDK's
1529- /// `address_infos` were discarded), so this pins that `store` actually
1530- /// fires with the decremented entry — a helper-only test would still pass
1531- /// if `reconcile_address_infos` stopped persisting.
1532- # [ tokio :: test ]
1533- async fn reconcile_address_infos_persists_decremented_balance ( ) {
1546+ /// Wallet wired to a capturing persister — the shared fixture for the
1547+ /// reconciliation-seam tests below . `reconcile_address_infos` only
1548+ /// touches provider / wallet_manager / persister; the rest mirrors the
1549+ /// short-circuit fixture.
1550+ async fn reconcile_seam_wallet (
1551+ recorder : Arc < CapturingPersister > ,
1552+ ) -> (
1553+ crate :: wallet :: platform_addresses :: PlatformAddressWallet ,
1554+ Arc < RwLock < WalletManager < PlatformWalletInfo > > > ,
1555+ ) {
15341556 use crate :: broadcaster:: SpvBroadcaster ;
15351557 use crate :: events:: PlatformEventManager ;
15361558 use crate :: spv:: SpvRuntime ;
15371559 use crate :: wallet:: asset_lock:: manager:: AssetLockManager ;
15381560 use crate :: wallet:: persister:: WalletPersister ;
15391561 use crate :: wallet:: platform_addresses:: PlatformAddressWallet ;
1540- use dash_sdk:: query_types:: AddressInfo ;
15411562 use tokio:: sync:: Notify ;
15421563
1543- let recorder = Arc :: new ( CapturingPersister :: default ( ) ) ;
1544-
1545- // Wallet wired to the capturing persister. The rest mirrors the
1546- // short-circuit fixture — `reconcile_address_infos` only touches
1547- // provider / wallet_manager / persister.
15481564 let sdk = Arc :: new ( dash_sdk:: SdkBuilder :: new_mock ( ) . build ( ) . expect ( "mock sdk" ) ) ;
15491565 let wallet_manager = Arc :: new ( RwLock :: new ( WalletManager :: new ( sdk. network ) ) ) ;
1550- let persister = WalletPersister :: new ( WALLET , recorder. clone ( ) ) ;
1566+ let persister = WalletPersister :: new ( WALLET , recorder) ;
15511567 let event_manager = Arc :: new ( PlatformEventManager :: new ( Vec :: new ( ) ) ) ;
15521568 let spv = Arc :: new ( SpvRuntime :: new ( Arc :: clone ( & wallet_manager) , event_manager) ) ;
15531569 let broadcaster = Arc :: new ( SpvBroadcaster :: new ( spv) ) ;
@@ -1566,6 +1582,23 @@ mod tests {
15661582 asset_locks,
15671583 persister,
15681584 ) ;
1585+ ( wallet, wallet_manager)
1586+ }
1587+
1588+ /// Integration regression for the reconciliation *contract* — not
1589+ /// just the pure entry builder. `reconcile_address_infos` must build
1590+ /// AND **persist** a `PlatformAddressChangeSet` carrying the proof's
1591+ /// post-spend balance for a spent address resolved via the provider's
1592+ /// persisted state. The reported bug was the *missing persist* (the SDK's
1593+ /// `address_infos` were discarded), so this pins that `store` actually
1594+ /// fires with the decremented entry — a helper-only test would still pass
1595+ /// if `reconcile_address_infos` stopped persisting.
1596+ #[ tokio:: test]
1597+ async fn reconcile_address_infos_persists_decremented_balance ( ) {
1598+ use dash_sdk:: query_types:: AddressInfo ;
1599+
1600+ let recorder = Arc :: new ( CapturingPersister :: default ( ) ) ;
1601+ let ( wallet, wallet_manager) = reconcile_seam_wallet ( recorder. clone ( ) ) . await ;
15691602
15701603 // The provider knows the spent address via its persisted bijection
15711604 // (pre-spend balance 100).
@@ -1587,7 +1620,7 @@ mod tests {
15871620 ) ;
15881621
15891622 wallet
1590- . reconcile_address_infos ( & address_infos, "test top-up" )
1623+ . reconcile_address_infos ( & address_infos, & BTreeSet :: new ( ) , "test top-up" )
15911624 . await ;
15921625
15931626 // The reconciliation must have PERSISTED the decremented entry — the
@@ -1628,6 +1661,166 @@ mod tests {
16281661 ) ;
16291662 }
16301663
1664+ /// ADDR-09 watermark gate, credit side: when the seam COMMITS an entry
1665+ /// for an address the caller declared as a delta-credited output, it
1666+ /// must invalidate the incremental sync watermark INSIDE its critical
1667+ /// section (forcing the next BLAST pass to full-scan-reconcile instead
1668+ /// of re-applying the on-chain `AddToCredits` delta on top of the
1669+ /// just-committed absolute seed) — while KEEPING the reconciled `found`
1670+ /// seed visible for display / input budgeting.
1671+ #[ tokio:: test]
1672+ async fn reconcile_invalidates_watermark_when_credited_output_committed ( ) {
1673+ use dash_sdk:: query_types:: AddressInfo ;
1674+
1675+ let recorder = Arc :: new ( CapturingPersister :: default ( ) ) ;
1676+ let ( wallet, wallet_manager) = reconcile_seam_wallet ( recorder) . await ;
1677+
1678+ // Mid-incremental-sync provider: non-zero watermark, pre-credit
1679+ // seed balance 100 at nonce 1.
1680+ let addr = p2pkh ( 0x11 ) ;
1681+ let mut provider =
1682+ provider_tracking_address ( Arc :: clone ( & wallet_manager) , WALLET , addr, funds ( 100 , 1 ) ) ;
1683+ provider. set_stored_sync_state ( 10 , 20 , 30 ) ;
1684+ * wallet. provider . write ( ) . await = Some ( provider) ;
1685+
1686+ // A transition credited the address (delta on-chain); the proof
1687+ // attests the post-credit ABSOLUTE balance 600. Output credits
1688+ // leave the address nonce untouched, so it stays at 1.
1689+ let credited = PlatformAddress :: P2pkh ( [ 0x11 ; 20 ] ) ;
1690+ let mut address_infos = AddressInfos :: new ( ) ;
1691+ address_infos. insert (
1692+ credited,
1693+ Some ( AddressInfo {
1694+ address : credited,
1695+ nonce : 1 ,
1696+ balance : 600 ,
1697+ } ) ,
1698+ ) ;
1699+ let credited_outputs: BTreeSet < PlatformP2PKHAddress > = [ addr] . into_iter ( ) . collect ( ) ;
1700+
1701+ let cs = wallet
1702+ . reconcile_address_infos ( & address_infos, & credited_outputs, "test credit" )
1703+ . await ;
1704+ assert_eq ! ( cs. addresses. len( ) , 1 , "the credit must be committed" ) ;
1705+
1706+ let guard = wallet. provider . read ( ) . await ;
1707+ let provider = guard. as_ref ( ) . expect ( "provider present" ) ;
1708+ assert_eq ! (
1709+ provider. last_sync_timestamp( ) ,
1710+ None ,
1711+ "committed credited output must zero the watermark so the next \
1712+ pass takes the full-scan branch (the ADDR-09 gate)"
1713+ ) ;
1714+ assert_eq ! ( provider. last_sync_height( ) , 0 ) ;
1715+ assert_eq ! ( provider. last_known_recent_block( ) , 0 ) ;
1716+ // The reconciled seed survives the invalidation.
1717+ let seed: Vec < _ > = provider. current_balances ( ) . collect ( ) ;
1718+ assert_eq ! ( seed. len( ) , 1 ) ;
1719+ assert_eq ! (
1720+ seed[ 0 ] . 2 ,
1721+ funds( 600 , 1 ) ,
1722+ "invalidation must not drop the just-reconciled found seed"
1723+ ) ;
1724+ }
1725+
1726+ /// ADDR-09 watermark gate, drain side: a reconciliation that only
1727+ /// touches INPUT addresses (empty `credited_outputs` — e.g. an
1728+ /// external-recipient transfer or a withdrawal) must PRESERVE the
1729+ /// incremental watermark. Inputs are recorded on-chain as absolute
1730+ /// `SetBalanceToAddress` ops, idempotent under incremental re-apply,
1731+ /// so forcing a full scan would only burn the fast cadence.
1732+ #[ tokio:: test]
1733+ async fn reconcile_keeps_watermark_without_credited_outputs ( ) {
1734+ use dash_sdk:: query_types:: AddressInfo ;
1735+
1736+ let recorder = Arc :: new ( CapturingPersister :: default ( ) ) ;
1737+ let ( wallet, wallet_manager) = reconcile_seam_wallet ( recorder) . await ;
1738+
1739+ let addr = p2pkh ( 0x11 ) ;
1740+ let mut provider =
1741+ provider_tracking_address ( Arc :: clone ( & wallet_manager) , WALLET , addr, funds ( 100 , 1 ) ) ;
1742+ provider. set_stored_sync_state ( 10 , 20 , 30 ) ;
1743+ * wallet. provider . write ( ) . await = Some ( provider) ;
1744+
1745+ // A spend drained the address: absolute post-spend balance 5,
1746+ // bumped input nonce 4. No credited outputs declared.
1747+ let spent = PlatformAddress :: P2pkh ( [ 0x11 ; 20 ] ) ;
1748+ let mut address_infos = AddressInfos :: new ( ) ;
1749+ address_infos. insert (
1750+ spent,
1751+ Some ( AddressInfo {
1752+ address : spent,
1753+ nonce : 4 ,
1754+ balance : 5 ,
1755+ } ) ,
1756+ ) ;
1757+
1758+ let cs = wallet
1759+ . reconcile_address_infos ( & address_infos, & BTreeSet :: new ( ) , "test drain" )
1760+ . await ;
1761+ assert_eq ! ( cs. addresses. len( ) , 1 , "the drain must be committed" ) ;
1762+
1763+ let guard = wallet. provider . read ( ) . await ;
1764+ let provider = guard. as_ref ( ) . expect ( "provider present" ) ;
1765+ assert_eq ! (
1766+ provider. last_sync_timestamp( ) ,
1767+ Some ( 20 ) ,
1768+ "input-only reconciliation must keep the incremental watermark"
1769+ ) ;
1770+ assert_eq ! ( provider. last_sync_height( ) , 10 ) ;
1771+ assert_eq ! ( provider. last_known_recent_block( ) , 30 ) ;
1772+ }
1773+
1774+ /// ADDR-09 watermark gate keys on entries actually COMMITTED, not
1775+ /// merely requested: a credited output whose proof entry matches the
1776+ /// committed seed exactly (`unchanged_skipped` — a background sync
1777+ /// already applied this credit and advanced the watermark past it)
1778+ /// must NOT trigger invalidation.
1779+ #[ tokio:: test]
1780+ async fn reconcile_keeps_watermark_when_credited_output_not_committed ( ) {
1781+ use dash_sdk:: query_types:: AddressInfo ;
1782+
1783+ let recorder = Arc :: new ( CapturingPersister :: default ( ) ) ;
1784+ let ( wallet, wallet_manager) = reconcile_seam_wallet ( recorder) . await ;
1785+
1786+ // Seed already carries the post-credit state (600, nonce 1) — the
1787+ // background sync applied the credit before this reconcile ran.
1788+ let addr = p2pkh ( 0x11 ) ;
1789+ let mut provider =
1790+ provider_tracking_address ( Arc :: clone ( & wallet_manager) , WALLET , addr, funds ( 600 , 1 ) ) ;
1791+ provider. set_stored_sync_state ( 10 , 20 , 30 ) ;
1792+ * wallet. provider . write ( ) . await = Some ( provider) ;
1793+
1794+ let credited = PlatformAddress :: P2pkh ( [ 0x11 ; 20 ] ) ;
1795+ let mut address_infos = AddressInfos :: new ( ) ;
1796+ address_infos. insert (
1797+ credited,
1798+ Some ( AddressInfo {
1799+ address : credited,
1800+ nonce : 1 ,
1801+ balance : 600 ,
1802+ } ) ,
1803+ ) ;
1804+ let credited_outputs: BTreeSet < PlatformP2PKHAddress > = [ addr] . into_iter ( ) . collect ( ) ;
1805+
1806+ let cs = wallet
1807+ . reconcile_address_infos ( & address_infos, & credited_outputs, "test unchanged" )
1808+ . await ;
1809+ assert ! (
1810+ cs. addresses. is_empty( ) ,
1811+ "unchanged entry must be skipped, not re-committed"
1812+ ) ;
1813+
1814+ let guard = wallet. provider . read ( ) . await ;
1815+ let provider = guard. as_ref ( ) . expect ( "provider present" ) ;
1816+ assert_eq ! (
1817+ provider. last_sync_timestamp( ) ,
1818+ Some ( 20 ) ,
1819+ "a credit the sync already applied (and advanced the watermark \
1820+ past) must not force a full rescan"
1821+ ) ;
1822+ }
1823+
16311824 /// Freshness guard: an entry whose nonce is below the committed seed's
16321825 /// (a background sync — or a later transition — already committed
16331826 /// fresher state) must be dropped, not applied over the fresher value.
0 commit comments