Skip to content

fix(platform-wallet): stop the startup sequence reporting integrity it did not establish - #4426

Open
bfoss765 wants to merge 2 commits into
v4.2-devfrom
fix/wallet-startup-integrity
Open

fix(platform-wallet): stop the startup sequence reporting integrity it did not establish#4426
bfoss765 wants to merge 2 commits into
v4.2-devfrom
fix/wallet-startup-integrity

Conversation

@bfoss765

Copy link
Copy Markdown
Collaborator

Three defects on the wallet bring-up path, all of which end the same way: start_wallet_subsystems returns a status that promises a contact's DIP-15 addresses exist before Core SPV starts, when they do not. An address the wallet is not watching when the compact-filter scan passes its funding height produces no transaction at all — so each of these is a silent data gap, not a cosmetic mislabel.

Two come from the automated review of #4359 (findings F1 and F2); the third is the follow-through on a deferral #4368 took deliberately.

1. A contact pass that reached nobody was recorded as a completed sync (F1)

sync_contact_requests is log-and-continue per identity — right for the recurring sweep, and it collapsed two opposite endings into one return value. "Platform answered, and there is nothing new" and "Platform answered nobody" both arrived as Ok(vec![]). With DAPI unreachable every identity's fetch hit the continue, the sweep returned an empty success, startup.rs called record_sync_ran, and status() reported Ready.

Ready is precisely the claim that a contact pass completed, so this fed the persistence-corruption class the audit was tracking: SPV starts against an address set that is silently short.

Fix. The pass now reports what it reached rather than only what it found:

  • sync_contact_requests_reporting returns a ContactSyncReport carrying identities_attempted, the identities nothing was ingested for, and the identities whose sent side alone failed.
  • sync_contact_requests keeps its shape and raises ContactSyncUnreachable when there were identities to read and not one was read.
  • Startup records the sync only on a complete pass. A degraded one leaves dashpay_sync_ran = false, so status() stays PartialAccountsPending.

Partial passes keep sensible semantics: what was fetched is real and stays persisted, and the failures retry themselves — a failed fetch leaves that direction's high-water cursor unadvanced, so the next sweep re-requests exactly the range it missed. A partial pass is still not complete, because the identities it missed have contact requests nobody looked at and account builds nobody enqueued.

The recurring sweep at dashpay_sync.rs already logs-and-continues on an Err from this call, so the new error is a strict improvement there too: a total outage used to be recorded as a successful sweep.

2. A partial identity scan was never retried once any identity was on file (F2 = #4365)

ScanTally::is_trustworthy is identities_seen > 0 || failed_probes == 0, so a scan that saw index 0 and got no answer at index 1 returns Ok — correctly, since discarding what it found would be worse. But start_wallet_subsystems skipped discovery whenever any identity was on file, and nothing recorded that the scan had been partial. There was no next scan. The second identity, and every contact hanging off it, stayed invisible for the life of the installation.

Fix. A scan now publishes a verdict — complete, or the specific indices it could not answer — and the shortcut consults it. Two things follow:

  • Within the launch: a partial scan is retried immediately, with the scan key already resolved and inside the budget the caller granted. This is where most of the value lands, and it works on every host today.
  • Across launches: the verdict rides PlatformWalletChangeSet::identity_scan_state and restores through IdentityManagerStartState::scan_states, so a host that persists it re-opens the question on the next launch.

The budget-expiry path gets the same treatment, since a scan dropped mid-await never reaches its own bookkeeping — without it that path reproduces the bug in its own right, by consulting local state, finding the sighting persisted before cancellation, and recording a warm launch.

Absence of a verdict deliberately reads as "unknown", not "incomplete". Treating unknown as incomplete would make every launch on a non-adopting host pay for a full gap-limit scan plus a Keychain round trip before every Core SPV start — the cost the shortcut exists to avoid, and which review specifically asked to remove.

Refs #4365 rather than closing it: no persister vtable carries the field yet, so cross-launch retention still needs the host slot. See Residual limitations.

3. The seed-binding gate existed only in the Swift wrapper (#4368 follow-through)

Everything the drain does with key material is unauthenticated, and register_contact_account keys its existence check on (index, us, them)not on the xpub. A provider resolving the wrong seed therefore writes contact receiving addresses once, and every later correct-seed pass no-ops forever. The corruption is permanent and its only symptom is payments that never arrive.

iOS enforces this in PlatformWalletManagerStartup.swift before it calls across. #4368 named the exposure and deferred it:

a future JNI client inherits it… the stronger home

Fix. The shared sequence verifies the binding itself, via the existing PlatformWallet::verify_seed_binds, immediately before the signer-present drain. Three properties worth calling out:

  • Cost is proportional to risk. The check runs only when drainable_contact_crypto_count() > 0. With nothing queued the drain would derive nothing, so there is no wrong-seed write to prevent — and a warm launch still resolves no key material at all.
  • Fails closed on every error, not only on a mismatch. A provider that cannot answer has not been shown to own the wallet. Skipping costs nothing unrecoverable: the queue is untouched, so the next signer-present drain completes exactly the work this one declined to guess at.
  • Reported, not raised. New WalletStartupStatus::SeedBindingUnverified (FFI discriminant 5, Swift seedBindingUnverified), plus seed_binding_unverified on the outcome. Core sync must start regardless.

The Swift check stays. The two are not redundant: Swift throws and refuses the call outright, which is the right behaviour on a host that can, while the Rust one fails closed and reports — it has to let Core SPV start.

Also fixed

A latent misreport the rescan path exposed. DiscoveryFailed and PartialNoIdentity both claim the identity question is still open. That used to be structurally guaranteed — discovery ran only when nothing was on file, and every branch that found something returned early — but a rescan forced by an incomplete prior scan reaches those branches with an identity already recorded. Both are now gated on identity_id.is_none(), so a failed rescan no longer hides a sync and drain that both ran.

Tests

20 new tests; cargo test -p platform-wallet --features shielded goes 837 → 857, 0 failures. Clippy clean.

Three drive the real start_wallet_subsystems over a mock SDK rather than restating the tally rules:

Test Proves
a_wrong_seed_provider_never_reaches_the_drain status is SeedBindingUnverified, no contact account registered, queue intact for the next drain
the_owning_seed_passes_the_gate_and_the_drain_runs the gate is not simply refusing everything — the op drains and the account appears
an_empty_queue_skips_the_gate_entirely with nothing queued, a provider that would fail is never consulted
a_contact_pass_that_reached_nobody_is_not_a_completed_sync F1 end to end — the mock's failing fetches are the DAPI-unreachable shape; asserts the report, the new error, unadvanced cursors (the retry guarantee), and dashpay_sync_ran == false

Plus unit coverage for the rules themselves: ContactSyncReport across clean-empty / no-identities / partial / sent-side-only / total; ScanTally::verdict for the exact #4365 shape (found at 0, failed at 1 → trustworthy and incomplete); and the scan-verdict round trip, including that unknown ≠ incomplete and that a clean rescan clears an earlier partial verdict.

Residual limitations

  • fix(platform-wallet): a partial identity scan is never retried once any identity is on file #4365 is not closed across launches. The verdict has a changeset slot and a start-state field, but no persister vtable carries it yet, so on current hosts it is process-lifetime only — the same documented caveat pending_contact_crypto_added already carries. Within a process it redirects a second bring-up, and the in-launch retry closes the window whenever Platform recovers inside the budget. What remains open is a probe that fails for the entire budget and is never revisited after a restart. Adopting it needs an FFI vtable slot plus SwiftData/Room columns; the SQLite reference persister cannot demonstrate the round trip either, since its load() still does not rehydrate ClientStartState::wallets (WALLET_RESTORE is not attested).
  • The Rust seed check is not marker-cached. Swift's is. On iOS this adds one derivation per launch that has queued contact-crypto work — negligible against a drain that resolves the mnemonic per entry, and zero on a warm launch. Threading the marker through the FFI would remove it.
  • A rescan re-probes from index 0, matching the manual "Find identities" path. Only reached when a verdict says the previous scan was partial.
  • SeedBindingUnverified is a new enum variant in Rust, the FFI (discriminant 5) and Swift. Additive and appended, but a host matching exhaustively on the status will need the arm.

🤖 Generated with Claude Code

…t did not establish

Three defects on the bring-up path introduced or left open by #4359, all of
which end the same way: `start_wallet_subsystems` returns a status that
promises a contact's DIP-15 addresses exist before Core SPV starts, when they
do not. An address the wallet is not watching when the compact-filter scan
passes its funding height produces no transaction at all, so each of these is
a silent data gap rather than a cosmetic mislabel.

1. A contact pass that reached nobody was recorded as a completed sync.
   `sync_contact_requests` is log-and-continue per identity — correct for the
   recurring sweep, and it collapsed "Platform answered, nothing new" into the
   same `Ok(vec![])` as "Platform answered nobody". With DAPI unreachable the
   sweep returned an empty success, startup called `record_sync_ran`, and
   `status()` reported `Ready`.

   The pass now reports what it reached. `sync_contact_requests_reporting`
   returns a `ContactSyncReport` carrying the per-identity failure set;
   `sync_contact_requests` keeps its shape and raises
   `ContactSyncUnreachable` when nothing at all was read. Startup records the
   sync only on a complete pass, so a degraded one stays
   `PartialAccountsPending`. Failures retry themselves: a failed fetch leaves
   that direction's high-water cursor unadvanced, so the next sweep
   re-requests exactly the range it missed.

2. A partial identity scan was never retried once any identity was on file.
   `ScanTally::is_trustworthy` is `identities_seen > 0 || failed_probes == 0`,
   so a scan that saw index 0 and got no answer at index 1 returns `Ok`; the
   warm-launch shortcut then skipped discovery on every later launch, and
   nothing recorded that the scan had been partial. The second identity, and
   all of its contacts, stayed invisible for the life of the installation.

   A scan now publishes a verdict — complete, or the indices it could not
   answer — which the shortcut consults. Two things follow: a partial scan is
   retried inside its own launch, with the scan key already resolved and
   within the budget the caller granted; and the verdict rides the changeset
   so a host that persists it re-opens the question on the next launch.
   Absence of a verdict deliberately reads as "unknown", not "incomplete", so
   hosts that have not adopted the field keep the shortcut instead of paying
   for a scan plus a Keychain round trip before every Core SPV start. The
   budget-expiry path records the same verdict, since a scan dropped mid-await
   never reaches its own bookkeeping.

   Refs #4365. Not closed: no persister vtable carries the field yet, so
   cross-launch retention still needs the host slot.

3. The seed-binding gate existed only in the Swift wrapper.
   Everything the drain does with key material is unauthenticated, and
   `register_contact_account` keys its existence check on `(index, us, them)`
   rather than on the xpub — so a provider resolving the wrong seed writes
   contact receiving addresses once, and every later correct-seed pass no-ops.
   The corruption is permanent and its only symptom is payments that never
   arrive. iOS gated this in Swift; a JNI binding added later would have
   inherited the ungated path.

   The shared sequence now verifies the binding itself, immediately before the
   drain and only when something is actually queued, so a warm launch with an
   empty queue still resolves no key material. It fails closed on any error —
   a provider that cannot answer has not been shown to own the wallet — and
   skipping costs nothing unrecoverable, because the queue is left intact for
   the next signer-present drain. Reported as the new
   `SeedBindingUnverified` status rather than raised, since Core sync must
   start regardless.

Also fixes a latent misreport the rescan path exposed: the discovery-failure
statuses claim the identity question is open, and a rescan can now reach them
with an identity already on file. They are gated on `identity_id.is_none()`.

20 new tests, including three that drive the real `start_wallet_subsystems`
over a mock SDK: a wrong-seed provider registers no contact account and leaves
the queue intact, the owning seed drains, and an empty queue never consults the
gate at all. The mock's failing fetches reproduce the DAPI-unreachable case for
(1) end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@bfoss765, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 12 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3d633e24-5efe-4f1b-9bb9-653073e813b5

📥 Commits

Reviewing files that changed from the base of the PR and between 0b5fc6f and c968b30.

📒 Files selected for processing (16)
  • packages/rs-platform-wallet-ffi/src/dashpay.rs
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet-ffi/src/wallet_startup.rs
  • packages/rs-platform-wallet/src/changeset/changeset.rs
  • packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs
  • packages/rs-platform-wallet/src/changeset/mod.rs
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/manager/startup.rs
  • packages/rs-platform-wallet/src/wallet/apply.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/manager/mod.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerStartup.swift

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added this to the v4.2.0 milestone Aug 19, 2026
@thepastaclaw

thepastaclaw commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

🔍 Review in progress — actively reviewing now (commit c968b30)
Stage: Codex precheck starting
ETA: complete ~00:08 UTC (median 13m across 30 recent reviews)
Running 4m · Last checked: 2026-08-20 00:00 UTC

…ardening

Follow-up to bad2093 on this branch. Each of these is the same failure the
original commit set out to fix, surviving on a path it did not cover.

1. The seed-binding gate still had an ungated entry point.
   The gate landed in `start_wallet_subsystems`, but
   `platform_wallet_drain_pending_contact_crypto` — the FFI the JNI binding
   calls — went straight to `drain_pending_contact_crypto` and
   `drain_auto_accepts` with no check at all. A JNI client draining with a
   wrong-seed resolver therefore wrote contact receiving accounts from the
   wrong seed, permanently: `register_contact_account` keys its existence
   check on `(index, us, them)` and not on the xpub, so no later correct-seed
   pass revisits them. That is the exact defect the commit message said was
   closed, still reachable from the entry point it named as the reason to
   close it.

   The gate moves into `PlatformWallet::drain_pending_contact_crypto_verified`
   — one primitive that verifies, then runs both drains — and the startup
   sequence and the FFI now both drain through it. Behaviour is unchanged on
   each: the check is still skipped when nothing is queued (a warm launch
   resolves no key material), still fails closed on every verification error
   and not only on a mismatch, and still leaves the queue intact. The FFI
   reports a refusal as `ErrorInvalidParameter` for `SeedMismatch` — the same
   code the standalone verify already returns, so a host recognises the
   wrong-seed condition identically however it arrives — and
   `ErrorWalletOperation` for a provider that simply could not answer.

2. A known-incomplete identity scan could still report `Ready`.
   `StartupTally` had no way to say "an identity is known and the set it
   belongs to is not". The discovery signals are gated on
   `identity_id.is_none()` (correctly — a rescan reaches them with an identity
   on file), so a launch whose rescan was forced by an incomplete verdict and
   then failed fell through every check to `Ready`: the status that promises
   the identity set is settled, on the one launch that knows it is not.

   `identity_scan_incomplete` is recorded from the verdict on record once
   discovery is done, and `status()` returns the new
   `IdentityScanIncomplete` rather than `Ready`. Reading the verdict rather
   than this call's discovery counters is what makes it correct on the
   launches that have no counters to read — a warm shortcut, or a rescan
   abandoned before it started — and it catches the same defect reached from
   the other side, a first scan that came back partial. The check is ranked
   last, so the only run whose status changes is the one that used to lie;
   every other run keeps a status clients already handle and reads the flag
   on the outcome. `discovery_worth_retrying` covers the new status: the
   unanswered indices are exactly what another scan could answer.

   The test that pinned this, `a_failed_rescan_does_not_reopen_a_settled_
   identity`, asserted `Ready` for precisely the incomplete-rescan case. Its
   real subject — a failed rescan must not re-open an identity already on
   file — is preserved and now sits alongside an assertion that the scan gap
   IS reported, under a name that says so.

3. A local fault mid-scan published no verdict at all.
   `publish_scan_verdict` has one call site, below four `?` early returns in
   `discover_inner` (breadcrumb derivation, the wallet-info lookup,
   `add_identity`, `add_keys`). A persistence write that failed, or a wallet
   that left the manager, therefore abandoned the walk part-way through the
   index space and recorded nothing — and "unknown" is what keeps the
   warm-launch shortcut armed. Worse, when a previous scan had recorded a
   COMPLETE verdict, that stale verdict survived the abandoned scan. #4365's
   shape, on the local-fault path.

   The scan body now runs in a block whose result is carried out, so no `?`
   inside it can skip the publish — including any added later. The index the
   walk died on is recorded as unanswered first, because a verdict built from
   the probe bookkeeping alone would see an empty failed-index list and
   publish an abandoned scan as complete, which is strictly worse than
   publishing nothing. The abort index is tracked apart from `failed_indices`
   so `failed_probes` and `IdentityDiscoveryIncomplete` keep meaning "probes
   Platform never answered"; the verdict merges the two, since to a later
   launch they are the same fact. Note the scan loop is re-indented by one
   level and not otherwise touched — `git diff -w` shows the real change.

The FFI outcome struct gains `identity_scan_incomplete` and the status enum
appends `IdentityScanIncomplete = 6`; the Swift mirror follows both, as the
`SeedBindingUnverified` append did.

11 new tests. The gate: a wrong-seed provider is refused with the typed error
and registers zero contact accounts while the queue survives, the owning seed
drains, an empty queue never consults the provider. The scan signal: both
directions at the tally level, plus the wire-up driven through the real
`start_wallet_subsystems`. The verdict: a real local fault injected mid-scan
(a seedless wallet against the resident-key derive) replaces a stale complete
verdict with an incomplete one, so the next launch re-scans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bfoss765

Copy link
Copy Markdown
Collaborator Author

Pushed c968b30 addressing the three findings. All are the same failure this PR set out to fix, surviving on a path the first pass didn't cover.

1 — the seed-binding gate had an ungated entry point. The gate landed in start_wallet_subsystems, but platform_wallet_drain_pending_contact_crypto — the FFI a JNI client binds to — still called drain_pending_contact_crypto / drain_auto_accepts directly with no check. That's the exact entry point my commit message cited as the reason for the gate, so a JNI client draining with a wrong-seed resolver would still have written permanently-wrong contact accounts.

Rather than add a second copy of the check, I moved it into PlatformWallet::drain_pending_contact_crypto_verified — verify, then run both drains — and routed both the startup sequence and the FFI through it. The inline gate in startup.rs is gone, so the two paths can't drift. Behaviour on the startup path is unchanged: skipped when nothing is queued, fail-closed on any verification error and not only on a mismatch, queue left intact. On the FFI a refusal is ErrorInvalidParameter for SeedMismatch — the same code platform_wallet_verify_seed_binds_to_wallet already returns, so a host recognises the wrong-seed condition identically however it arrives — and ErrorWalletOperation otherwise. No new result code; I checked the registry rather than allocating one.

2 — a known-incomplete scan could still report Ready. StartupTally had no way to say "an identity is known and the set it belongs to is not". The discovery signals are gated on identity_id.is_none() (correctly — a rescan reaches them with an identity on file), so a launch whose rescan was forced by an incomplete verdict and then failed fell through every check to Ready — the status that promises a settled identity set, on the one launch that knows it isn't.

identity_scan_incomplete is now recorded from the verdict on record once discovery is done, and status() returns a new IdentityScanIncomplete. Reading the verdict rather than this call's discovery counters is what makes it right on launches that have no counters to read. The check is ranked last, so the only run whose status changes is the one that used to lie.

I have to flag that my own test was pinning the defect. a_failed_rescan_does_not_reopen_a_settled_identity asserted Ready for precisely the incomplete-rescan case. Its real subject — a failed rescan must not re-open an identity already on file — was worth keeping, so I've kept both halves, added the assertion that the gap IS reported, and renamed it to a_failed_rescan_reports_the_scan_gap_without_reopening_the_identity so the name no longer describes only the half that was right.

3 — a local fault mid-scan published no verdict. publish_scan_verdict sits below four ? early returns, so a failed persistence write or a wallet that left the manager abandoned the walk and recorded nothing — and "unknown" keeps the warm shortcut armed. Worse: where a previous scan had recorded a complete verdict, that stale verdict survived the abandoned scan. #4365's shape on the local-fault path.

The scan body now runs in a block whose result is carried out, so no ? inside it — including any added later — can skip the publish. I record the index the walk died on as unanswered first, because a verdict built from the probe bookkeeping alone sees an empty failed-index list and would publish an abandoned scan as complete, which is strictly worse than publishing nothing. The abort index is tracked apart from failed_indices so failed_probes and IdentityDiscoveryIncomplete keep meaning "probes Platform never answered"; verdict() merges them, since to a later launch they're the same fact.

One review note on that file: the loop body is re-indented one level and not otherwise touched — git diff -w is 215/8 against the raw 323/116.

The FFI outcome struct gains identity_scan_incomplete and the status enum appends IdentityScanIncomplete = 6, with the Swift mirror following both — same shape as the SeedBindingUnverified append already in this PR.

11 new tests. cargo test -p platform-wallet --features shielded is green (867) and cargo check -p platform-wallet-ffi --features shielded --all-targets is clean; I also checked rs-unified-sdk-jni, since it's the client finding 1 is about.

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.74%. Comparing base (c99872b) to head (c968b30).
⚠️ Report is 2 commits behind head on v4.2-dev.

Additional details and impacted files
@@             Coverage Diff              @@
##           v4.2-dev    #4426      +/-   ##
============================================
- Coverage     87.74%   87.74%   -0.01%     
============================================
  Files          2681     2681              
  Lines        342632   342632              
============================================
- Hits         300658   300657       -1     
- Misses        41974    41975       +1     
Components Coverage Δ
dpp 88.96% <ø> (-0.01%) ⬇️
drive 86.27% <ø> (ø)
drive-abci 89.43% <ø> (ø)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.92% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 47.14% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants