Skip to content

fix(platform-wallet): snapshot identity cross-check + typed persister-load errors (#3980 follow-ups)#3984

Merged
lklimek merged 6 commits into
feat/platform-wallet-rehydrationfrom
fix/3980-review-followups
Jul 2, 2026
Merged

fix(platform-wallet): snapshot identity cross-check + typed persister-load errors (#3980 follow-ups)#3984
lklimek merged 6 commits into
feat/platform-wallet-rehydrationfrom
fix/3980-review-followups

Conversation

@Claudius-Maginificent

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

Follow-ups to #3980's review threads, stacked on its head branch (fable/3692-review-fixes):

  • A persisted snapshot row whose ManagedWalletInfo disagreed with the row's account_manifest was only checked for wallet_id/network, and a wrong-row snapshot was reported as DecodeError — conflating "wrong data" with "unreadable bytes".
  • load_from_persistor flattened persister failures into WalletCreation(String), discarding is_transient() / PersistenceErrorKind, so callers could not distinguish retryable from permanent load failures.

What was done?

  • Snapshot/manifest cross-check: new helper snapshot_accounts_match_manifest (manager/load.rs) compares the snapshot's account set against the row's account_manifest; mismatches skip the row via the existing skip machinery. The comparison covers only the account families both enumerations can carry — the manifest (Wallet::all_accounts) is ECDSA-only and includes PlatformPayment; the snapshot (ManagedWalletInfo::all_managed_accounts) is the mirror image (BLS/EdDSA provider keys, no PlatformPayment) — so legitimate snapshots are never rejected.
  • New CorruptKind::SnapshotIdentityMismatch variant (with Display), used for wallet_id/network and account-set mismatches; FFI wire code LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH = 103 mapped in rs-platform-wallet-ffi and in Swift reasonDescription (numeric case 103 + comment citing the wire-contract source).
  • Typed persister-load error: PlatformWalletError::PersisterLoad(#[from] PersistenceError); load_from_persistor now propagates the backend error, preserving is_transient() / PersistenceErrorKind.

How Has This Been Tested?

  • rehydration_load integration suite extended 7 → 11 tests: rt_snapshot_account_set_mismatch_is_skipped, rt_snapshot_mismatch_skip_coexists_with_healthy_load, rt_persister_load_transient_error_is_typed_and_retryable, rt_persister_load_permanent_error_is_typed_and_not_retryable; existing rt_snapshot_wallet_id_mismatch_is_skipped asserts the new variant.
  • Full suites for platform-wallet, platform-wallet-ffi, platform-wallet-storage: 688 passed, 0 failed. cargo fmt and clippy -D warnings clean.
  • Swift change is mechanical (no Swift toolchain on the build host); independent QA review passed with 0 MEDIUM+ findings.

Breaking Changes

None. CorruptKind is #[non_exhaustive]; the new variant and wire code 103 are additive.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Attribution

🤖 Co-authored by Claudius the Magnificent AI Agent

QuantumExplorer and others added 5 commits July 2, 2026 18:12
…s rehydration

Review follow-ups for the watch-only rehydration PR:

- Carry the FFI persister's fully-restored ManagedWalletInfo across
  load() as ClientWalletStartState::core_wallet_info (still keyless:
  no Wallet, no seed). The manager consumes the snapshot directly, so
  per-account UTXO/tx-record attribution, exact pool contents
  (derived-but-unused deep addresses stay in the SPV watch set), and
  per-index used flags survive a restart verbatim - and the second
  eager gap-window derivation on the launch path disappears. The
  core_state/used_core_addresses projection remains as the fallback
  for persisters that cannot reconstruct a snapshot (SQLite until
  #3968).
- Run the rehydration mark<->refill loop to a fixpoint so a previously
  used address in the wedge zone (past the discovery horizon, within
  gap-refill reach) is re-marked used instead of coming back
  pool-visible as fresh (address-reuse leak; regression test added).
- Hoist the load_from_persistor idempotency check ahead of per-wallet
  reconstruction so repeat loads skip the rebuild instead of
  discarding it at the WalletExists arm; validate a carried snapshot's
  wallet_id/network against its row and skip mismatches as corrupt.
- Consume LoadOutcomeFFI in Swift loadFromPersistor: skipped wallets
  are logged, surfaced via lastLoadSkippedWallets, excluded from the
  get_wallet loop (no more spurious NotFound in lastError), and the
  outcome is freed via platform_wallet_load_outcome_free.
- Drop RehydrateRowError (variant-for-variant mirror of CorruptKind);
  build_watch_only_wallet returns CorruptKind directly.
- Reuse vec_to_ptr for the FFI skipped array.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the agreed model: platform-wallet is the single writer of
wallet-state tables and the authority for state transitions; the
persisted store is the durable history and the client's read model
(display never blocks on load/unlock); the store schema is a versioned
public contract; load() consumes the store verbatim (no re-derivation);
persist errors are hard errors because local-only state (manifest,
used-flags, metadata) is unrecoverable by rescan; load is seedless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…entityMismatch

A carried ManagedWalletInfo snapshot is now cross-checked against its
persisted row on two axes instead of one:

- wallet_id/network mismatch previously reused CorruptKind::DecodeError,
  conflating an unreadable row with a well-formed but wrong-row snapshot.
  It now maps to a dedicated CorruptKind::SnapshotIdentityMismatch.
- the snapshot's account set is validated against the row's account
  manifest (the oracle that builds the watch-only wallet). A snapshot
  describing a different topology is skipped rather than consumed. The
  comparison ignores the account families the two enumerations
  structurally differ on (BLS/EdDSA provider keys vs PlatformPayment) so
  a legitimate snapshot is never rejected.

Both surface through the existing per-row skip machinery (no panic, batch
continues). The new variant gets FFI wire code 103
(LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH); Swift reasonDescription
maps 103 and cites the wire-contract source, as the constants are not
exposed as named symbols in the generated C header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…hy load

Adds a combined-batch case where a SnapshotIdentityMismatch skip and a
successful snapshot load occur in the same load_from_persistor pass:
the healthy row loads, the mismatched row is skipped and its handler
fires exactly once, and the batch returns Ok. Mirrors the existing
corrupt-manifest + healthy-load combined test for the snapshot path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
load_from_persistor wrapped every persister load failure as
WalletCreation(String), flattening the backend's typed error and losing
its retry classification. It now propagates a dedicated PersisterLoad
variant carrying the PersistenceError verbatim, so callers keep
is_transient() / PersistenceErrorKind and can distinguish a transient
backend hiccup (retry) from a permanent failure (do not retry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 54595ad7-9876-405d-90be-0e1184143aca

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/3980-review-followups

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.

Base automatically changed from fable/3692-review-fixes to feat/platform-wallet-rehydration July 2, 2026 16:02
@lklimek lklimek marked this pull request as ready for review July 2, 2026 16:03
@thepastaclaw

thepastaclaw commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 40806d6)

…ation)

PR #3980 was squash-merged into feat/platform-wallet-rehydration; the
squash is byte-identical tree-wide to this branch's fork point, so the
only net delta preserved here is the 3 WS-A follow-up commits.

Conflicts (all in files WS-A intentionally supersedes) resolved in favour
of the WS-A changes:
- manager/load.rs: SnapshotIdentityMismatch + account-set cross-check
  over the original DecodeError-only snapshot check.
- tests/rehydration_load.rs: new-variant assertions + the added
  account-mismatch, combined-batch, and typed-persister-load tests.
- swift PlatformWalletManager.swift: reasonCode 103 mapping + wire-contract
  citation.

No semantic overlap: the squash added nothing this branch lacked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
@lklimek lklimek merged commit 5460c0e into feat/platform-wallet-rehydration Jul 2, 2026
3 checks passed
@lklimek lklimek deleted the fix/3980-review-followups branch July 2, 2026 16:19

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR #3984 correctly implements the two stated scope items: a snapshot-vs-manifest cross-check surfaced as CorruptKind::SnapshotIdentityMismatch with wire code 103 propagated end-to-end, and typed PersistenceError preservation through load_from_persistor via #[from]. Verified against 161d8b8: the identity check at load.rs:172-183, the filtered comparison at load.rs:338-366, and the new integration tests all check out; FFI ownership and Swift defer-based free are correct. Four in-scope observations (all suggestion/nitpick) around defense-in-depth of the account-set comparison, diagnostic detail lost by collapsing three failure modes into one dataless variant, LoadOutcome::loaded conflating idempotent-skip with fresh-load, and Swift-side hardcoded wire codes.

Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5[high] (general, failed), opus (security-auditor), claude-sonnet-5 (security-auditor), gpt-5.5[high] (security-auditor, failed), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5[high] (rust-quality, failed), opus (ffi-engineer), claude-sonnet-5 (ffi-engineer), gpt-5.5[high] (ffi-engineer, failed); verifier: opus; specialists: security-auditor, rust-quality, ffi-engineer

🟡 2 suggestion(s) | 💬 2 nitpick(s)

Findings not posted inline (2)

These findings could not be anchored to the current diff, but they are still part of this review.

  • [NITPICK] packages/rs-platform-wallet/src/manager/load.rs:124-136: Idempotent-skip path is indistinguishable from fresh-load in LoadOutcome::loaded — The idempotency fast-path (lines 130-135) pushes expected_wallet_id onto outcome.loaded when a wallet is already registered, identical to the fresh-reconstruction push at end of loop. LoadOutcome::loaded's doc comment only says "Wallets fully reconstructed and registered, in load order" — i...
  • [NITPICK] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:341-356: Swift reasonDescription hardcodes numeric wire codes with no compile-time tie-back to RustreasonDescription matches integer literals 100/101/102/103/199/200 against Rust-side LOAD_SKIP_REASON_* pub const u32 values that never surface as named symbols in the generated C header. The Rust FFI has a unit-test pinning the numeric contract on its side, but nothing prevents...
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/manager/load.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/load.rs:338-366: Snapshot/manifest cross-check has defense-in-depth gaps in filtered families and empty-set case
  `snapshot_accounts_match_manifest` compares only the set of `AccountType` variants after filtering out `ProviderOperatorKeys`, `ProviderPlatformKeys`, and `PlatformPayment { .. }` (line 345-352). The filter is justified — those are the known enumeration asymmetries between `Wallet::all_accounts` and `ManagedWalletInfo::all_managed_accounts` — but three residual gaps remain in the very scenario this function was added to defend against (a wrong-row / tampered snapshot):

  1. **xpubs are not compared.** `info.wallet_id` is a persisted field, not a re-derivation. A row with matching `wallet_id`/`network`/account-type set but attacker-controlled per-account xpubs will pass, and `wallet_info = *info` then consumes those pools into the SPV watch set while `build_watch_only_wallet` uses the manifest's xpubs — divergent detection/attribution surfaces without any signing-side failure.
  2. **Asymmetric filtering hides side-specific extras.** `PlatformPayment` is filtered on the manifest side (where it can appear) and `ProviderOperatorKeys`/`ProviderPlatformKeys` on the snapshot side (where they can appear). A snapshot with an *extra* `ProviderOperatorKeys` the manifest wouldn't attest to, or a manifest with an *extra* `PlatformPayment` the snapshot wouldn't attest to, is invisible because each is filtered on exactly the side that would have caught it.
  3. **Vacuous empty-set match.** If a row's manifest reduces to only filtered families (e.g. a single `PlatformPayment` entry — `build_watch_only_wallet` only requires non-empty) and the snapshot's comparable set is also empty, `∅ == ∅` returns `true` and any wrong-row snapshot with `wallet_id`/`network` set to match sails through. Unlikely on real wallets (which always carry a Standard account), but silently defeats the guard when it occurs.

  All three sit behind the documented trust boundary (the persister is trusted as-is, tracked follow-up for cryptographic manifest binding to `wallet_id`), so none is a new hole opened by this PR — but each undermines the specific narrowing this PR claims to add. Consider requiring at least one comparable account on both sides, comparing per-account xpubs (or a hash) for the shared families, and rejecting snapshot-only families that aren't in the manifest.

In `packages/rs-platform-wallet/src/manager/load_outcome.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/load_outcome.rs:44-66: SnapshotIdentityMismatch collapses three distinct failure modes into one undifferentiated variant
  The new `SnapshotIdentityMismatch` variant is a dataless enum tag, and `load.rs:172-175` sets it uniformly for all three branches of the `||` guard: `wallet_id` mismatch, `network` mismatch, or account-set divergence. Before this PR, a `wallet_id`/`network` mismatch produced a formatted `DecodeError` string that named exactly which field diverged and with what values, readable directly from the `on_wallet_skipped_on_load` log line. Now the FFI log (`manager.rs:429-432`, unchanged) records only `wallet_id` + the static string "snapshot does not match its persisted row". An operator triaging a skipped row from logs can no longer tell whether wallet_id, network, or the account set tripped the guard without reproducing locally. The variant-vs-string trade-off is otherwise fine (variants are match-able and stable) — worth carrying at least a small discriminant enum (`WalletId`/`Network`/`AccountSet`) so the check that failed remains observable without leaking snapshot bytes.

Comment on lines +338 to +366
fn snapshot_accounts_match_manifest(
info: &ManagedWalletInfo,
manifest: &[crate::changeset::AccountRegistrationEntry],
) -> bool {
use key_wallet::account::AccountType;
use std::collections::BTreeSet;

fn comparable(t: &AccountType) -> bool {
!matches!(
t,
AccountType::ProviderOperatorKeys
| AccountType::ProviderPlatformKeys
| AccountType::PlatformPayment { .. }
)
}

let manifest_types: BTreeSet<AccountType> = manifest
.iter()
.map(|e| e.account_type)
.filter(comparable)
.collect();
let snapshot_types: BTreeSet<AccountType> = info
.all_managed_accounts()
.iter()
.map(|a| a.managed_account_type().to_account_type())
.filter(comparable)
.collect();
manifest_types == snapshot_types
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Snapshot/manifest cross-check has defense-in-depth gaps in filtered families and empty-set case

snapshot_accounts_match_manifest compares only the set of AccountType variants after filtering out ProviderOperatorKeys, ProviderPlatformKeys, and PlatformPayment { .. } (line 345-352). The filter is justified — those are the known enumeration asymmetries between Wallet::all_accounts and ManagedWalletInfo::all_managed_accounts — but three residual gaps remain in the very scenario this function was added to defend against (a wrong-row / tampered snapshot):

  1. xpubs are not compared. info.wallet_id is a persisted field, not a re-derivation. A row with matching wallet_id/network/account-type set but attacker-controlled per-account xpubs will pass, and wallet_info = *info then consumes those pools into the SPV watch set while build_watch_only_wallet uses the manifest's xpubs — divergent detection/attribution surfaces without any signing-side failure.
  2. Asymmetric filtering hides side-specific extras. PlatformPayment is filtered on the manifest side (where it can appear) and ProviderOperatorKeys/ProviderPlatformKeys on the snapshot side (where they can appear). A snapshot with an extra ProviderOperatorKeys the manifest wouldn't attest to, or a manifest with an extra PlatformPayment the snapshot wouldn't attest to, is invisible because each is filtered on exactly the side that would have caught it.
  3. Vacuous empty-set match. If a row's manifest reduces to only filtered families (e.g. a single PlatformPayment entry — build_watch_only_wallet only requires non-empty) and the snapshot's comparable set is also empty, ∅ == ∅ returns true and any wrong-row snapshot with wallet_id/network set to match sails through. Unlikely on real wallets (which always carry a Standard account), but silently defeats the guard when it occurs.

All three sit behind the documented trust boundary (the persister is trusted as-is, tracked follow-up for cryptographic manifest binding to wallet_id), so none is a new hole opened by this PR — but each undermines the specific narrowing this PR claims to add. Consider requiring at least one comparable account on both sides, comparing per-account xpubs (or a hash) for the shared families, and rejecting snapshot-only families that aren't in the manifest.

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still present at 40806d6.

Comment on lines 44 to +66
@@ -53,6 +61,9 @@ impl std::fmt::Display for CorruptKind {
match self {
Self::MissingManifest => f.write_str("missing account manifest"),
Self::MalformedXpub => f.write_str("malformed account xpub"),
Self::SnapshotIdentityMismatch => {
f.write_str("snapshot does not match its persisted row")
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: SnapshotIdentityMismatch collapses three distinct failure modes into one undifferentiated variant

The new SnapshotIdentityMismatch variant is a dataless enum tag, and load.rs:172-175 sets it uniformly for all three branches of the || guard: wallet_id mismatch, network mismatch, or account-set divergence. Before this PR, a wallet_id/network mismatch produced a formatted DecodeError string that named exactly which field diverged and with what values, readable directly from the on_wallet_skipped_on_load log line. Now the FFI log (manager.rs:429-432, unchanged) records only wallet_id + the static string "snapshot does not match its persisted row". An operator triaging a skipped row from logs can no longer tell whether wallet_id, network, or the account set tripped the guard without reproducing locally. The variant-vs-string trade-off is otherwise fine (variants are match-able and stable) — worth carrying at least a small discriminant enum (WalletId/Network/AccountSet) so the check that failed remains observable without leaking snapshot bytes.

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still present at 40806d6.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The latest delta 161d8b8..40806d6 is empty (retarget/merge onto the squashed #3980 base with an identical tree), so no new code was introduced in this pass. All four prior findings were re-verified directly against HEAD and are STILL VALID at unchanged line ranges: two suggestion-level concerns (defense-in-depth gaps in snapshot_accounts_match_manifest; loss of diagnostic granularity in the dataless SnapshotIdentityMismatch variant) and two nitpicks (idempotent-skip conflation in LoadOutcome::loaded; Swift-side hardcoded FFI wire codes). No blocking issues.

Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5[high] (general, failed), opus (security-auditor), claude-sonnet-5 (security-auditor), gpt-5.5[high] (security-auditor, failed), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5[high] (rust-quality, failed), opus (ffi-engineer), claude-sonnet-5 (ffi-engineer), gpt-5.5[high] (ffi-engineer, failed); verifier: opus; specialists: security-auditor, rust-quality, ffi-engineer

🟡 2 suggestion(s) | 💬 2 nitpick(s)

Findings not posted inline (2)

These findings could not be anchored to the current diff, but they are still part of this review.

  • [NITPICK] packages/rs-platform-wallet/src/manager/load.rs:124-136: Idempotent-skip path is indistinguishable from fresh-load in LoadOutcome::loaded — Carried forward from the prior review (161d8b8) — verified byte-identical at HEAD. The idempotency fast-path (lines 130-135) pushes expected_wallet_id onto outcome.loaded when a wallet is already registered, identical in shape to the fresh-reconstruction push at end-of-loop (line 302) and th...
  • [NITPICK] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:344-354: Swift reasonDescription hardcodes numeric FFI wire codes with no compile-time tie-back to Rust — Carried forward from the prior review (161d8b8) — verified byte-identical at HEAD. reasonDescription matches integer literals 100/101/102/103/199/200 against Rust-side LOAD_SKIP_REASON_* pub const u32 values (rs-platform-wallet-ffi/src/manager.rs) that never surface as named...
Carried-forward findings already raised (1)

These findings were not re-posted as new inline comments because an existing review thread already covers them.

  • [SUGGESTION] (deduped existing open thread) packages/rs-platform-wallet/src/manager/load.rs:338-366: Snapshot/manifest cross-check has defense-in-depth gaps against a wrong-row snapshot — Carried forward from the prior review (161d8b8) — verified byte-identical at HEAD 40806d6. snapshot_accounts_match_manifest compares only the set of AccountType variants after filtering ProviderOperatorKeys, ProviderPlatformKeys, and PlatformPayment { .. } (lines 345-352). The filter...
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/manager/load_outcome.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/load_outcome.rs:44-66: SnapshotIdentityMismatch collapses three failure modes and loses diagnostic granularity
  Carried forward from the prior review (161d8b87) — verified byte-identical at HEAD. `CorruptKind::SnapshotIdentityMismatch` is a dataless variant (line 52) and `load.rs:172-183` sets it uniformly across all three branches of the `||` guard: `wallet_id` mismatch, `network` mismatch, and account-set divergence. Before this PR, a `wallet_id`/`network` mismatch produced a formatted `DecodeError` string naming exactly which field diverged and its two values. Now the `Display` impl (lines 64-66) always renders the identical static string "snapshot does not match its persisted row", and the FFI log (`rs-platform-wallet-ffi/src/manager.rs`) records only `wallet_id` + this static string. An operator triaging a skipped row from logs alone can no longer tell whether wallet_id, network, or the account set tripped the guard without reproducing locally.

  The enum-over-string design is otherwise the right call (match-able and does not leak snapshot bytes) — carrying a small discriminant sub-enum (`WalletId`/`Network`/`AccountSet`) as data on the variant would preserve which check failed without regressing on the secrecy goal.

In `packages/rs-platform-wallet/src/manager/load.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/load.rs:338-366: Snapshot/manifest cross-check has defense-in-depth gaps against a wrong-row snapshot
  Carried forward from the prior review (161d8b87) — verified byte-identical at HEAD 40806d64. `snapshot_accounts_match_manifest` compares only the set of `AccountType` variants after filtering `ProviderOperatorKeys`, `ProviderPlatformKeys`, and `PlatformPayment { .. }` (lines 345-352). The filter is correct — those are the known enumeration asymmetries between `Wallet::all_accounts` and `ManagedWalletInfo::all_managed_accounts` — but three residual gaps remain in the exact scenario this function was added to defend against:

  1. **xpubs are not compared.** `info.wallet_id` is a persisted field, not a re-derivation. A row with matching `wallet_id`/`network`/account-type set but attacker- or corruption-supplied per-account xpubs still passes. `wallet_info = *info` (line 166) then consumes those pools verbatim into the SPV watch set while `build_watch_only_wallet` uses the manifest's xpubs — divergent detection/attribution surfaces without any signing-side failure.
  2. **Asymmetric filtering hides side-specific extras.** `PlatformPayment` is filtered on the manifest side (where it legitimately appears) and `ProviderOperatorKeys`/`ProviderPlatformKeys` on the snapshot side (where they legitimately appear). A snapshot with an *extra* `ProviderOperatorKeys`/`ProviderPlatformKeys` the manifest doesn't attest to, or a manifest with an extra `PlatformPayment` the snapshot doesn't attest to, is invisible because each is filtered on exactly the side that would have caught it.
  3. **Vacuous empty-set match.** If a row's manifest reduces to only filtered families (e.g. a single `PlatformPayment` entry — `build_watch_only_wallet` only requires non-empty) and the snapshot's comparable set is also empty, `∅ == ∅` returns `true` and any wrong-row snapshot with matching `wallet_id`/`network` sails through.

  All three sit behind the documented trust boundary (`# Trust boundary` docs, tracked follow-up for cryptographic manifest binding to `wallet_id`), so none is a new hole this PR opens — but each undermines the specific narrowing this PR claims to add. The lowest-cost hardening is guarding the empty-set case (`return false` when both comparable sets are empty); comparing a hash of per-account xpubs for the shared families and rejecting snapshot-only families would close the other two.

Comment on lines 44 to +66
@@ -53,6 +61,9 @@ impl std::fmt::Display for CorruptKind {
match self {
Self::MissingManifest => f.write_str("missing account manifest"),
Self::MalformedXpub => f.write_str("malformed account xpub"),
Self::SnapshotIdentityMismatch => {
f.write_str("snapshot does not match its persisted row")
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: SnapshotIdentityMismatch collapses three failure modes and loses diagnostic granularity

Carried forward from the prior review (161d8b8) — verified byte-identical at HEAD. CorruptKind::SnapshotIdentityMismatch is a dataless variant (line 52) and load.rs:172-183 sets it uniformly across all three branches of the || guard: wallet_id mismatch, network mismatch, and account-set divergence. Before this PR, a wallet_id/network mismatch produced a formatted DecodeError string naming exactly which field diverged and its two values. Now the Display impl (lines 64-66) always renders the identical static string "snapshot does not match its persisted row", and the FFI log (rs-platform-wallet-ffi/src/manager.rs) records only wallet_id + this static string. An operator triaging a skipped row from logs alone can no longer tell whether wallet_id, network, or the account set tripped the guard without reproducing locally.

The enum-over-string design is otherwise the right call (match-able and does not leak snapshot bytes) — carrying a small discriminant sub-enum (WalletId/Network/AccountSet) as data on the variant would preserve which check failed without regressing on the secrecy goal.

source: ['claude']

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.

4 participants