Skip to content

Found-032: platform-address sync watermark regresses to 0 on every empty incremental delta (update_sync_state copies last_known_recent_block verbatim) #4029

Description

@Claudius-Maginificent

Summary

Re-verified on v4.1-dev at 259ae1d105 after the #4004/#4005/#4008 reconcile/watermark rework — the finding's original "watermark never advances" shape is partially fixed, but a regression-to-zero defect remains live:

  • Fixed half: new_sync_height now advances on an empty delta (rs-sdk sets it from the response metadata tip), so the height watermark no longer stalls.
  • Live half: rs-sdk's incremental_catch_up sets result.last_known_recent_block only from actually returned recent-tree entries — on a quiet pass (zero entries) it stays 0 by design (the boundary cursor must be a real tree key). PlatformPaymentAddressProvider::update_sync_state then copies all three scalars verbatim, so a previously stored non-zero boundary watermark is wiped back to 0 on every empty delta (BLAST cadence ≈ 15 s).

Consequences:

  1. PlatformAddressWallet::sync_watermark() maps 0 → None, so the watermark surface reports "no watermark" after any quiet pass — the exact pa_007 symptom from the e2e run (all sync_watermark() reads return None).
  2. The in-memory value now disagrees with the persisted one: PlatformAddressChangeSet only carries last_known_recent_block when > 0 and merges monotonically, so disk keeps the old boundary while memory holds 0 — after restart the value reappears, then vanishes again on the next quiet pass.
  3. With the boundary lost, the next pass falls back from exclusive RangeAfter(boundary) to inclusive RangeFrom(sync_height); an AddToCredits entry sitting exactly at the boundary block can be re-applied — the same double-count family as ADDR-09, reintroduced through the fallback.

Affected crate + location

Root cause

Contract mismatch between rs-sdk and rs-platform-wallet: rs-sdk uses 0 as "no boundary observed this pass", while the provider treats the copied value as "the boundary watermark" (and its own persistence layer already assumes monotonicity via the > 0 gate and .max() merge). Nothing keeps the previous non-zero value when the new result carries the 0 sentinel.

Reproduction

Crate-level unit test (no network):

  • Crate-level repro: found_032_empty_delta_must_not_regress_recent_block_watermark
    (in packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs, branch repro/pr3549-platform-a)
  • Command:
    cargo test -p platform-wallet found_032_empty_delta_must_not_regress_recent_block_watermark
    
  • RED by design: fails today with stored 42 before the pass, got 0 after; passes once the provider (or the result contract) preserves the boundary across empty deltas.

Expected vs Actual

  • Expected: the recent-block boundary watermark is monotone across successful passes — an empty delta leaves it unchanged (matching the persistence layer's own > 0/.max() semantics); sync_watermark() keeps reporting the last known boundary.
  • Actual: any quiet pass zeroes it in memory; sync_watermark() flips to None; memory and disk diverge until restart.

Severity

  • risk: 0.75 (every quiet 15-second BLAST pass triggers it; quiet passes are the common case)
  • impact: 0.5 (watermark surface unusable for progress/diagnostics; memory/disk divergence; boundary-block delta re-application window reintroduces an ADDR-09-shaped double-count)
  • scope: 0.4 (platform-address sync subsystem, all wallets)
  • overall = (0.75+0.5+0.4)/3 = 0.55 → MEDIUM (3)

Suggested fix direction

Smallest fix: in update_sync_state, apply the same monotone gate the persistence layer uses —

if result.last_known_recent_block > 0 {
    self.last_known_recent_block = result.last_known_recent_block;
}

(Height/timestamp stay verbatim — those do advance on empty deltas.) Alternatively, change the rs-sdk contract so AddressSyncResult echoes the input boundary when no new entry supersedes it — but that widens the change into every sync_address_balances consumer; the provider-side gate is self-contained. Keeping a stale boundary is safe by design: the next pass's RangeAfter proof detects a compacted-away boundary and runs the compacted phase.

Cross-references

Note on the sh_012 half of the original finding

The e2e also observed ShieldedInsufficientBalance available=0 (local balance map not refreshed on empty delta). On the current base the incremental-only branch seeds result.found from current_balances() and the full-scan branch rebuilds it from the tree, so that half appears addressed by the #4004/#4005 rework; only the boundary-watermark regression above was still reproducible from source. If sh_012 still reds on a live run after this fix, it should be re-triaged separately.

🤖 Co-authored by Claudius the Magnificent AI Agent

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions