fix(drive): return empty history for contracts that don't keep history#3884
Conversation
📝 WalkthroughWalkthroughThis 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. ChangesActivity Core: Types, Derivation, and Storage
Shielded Operations: Coordinator Wiring and Activity Recording
Persistence and Restoration: Coordinator Sync and FFI Bridge
Swift UI: Activity Display and Navigation
Supporting Changes: Error Docs, Contract Verification, Misc
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
|
✅ Review complete (commit 203ef3a) |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift (2)
264-266: ⚡ Quick winUse the static constant for the credits divisor.
Line 266 hard-codes
100_000_000_000.0instead of referencing thecreditsPerDashstatic 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 winAdd 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 >= 4before 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
Atpackages/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_V12orPlatformVersion::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
📒 Files selected for processing (33)
packages/rs-drive/src/verify/contract/verify_contract_history/v0/mod.rspackages/rs-platform-wallet-ffi/src/persistence.rspackages/rs-platform-wallet-ffi/src/shielded_persistence.rspackages/rs-platform-wallet-ffi/src/shielded_send.rspackages/rs-platform-wallet/src/changeset/shielded_changeset.rspackages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rspackages/rs-platform-wallet/src/error.rspackages/rs-platform-wallet/src/wallet/platform_wallet.rspackages/rs-platform-wallet/src/wallet/shielded/activity.rspackages/rs-platform-wallet/src/wallet/shielded/activity_recorder.rspackages/rs-platform-wallet/src/wallet/shielded/coordinator.rspackages/rs-platform-wallet/src/wallet/shielded/file_store.rspackages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rspackages/rs-platform-wallet/src/wallet/shielded/mod.rspackages/rs-platform-wallet/src/wallet/shielded/operations.rspackages/rs-platform-wallet/src/wallet/shielded/seed_pool.rspackages/rs-platform-wallet/src/wallet/shielded/store.rspackages/rs-platform-wallet/src/wallet/shielded/sync.rspackages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rspackages/rs-sdk-ffi/src/error.rspackages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swiftpackages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletsContentView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift
| 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, | ||
| }, |
There was a problem hiding this comment.
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.
| 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.
| 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; | ||
| }; |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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.
| /// 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() } | ||
| } | ||
| } |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
Codecov Report❌ Patch coverage is
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
🚀 New features to boost your workflow:
|
|
✅ 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:
|
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>
95d96d5 to
203ef3a
Compare
|
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 The earlier CodeRabbit/automated comments on |
|
Reviewed |
thepastaclaw
left a comment
There was a problem hiding this comment.
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)
| 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()))); | ||
| } | ||
| } |
There was a problem hiding this comment.
💬 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.
| 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']
Issue being fixed or feature implemented
getDataContractHistoryagainst a contract withkeepsHistory=false(e.g. the testnet DPNS contractGWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec) failed with a proof-verification error instead of returning an empty result: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.skipinpackages/wasm-sdk/tests/functional/contracts.spec.ts.What was done?
Root cause.
verify_contract_history_v0always 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 withkeepsHistory=falsestores its single current version as anItemat 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 asCorruptedProof.Fix (
packages/rs-drive/src/verify/contract/verify_contract_history/v0/mod.rs): detect the non-history shape — exactly one proved element atcontract_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 asCorruptedProof, mirroring the strict discrimination applied to the history-path entries. ReturningSome(empty)(notNone) flows cleanly throughFromProof for DataContractHistoryand 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
Itemwhere a real historyTreeexists.How Has This Been Tested?
cargo test -p drive --lib verify_contract_history— 5 passing, including two new regression tests (should_return_empty_history_for_non_history_contractand a non-defaultlimit/start_at_msvariant). ExistingkeepsHistory=truetests still pass.cargo check -p drive --no-default-features --features verify— clean (the verifier builds under theverifyfeature only; confirmscontract_root_path_vec/is_any_itemare available there).Manual / live testnet (read-only, no funded identity needed; rebuild the xcframework first):
GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec(limit 10) → graceful empty result, no error.HLY575cNazmc5824FxqaEMEBuzFeE4a98GDRNKbyJqCM(the known history-keeping testnet contract used by the app'sDiagnosticsView) → ≥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:
For repository code-owners and collaborators only