Skip to content

Commit a1d40ca

Browse files
fix(platform-wallet): gate ADDR-09 watermark invalidation inside the reconcile seam (#4005)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c326833 commit a1d40ca

7 files changed

Lines changed: 360 additions & 78 deletions

File tree

packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs

Lines changed: 14 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -289,54 +289,22 @@ impl PlatformAddressWallet {
289289
// Persistence errors are logged inside the seam rather than
290290
// propagated: Platform already accepted the transition, and a
291291
// persistence hiccup shouldn't mask that.
292-
let cs = self
293-
.reconcile_address_infos(&address_infos, "fund from asset lock")
294-
.await;
295-
296-
// ADDR-09: force the next BLAST sync to full-scan-reconcile
297-
// instead of applying an incremental delta.
298292
//
299-
// `reconcile_address_infos` above set the provider's committed
300-
// `found` seed to the proof-attested ABSOLUTE balance `X`, but the
301-
// top-up is recorded on-chain as a DELTA (`AddBalanceToAddress` →
302-
// `AddToCredits`) in Drive's recent-address-balance-changes tree,
303-
// and we did NOT advance the incremental watermark. An incremental
304-
// next pass would seed `result.found` from `current_balances()`
305-
// (already `X`) and then re-apply that recent `AddToCredits(X)`
306-
// delta from the stale watermark, landing at `X + X = 2X` — the
307-
// ADDR-09 double-count. Zeroing the in-memory watermark makes
308-
// `last_sync_timestamp()` return `None`, so the next pass full-scans
309-
// (absolute seed from the tree, catch-up from the fresh checkpoint)
310-
// and reconciles to the correct `X`. This is the automated
293+
// ADDR-09: every recipient of an asset-lock top-up is credited via
294+
// an on-chain `AddBalanceToAddress` DELTA, so the whole recipient
295+
// set goes into the seam's `credited_outputs` gate. Committing
296+
// their proof-attested ABSOLUTE balances while the incremental
297+
// watermark stayed stale would let the next incremental BLAST pass
298+
// re-apply the delta on top → `X + X = 2X`, the ADDR-09
299+
// double-count. The seam invalidates the watermark inside its own
300+
// critical section (see `reconcile_address_infos` and
301+
// `PlatformPaymentAddressProvider::invalidate_sync_watermark`),
302+
// forcing the next pass to full-scan-reconcile — the automated
311303
// equivalent of the manual Sync-tab "Clear" + "Sync Now".
312-
//
313-
// Unlike transfer/withdrawal (which also route through the seam),
314-
// an asset-lock top-up credits its recipient with a pure additive
315-
// delta and no offsetting input for that same address, so updating
316-
// the `found` seed alone does not neutralize the re-applied delta —
317-
// hence this path-specific watermark invalidation.
318-
//
319-
// DURABILITY: in-memory only. The persisted sync watermark cannot
320-
// be reset to 0 through the normal changeset —
321-
// `PlatformAddressChangeSet::merge` combines `sync_height` with
322-
// `.max()` and the persister only fires `on_persist_sync_state_fn`
323-
// when a component is `> 0` — so a durable zero would have to fight
324-
// both the merge and the `> 0` gate. Instead we rely on the
325-
// in-session BLAST cadence (~15s): the next pass full-scans,
326-
// reconciles to `X`, and persists a correct FORWARD watermark, so
327-
// durable state self-corrects within ~15s. The only residual gap is
328-
// an app kill inside that ~15s window; a restart then resumes
329-
// incremental sync from the stale persisted watermark and the
330-
// double-count could briefly reappear until the next full rescan
331-
// (or a manual Clear). That narrow window is accepted here rather
332-
// than over-engineering a durable invalidation against the
333-
// `.max()` merge / `> 0` gate.
334-
{
335-
let mut guard = self.provider.write().await;
336-
if let Some(provider) = guard.as_mut() {
337-
provider.invalidate_sync_watermark();
338-
}
339-
}
304+
let credited_outputs = super::credited_outputs_set(addresses.keys());
305+
let cs = self
306+
.reconcile_address_infos(&address_infos, &credited_outputs, "fund from asset lock")
307+
.await;
340308

341309
if let Some(out_point) = tracked_out_point {
342310
// Platform DID accept the top-up — propagating an Err

packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
//! DIP-17 platform payment address wallet and provider.
22
3-
use std::collections::BTreeMap;
3+
use std::collections::{BTreeMap, BTreeSet};
44

55
use dpp::address_funds::PlatformAddress;
66
use dpp::fee::Credits;
77
pub use dpp::prelude::AddressNonce;
8+
use key_wallet::PlatformP2PKHAddress;
89

910
#[cfg(doc)]
1011
use crate::PlatformWalletError;
@@ -42,6 +43,27 @@ where
4243
.ok_or(crate::PlatformWalletError::InputSumOverflow)
4344
}
4445

46+
/// Collect the P2PKH members of an address iterator into the set shape
47+
/// [`PlatformAddressWallet::reconcile_address_infos`] takes for its
48+
/// `credited_outputs` parameter — the addresses a transition credits via
49+
/// an on-chain `AddBalanceToAddress` DELTA (transfer outputs, asset-lock
50+
/// top-up recipients, identity-registration change, identity→address
51+
/// credit-transfer recipients), as opposed to inputs, which are recorded
52+
/// as absolute `SetBalanceToAddress` ops. Non-P2PKH addresses are skipped:
53+
/// wallet-owned platform-payment addresses are always P2PKH, so a non-P2PKH
54+
/// output can never be an owned address the seam would reconcile.
55+
pub(crate) fn credited_outputs_set<'a>(
56+
addresses: impl IntoIterator<Item = &'a PlatformAddress>,
57+
) -> BTreeSet<PlatformP2PKHAddress> {
58+
addresses
59+
.into_iter()
60+
.filter_map(|addr| match addr {
61+
PlatformAddress::P2pkh(hash) => Some(PlatformP2PKHAddress::new(*hash)),
62+
_ => None,
63+
})
64+
.collect()
65+
}
66+
4567
pub use provider::{
4668
PerAccountPlatformAddressState, PerWalletPlatformAddressState, PlatformAddressTag,
4769
};

packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs

Lines changed: 216 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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 persisterthe 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

Comments
 (0)