Skip to content

fix(drive): return empty history for contracts that don't keep history#3884

Merged
QuantumExplorer merged 1 commit into
v3.1-devfrom
claude/thirsty-meitner-cb5a3e
Jun 13, 2026
Merged

fix(drive): return empty history for contracts that don't keep history#3884
QuantumExplorer merged 1 commit into
v3.1-devfrom
claude/thirsty-meitner-cb5a3e

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jun 13, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

getDataContractHistory against a contract with keepsHistory=false (e.g. the testnet DPNS contract GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec) failed with a proof-verification error instead of returning an empty result:

Proof verification error: dash drive: proof: corrupted error: we did not get back an element for the correct path for the historical contract

A contract that doesn't keep history simply has no history — that should be a graceful empty result, not an error. This also matches a long-standing it.skip in packages/wasm-sdk/tests/functional/contracts.spec.ts.

What was done?

Root cause. verify_contract_history_v0 always assumes the queried contract keeps history. It expects every proved key-value at the 3-segment history path [DataContractDocuments, contract_id, 0]. But a contract with keepsHistory=false stores its single current version as an Item at the 2-segment contract root path (key [0]), one level up. GroveDB therefore proves that element, the path is length 2 (not 3), and the verifier rejected a perfectly valid proof as CorruptedProof.

Fix (packages/rs-drive/src/verify/contract/verify_contract_history/v0/mod.rs): detect the non-history shape — exactly one proved element at contract_root_path_vec(id) (key [0]) that is a present item (is_any_item()) — and return an empty history map. Any other shape at that path still fails as CorruptedProof, mirroring the strict discrimination applied to the history-path entries. Returning Some(empty) (not None) flows cleanly through FromProof for DataContractHistory and the FFI.

Soundness: no regression. The proof remains cryptographically bound to the state root (merk hash checks) and the signed block (tenderdash); a server cannot forge an Item where a real history Tree exists.

Note: the misleading "Failed to fetch balances:" FFI error prefix that compounded this message was a separate issue, already fixed and merged in #3878. This PR was rebased onto current v3.1-dev (which already includes both #3878 and the shielded-activity feature), so it now contains only the verifier fix.

How Has This Been Tested?

  • cargo test -p drive --lib verify_contract_history5 passing, including two new regression tests (should_return_empty_history_for_non_history_contract and a non-default limit/start_at_ms variant). Existing keepsHistory=true tests still pass.
  • cargo check -p drive --no-default-features --features verify — clean (the verifier builds under the verify feature only; confirms contract_root_path_vec/is_any_item are available there).

Manual / live testnet (read-only, no funded identity needed; rebuild the xcframework first):

  • No-history: query DPNS GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec (limit 10) → graceful empty result, no error.
  • Happy path: query HLY575cNazmc5824FxqaEMEBuzFeE4a98GDRNKbyJqCM (the known history-keeping testnet contract used by the app's DiagnosticsView) → ≥1 entry.

Breaking Changes

None. Client-side proof-verification bug fix; no consensus or wire-format change. Behavior changes only from "error" to "empty result" for the no-history case.

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 (no breaking changes)
  • 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

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements end-to-end shielded activity logging: deriving deduplicated activity entries from Orchard bundles and scan data, recording pending entries during operations with status lifecycle management, persisting through FFI callbacks to Swift, and displaying activity history through dedicated UI screens.

Changes

Activity Core: Types, Derivation, and Storage

Layer / File(s) Summary
Activity Model, Derivation Algorithm, and Live Recording
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/sync/ovk_builder_roundtrip_tests.rs
Introduces ShieldedActivityEntry, direction/status/kind enums, and stable ID derivation from visible output cmxs; implements overlap-based deduplication with scan-learned confirmations, rho linkage for fee recovery, and kind classification (received/sent/spend/self-transfer); adds live build_pending_entry and visible_output_cmxs for bundle recording; includes comprehensive test coverage.
Store Trait Extension and SubwalletState Activity Storage
packages/rs-platform-wallet/src/wallet/shielded/store.rs, packages/rs-platform-wallet/src/wallet/shielded/file_store.rs
Extends ShieldedStore trait with activity save/paginate/lookup; implements upsert-by-id and display-sorted pagination with pending-first ordering; integrates into InMemoryShieldedStore and FileBackedShieldedStore.
Changeset and Sync-Start-State Activity Fields
packages/rs-platform-wallet/src/changeset/shielded_changeset.rs, packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs
Adds activity_entries map to ShieldedChangeSet with merge/split-by-wallet-id routing; adds persisted activity vector to rehydrate across sessions.
Module Exports and Re-exports
packages/rs-platform-wallet/src/wallet/shielded/mod.rs
Exposes activity submodules and re-exports public types through module surface.

Shielded Operations: Coordinator Wiring and Activity Recording

Layer / File(s) Summary
Activity Lifecycle and Status Management Helpers
packages/rs-platform-wallet/src/wallet/shielded/operations.rs (lines 1–331)
Introduces record_pending_activity, activity upsert, and record_activity_status helper to flip entry status without clobbering scan-confirmed rows (preserves scan-learned block height).
Shield Operation: Activity Recording and Status Flow
packages/rs-platform-wallet/src/wallet/shielded/operations.rs (lines 371–603)
Reworks shield to record pending activity before broadcast, classify definitive vs ambiguous failures, mark Confirmed/Failed/Pending by outcome type.
Unshield, Transfer, Withdraw: Activity Recording Pattern
packages/rs-platform-wallet/src/wallet/shielded/operations.rs (lines 658–781, 825–921, 970–1073)
Records pending before broadcast, marks Confirmed on success, Failed on definitive errors; preserves ShieldedSpendUnconfirmed as Pending-preserving special case.
IdentityCreateFromShieldedPool: Activity Recording and Error Handling
packages/rs-platform-wallet/src/wallet/shielded/operations.rs (lines 1160–1426)
Records identity-create activity post-build, marks Confirmed/Failed with identity-id linkage; keeps ShieldedBroadcastUnconfirmed as Pending/reservation-preserving case.
Activity Status Management Tests
packages/rs-platform-wallet/src/wallet/shielded/operations.rs (lines 2212–2311)
Validates no-clobber behavior for scan-confirmed rows and Pending→Confirmed transitions.
Coordinator Threading through FFI Entry Points
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/seed_pool.rs
Updates FFI entry points to resolve wallet and coordinator; threads coordinator reference into all shielded operation methods.
Fund-from-Asset-Lock: Activity Recording and Actions Return
packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs
Refactors helper to return landed bundle's serialized actions; records pending→confirmed activity post-commitment using actions and coordinator; performs keyset matching for multi-account scenarios.

Persistence and Restoration: Coordinator Sync and FFI Bridge

Layer / File(s) Summary
Coordinator Restore and Sync-Time Activity Derivation
packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs
Upserts persisted activity into store during wallet rehydration; derives activity from stored notes during sync, dedupes against existing cmxs, upgrades pending/height-less rows to confirmed; includes recipient classification helper.
Per-Batch Block Height Threading
packages/rs-platform-wallet/src/wallet/shielded/sync.rs
Threads per-batch proven block height through IVK and trial decryption instead of pass-wide max; uses per-note height for consistent activity clustering.
FFI Activity Callbacks and Marshaling
packages/rs-platform-wallet-ffi/src/persistence.rs, packages/rs-platform-wallet-ffi/src/shielded_persistence.rs
Adds on_persist_shielded_activity_fn and load/free callbacks; implements activity flatten to ShieldedActivityFFI with discriminant tags and pointer buffers; decodes restore with forward-compat fallbacks and safe 32-byte array handling.
Swift Model and Persistence Handler Wiring
packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
Defines PersistentShieldedActivity with composite uniqueness; implements upsert by (walletId, accountIndex, entryId) and network-scoped load with allocation tracking; extends wallet wipe to delete activity rows; adds C shim callbacks.

Swift UI: Activity Display and Navigation

Layer / File(s) Summary
Activity List and Detail Views
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift
Implements activity list with pending/history partitioning, detail sheet with direction/amount/fee/status/memo/identity linkage, kind display mapping, and amount formatting.
Navigation Integration and Routing
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
Adds "Shielded Activity" link in wallet detail; registers route destination in content view; adds storage explorer section with network-scoped counts.
Storage Explorer Activity Views
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift, packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift
Adds read-only activity log list and detail views in storage explorer with classification mapping and timestamp display.

Supporting Changes: Error Docs, Contract Verification, Misc

Layer / File(s) Summary
Error Documentation and Service Cleanup
packages/rs-platform-wallet/src/error.rs, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift, packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift, packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift
Generalizes error docs from spend-only to all shielded operations; adds activity deletion to service cleanup; updates comment on unconfirmed handling.
SwiftData Container and Model Wiring
packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift
Registers model in container; clarifies shield operation throw contract.
Contract History Verification No-History Handling
packages/rs-drive/src/verify/contract/verify_contract_history/v0/mod.rs
Handles keeps_history = false contracts by treating root/key [0] as valid empty-history case; adds verification tests.
SDK FFI Error Mapping Improvement
packages/rs-sdk-ffi/src/error.rs
Emits unmatched SDK errors verbatim instead of hard-coded prefix.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • dashpay/platform#3753: Asset-lock funding pipeline that this PR extends with activity recording.
  • dashpay/platform#3870: Core shielded activity persistence implementation that parallels this PR's FFI/Swift wiring.
  • dashpay/platform#3653: Wallet deletion refactoring; this PR adds explicit activity row deletion to deleteWalletData.

Suggested labels

ready for final review

Suggested reviewers

  • shumkov
  • lklimek
  • thepastaclaw

Poem

🐰 A bunny hops through bundles bright,
Recording every shielded sight,
From pending notes to confirmed states,
Activity flows through all the gates,
Persisted logs in storage keep,
While UI views let wallets peep! 🏠✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and clearly summarizes the main fix: returning empty history for non-history contracts instead of failing verification.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/thirsty-meitner-cb5a3e

@thepastaclaw

thepastaclaw commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 203ef3a)

@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 (3)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift (2)

264-266: ⚡ Quick win

Use the static constant for the credits divisor.

Line 266 hard-codes 100_000_000_000.0 instead of referencing the creditsPerDash static constant defined at line 51. This creates a maintenance hazard if the conversion factor ever changes.

♻️ Proposed fix
                     LabeledContent("Amount", value: entry.signedAmountText)
                     if entry.hasFee {
-                        LabeledContent("Fee", value: String(format: "%.6f DASH", Double(entry.fee) / 100_000_000_000.0))
+                        LabeledContent("Fee", value: String(format: "%.6f DASH", Double(entry.fee) / Self.creditsPerDash))
                     }
🤖 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/Core/Views/ShieldedActivityView.swift`
around lines 264 - 266, Replace the hard-coded divisor in the fee display with
the static constant creditsPerDash: in the LabeledContent that shows Fee (where
it currently uses Double(entry.fee) / 100_000_000_000.0), divide by
Double(creditsPerDash) instead (e.g., Double(entry.fee) /
Double(creditsPerDash)) so the fee conversion uses the defined static constant
rather than a magic number; keep the same String(format:) formatting and
reference the entry.fee property as before.

79-88: ⚡ Quick win

Add length validation for the memo before decoding.

The Dash memo format is documented as 36 bytes (line 75), but the decoder only checks memo.count >= 4 before reading the kind tag. A malformed or truncated memo could produce misleading results. Validating the full 36-byte length up front would make the contract explicit.

🛡️ Proposed fix
     var memoText: String? {
-        guard memo.count >= 4 else { return nil }
+        guard memo.count == 36 else { return nil }
         let bytes = [UInt8](memo)
🤖 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/Core/Views/ShieldedActivityView.swift`
around lines 79 - 88, The memoText computed property currently only checks
memo.count >= 4 before reading the kind tag; update it to validate the memo
length matches the expected Dash memo size (36 bytes) up front (e.g., guard
memo.count == 36 else { return nil }) before converting to [UInt8], then proceed
with existing kind extraction and payload decoding; ensure any subsequent
slicing (bytes[4...]) remains safe and unchanged otherwise so
malformed/truncated memos return nil.
packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs (1)

231-231: Pin protocol version for deterministic shield-bundle replay
At packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs (line 231), PlatformVersion::latest() currently resolves to v12 (LATEST_PLATFORM_VERSION / PLATFORM_V12), but it will change when rs-platform-version adds newer protocol versions. If this regression is intended to stay fixed to a specific protocol/bundle schema, pin it (e.g., dpp::version::PLATFORM_V12 or PlatformVersion::get(12)).

🤖 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/ovk_builder_roundtrip_tests.rs`
at line 231, The test currently calls PlatformVersion::latest(), which makes the
shield-bundle replay non-deterministic as new protocol versions are released;
replace PlatformVersion::latest() with a pinned version such as
dpp::version::PLATFORM_V12 or PlatformVersion::get(12) in the
ovk_builder_roundtrip_tests (replace the PlatformVersion::latest() argument) so
the test always uses the intended V12 protocol/schema.

Source: Coding guidelines

🤖 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 1832-1841: The match arm constructing
ShieldedActivityKind::IdentityCreate must respect the has_identity_id flag
instead of always using ffi.identity_id; update the kind_tag == 6 arm to check
ffi.has_identity_id (e.g., if ffi.has_identity_id != 0 then construct
IdentityCreate with the identity_id from ffi, otherwise construct IdentityCreate
with a None/absent identity_id per the IdentityCreate type) so you don't
synthesize zero IDs from rows with has_identity_id == 0.

In `@packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs`:
- Around line 582-591: The code currently constructs a `confirmed` activity via
`with_status(..., ShieldedActivityStatus::Confirmed, None)` and unconditionally
calls `crate::wallet::shielded::operations::queue_shielded_activity(...)` which
upserts with `block_height: None` and can overwrite a concurrently-inserted
`Confirmed` row that has a real `block_height`; change this to preserve any
existing confirmed row's `block_height` by reusing the merge/update logic in
`record_activity_status` (or by first loading the existing activity for the same
`SubwalletId::new(self.wallet_id(), account)` and, if it exists with `status ==
Confirmed` and a non-None `block_height`, copy that height into `confirmed`
before calling `queue_shielded_activity`), so the upsert never downgrades an
already-rich confirmed row.
- Around line 555-580: The code currently falls back to keys_map.iter().next()
when no IVK match is found and then records a
ShieldedActivityKind::ShieldFromAssetLock for that arbitrary account; change
this so we do not attribute external Type 18 outputs to a bound account — remove
the fallback to keys_map.iter().next() and return early when matched is None
(i.e., only proceed to call build_pending_entry when keys_map.iter().find(...)
yields Some), leaving the calls to
crate::wallet::shielded::activity_recorder::visible_output_cmxs, matched,
build_pending_entry, and ShieldedActivityKind::ShieldFromAssetLock unchanged
otherwise.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 2419-2476: persistShieldedActivity and PersistentShieldedActivity
currently key rows only by (walletId, accountIndex, entryId) which lets activity
bleed across networks; add a network discriminator (e.g., networkId or
networkTag) to the PersistentShieldedActivity model and include it in all
upsert/load/delete predicates and constructors. Specifically: add a network
field on PersistentShieldedActivity, populate it when creating the row in
persistShieldedActivity (use snap or resolve from the wallet row), update the
fetch predicate in persistShieldedActivity
(`#Predicate`<PersistentShieldedActivity>) to include the network discriminator,
set the network field on the existing update branch, and ensure any other
loaders/wipers that query PersistentShieldedActivity also include the network
discriminator so activity is scoped per-network.

---

Nitpick comments:
In
`@packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs`:
- Line 231: The test currently calls PlatformVersion::latest(), which makes the
shield-bundle replay non-deterministic as new protocol versions are released;
replace PlatformVersion::latest() with a pinned version such as
dpp::version::PLATFORM_V12 or PlatformVersion::get(12) in the
ovk_builder_roundtrip_tests (replace the PlatformVersion::latest() argument) so
the test always uses the intended V12 protocol/schema.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift`:
- Around line 264-266: Replace the hard-coded divisor in the fee display with
the static constant creditsPerDash: in the LabeledContent that shows Fee (where
it currently uses Double(entry.fee) / 100_000_000_000.0), divide by
Double(creditsPerDash) instead (e.g., Double(entry.fee) /
Double(creditsPerDash)) so the fee conversion uses the defined static constant
rather than a magic number; keep the same String(format:) formatting and
reference the entry.fee property as before.
- Around line 79-88: The memoText computed property currently only checks
memo.count >= 4 before reading the kind tag; update it to validate the memo
length matches the expected Dash memo size (36 bytes) up front (e.g., guard
memo.count == 36 else { return nil }) before converting to [UInt8], then proceed
with existing kind extraction and payload decoding; ensure any subsequent
slicing (bytes[4...]) remains safe and unchanged otherwise so
malformed/truncated memos return nil.
🪄 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: e91e3e04-d408-4d8c-93c1-75957ccf5c48

📥 Commits

Reviewing files that changed from the base of the PR and between 65042eb and 95d96d5.

📒 Files selected for processing (33)
  • packages/rs-drive/src/verify/contract/verify_contract_history/v0/mod.rs
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet-ffi/src/shielded_persistence.rs
  • packages/rs-platform-wallet-ffi/src/shielded_send.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/error.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/seed_pool.rs
  • packages/rs-platform-wallet/src/wallet/shielded/store.rs
  • packages/rs-platform-wallet/src/wallet/shielded/sync.rs
  • packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs
  • packages/rs-sdk-ffi/src/error.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.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 on lines +1832 to +1841
let kind = match ffi.kind_tag {
0 => ShieldedActivityKind::Shield,
1 => ShieldedActivityKind::ShieldFromAssetLock,
2 => ShieldedActivityKind::Received,
3 => ShieldedActivityKind::Sent,
4 => ShieldedActivityKind::Unshield,
5 => ShieldedActivityKind::Withdrawal,
6 => ShieldedActivityKind::IdentityCreate {
identity_id: ffi.identity_id,
},

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Honor has_identity_id when decoding IdentityCreate rows.

At Line 1839, kind_tag == 6 always reconstructs IdentityCreate with ffi.identity_id, even when has_identity_id == 0. This breaks the value+flag contract and can create synthetic zero IDs from partial/corrupt rows.

Suggested fix
-                            6 => ShieldedActivityKind::IdentityCreate {
-                                identity_id: ffi.identity_id,
-                            },
+                            6 if ffi.has_identity_id != 0 => {
+                                ShieldedActivityKind::IdentityCreate {
+                                    identity_id: ffi.identity_id,
+                                }
+                            }
+                            6 => {
+                                tracing::warn!(
+                                    "shielded-activity kind_tag=6 without identity_id; folding to ShieldedSpend"
+                                );
+                                ShieldedActivityKind::ShieldedSpend
+                            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let kind = match ffi.kind_tag {
0 => ShieldedActivityKind::Shield,
1 => ShieldedActivityKind::ShieldFromAssetLock,
2 => ShieldedActivityKind::Received,
3 => ShieldedActivityKind::Sent,
4 => ShieldedActivityKind::Unshield,
5 => ShieldedActivityKind::Withdrawal,
6 => ShieldedActivityKind::IdentityCreate {
identity_id: ffi.identity_id,
},
let kind = match ffi.kind_tag {
0 => ShieldedActivityKind::Shield,
1 => ShieldedActivityKind::ShieldFromAssetLock,
2 => ShieldedActivityKind::Received,
3 => ShieldedActivityKind::Sent,
4 => ShieldedActivityKind::Unshield,
5 => ShieldedActivityKind::Withdrawal,
6 if ffi.has_identity_id != 0 => {
ShieldedActivityKind::IdentityCreate {
identity_id: ffi.identity_id,
}
}
6 => {
tracing::warn!(
"shielded-activity kind_tag=6 without identity_id; folding to ShieldedSpend"
);
ShieldedActivityKind::ShieldedSpend
}
🤖 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-ffi/src/persistence.rs` around lines 1832 - 1841,
The match arm constructing ShieldedActivityKind::IdentityCreate must respect the
has_identity_id flag instead of always using ffi.identity_id; update the
kind_tag == 6 arm to check ffi.has_identity_id (e.g., if ffi.has_identity_id !=
0 then construct IdentityCreate with the identity_id from ffi, otherwise
construct IdentityCreate with a None/absent identity_id per the IdentityCreate
type) so you don't synthesize zero IDs from rows with has_identity_id == 0.

Comment on lines +555 to +580
let matched = keys_map.iter().find(|(_, ks)| {
!crate::wallet::shielded::activity_recorder::visible_output_cmxs(actions, ks).is_empty()
});
let Some((&account, keyset)) = matched.or_else(|| keys_map.iter().next()) else {
return;
};

let Some(pending) = build_pending_entry(
keyset,
crate::wallet::shielded::activity_recorder::LiveEntryParams {
kind: ShieldedActivityKind::ShieldFromAssetLock,
direction: ShieldedDirection::In,
amount: shield_amount,
// The flat pool fee is charged on the L1 side (asset-lock
// value − shield_amount); the note value is exactly
// `shield_amount`, so no shielded-pool fee is derivable
// from the bundle here.
fee: None,
counterparty: None,
memo: None,
actions,
spent_notes: &[],
},
) else {
return;
};

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Don’t attribute external Type 18 outputs to an arbitrary bound account.

When no keyset matches visible_output_cmxs, this falls back to keys_map.iter().next() and still records the activity as ShieldedDirection::In. For an external recipient, that manufactures an inbound ShieldFromAssetLock row under the lowest bound account even though that account never received the note. Skip recording unless an IVK match exists, or record a different outgoing/external variant for the fallback case.

Suggested minimal fix
-        let Some((&account, keyset)) = matched.or_else(|| keys_map.iter().next()) else {
+        let Some((&account, keyset)) = matched else {
             return;
         };
🤖 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 555 - 580, The code currently falls back to keys_map.iter().next()
when no IVK match is found and then records a
ShieldedActivityKind::ShieldFromAssetLock for that arbitrary account; change
this so we do not attribute external Type 18 outputs to a bound account — remove
the fallback to keys_map.iter().next() and return early when matched is None
(i.e., only proceed to call build_pending_entry when keys_map.iter().find(...)
yields Some), leaving the calls to
crate::wallet::shielded::activity_recorder::visible_output_cmxs, matched,
build_pending_entry, and ShieldedActivityKind::ShieldFromAssetLock unchanged
otherwise.

Comment on lines +582 to +591
let confirmed = with_status(&pending, ShieldedActivityStatus::Confirmed, None);
let id = crate::wallet::shielded::SubwalletId::new(self.wallet_id(), account);
crate::wallet::shielded::operations::queue_shielded_activity(
coordinator.store(),
Some(self.persister()),
self.wallet_id(),
id,
confirmed,
)
.await;

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve a scan-confirmed row before this confirmed upsert.

This path unconditionally saves confirmed with block_height: None. Because it runs after broadcast_and_wait, a concurrent scan can already have inserted the same activity with a real height; this write will then downgrade that richer row and erase the chain-derived height. Reuse the merge logic from record_activity_status or explicitly keep an existing Confirmed row that already has block_height.

🤖 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 582 - 591, The code currently constructs a `confirmed` activity via
`with_status(..., ShieldedActivityStatus::Confirmed, None)` and unconditionally
calls `crate::wallet::shielded::operations::queue_shielded_activity(...)` which
upserts with `block_height: None` and can overwrite a concurrently-inserted
`Confirmed` row that has a real `block_height`; change this to preserve any
existing confirmed row's `block_height` by reusing the merge/update logic in
`record_activity_status` (or by first loading the existing activity for the same
`SubwalletId::new(self.wallet_id(), account)` and, if it exists with `status ==
Confirmed` and a non-None `block_height`, copy that height into `confirmed`
before calling `queue_shielded_activity`), so the upsert never downgrades an
already-rich confirmed row.

Comment on lines +2419 to +2476
/// Upsert a batch of derived activity entries by
/// `(walletId, accountIndex, entryId)`. Re-persisting the same
/// `entryId` refines the existing row in place: a `Pending` row flips
/// to `Confirmed`/`Failed`, and a scan-derived `ShieldedSpend` can be
/// upgraded to a richer kind (the Rust side re-emits the same id).
func persistShieldedActivity(walletId: Data, snapshots: [ShieldedActivitySnapshot]) {
onQueue {
for snap in snapshots {
let wid = snap.walletId
let acct = snap.accountIndex
let eid = snap.entryId
let predicate = #Predicate<PersistentShieldedActivity> {
$0.walletId == wid && $0.accountIndex == acct && $0.entryId == eid
}
var descriptor = FetchDescriptor<PersistentShieldedActivity>(predicate: predicate)
descriptor.fetchLimit = 1
if let existing = try? backgroundContext.fetch(descriptor).first {
existing.kindTag = snap.kindTag
existing.direction = snap.direction
existing.status = snap.status
existing.amount = snap.amount
existing.fee = snap.fee
existing.hasFee = snap.hasFee
existing.blockHeight = snap.blockHeight
existing.hasBlockHeight = snap.hasBlockHeight
existing.createdAtMs = snap.createdAtMs
existing.identityId = snap.identityId
existing.counterparty = snap.counterparty
existing.memo = snap.memo
existing.noteCmxs = snap.noteCmxs
existing.spentNullifiers = snap.spentNullifiers
existing.lastUpdated = Date()
} else {
let row = PersistentShieldedActivity(
walletId: snap.walletId,
accountIndex: snap.accountIndex,
entryId: snap.entryId,
kindTag: snap.kindTag,
direction: snap.direction,
status: snap.status,
amount: snap.amount,
fee: snap.fee,
hasFee: snap.hasFee,
blockHeight: snap.blockHeight,
hasBlockHeight: snap.hasBlockHeight,
createdAtMs: snap.createdAtMs,
identityId: snap.identityId,
counterparty: snap.counterparty,
memo: snap.memo,
noteCmxs: snap.noteCmxs,
spentNullifiers: snap.spentNullifiers
)
backgroundContext.insert(row)
}
}
if !self.inChangeset { try? backgroundContext.save() }
}
}

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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Add network scoping to persisted shielded activity rows.

This new activity table is keyed only by (walletId, accountIndex, entryId), but this file already treats walletId as reusable across networks (PersistentWallet is one row per network for the same id). That makes the new loader/filter path and the new wipe path unable to distinguish sibling-network activity: one network can restore the other network’s history, and deleting one network’s wallet can erase the other network’s activity. Please persist a network discriminator on PersistentShieldedActivity (or relate it to the network-scoped wallet row) and include it in the upsert/load/delete predicates.

🤖 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/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`
around lines 2419 - 2476, persistShieldedActivity and PersistentShieldedActivity
currently key rows only by (walletId, accountIndex, entryId) which lets activity
bleed across networks; add a network discriminator (e.g., networkId or
networkTag) to the PersistentShieldedActivity model and include it in all
upsert/load/delete predicates and constructors. Specifically: add a network
field on PersistentShieldedActivity, populate it when creating the row in
persistShieldedActivity (use snap or resolve from the wallet row), update the
fetch predicate in persistShieldedActivity
(`#Predicate`<PersistentShieldedActivity>) to include the network discriminator,
set the network field on the existing update branch, and ensure any other
loaders/wipers that query PersistentShieldedActivity also include the network
discriminator so activity is scoped per-network.

@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

Verified the contract-history verifier fix and the FFI error-message change against source. The fix is correct, well-tested, and cryptographically sound — GroveDB still binds the proved tuple to the authenticated state root. One in-scope robustness suggestion remains on the new branch (validate proof shape more strictly). No blockers.

🟡 1 suggestion(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-drive/src/verify/contract/verify_contract_history/v0/mod.rs`:
- [SUGGESTION] packages/rs-drive/src/verify/contract/verify_contract_history/v0/mod.rs:55-68: Tighten the non-history branch: require a single Item element
  The new branch returns `Ok((root_hash, Some(BTreeMap::new())))` on the first proved tuple matching `(contract_root_path_vec(&contract_id), [0])` and only guards `maybe_element.is_none()`. Two robustness gaps in this newly added code:

  1. The element type is not validated. The storage invariant is that a non-history contract is stored as an `Item` at the contract root path; if `verify_query` ever returned a `Tree` (or any non-Item) element at that path/key, the verifier would silently accept it as an empty history rather than raising `CorruptedProof`. This is the inverse of the existing strict path discrimination directly below.
  2. The function returns from inside the `for (path, key, maybe_element) in proved_key_values.drain(..)` loop without inspecting siblings. If `verify_query` ever produced a mixed result (one matching tuple plus unrelated trailing tuples), the trailing entries would be silently dropped.

  In practice GroveDB's contract-history path query produces a single tuple for this shape, so neither is a present bug — but asserting the shape (single entry + `into_item_bytes()` succeeds) before returning would make the discrimination defensible and match the strictness applied to the contract_storage_path arm.

Comment thread packages/rs-drive/src/verify/contract/verify_contract_history/v0/mod.rs Outdated
@codecov

codecov Bot commented Jun 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.97590% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.22%. Comparing base (65042eb) to head (203ef3a).
⚠️ Report is 13 commits behind head on v3.1-dev.

Files with missing lines Patch % Lines
.../verify/contract/verify_contract_history/v0/mod.rs 93.97% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##           v3.1-dev    #3884   +/-   ##
=========================================
  Coverage     87.22%   87.22%           
=========================================
  Files          2641     2641           
  Lines        328569   328652   +83     
=========================================
+ Hits         286597   286675   +78     
- Misses        41972    41977    +5     
Components Coverage Δ
dpp 87.69% <ø> (ø)
drive 86.14% <93.97%> (+<0.01%) ⬆️
drive-abci 89.45% <ø> (ø)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.20% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.55% <ø> (ø)
🚀 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.

@github-actions

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: "6097b55fba65f727b02d0a2f32a031cc562f0574a3eb33d0b7c9d451381f0651"
)

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.

getDataContractHistory against a contract with keepsHistory=false (e.g.
the testnet DPNS contract) failed with "corrupted proof: we did not get
back an element for the correct path for the historical contract".

verify_contract_history assumed the queried contract keeps history and
always expected proved elements at the 3-segment history path
[DataContractDocuments, contract_id, 0]. A contract that does not keep
history stores its single version as an Item at the 2-segment contract
root path (key [0]), one level up, so GroveDB legitimately proves that
element and the verifier rejected the valid proof as corrupted.

Detect that shape — exactly one proved element at the contract root path
(key [0]) that is a present item — and return an empty history map. Any
other shape at that path still fails as CorruptedProof, matching the
strictness applied to the history-path entries. Add regression tests for
the keepsHistory=false case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@QuantumExplorer QuantumExplorer force-pushed the claude/thirsty-meitner-cb5a3e branch from 95d96d5 to 203ef3a Compare June 13, 2026 20:49
@QuantumExplorer

Copy link
Copy Markdown
Member Author

Heads up for reviewers: this branch was originally cut from an early snapshot of the shielded-activity feature, so the PR initially bundled 16 stale activity commits that have since been merged into v3.1-dev independently. I've rebased onto current v3.1-dev, so the PR now contains a single commit touching one file: the verify_contract_history fix.

The earlier CodeRabbit/automated comments on rs-platform-wallet*, PlatformWalletPersistenceHandler.swift, ShieldedActivityView.swift, etc. are against that already-merged activity code and are out of scope here — they belong to the activity feature's own history, not this fix, so I've left them unaddressed. The one in-scope review (thepastaclaw, on the verifier branch) has been addressed and resolved. The merge conflict is gone (PR is now mergeable).

@QuantumExplorer

Copy link
Copy Markdown
Member Author

Reviewed

@QuantumExplorer QuantumExplorer merged commit 8b8961c into v3.1-dev Jun 13, 2026
17 of 18 checks passed
@QuantumExplorer QuantumExplorer deleted the claude/thirsty-meitner-cb5a3e branch June 13, 2026 20:54

@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

Cumulative review: PR scope is a single file (verify_contract_history_v0). The prior PastaClaw finding about tightening the non-history branch (require a single Item element) is FIXED at lines 61-75 — current code requires exactly one proved tuple, validates the contract root path and key [0], and explicitly checks is_any_item(), returning CorruptedProof otherwise. No new in-scope blocking or suggestion-level findings in the latest delta; only a shared nitpick (both Claude and Codex) about a tiny Vec allocation for key comparison in this cold verification path.

💬 1 nitpick(s)

Comment on lines +61 to +75
if proved_key_values.len() == 1 {
let (path, key, maybe_element) = &proved_key_values[0];
if path == &contract_root_path_vec(&contract_id) && key == &vec![0u8] {
let is_item = maybe_element
.as_ref()
.map(|element| element.is_any_item())
.unwrap_or(false);
if !is_item {
return Err(Error::Proof(ProofError::CorruptedProof(
"expected a contract item at the contract root path".to_string(),
)));
}
return Ok((root_hash, Some(BTreeMap::new())));
}
}

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.

💬 Nitpick: Compare key as a slice to avoid the Vec allocation

key == &vec![0u8] allocates a one-byte Vec on every verification just to compare against a constant. Use a slice compare instead — it avoids the allocation and reads more idiomatically. Cold path, so no measurable perf impact, but trivial to tighten. The path == &contract_root_path_vec(&contract_id) allocation is harder to eliminate without a new helper, so leave it.

Suggested change
if proved_key_values.len() == 1 {
let (path, key, maybe_element) = &proved_key_values[0];
if path == &contract_root_path_vec(&contract_id) && key == &vec![0u8] {
let is_item = maybe_element
.as_ref()
.map(|element| element.is_any_item())
.unwrap_or(false);
if !is_item {
return Err(Error::Proof(ProofError::CorruptedProof(
"expected a contract item at the contract root path".to_string(),
)));
}
return Ok((root_hash, Some(BTreeMap::new())));
}
}
if proved_key_values.len() == 1 {
let (path, key, maybe_element) = &proved_key_values[0];
if path == &contract_root_path_vec(&contract_id) && key.as_slice() == [0u8] {
let is_item = maybe_element
.as_ref()
.map(|element| element.is_any_item())
.unwrap_or(false);
if !is_item {
return Err(Error::Proof(ProofError::CorruptedProof(
"expected a contract item at the contract root path".to_string(),
)));
}
return Ok((root_hash, Some(BTreeMap::new())));
}
}

source: ['claude', 'codex']

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