Skip to content

feat(platform): shielded transaction history#3870

Merged
QuantumExplorer merged 16 commits into
v3.1-devfrom
claude/shielded-tx-history
Jun 12, 2026
Merged

feat(platform): shielded transaction history#3870
QuantumExplorer merged 16 commits into
v3.1-devfrom
claude/shielded-tx-history

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jun 12, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

The wallet stack persists shielded materials — own notes and OVK-recovered sends — but no user-facing history: nothing shows "what happened in this wallet" the way the core side's transaction list does. Privacy makes this nontrivial: the chain has no per-wallet transaction list to fetch (notes and nullifiers are anonymous, and the note→transition linkage subsystem was deliberately removed in #3823), so history must be derived.

Privacy decision (deliberate): classification is client-side only. No node/DAPI queries or note→transition indexes were added — a richer node-side index would institutionalize note↔transition correlation, and its query pattern (fetching transitions at exactly the wallet's heights) would fingerprint wallet activity to serving nodes. Every query this feature relies on is traffic the wallet already emits, so a rescanning wallet is indistinguishable from a syncing one.

What was done?

One activity log, two derivation paths, one shared id — sha256(sorted wallet-visible output cmxs) — so a live entry and a later rescan of the same bundle dedupe to a single row, and richer entries upgrade coarser ones in place.

rs-platform-wallet

  • ShieldedActivityEntry (kind / direction / amount / fee / counterparty / 36-byte memo / height / Pending-Confirmed-Failed status / cmx + nullifier linkage), store + file-store methods, ShieldedChangeSet.activity_entries riding the existing persister flush.
  • Live recorder: all six operation paths record a Pending entry at build time (exact type, amount, fee, recipient, memo, T20 identity id) and flip it Confirmed/Failed after broadcast. The entry's cmx set is recovered by running the wallet's own IVK-decrypt + OVK-recovery over the built bundle's actions — the same recovery the scan runs — so live id == scan id by construction. The skip-branch (no visible cmx) logs loudly instead of silently dropping history.
  • Scan deriver (the restore path): clusters persisted notes/outgoing notes by block height, then classifies by where value landed:
    • rho linkage: a change note's rho equals the nullifier of the action that consumed our note, so rho ∈ own nullifiers is zero-false-positive proof of an own spend — recovering the exact amount that left the pool, and the exact fee for transfers (spent − sent − change). Recall is probabilistic (~50% per 2-action bundle: Orchard shuffles spends and outputs independently before pairing — indexed_spends.shuffle / indexed_outputs.shuffle in orchard::builder), and a missed link degrades honestly, never wrongly.
    • external OVK note → Sent (recipient/value/memo); own notes with no OVK pairing → Received (a true third-party receive has exactly this shape); unlinked self-pay clusters → self-transfer ShieldedSpend (a shield-to-self and an unlucky-shuffle change are indistinguishable — labeling either Received would show a spend as money arriving).
    • Backfill runs once over already-persisted notes, so wallets upgrading with existing state get history immediately.

FFI / Swift: persist + load callbacks (on_persist_shielded_activity_fn + load/free pair) following the existing one-way changeset→SwiftData pattern; PersistentShieldedActivity model (upsert by entry id); "Shielded Activity" list + detail sheet in SwiftExampleApp (Pending section on top, kind icons, signed amounts, memo snippets; ShieldedSpend rows carry an explainer footnote).

How Has This Been Tested?

  • 27 new unit tests (classification table for both paths, rho-linked spend/fee recovery, self-pay vs third-party receive, id dedupe live-then-scan, changeset merge, pending→confirmed flip, zero-value filler exclusion, backfill, ordering, plus a real-proof recorder test pinning that a live entry builds from a real bundle). cargo test -p platform-wallet --features shielded: 272 passed. fmt/clippy/ffi checks clean; ./build_ios.sh --target sim --profile release green.
  • Live on devnet paloma (rc.2): backfill over a wallet holding 45 notes classified a week of real history with no false receives (the pre-fix deriver showed a 0.1 identity-create spend as "+0.398 received" — the rho/self-pay redesign exists because of that live finding); a fresh transfer recorded Sent with the exact pinned fee (162,851,200 credits) and a round-tripped UTF-8 memo, Pending→Confirmed; the scan-derived entry for a pre-feature transfer recovered recipient, amount, and memo via OVK; UI list and detail render as designed.

Breaking Changes

None. New changeset field defaults empty for old flows; FFI additions are additive; SwiftData model is new (pre-release: no migration story needed). No consensus, serialization, or protocol-version changes.

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

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Persistent shielded (Orchard) activity log: persisted/restored across sessions, paged queries, lookup by entry ID, and upsert behavior.
    • Live recording of shielded operations (pending → confirmed/failed) and automatic derivation of activity from scans with best-effort confirmations.
  • UI

    • Shielded Activity list, detail view, Wallet navigation link, Storage Explorer entries, and storage inspector views.
  • Tests

    • Added unit/integration tests for derivation, live recording, persistence, pagination, and display ordering.

The wallet persisted shielded materials (own notes, OVK-recovered
sends) but no user-facing history. This derives a per-operation
activity log — client-side only, by explicit privacy decision: no
node/DAPI queries or note→transition indexes were added, so a
rescanning wallet's traffic stays indistinguishable from a syncing one.

Two derivation paths share one id (sha256 of the bundle's
wallet-visible output cmxs), so a live entry and a later rescan of the
same bundle dedupe to one row, and a richer entry upgrades a coarser
one in place:

- Live recorder: each operation path (shield, fund-from-asset-lock,
  transfer, unshield, withdraw, identity-create) records a Pending
  entry at build time — exact type, amount, fee, recipient, memo,
  identity id — and flips it Confirmed/Failed after broadcast.
- Scan deriver (restore path): clusters persisted notes / outgoing
  notes by block height and classifies by where value landed.
  A change note's rho equals the nullifier of the action that consumed
  our note, so a rho∈own-nullifiers match is zero-false-positive proof
  of an own spend, recovering exact amounts (and exact fees for
  transfers). The link is probabilistic in recall (~50% per 2-action
  bundle — Orchard shuffles spends and outputs independently before
  pairing), so unlinked self-pay clusters surface as honest
  self-transfers rather than a spend masquerading as "Received";
  true third-party receives (no OVK pairing) stay Received.

Stack: ShieldedActivityEntry + deriver + recorder + store/changeset
plumbing in rs-platform-wallet; persist/load callbacks in
rs-platform-wallet-ffi; PersistentShieldedActivity SwiftData model +
persister wiring in swift-sdk; Shielded Activity list + detail view in
SwiftExampleApp.

Verified on devnet paloma: backfill over a wallet with 45 notes
classified a week of history with no false receives; a live transfer
recorded Sent with the exact pinned fee (162,851,200 credits) and a
round-tripped memo; the scan-derived entry for a pre-feature transfer
recovered recipient, amount, and memo via OVK.

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

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a derived shielded activity system: Rust domain types and derivation, live recorder integration into shielded operations, store persistence and startup restore, FFI marshalling for host persistence/load, SwiftData model and persistence handler, and Swift example UI to list and inspect activity.

Changes

Shielded Activity Persistence & Display System

Layer / File(s) Summary
Activity model and module surface
packages/rs-platform-wallet/src/wallet/shielded/activity.rs, packages/rs-platform-wallet/src/wallet/shielded/mod.rs
Adds ShieldedActivityEntry, enums (ShieldedDirection, ShieldedActivityStatus, ShieldedActivityKind), id computation, scan derivation (derive_activity_from_scan_data), sorting, tests, and public re-exports.
Changeset, startup state, and store support
packages/rs-platform-wallet/src/changeset/shielded_changeset.rs, packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs, packages/rs-platform-wallet/src/wallet/shielded/store.rs, packages/rs-platform-wallet/src/wallet/shielded/file_store.rs
Adds activity_entries to ShieldedChangeSet, activity to start-state, new ShieldedStore activity APIs, SubwalletState activity map, InMemory and FileBacked store plumbing, and unit tests for upsert/paging/lookup.
Live activity recorder
packages/rs-platform-wallet/src/wallet/shielded/activity_recorder.rs, packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs
Extracts visible output CMXs from actions, builds pending entries (build_pending_entry), helpers (with_status, non_zero_memo, changeset_for_entry), and tests including a real-bundle roundtrip.
Operation-driven recording & wiring
packages/rs-platform-wallet/src/wallet/shielded/operations.rs, packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs, packages/rs-platform-wallet/src/wallet/platform_wallet.rs, packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs
Introduces queue_shielded_activity, records pending activity for shield/unshield/transfer/withdraw/identity-create and asset-lock funding, flips statuses on outcomes, and wires coordinator/persister through platform wallet and FFI entrypoints.
Sync-time derivation & coordinator integration
packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs
Restores persisted activity on startup, derives activity each sync pass (derive_activity_into_changeset), persists new entries, and upgrades Pending entries to Confirmed when confirmations are observed.
FFI contracts
packages/rs-platform-wallet-ffi/src/shielded_persistence.rs, packages/rs-platform-wallet-ffi/src/persistence.rs
Adds ShieldedActivityFFI and ShieldedActivityRestoreFFI C ABIs and new PersistenceCallbacks fields (on_persist_shielded_activity_fn, on_load_shielded_activity_fn, on_load_shielded_activity_free_fn) under cfg(feature = "shielded").
Rust FFI persistence implementation
packages/rs-platform-wallet-ffi/src/persistence.rs
FFIPersister::store flattens activity_entries into ShieldedActivityFFI rows, builds owned buffers for pointer-backed fields, calls host persist callback; FFIPersister::load validates callback/free pairing, decodes host-provided ShieldedActivityRestoreFFI rows into ShieldedActivityEntry and appends to subwallet start-state. Includes decode helpers and enum tag mapping.
Swift persistence model & handler
packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift, packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift
Adds PersistentShieldedActivity SwiftData model, adds it to the model container, and implements handler-side persist, load, free, allocation tracking, and callback shim logic.
Swift example UI
packages/swift-sdk/SwiftExampleApp/.../ShieldedActivityView.swift, WalletDetailView.swift, WalletsContentView.swift, StorageExplorerView.swift, StorageModelListViews.swift, StorageRecordDetailViews.swift
Adds Shielded Activity list and detail views, navigation routes, storage list/detail views, and integrates navigation entries in wallet and storage UIs.
Tests
packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs, shielded activity unit tests in activity.rs and store tests
Covers id stability, derivation classifications, memo handling, display ordering, live-recorder real-bundle building, and store pagination/upsert behaviors.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • dashpay/platform#3838: Related identity-create flow changes that interact with IdentityCreate activity handling.
  • dashpay/platform#3802: Related shielded operations edits that affect fee extraction used by activity persistence.
  • dashpay/platform#3753: Related fund-from-asset-lock orchestration which this PR extends for activity recording.

Suggested labels

ready for final review

Suggested reviewers

  • shumkov
  • thepastaclaw
  • llbartekll
  • lklimek

Poem

🐰 I hopped through Orchard bundles light and spry,

I gathered cmxs beneath the sky,
I packed each entry, memo, and id,
Sent them through FFI so Swift could bid,
Hooray — the rabbit saved your shielded sighs!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(platform): shielded transaction history' accurately and concisely captures the main change: adding a client-side derived activity log for shielded wallet operations.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/shielded-tx-history

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 and usage tips.

@thepastaclaw

thepastaclaw commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit e3cddf8)

check-storage-explorer requires every DashModelContainer model in the
explorer's tile/count, list, and detail views — adds all three for the
new activity model, following the shielded-outgoing-note pattern.

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

@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

Two convergent, in-scope correctness bugs in the new shielded activity log: the live ShieldFromAssetLock recorder ignores its own documented IVK-match contract and silently selects the wrong account in multi-account wallets, and the scan deriver's dedupe set unconditionally suppresses confirmation of Pending live rows that ambiguous post-broadcast paths explicitly rely on scans to upgrade. Lower-severity nits around FFI tag forward-compat and empty-buffer pointer contracts were noted but are not blocking.

🔴 2 blocking | 💬 2 nitpick(s)

🤖 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/wallet/shielded/activity.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/activity.rs:398-402: Pending live entries are never confirmed by later scans
  `derive_activity_into_changeset` (coordinator.rs:776) collects `existing_ids` via `store.get_activity_ids`, which returns ids for ALL persisted entries regardless of status. The scan deriver then `continue`s on any id in that set (activity.rs:399-401). The `ShieldedSpendUnconfirmed` and `ShieldedBroadcastUnconfirmed` paths (operations.rs:645-649 and 782-785) intentionally leave the activity row `Pending` with the documented expectation that 'a later scan that finds the spend will flip the row to Confirmed (its id matches).' That promise cannot be kept: the scan recomputes the same id, sees it in `existing_ids`, and discards the cluster before any update can be emitted. The unit test `live_entry_then_scan_of_same_cluster_yields_one_entry` codifies the current (broken) behavior — the scan must instead diff against existing entries' status/block_height and emit a status/height-only upsert when a Pending row's cluster reappears in scan data.

In `packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs:529-557: ShieldFromAssetLock activity recorder ignores its own IVK-match contract
  The function's doc comment (lines 507-517) states it 'Finds the keyset whose IVK recognizes the funded note's recipient (the row then lands under that account), falling back to the lowest bound account — mirrors the `sender_ovk` selection above.' The actual code at line 535 just does `keys_map.iter().next()`, picking the lowest-indexed bound account unconditionally. The `sender_ovk` selection at lines 304-316 already shows the correct pattern (try `incoming_viewing_key.diversifier_index(recipient.inner())` first, then fall back). In a multi-account wallet where account 0 is bound but a different bound account actually received the funding, this falls through to `build_pending_entry` with the wrong IVK/OVK: `visible_output_cmxs` returns empty (the funded output decrypts under another account's keys), so `build_pending_entry` returns `None` and the rich ShieldFromAssetLock row is silently dropped. The shared-id dedupe contract this feature relies on is also violated: if a future code path did persist under the fallback account, the eventual scan-derived entry under the IVK-matched account would land at a different `(wallet_id, account_index, entry_id)` natural key, producing two unrelated rows for the same on-chain event.

Comment thread packages/rs-platform-wallet/src/wallet/shielded/activity.rs
Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs
Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs
…rder account match)

- The scan deriver now returns on-chain sightings of clusters whose id
  already has a row (DerivedActivity::confirmations) instead of
  discarding them; the coordinator upgrades still-Pending / height-less
  stored rows to Confirmed at the observed height via with_status, so
  the ambiguous post-broadcast paths' documented promise ("a later scan
  flips the row") actually holds — and the live entry's richer fields
  survive the upgrade untouched.
- The ShieldFromAssetLock recorder now selects the bound account whose
  keyset actually recognizes a visible output in the landed bundle
  (falling back to the lowest), matching its documented contract — a
  multi-account wallet no longer silently drops the entry or lands it
  under the wrong natural key.
- FFI: empty cmx/nullifier buffers marshal real nulls per the
  documented "valid or null" pointer contract; unknown direction/status
  bytes on load warn loudly, with unknown status folding to Pending
  (re-confirmable) instead of the materially-distinct Failed.

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

@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

Incremental review at 1fc46d9. Latest delta is Swift-only (StorageExplorer browser + ShieldedActivityView for PersistentShieldedActivity), so all four prior findings were re-verified at the current HEAD and remain STILL VALID — two blocking Rust lifecycle bugs and two FFI hygiene nits, none touched by the delta. Codex surfaced one additional in-scope bug in the new Swift list: it partitions and badges by hasBlockHeight, but the Rust live recorder marks successful broadcasts Confirmed with block_height=None, so confirmed shielded operations render as Pending until a later scan backfills the height. Five validated in-scope findings total (4 carried + 1 new); blocking issues remain — request changes.

🔴 1 blocking

4 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift:127-204: Confirmed rows without block height are displayed as Pending
  `pending` / `settled` partition by `!hasBlockHeight` / `hasBlockHeight` (lines 127-140) and the row badge at line 196 shows "Pending" whenever `!hasBlockHeight`, falling through to "Failed" only when `entry.status == 2`. But the Rust live recorders mark successful broadcasts `Confirmed` while passing `block_height: None` (see `operations.rs` lines 481-487, 601-607, 757-763, 906-908, 1240-1242), explicitly expecting the height to be filled by a later scan (which today is itself blocked by the dedupe issue above). Until that scan lands, every successful Shield / Unshield / Transfer / Withdrawal / IdentityCreate sits in the "Pending" section with a "Pending" badge despite already being Confirmed in the underlying data — the user-facing history reports the wrong state for the most common success path. The detail view at line 223-228 already uses `entry.status` correctly; the list/badge code should do the same. Partition by `entry.status == 0` (or by a `pending` computed property), and gate the badge on the same condition.

The live recorders mark a successful broadcast Confirmed with
block_height=None (the scan backfills the height later), so a
height-less row is the COMMON success shape. Partitioning/badging on
hasBlockHeight rendered every fresh success as "Pending"; both now key
on entry.status (matching the detail view), with just-confirmed
height-less rows floating to the top of History.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift (1)

1885-1889: ⚡ Quick win

Decode discriminants to human-readable labels for storage-explorer usability.

The Classification section displays kindTag, direction, and status as raw integers (e.g., 0, 1, 2). Other detail views in this file decode enum discriminants into display names—PublicKeyStorageDetailView (lines 574–587) uses computed properties, and AssetLockStorageDetailView (lines 2113–2122) uses a local helper.

Add three small helper functions to decode these discriminants so the storage explorer shows labels like "Shield (0)", "In (0)", and "Pending (0)" instead of bare integers.

📖 Proposed helpers

Add these helpers to the ShieldedActivityStorageDetailView struct (after the body property):

+    private func kindDisplay(_ tag: Int) -> String {
+        let name: String
+        switch tag {
+        case 0: name = "Shield"
+        case 1: name = "ShieldFromAssetLock"
+        case 2: name = "Received"
+        case 3: name = "Sent"
+        case 4: name = "Unshield"
+        case 5: name = "Withdrawal"
+        case 6: name = "IdentityCreate"
+        case 7: name = "ShieldedSpend"
+        default: return "Unknown(\(tag))"
+        }
+        return "\(name) (\(tag))"
+    }
+
+    private func directionDisplay(_ raw: Int) -> String {
+        let name: String
+        switch raw {
+        case 0: name = "In"
+        case 1: name = "Out"
+        case 2: name = "Self"
+        default: return "Unknown(\(raw))"
+        }
+        return "\(name) (\(raw))"
+    }
+
+    private func statusDisplay(_ raw: Int) -> String {
+        let name: String
+        switch raw {
+        case 0: name = "Pending"
+        case 1: name = "Confirmed"
+        case 2: name = "Failed"
+        default: return "Unknown(\(raw))"
+        }
+        return "\(name) (\(raw))"
+    }

Then update the Classification section:

 Section("Classification") {
-    FieldRow(label: "Kind Tag", value: "\(record.kindTag)")
-    FieldRow(label: "Direction", value: "\(record.direction)")
-    FieldRow(label: "Status", value: "\(record.status)")
+    FieldRow(label: "Kind Tag", value: kindDisplay(record.kindTag))
+    FieldRow(label: "Direction", value: directionDisplay(record.direction))
+    FieldRow(label: "Status", value: statusDisplay(record.status))
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift`
around lines 1885 - 1889, Add three helper methods inside the
ShieldedActivityStorageDetailView struct (after the body property) that map the
numeric discriminants for kindTag, direction, and status into human-readable
labels (e.g., "Shield (0)", "In (0)", "Pending (0)"); then update the
Classification Section to call these helpers instead of interpolating raw
integers for record.kindTag, record.direction, and record.status (replace the
FieldRow value arguments for "Kind Tag", "Direction", and "Status" to use the
new helper methods).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-platform-wallet-ffi/src/persistence.rs`:
- Around line 2148-2157: The function decode_cmx_array trusts the host-supplied
count and multiplies it by 32 before calling slice::from_raw_parts, which can
overflow; guard the multiplication by using checked_mul (or an equivalent check)
on count and return early (e.g., Vec::new()) if it would overflow or if byte_len
is unreasonably large, and only then call slice::from_raw_parts with the safe
byte_len; keep the existing null-pointer and zero-count checks and locate this
change in decode_cmx_array to replace the direct count * 32 expression with a
checked multiplication and early-fail path.

In `@packages/rs-platform-wallet/src/wallet/shielded/activity_recorder.rs`:
- Around line 106-117: build_pending_entry currently treats note_cmxs.is_empty()
as a degenerate case and returns None, which drops exact-input exit bundles that
legitimately have zero visible Orchard outputs; instead, when
visible_output_cmxs(params.actions, keys) is empty, still construct and return a
Pending ShieldedActivityEntry that contains no visible outputs but includes a
deterministic fallback identifier/linkage so the row can be reconstructed later.
Implement this by deriving a stable unique id from bundle-level data available
in LiveEntryParams (e.g., bundle commitment/txid/anchor/actions hash) and
populate the ShieldedActivityEntry with that fallback id and an explicit
flag/empty outputs list; update build_pending_entry (and ensure the scan path
uses the same derivation) so exact-input unshield/withdraw/identity-create flows
handled by reserve_unspent_notes / reserve_unspent_notes_for_denomination are
recorded rather than skipped.

In `@packages/rs-platform-wallet/src/wallet/shielded/activity.rs`:
- Around line 505-545: The classifier incorrectly marks backfilled shield
clusters as SelfTransfer because it only checks cluster.outgoing.is_empty();
change the branch to also consult the subwallet-level "has spent shielded"
signal (e.g., a boolean like wallet_has_spent_shielded or
subwallet.has_any_shielded_spend available in context) before choosing
ShieldedActivityKind::ShieldedSpend / ShieldedDirection::SelfTransfer; if the
wallet_has_spent_shielded flag is false, treat the cluster as Received (keep
ShieldedActivityKind::Received / ShieldedDirection::In and existing fields),
otherwise keep the existing SelfTransfer behavior; update the comment to reflect
the new guard and ensure the flag is passed into the function that constructs
these ShieldedActivityEntry values.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 2856-2858: The activity marshal/restore path uses cmxLen and nfLen
to set note_cmxs_count and spent_nullifiers_count (via cmxLen / 32 and nfLen /
32) and later multiplies those counts by 32 without bounds checks, which can
truncate trailing bytes or overflow; update the restore and marshal code in
PlatformWalletPersistenceHandler (the functions handling note_cmxs_count,
spent_nullifiers_count, cmxLen, nfLen, and their pointer conversions) to first
validate that cmxLen % 32 == 0 and nfLen % 32 == 0 and reject/return an error
for malformed lengths, and replace plain arithmetic with checked arithmetic
(e.g., use safe multiply operations or bounds-checked UInt conversions) before
allocating buffers or indexing pointers to prevent overflow and silent
truncation.

---

Nitpick comments:
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift`:
- Around line 1885-1889: Add three helper methods inside the
ShieldedActivityStorageDetailView struct (after the body property) that map the
numeric discriminants for kindTag, direction, and status into human-readable
labels (e.g., "Shield (0)", "In (0)", "Pending (0)"); then update the
Classification Section to call these helpers instead of interpolating raw
integers for record.kindTag, record.direction, and record.status (replace the
FieldRow value arguments for "Kind Tag", "Direction", and "Status" to use the
new helper methods).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a1db88d0-00d0-4269-af27-a2bec50c592a

📥 Commits

Reviewing files that changed from the base of the PR and between 0121185 and 00133c3.

📒 Files selected for processing (23)
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet-ffi/src/shielded_persistence.rs
  • packages/rs-platform-wallet/src/changeset/shielded_changeset.rs
  • packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-platform-wallet/src/wallet/shielded/activity.rs
  • packages/rs-platform-wallet/src/wallet/shielded/activity_recorder.rs
  • packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs
  • packages/rs-platform-wallet/src/wallet/shielded/file_store.rs
  • packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs
  • packages/rs-platform-wallet/src/wallet/shielded/mod.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs
  • packages/rs-platform-wallet/src/wallet/shielded/store.rs
  • packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletsContentView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift

Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs
Comment thread packages/rs-platform-wallet/src/wallet/shielded/activity.rs
- decode_cmx_array guards the host-supplied count with checked_mul
  before from_raw_parts (corrupt rows drop the linkage instead of UB).
- Scan classifier: a self-pay cluster in a subwallet with ZERO spent
  notes is necessarily a shield-to-self (change-from-spend is
  impossible), so it now reads as Received/In; the SelfTransfer
  residual is reserved for wallets that have spends. Tests pin both.
- Swift trampoline uses checked multiplication for the Rust-supplied
  linkage counts; the restore marshal rejects blobs that aren't whole
  32-byte multiples (null/0) instead of silently truncating.
- Storage-explorer activity detail decodes kind/direction/status
  discriminants to labeled values.

Not changed: build_pending_entry's None branch (review suggested a
fallback id for zero-visible-output bundles) — build_spend_bundle_with
adds the change/recipient output unconditionally, OVK-keyed to the
wallet's own external OVK, so even an exact-input spend carries a
wallet-visible output; the branch guards a shape our builders cannot
produce and warns loudly if it ever fires.

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

@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 cleanly resolves all five prior review findings: the post-broadcast Pending→Confirmed promise is now backed by DerivedActivity::confirmations plus the coordinator upgrade loop; ShieldFromAssetLock now picks the account whose IVK actually recognizes a visible output; empty cmx/nullifier buffers cross the FFI boundary as real null pointers; and unknown direction/status discriminants now log a tracing::warn! with status folding to Pending. One genuinely PR-introduced correctness blocker remains: the live recorder writes activity entries only to the host persister and never to the in-memory ShieldedStore, so intra-session scans miss them via existing_ids and silently upsert coarser scan-derived rows over the rich live entries. A couple of smaller items (Failed-no-height upgrade gate, two whitespace-mangled log strings, and an unchecked count*32 multiplication at the restore FFI boundary) are worth fixing alongside.

🔴 1 blocking | 🟡 2 suggestion(s) | 💬 1 nitpick(s)

🤖 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/wallet/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:219-245: Live activity entries are never inserted into the in-memory ShieldedStore — intra-session scans clobber rich live rows
  `record_pending_activity` (and the sibling `queue_shielded_activity` used by `record_shield_from_asset_lock_activity`) only push the entry through `queue_shielded_changeset` → `persister.store(...)`. They never call `store.save_activity(id, entry)` on the in-memory `ShieldedStore`. The scan deriver, however, gates dedupe on `store.get_activity_ids(*id)` at coordinator.rs:776, which reads exclusively from the in-memory store. The in-memory store is only populated with activity at bind/restore time (coordinator.rs:421–425) and at scan time (lines 787, 818).

  Concrete failure path (single-op block, the common case): the user does an Unshield → live recorder writes a `Pending`/`Confirmed` Unshield with cmxs = [change_cmx] only to the persister. The next sync pass finds the change note at height H, builds a cluster with cmxs = [change_cmx], computes `compute_activity_id(cmxs)` — the same id as the live entry. `existing_ids` does NOT contain it (the store hasn't seen it), so the scan classifies it as a `Received` (or rho-linked `ShieldedSpend`) and falls into `new_entries`. `save_activity` upserts by id, so both the in-memory store AND the persisted row end up with the coarser scan-derived row — the Unshield/Withdrawal/IdentityCreate kind, fee, counterparty, and memo are silently destroyed. This directly violates the doc-comment invariant at activity.rs:370–378 ("a live `IdentityCreate` / `Unshield` / `Withdrawal` is never clobbered by a coarser scan-derived `Sent` / `ShieldedSpend`") and the same-line existing_ids contract.

  Mirror flow exists for multi-op same-block: two live entries with disjoint cmxs A and B end up with cluster cmxs = A∪B and id = compute_activity_id(A∪B), which does not match either live id, so the scan emits a third spurious aggregate row even when the live entries are eventually restored. Both flows have the same root cause — the live recorder must also write to the in-memory store so the dedupe set stays consistent across the session.

In `packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:807-812: Confirmation upgrade gate silently flips a Failed-no-height row to Confirmed
  The gate is `stored.status == Pending || stored.block_height.is_none()`. The `Failed` arms at operations.rs:651–660 call `record_activity_status(..., Failed, None)`, which produces exactly a `Failed` row with `block_height = None`. If a scan ever sees the cluster on-chain (e.g. a broadcast misclassified as definitively failed that actually landed, or a future caller marks `Failed` while still not knowing the height), this gate will silently rewrite that user-visible `Failed` to `Confirmed` at the observed height.

  The inline comment frames the scope as "the ambiguous post-broadcast paths leave their live row Pending" — Failed is not in that scope. Either tighten the gate to `status == Pending` (or `status == Pending && block_height.is_none()`) so an explicitly-Failed row stays Failed, or update the doc-comment to make the "chain truth always wins, including for Failed" policy explicit. As written, the encoded rule is broader than the rationale.

In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:2148-2158: decode_cmx_array multiplies host-supplied count by 32 without overflow check
  `count * 32` runs inside the unsafe `slice::from_raw_parts` call. `from_raw_parts` requires the byte length to not exceed `isize::MAX`, and in release builds an unchecked multiplication wraps silently. A corrupted or malicious host row with a large `count` would either truncate the slice or produce an invalid view of host memory at the activity restore boundary — neither failure mode is caught by the subsequent `chunks_exact(32)` filter (it operates on whatever the slice ends up being). Since this is the only place host-provided counts feed the persisted activity linkage on restore, the multiplication should be a checked op with a bounds clamp.

Comment thread packages/rs-platform-wallet/src/wallet/shielded/operations.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs
Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs
Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs

@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

Incremental verification against head 97e190c. The PR's stated goal — shielded transaction history with live-recorded entries reconciled against scan-derived rows — has one validated blocking issue carried over from the prior review: live activity entries are persisted to the host but never inserted into the in-memory ShieldedStore, so the dedupe set (existing_ids) the scan deriver reads is incomplete within a session and the scan can silently overwrite a rich live row with a coarser derived row. Two carry-forward smaller items also remain (Failed-no-height upgrade gate; whitespace-mangled warn strings). The earlier decode_cmx_array count*32 overflow was partially addressed (checked_mul added), but the isize::MAX precondition for slice::from_raw_parts is still unenforced.

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

3 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 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-ffi/src/persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:2148-2168: decode_cmx_array still misses the isize::MAX precondition for slice::from_raw_parts
  The patch turned `count * 32` into `checked_mul(32)`, which closes the wraparound. But `slice::from_raw_parts` additionally requires the total byte length to be no greater than `isize::MAX` — any larger length is UB even though the multiplication didn't wrap. A corrupted host-persisted `count` can still produce a `byte_len` between `isize::MAX + 1` and `usize::MAX`. Reject those before constructing the slice; this is the same FFI-boundary distrust rationale the new overflow guard already adopts.

Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs
Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs Outdated
…d scan dedupe

Addresses the remaining review findings on the shielded activity log:

- Live recorders (record_pending_activity / record_activity_status /
  queue_shielded_activity) now save_activity into the in-memory
  ShieldedStore before queueing to the host persister, so the scan
  deriver's dedupe sees intra-session live entries and can no longer
  clobber a rich live row with a coarser scan-derived one. The Shield
  (T15) and ShieldFromAssetLock/seed paths gained the store/coordinator
  plumbing this requires (FFI extern "C" signatures unchanged).
- The deriver dedupes by cmx OVERLAP against stored entries (cmx →
  entry-id map) instead of exact id equality: a same-block cluster
  merging two live ops now confirms BOTH entries at the observed height
  instead of synthesizing a spurious aggregate row. New test pins it.
- Confirmation-upgrade gate keeps upgrading Failed-no-height rows, now
  with the chain-truth-wins policy stated explicitly (observed on
  devnet: a client-side result-proof fetch error marked an
  actually-landed identity-create as Failed).
- decode_cmx_array also enforces slice::from_raw_parts' isize::MAX
  bound, and its stale "multiple of 32" doc clause now describes the
  real guards; the unknown-tag warn messages are single-line literals.

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

@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 (ae31193) resolves the prior blocking issue: queue_shielded_activity now upserts into the in-memory ShieldedStore before the persister queue, closing the same-session scan-clobbers-live-row hazard. The prior decode_cmx_array isize::MAX, warn! whitespace, and doc-claim nits are also fixed. The Failed-no-height upgrade gate is unchanged but now carries an explicit 'Chain truth wins' justification tied to an observed devnet incident — accepting as intentionally deferred. One small carry-forward remains: a stale doc fragment sits above decode_cmx_array.

💬 1 nitpick(s)

Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs Outdated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/rs-platform-wallet/src/wallet/shielded/operations.rs (1)

466-518: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Shield still treats ambiguous wait failures as definitive failure.

This path now persists a live activity row, but any broadcast_and_wait(...) error immediately flips it to Failed. A timeout or result-proof fetch failure after relay admission is still ambiguous: the shield can land later, and a host retry can top up the pool a second time with fresh address nonces/balances. Please stage shield like the other spend flows: separate broadcast() from wait_for_response(), keep the row Pending on no-verdict wait failures, and avoid surfacing a definitive failure in that case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet/src/wallet/shielded/operations.rs` around lines
466 - 518, Split the combined call to
state_transition.broadcast_and_wait::<StateTransitionProofResult> into two
steps: call a non-waiting broadcast (e.g., broadcast()) first and only call
wait_for_response()/the equivalent waiting/fetching logic afterwards; update the
error handling so that ambiguous/timeout/fetch errors from the wait step do NOT
call record_activity_status(..., ShieldedActivityStatus::Failed, ...), leaving
the persisted activity row as Pending, while only definitive failures (e.g.,
explicit insufficient-funds/rich error detected from the broadcast response)
should map to PlatformWalletError::ShieldedBroadcastFailed and trigger
record_activity_status(..., ShieldedActivityStatus::Failed, ...); keep the final
record_activity_status(..., ShieldedActivityStatus::Confirmed, ...) on
successful confirmation. Ensure you reference
state_transition.broadcast_and_wait::<StateTransitionProofResult>, any new
state_transition.broadcast()/wait_for_response() helpers,
record_activity_status, and ShieldedActivityStatus variants when making the
change.
packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs (1)

345-413: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Type 18 never emits Pending or Failed activity rows.

build_and_broadcast_shielded(...) only gives this method control back after broadcast_and_wait succeeds, and record_shield_from_asset_lock_activity(...) is only called on that success path. That means shielded_fund_from_asset_lock, its resume path, and every shielded_seed_pool_notes batch stay invisible while in flight and disappear entirely on definitive failure. Please split this like the other live-recorded flows: build the bundle, upsert a Pending entry immediately, then classify the broadcast result into Confirmed/Failed (or keep Pending on ambiguous outcomes).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs`
around lines 345 - 413, The current flow in shielded_fund_from_asset_lock only
records activity after build_and_broadcast_shielded returns success, so
in-flight and failed operations never get Pending/Failed rows; change the
sequence to first build the bundle (call the builder logic that currently lives
inside the submit closure) and immediately upsert a Pending activity via
record_shield_from_asset_lock_activity for the to-be-broadcast landed_actions
placeholder (or metadata), then perform submit_with_cl_height_retry +
build_and_broadcast_shielded as before (including the
is_instant_lock_proof_invalid retry that calls
asset_locks.upgrade_to_chain_lock_proof / advance_asset_lock_status /
queue_asset_lock_changeset), and finally reconcile the Pending row: mark
Confirmed on success (update the same activity entry) or mark Failed (or leave
Pending for ambiguous outcomes) on error; ensure both the instant-lock and
chain-lock paths use the same pre-broadcast Pending upsert and post-broadcast
classification.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs`:
- Around line 345-413: The current flow in shielded_fund_from_asset_lock only
records activity after build_and_broadcast_shielded returns success, so
in-flight and failed operations never get Pending/Failed rows; change the
sequence to first build the bundle (call the builder logic that currently lives
inside the submit closure) and immediately upsert a Pending activity via
record_shield_from_asset_lock_activity for the to-be-broadcast landed_actions
placeholder (or metadata), then perform submit_with_cl_height_retry +
build_and_broadcast_shielded as before (including the
is_instant_lock_proof_invalid retry that calls
asset_locks.upgrade_to_chain_lock_proof / advance_asset_lock_status /
queue_asset_lock_changeset), and finally reconcile the Pending row: mark
Confirmed on success (update the same activity entry) or mark Failed (or leave
Pending for ambiguous outcomes) on error; ensure both the instant-lock and
chain-lock paths use the same pre-broadcast Pending upsert and post-broadcast
classification.

In `@packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- Around line 466-518: Split the combined call to
state_transition.broadcast_and_wait::<StateTransitionProofResult> into two
steps: call a non-waiting broadcast (e.g., broadcast()) first and only call
wait_for_response()/the equivalent waiting/fetching logic afterwards; update the
error handling so that ambiguous/timeout/fetch errors from the wait step do NOT
call record_activity_status(..., ShieldedActivityStatus::Failed, ...), leaving
the persisted activity row as Pending, while only definitive failures (e.g.,
explicit insufficient-funds/rich error detected from the broadcast response)
should map to PlatformWalletError::ShieldedBroadcastFailed and trigger
record_activity_status(..., ShieldedActivityStatus::Failed, ...); keep the final
record_activity_status(..., ShieldedActivityStatus::Confirmed, ...) on
successful confirmation. Ensure you reference
state_transition.broadcast_and_wait::<StateTransitionProofResult>, any new
state_transition.broadcast()/wait_for_response() helpers,
record_activity_status, and ShieldedActivityStatus variants when making the
change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 073912ab-1893-41bb-9705-d750d6bb1f69

📥 Commits

Reviewing files that changed from the base of the PR and between 97e190c and d64de16.

📒 Files selected for processing (8)
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet-ffi/src/shielded_send.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-platform-wallet/src/wallet/shielded/activity.rs
  • packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs
  • packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs
  • packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/rs-platform-wallet-ffi/src/persistence.rs

Shield (T15) now mirrors broadcast_shielded_spend's staging: broadcast
first (a definitive CheckTx verdict marks the activity row Failed with
the enriched address-funds diagnostics), then wait_for_response — a
consensus rejection in the result is Failed, while an ambiguous wait
failure (timeout / result-proof fetch error) leaves the row Pending and
surfaces ShieldedSpendUnconfirmed, since the shield may still land and
the scan's cmx-overlap confirmation flips the row when its note appears
on-chain.

Type 18 deliberately keeps recording only the landed bundle: the
CL-height retry loop re-randomizes the bundle (different cmxs => a
different activity id) per attempt, so a pre-broadcast Pending row
would orphan whenever a retry lands; in-flight/failed T18s are
surfaced via the tracked asset-lock lifecycle, which owns the
recoverable L1 value. Rationale documented on the recorder.

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

Copy link
Copy Markdown
Member Author

Re the two outside-diff CodeRabbit findings (run 073912ab):

Shield ambiguous wait failures — fixed in 60b7259. Shield (T15) now mirrors broadcast_shielded_spend's staging: broadcast() first (a definitive CheckTx verdict → activity row Failed, with the enriched address-funds diagnostics preserved), then wait_for_response() — a consensus rejection in the result is Failed, while an ambiguous failure (timeout / result-proof fetch error) leaves the row Pending and surfaces ShieldedSpendUnconfirmed; the scan's cmx-overlap confirmation flips the row when the shielded note appears on-chain.

Type 18 Pending/Failed staging — deliberately not implemented, rationale now documented on the recorder (60b7259): submit_with_cl_height_retry re-builds and re-randomizes the Orchard bundle on every attempt, so each attempt has different output cmxs — and the activity id is keyed to those cmxs. A pre-broadcast Pending row would orphan (unconfirmable forever; its cmxs never reach chain) whenever a retry is the attempt that lands. In-flight and failed Type 18s are already surfaced through the tracked asset-lock lifecycle (Built/Broadcast/Locked/Consumed + the resumable-funding UI), which tracks the L1 lock — the artifact that actually carries the recoverable value on failure.

@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

Latest delta is a one-line doc cleanup that resolved the prior stale fragment above decode_cmx_array. Cumulative review surfaces one PR-introduced ordering bug (live Confirmed entries without height collide with the pending sort band) and three architectural/maintenance suggestions in the new code (FFI parallel-iteration coupling flagged by three reviewers, duplicated non_zero_memo helper, and write-lock held across the per-subwallet derivation loop). No blockers.

🟡 3 suggestion(s) | 💬 1 nitpick(s)

🤖 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/wallet/shielded/activity.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/activity.rs:616-631: sort_activity_for_display ignores status, mixing live Confirmed rows into the pending band
  This PR introduces a live recorder that flips successful ops to ShieldedActivityStatus::Confirmed before any block_height is known — see record_activity_status calls such as operations.rs:1287-1295 which pass status=Confirmed with block_height=None, expecting a later scan to backfill the height. The sorter here treats every block_height==None as 'pending' and floats it above all heighted rows, so a live-confirmed (or failed-without-height) row will appear in the pending band until the scan deriver lands. The trait contract at store.rs:207 (and the Swift UI's recent partition-by-status workaround in a35bc9ad) both already model pending as a status, not as 'no height'. Switch the primary sort key to status==Pending so the canonical Rust sorter matches the documented contract and the Swift partition; height becomes the secondary key as before.

In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:1336-1430: Persist-side FFI batch builds backing buffers and entry pointers in two separate passes
  The activity persist path iterates shielded_cs.activity_entries.values() to build a `backing: Vec<(Vec<u8>, Vec<u8>)>` of owned cmx/nullifier bytes, then iterates shielded_cs.activity_entries.iter() a second time and pairs each (id, e) with `backing_iter.next().expect("backing has one entry per activity entry")` to populate the FFI struct's note_cmxs_ptr / spent_nullifiers_ptr. This works today because BTreeMap iteration order is deterministic and the two passes walk the same map, but the FFI pointer-validity invariant (each `*_ptr` must point into the matching backing slot) is load-bearing on that implicit ordering. A future change that filters one pass, switches the container type, or sorts only one side would silently mis-pair pointers with the wrong entry — lengths would still line up so the expect wouldn't catch it, and the host would receive cmx/nullifier bytes from a different row. Build backing and the FFI entries in a single pass (or zip after constructing both vecs) so the parallel-array invariant is a compile-time property instead of a runtime assertion in an FFI hot path.

In `packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:749-842: derive_activity_into_changeset holds the store write lock across every subwallet's CPU-bound classification
  The function grabs self.store.write().await once at the top and holds it across the entire per-subwallet loop: get_all_notes, get_outgoing_notes, get_activity, derive_activity_from_scan_data, save_activity, and per-confirmation get_activity_by_entry_id. With N bound subwallets and 1k+ notes each, the deriver serializes every other consumer of the store for the full classification window even though only save_activity strictly needs the write guard. The deriver is already idempotent (the overlap dedupe + upsert-by-id contract tolerates re-runs), so a read pass to gather inputs followed by a focused write pass for the upserts would cut contention without changing correctness.

Comment thread packages/rs-platform-wallet/src/wallet/shielded/activity.rs
Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs
- sort_activity_for_display keys the pending band on STATUS (matching
  the Swift partition and the store contract): a live-Confirmed row
  whose height the scan hasn't backfilled sorts at the top of the
  settled band instead of masquerading as pending. Test extended.
- The activity persist FFI builds each entry and its owned cmx /
  nullifier buffers in one structurally-paired pass, so the
  pointer-into-backing invariant can't silently mis-pair if either
  side is ever filtered or reordered.
- derive_activity_into_changeset snapshots inputs under a read lock,
  classifies lock-free, and takes the write lock only for the upserts —
  the derivation is idempotent, so anything landing between passes
  reconciles on the next one.
- non_zero_memo exists once (the deriver's slice-keyed helper); the
  recorder's fixed-array version is a thin adapter.

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

@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

Latest push cleanly resolves all four prior d64de16 findings. New activity-derivation logic introduces one blocking correctness issue: incoming notes are stored at the pass-wide max height while outgoing notes keep per-batch heights, so cluster_events cannot correctly cluster their joint events in multi-batch syncs. Two suggestion-level concurrency races and a minor error-message wording issue are also flagged.

🔴 1 blocking | 🟡 2 suggestion(s) | 💬 1 nitpick(s)

1 additional finding(s) omitted (not in diff).

🤖 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/wallet/shielded/activity.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/activity.rs:321-336: cluster_events clusters on heights that incoming notes do not preserve
  `cluster_events` keys on `ShieldedNote::block_height` (incoming) and `ShieldedOutgoingNote::block_height` (outgoing). However, the sync path stores incoming notes with the pass-wide `max_block_height` (sync.rs:399 + 609 — `max_block_height = max_block_height.max(batch.block_height)` accumulated across all batches, then stamped on every decoded note in every batch), while outgoing notes preserve the per-batch height (sync.rs:635 uses `r.block_height`). In any multi-batch sync — including initial sync, backfills, or a resume — an OVK-recovered outgoing note from an earlier batch will key on its real height while its matching incoming change note from the same bundle gets pulled up to the run-wide max. The deriver then splits one bundle across two clusters: the cluster containing only the outgoing note will classify as `Sent` with no change, and the later max-height cluster will mix the bundle's change with unrelated incoming notes from other batches, producing wrong amounts/fees/IDs and breaking the rho-link/OVK shape the classifier relies on. Either store incoming notes with their per-batch `batch.block_height` (matching what outgoing notes already do) or pass per-note heights through to the deriver so the cluster key is the true block of each event.

In `packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:815-822: Scan-derived entries are saved against a stale snapshot
  The lock split snapshots `existing_cmxs` under a read lock, releases it for classification, then under a fresh write lock unconditionally saves every `derived.new_entries`. A live recorder writing a richer Pending/Confirmed row for the same cmx set between the two phases will not appear in the snapshot, so the classifier still emits a `new_entries` row for that cluster. Depending on whether the scan-derived id collides with the live id, the write either overwrites the richer live row with the coarser scan-derived one (losing fee/kind/memo/counterparty/created identity id) or leaves a duplicate row in the store; the next pass's `existing_cmxs` will then dedupe future passes but cannot recover the lost fields. The in-code comment claims idempotence is sufficient, but neither failure mode is reconciled by a subsequent pass since scan-only data cannot reconstruct live-recorder fields. Re-check current activity cmx overlap (or entry id) under the write lock and treat overlaps as confirmations/no-ops instead of unconditional `save_activity` calls.

In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:274-288: record_activity_status flips status on a stale captured pending entry
  `record_activity_status` always calls `with_status(entry, ...)` on the `pending_entry` captured before broadcast. If a concurrent sync pass observes the operation's outputs and has already upgraded the stored row to `Confirmed` with a real `block_height` between broadcast and the wait_for result, the post-wait `Confirmed`-without-height write will overwrite the row in the store and clobber the height the scan just learned. The scan-backfill contract (height comes from chain) is then broken for any operation whose result-wait races a scan that already saw the outputs. Read the current stored entry by id inside `record_activity_status` and apply the status change to that row (preserving any block_height the scan has already populated) before queueing the upsert.

Comment thread packages/rs-platform-wallet/src/wallet/shielded/activity.rs
Comment thread packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs
Comment thread packages/rs-platform-wallet/src/wallet/shielded/operations.rs
…rding

- Stamp incoming notes with their own chunk's proven height (matching
  OVK-recovered outgoing notes) instead of the pass-wide max, so a
  bundle's change and send agree on the cluster key in multi-batch syncs
- Re-check cmx overlap under the write lock before inserting scan-derived
  entries; overlaps with rows a live recorder landed mid-derivation
  degrade to confirmation sightings instead of clobbering/duplicating
- record_activity_status now flips the CURRENT stored row (read under the
  save's write lock), never the stale pre-broadcast capture; a row the
  scan already confirmed with a height is left untouched

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/rs-platform-wallet/src/wallet/shielded/sync.rs (1)

587-613: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Historical notes never pick up the new per-batch height.

Line 589 still skips every note below the existing watermark, so rows written under the old pass-wide height scheme never get rewritten with d.block_height from Line 613. derive_activity_into_changeset clusters on the persisted note height, so previously synced history can stay split or misclassified until the user clears and resyncs. Please add a migration/update path for stored notes whose height differs instead of gating all pre-watermark rows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet/src/wallet/shielded/sync.rs` around lines 587 -
613, The loop currently skips any discovered note with d.position <
sub_watermark so existing stored notes never get their block_height updated;
change the logic in the loop that uses watermarks.get(id).copied().unwrap_or(0)
and the discovered iteration to still load the existing stored note for
positions below the watermark and compare its stored block_height with
d.block_height, and if they differ call the store update path (e.g., via your
store module functions that insert/update ShieldedNote) to rewrite the record
with the new ShieldedNote { note_data, position: d.position, cmx: d.cmx,
nullifier: ..., block_height: d.block_height } so historical rows are migrated
in-place rather than being skipped; keep the original insertion behavior for
notes that do not exist.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/rs-platform-wallet/src/wallet/shielded/sync.rs`:
- Around line 587-613: The loop currently skips any discovered note with
d.position < sub_watermark so existing stored notes never get their block_height
updated; change the logic in the loop that uses
watermarks.get(id).copied().unwrap_or(0) and the discovered iteration to still
load the existing stored note for positions below the watermark and compare its
stored block_height with d.block_height, and if they differ call the store
update path (e.g., via your store module functions that insert/update
ShieldedNote) to rewrite the record with the new ShieldedNote { note_data,
position: d.position, cmx: d.cmx, nullifier: ..., block_height: d.block_height }
so historical rows are migrated in-place rather than being skipped; keep the
original insertion behavior for notes that do not exist.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: df3fe1d3-315d-42bb-80ca-441900306876

📥 Commits

Reviewing files that changed from the base of the PR and between 60b7259 and 50b98ae.

📒 Files selected for processing (7)
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet/src/wallet/shielded/activity.rs
  • packages/rs-platform-wallet/src/wallet/shielded/activity_recorder.rs
  • packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs
  • packages/rs-platform-wallet/src/wallet/shielded/store.rs
  • packages/rs-platform-wallet/src/wallet/shielded/sync.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/rs-platform-wallet/src/wallet/shielded/store.rs
  • packages/rs-platform-wallet/src/wallet/shielded/activity_recorder.rs
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet/src/wallet/shielded/activity.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs

@QuantumExplorer

Copy link
Copy Markdown
Member Author

Re CodeRabbit's outside-diff finding (historical notes never pick up the new per-batch height): declining the migration path. This code is pre-release — the only stores written under the old pass-wide-height scheme are internal devnet test devices, which recover via the standard clear-and-resync path. An in-place compare-and-rewrite would also be mostly inert: normal syncs fetch from the aligned network-min watermark, so pre-watermark positions are never re-discovered and the migration would only fire opportunistically on lagging-subwallet rewinds. Not worth the complexity for data no production user holds.

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "ef8e93511ed2232772e2cbc77d4d72c3cc3a8bbfd51734b80a008a10ccdaf8dc"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

@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

Three prior findings (#1 per-batch note heights, #2 cmx overlap re-check, #3 record_activity_status race) are resolved at 50b98ae. Prior finding #4 (ShieldedSpendUnconfirmed text) still applies. The cumulative PR introduces three new in-scope blockers: the shield FFI flattens the new ShieldedSpendUnconfirmed variant into a generic error code (losing the do-not-retry signal hosts rely on), and the Swift Clear Shielded State and wallet-deletion paths both miss the new PersistentShieldedActivity table, so stale activity rows can rehydrate into Rust via the load callback after a user wipes shielded state or deletes/reimports a wallet.

🔴 3 blocking | 🟡 2 suggestion(s)

4 additional finding(s) omitted (not in diff).

🤖 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-ffi/src/shielded_send.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/shielded_send.rs:719-725: Shield FFI flattens ShieldedSpendUnconfirmed to generic ErrorWalletOperation
  `shielded_shield_from_account` now returns `PlatformWalletError::ShieldedSpendUnconfirmed { operation: "shield", ... }` for the ambiguous post-broadcast path (operations.rs:574). But the shield FFI wrapper at shielded_send.rs:719 maps every `Err(e)` straight to `ErrorWalletOperation` instead of going through `map_spend_result` like transfer/unshield/withdraw do. As a result Swift cannot distinguish `ErrorShieldedSpendUnconfirmed` (do NOT retry — broadcast may already have executed) from any other local failure, and `ShieldedBroadcastFailed` (definitive failure, safe to retry) is also collapsed into the generic code. The whole point of the new typed variant is the retry-safety split; this wrapper silently throws it away. Route the shield result through the same helper so the three retry-relevant codes (`ErrorShieldedSpendUnconfirmed`, `ErrorShieldedBroadcastFailed`, `ErrorWalletOperation`) are preserved.

In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift:619-628: Clear Shielded State leaves PersistentShieldedActivity rows behind
  The cumulative PR adds a `PersistentShieldedActivity` SwiftData table that is loaded through `on_load_shielded_activity_fn` on wallet startup and copied into the Rust-side `ShieldedSyncStartState`. `ShieldedService.clearLocalState` deletes `PersistentShieldedNote`, `PersistentShieldedOutgoingNote`, and `PersistentShieldedSyncState` but does not delete `PersistentShieldedActivity`. After a user uses the global Clear button — whose stated semantics are 'blow away shielded persistence' — old activity rows survive and rehydrate into Rust on the next bind, where they will continue to dedupe/suppress scan-derived rows (via the cmx-overlap path) and surface deleted history in the UI. Delete the activity table here too.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:3178-3203: Wallet deletion does not purge PersistentShieldedActivity rows
  The wallet-deletion path explicitly enumerates the per-wallet shielded tables that need a hand-rolled cascade because they are keyed by raw `walletId` with no relationship to `PersistentWallet` — note, outgoing-note, and sync-state. The new `PersistentShieldedActivity` table is keyed the same way but is not deleted here, so deleting a wallet (or deleting then reimporting one with the same id) leaves activity rows behind. On the next load those rows flow through `loadShieldedActivityCallback` into `ShieldedSyncStartState` and resurface as ghost history. The block comment already documents the pattern; extend it to the activity table.

In `packages/rs-platform-wallet/src/error.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/error.rs:216-224: ShieldedSpendUnconfirmed text says 'spent notes' but is also raised for shield
  Carried forward from the 41964622 review. operations.rs:574-577 returns `ShieldedSpendUnconfirmed { operation: "shield", ... }` for the ambiguous shield path, but the `#[error(...)]` template still reads "the spend may already be executed on chain — do not re-submit (the next sync reconciles the spent notes)". For an `operation == "shield"` value this renders as 'Shielded shield broadcast … the spend may already be executed … reconciles the spent notes', which is misleading: shielding from platform addresses does not spend shielded notes, so there is nothing for the scan to reconcile on the spent-note side, and callers (CLI + Swift surface, which prints the Display) get the wrong recovery guidance. Either soften the wording to be neutral about what kind of on-chain effect is pending, or split a shield-specific variant. Since the variant is otherwise the right shape, softening the template is the smaller change.

In `packages/rs-platform-wallet/src/wallet/shielded/sync.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/sync.rs:602-616: Per-batch note height is the response metadata height, not per-note mined height
  `ShieldedNote.block_height` is now stamped from `d.block_height`, which is `batch.block_height` — and `batch.block_height` is the response metadata height (`fetch_chunk.rs:44-49` returns `metadata.height`, the chain tip the proof was anchored against), not the height each individual note was mined at. A single chunk covers up to `mmr_chunk_size` (≈2048) commitment-tree positions that can span many historical blocks of activity, so on a cold restore every note surfaced in one fetch batch — incoming receipts and OVK-recovered sends across unrelated bundles — receives the same anchor height, and `cluster_events` (activity.rs:324) then merges those unrelated wallet operations into a single synthetic activity row with combined amount/kind/memo. The per-batch change correctly fixes the bundle-split issue (a single bundle's incoming change and OVK send now agree on the key), but the documented goal of clustering 'notes landing in the *same block*' (activity.rs:260) is not what the data actually carries. The deeper fix needs a per-note mined height from the SDK; until then please at least update the cluster_events comments to call out that 'same height' means 'same fetch batch' and document the cold-restore aggregation as a v1 limitation.

Comment thread packages/rs-platform-wallet/src/wallet/shielded/sync.rs
…ws on wipe

- Route the shield FFI result through map_spend_result so hosts keep the
  ErrorShieldedSpendUnconfirmed (do-not-retry) / ErrorShieldedBroadcastFailed
  (safe-to-retry) split instead of a generic ErrorWalletOperation
- Delete PersistentShieldedActivity in Clear Shielded State and in the
  wallet-deletion cascade so wiped history can't rehydrate into Rust via
  the load callback as ghost entries
- Soften ShieldedSpendUnconfirmed wording to cover the shield path (no
  spent notes to reconcile) and document that cluster heights are fetch
  proof anchor heights (same fetch batch), incl. cold-restore aggregation

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

@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

Incremental review of head f1ce3f7 against prior baseline 50b98ae. All five prior findings are resolved: shield FFI now routes through map_spend_result, both wipe paths purge PersistentShieldedActivity, the ShieldedSpendUnconfirmed message reads operation-neutral, and the per-batch height limitation is now explicitly documented as a v1 restore restriction. No new in-scope blocking issues were validated in the f1ce3f7 delta; the codex-ffi-engineer 'changeset_end swallows save failure' finding is a pre-existing persistence-handler behavior on a path unchanged by this PR and out of scope. One small in-scope nit confirmed: a stale inline comment in map_spend_result still describes spend-only semantics after shield was rewired through it.

💬 1 nitpick(s)

1 additional finding(s) omitted (not in diff).

…ment

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

@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

Incremental review of ac2f2b4 against prior baseline f1ce3f7. The prior nitpick about the stale spend-only comment in map_spend_result is FIXED. Cumulative review surfaces two new findings, both about the broadened ShieldedSpendUnconfirmed/activity model: a wallet-level activity list keys ForEach rows by entryId only despite the persistence model deliberately scoping uniqueness by (walletId, accountIndex, entryId), and the Swift mirror of result code 18 still documents spend-only semantics even though Rust now routes shield through the same helper.

🔴 1 blocking | 💬 1 nitpick(s)

1 additional finding(s) omitted (not in diff).

🤖 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift:162-171: Wallet-level activity ForEach collides rows from different shielded accounts
  `ShieldedActivityListView` queries `PersistentShieldedActivity` for one `walletId` (no `accountIndex` predicate) but both `ForEach` sections identify rows only by `\.entryId`. The persistence model in `PersistentShieldedActivity.swift` is uniqued on `(walletId, accountIndex, entryId)` precisely because the same visible-output `cmx` legitimately appears under two accounts of one wallet — e.g. an intra-wallet shielded transfer from account 0 to account 1 writes a Sent row on acct 0 and a Received row on acct 1, both with `entryId = sha256(cmx)`. Once both rows land in the same wallet-level `pending`/`settled` array, SwiftUI sees duplicate ForEach identifiers and will drop or reuse rows, leading to missing entries in the history list and potentially the wrong detail sheet binding. Key by a composite that includes `accountIndex` (or use `\.persistentModelID`) instead of `\.entryId` alone.

The activity table is uniqued on (walletId, accountIndex, entryId), so a
wallet-level list can hold the same entryId under two accounts (e.g. an
intra-wallet transfer's Sent + Received rows). Keying ForEach by entryId
alone made SwiftUI drop/reuse rows. Also neutralize the spend-only
wording on the Swift mirrors of result code 18 now that shield routes
through the same path.

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

@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

Both prior findings are FIXED in head 75406eb — the SwiftUI activity ForEach now keys on \.persistentModelID (with an explanatory comment) and the Swift mirror for code 18 now correctly enumerates shield/unshield/transfer/withdrawal with the shield-reserves-nothing carve-out. One small doc nit remains in the FFI struct comment, which still tells hosts to key activity rows on entry_id alone — a non-Swift host following this guidance would recreate the cross-account collision that prior finding 1 fixed.

💬 1 nitpick(s)

Comment thread packages/rs-platform-wallet-ffi/src/shielded_persistence.rs Outdated
…_id)

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

@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

Prior finding on ShieldedActivityFFI struct doc is FIXED at 433f099. However, two adjacent public docs in the same PR series still describe the old entry_id-only key contract: the FFI callback-level doc in persistence.rs and the SwiftData model-level doc in PersistentShieldedActivity.swift. Both are doc-only nitpicks; the actual code paths (composite #Unique in Swift, composite predicate in PlatformWalletPersistenceHandler) are correct.

💬 2 nitpick(s)

Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs Outdated
…ry_id) tuple

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

@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

Both prior nitpicks from the 433f099 review are FIXED in current HEAD — the FFI persist-activity callback doc (persistence.rs:357-362) and the Swift PersistentShieldedActivity doc (PersistentShieldedActivity.swift:8-35) now correctly key on (wallet_id/walletId, account_index/accountIndex, entry_id/entryId) and explain the intra-wallet-transfer collision case. Verified three new findings against the source: a stale implementation comment that wasn't updated alongside the public docs, a SwiftData→FFI tag conversion that truncates instead of validating, and a Result-swallowing fallback in record_activity_status that defeats the function's own anti-clobber invariant. Dropped Codex's blocking ABI-versioning concern as speculative for an internal monorepo FFI where Rust and Swift ship from the same SHA.

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

🤖 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:2840-2842: Activity-load truncates Swift `Int` tags into valid-looking u8 discriminants
  `PersistentShieldedActivity` stores `kindTag`, `direction`, and `status` as Swift `Int` (PersistentShieldedActivity.swift:50-54), but the FFI restore struct takes `u8`. `UInt8(truncatingIfNeeded:)` silently wraps an out-of-range stored value (e.g. corruption or a future tag that wasn't migrated) into a different valid discriminant — `256` becomes `0` (Shield/In/Pending), so Rust's intentional unknown-tag fallback path (persistence.rs:1832-1880) never fires. Use `UInt8(exactly:)` and skip the row when conversion fails, so corrupt persisted state is dropped rather than reinterpreted as a different valid activity classification.

In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:296-299: `record_activity_status` swallows store read errors, defeating its anti-clobber invariant
  The doc comment (lines 275-281) explicitly states the design: the flip must be applied to the CURRENT stored row to avoid erasing a scan-learned `Confirmed` height. But `get_activity_by_entry_id(...).unwrap_or_default()` collapses any storage `Err` into `None`, so the code then falls back to the captured pre-broadcast `entry` and queues a persister upsert. With any fallible `ShieldedStore` impl, that turns a transient read error into exactly the clobber the function was written to prevent — writing a stale `Failed`/`Confirmed-without-height` row over richer state precisely when the invariant could not be checked. Skip the flip (log and return) on read error so the result-wait can't overwrite scan truth blindly.

Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/shielded/operations.rs Outdated
…n read error

- Activity restore now converts kind/direction/status with UInt8(exactly:)
  and drops the row on overflow, so corrupt persisted tags can't wrap into
  a different valid discriminant and bypass Rust's unknown-tag fallback
- record_activity_status skips the flip (warn + return) when the store
  read fails instead of falling back to the stale pre-broadcast capture,
  which would have been exactly the clobber the merge exists to prevent
- Align the dispatch call-site comment with the composite upsert key

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

@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 incremental delta (3 files, 31 insertions) cleanly fixes all three prior PastaClaw findings: Swift activity-load now uses UInt8(exactly:) and skips out-of-range rows before any per-row buffer allocation (no leak); record_activity_status now matches the store read and bails on Err preserving the anti-clobber invariant; and the dispatch_shielded_changeset comment correctly names the (wallet_id, account_index, entry_id) upsert key. No new in-scope defects observed. The single codex-ffi-engineer suggestion (Swift String.withCString silently truncates memos at embedded NUL) is real but applies to a pre-existing shielded_send path untouched by this PR — out of scope.

@QuantumExplorer

Copy link
Copy Markdown
Member Author

Reviewed

@QuantumExplorer QuantumExplorer merged commit 5507621 into v3.1-dev Jun 12, 2026
16 of 17 checks passed
@QuantumExplorer QuantumExplorer deleted the claude/shielded-tx-history branch June 12, 2026 20:35
@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 71.20%. Comparing base (f4ed60f) to head (e3cddf8).
⚠️ Report is 20 commits behind head on v3.1-dev.

Additional details and impacted files
@@             Coverage Diff              @@
##           v3.1-dev    #3870      +/-   ##
============================================
+ Coverage     70.73%   71.20%   +0.47%     
============================================
  Files            20       20              
  Lines          2788     2837      +49     
============================================
+ Hits           1972     2020      +48     
- Misses          816      817       +1     
Components Coverage Δ
dpp ∅ <ø> (∅)
drive ∅ <ø> (∅)
drive-abci ∅ <ø> (∅)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value ∅ <ø> (∅)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier ∅ <ø> (∅)
🚀 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