Skip to content

feat: enable DashPay iOS flow + key health tooling#3765

Merged
QuantumExplorer merged 8 commits into
v3.1-devfrom
claude/thirsty-williams-6e9d18
May 28, 2026
Merged

feat: enable DashPay iOS flow + key health tooling#3765
QuantumExplorer merged 8 commits into
v3.1-devfrom
claude/thirsty-williams-6e9d18

Conversation

@QuantumExplorer
Copy link
Copy Markdown
Member

@QuantumExplorer QuantumExplorer commented May 28, 2026

Issue being fixed or feature implemented

Enable the DashPay end-to-end flow on the iOS SwiftExampleApp (search by DPNS name → send contact request → accept → send payment) and fix several blockers discovered while standing it up.

What was done?

rs-sdk

  • is_dpns_name_available was treating proven non-existence (a non-empty IndexMap with None values) as "name taken". Fixed to documents.values().all(is_none).

swift-sdk

  • SDK.init now picks a network-aware platformVersion default: PV 11 for mainnet/testnet (V0 getDocuments wire) and PV 12 for devnet/regtest. Without this, every Platform document query against testnet failed with "could not decode data contracts query" because PlatformVersion::latest() emits the V1 wire format.
  • KeychainManager.storeIdentityPrivateKey namespaces accounts by walletId (identity_privkey.<walletIdHex>.<derivationPath>). The old per-path scheme collided whenever two wallets had an identity at the same identity_index.
  • PlatformWalletPersistenceHandler.deleteWalletData now runs in four save phases — identity-children (DPNS names, DashPay profile, DashPay contact requests) → identities → accounts → wallet. SwiftData fatals during save() if it has to null out a non-optional inverse on a row in the same batch as the parent's delete; splitting the cascade across saves means each parent's children are already gone from the store by the time the parent is processed. Trades atomicity for keeping every model's inverse non-Optional (the schema stays honest about which references are always present). Costs four saves instead of one for a user-initiated wipe.

swift-example-app

  • AddIdentityKeyView: surfaces DashPay as a "(System)" entry in the contract-bounds picker since it's required for DashPay flows but isn't pre-saved locally. Locks KeyType to ECDSA secp256k1 + SecurityLevel to Medium when purpose is Encryption/Decryption (DPP enforces both — see validate_identity_public_keys_structure/v0/mod.rs). Forces the document-type binding when the contract only declares requiresIdentityEncryptionBoundedKey at document-type level (DashPay → contactRequest), with auto-fill.
  • CreateIdentityView: new default-on Register DashPay keys toggle that registers 2 extra keys (Encryption + Decryption, MEDIUM, ECDSA secp, bound to DashPay → contactRequest) alongside the default auth keys. Asset-lock minimum auto-scales for the extra per-key cost (+~13K duffs).
  • WalletKeyHealthSheet (new): diagnostic in Wallet Info. Re-derives each PersistentPublicKey from the wallet mnemonic, classifies as healthy / needsRederive / orphan, offers per-identity Re-derive (writes a fresh new-format keychain entry + sweeps the legacy one) or Delete Identity.
  • WalletRowView.platformBalance + BalanceCardView.platformBalance skip identities whose modelContext is nil (mid-cascade-delete window — BackingData.swift:866 fatals otherwise).
  • Dead code: removed stale Sources/.../DashPayService.swift (hardcoded key indices, didn't go through the new FFI), ObservableDashPayService (empty wrapper, never used), FriendsViewStubs (folded value types into FriendsView).

How Has This Been Tested?

Built + driven in the iOS Simulator end-to-end against testnet:

  • Created wallets + identities with the DashPay toggle on — all 5 keys appear in SwiftData with private material in Keychain at new-format accounts.
  • Wallet Info → Verify Identity Keys: all keys healthy on a fresh wallet; on a wallet whose Keychain still held legacy-format entries, those were flagged "needs re-derive", and Re-derive cleaned them up.
  • Delete Wallet: now succeeds cleanly with non-Optional inverses, via the four-phase save. Tested with a wallet that had identities + DPNS names + DashPay profile rows.
  • DPNS Register: a fresh random name correctly reads as Available (was always Taken before the rs-sdk fix).
  • Two wallets at identity_index = 0 keep distinct private keys in Keychain after recovery.

Not exercised on hardware or against mainnet.

Breaking Changes

None.

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

    • Verify Identity Keys sheet with per-key health checks and one-tap repairs.
    • Option to include DashPay encryption keys during identity creation; tighter key-selection UI.
  • Bug Fixes

    • Prevented crashes from reading invalidated data models in wallet lists, details, and balances.
    • Corrected DPNS name-availability checks.
  • Refactor

    • Improved wallet deletion and keychain wipe ordering for reliability.
    • Removed legacy DashPay service layer; Friends UI state simplified.

Review Change Stack

@QuantumExplorer QuantumExplorer requested a review from shumkov as a code owner May 28, 2026 15:13
@github-actions github-actions Bot added this to the v3.1.0 milestone May 28, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

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 wallet-scoped key storage and cleanup, a WalletKeyHealth diagnostic sheet (check/rederive/delete), purpose-driven key constraints and unified contract-bounds picker, optional DashPay key registration during identity creation, phased SwiftData wallet deletion, Rust FFI + model contract-bounds propagation, DPNS availability correctness, SDK platform-version resolution, and UI guards for invalidated SwiftData models.

Changes

Key Health Diagnostics and DashPay Integration

Layer / File(s) Summary
WalletId-scoped keychain account naming
packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift
Keychain account identifiers include walletId (identity_privkey.<walletId>.<derivationPath>); deletion functions handle both new and legacy account shapes, log-and-skip unverifiable rows, and provide guarded legacy cleanup.
Key health models and checker
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift
Adds per-key WalletKeyHealth and per-identity WalletIdentityKeyHealthReport, plus WalletKeyHealthChecker.runCheck/rederive/deleteOrphan to detect and repair/migrate missing or orphaned identity keys.
Key health UI wiring in WalletDetailView
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift
Adds "Verify Identity Keys" button, wallet-scoped @Query for identities, and conditional WalletKeyHealthSheet presentation with fallback UI.
Purpose-driven key constraints and contract bounds
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift
Derives effectiveKeyType/effectiveSecurityLevel from purpose (encryption/decryption locked to ECDSA secp256k1 + Medium), unifies system+saved contracts into a single picker, and enforces required document-type when contract-scope is disallowed.
DashPay optional key registration in identity creation
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift
Adds addDashPayKeys toggle and dynamic funding minimum; makeDashpayKeyPair derives DashPay key-pairs, persists private keys namespaced by walletId, and appends DashPay IdentityPubkey rows during submit.
Friends view state refactor
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift
Removes @StateObject service; uses inline @State and introduces small DashPayContact / DashPayContactRequest value types for UI state.

SwiftData Resilience, FFI, and SDK Infrastructure

Layer / File(s) Summary
FFI → persistence: contract-bounds propagation
packages/rs-platform-wallet-ffi/src/identity_persistence.rs, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
Extends IdentityKeyEntryFFI with contract-bounds kind/id/document-type C-string; persistence callback decodes these fields and persists contract-bounds ids + optional document-type name onto PersistentPublicKey on insert and refresh.
PersistentPublicKey: persist contract document-type
packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift
Adds contractBoundsDocumentTypeName column and updates conversion/initializer logic to round-trip contract-bounds including optional document-type.
Three-phase wallet deletion to avoid SwiftData crashes
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
deleteWalletData deletes identity cascade-children and saves, then deletes identity rows and saves, then deletes accounts and saves to avoid SwiftData inverse-nullification crashes.
Keychain purge ordering in wallet deletion
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
Moves keychain deletions for identity items earlier in deleteWallet before SwiftData deletion and removes the later in-place purge block.
Views filter invalidated SwiftData models
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift, packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift, packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift
Balance summations and Wallet row rendering now ignore SwiftData-invalidated identities or use walletLabel helper to avoid accessing invalidated model properties.
SDK platform version network-aware resolution
packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift
SDK.init(network:platformVersion:) computes resolvedPlatformVersion: use provided non-zero PV; zero maps to PV 11 for .mainnet/.testnet and PV 12 for .devnet/.regtest, then assigns to config.platform_version.
Persistence model relationship documentation & DPNS fix
packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift, packages/rs-sdk/src/platform/dpns_usernames/mod.rs
Updates PersistentAccount.wallet doc comment to describe delete-before-save pattern; Rust is_dpns_name_available treats fetch_many as available only when all returned Document values are None to support non-existence proofs.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

ready for final review

Suggested reviewers

  • shumkov
  • thepastaclaw

Poem

🐰 I hopped through keys and tidy trails,

namespaced bytes and careful fails.
DashPay toggles, checks that gleam,
SwiftData saved in phased-of-three.
A small rabbit cheers the team.

🚥 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: enable DashPay iOS flow + key health tooling' directly and clearly summarizes the main objectives of the changeset: enabling DashPay functionality in iOS and adding diagnostic tooling for key health 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.

✏️ 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/thirsty-williams-6e9d18

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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

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

⚠️ Outside diff range comments (2)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift (2)

222-234: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Established contact metadata (alias, note, isHidden) is not being read from the SDK.

The code maps establishedIds directly to DashPayContact structs using only the DashPay profile's display name, but never calls managed.getEstablishedContact(contactId:) to fetch the EstablishedContact object that holds per-contact metadata.

Impact:

  • The .filter { !$0.isHidden } at Line 94 is broken—all contacts default to isHidden = false.
  • The note display (Lines 372-377) never renders—note is always nil.
  • The contact alias (distinct from the profile's displayName) is ignored.
🔧 Proposed fix to fetch established contact metadata
 contacts = establishedIds.map { contactId in
+    // Fetch the EstablishedContact to get alias, note, isHidden
+    let established = try? managed.getEstablishedContact(contactId: contactId)
+    let alias = try? established?.getAlias()
+    let note = try? established?.getNote()
+    let isHidden = (try? established?.isHidden()) ?? false
+    
     let profile = (try? wallet.getDashPayProfile(identityId: contactId)) ?? nil
     let trimmedName = profile?.displayName?
         .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
-    let displayName = trimmedName.isEmpty
-        ? (String(contactId.toHexString().prefix(12)) + "…")
-        : trimmedName
+    // Prefer alias, then profile displayName, then hex fallback
+    let displayName: String
+    if let alias = alias, !alias.isEmpty {
+        displayName = alias
+    } else if !trimmedName.isEmpty {
+        displayName = trimmedName
+    } else {
+        displayName = String(contactId.toHexString().prefix(12)) + "…"
+    }
+    
     return DashPayContact(
         id: contactId,
         displayName: displayName,
-        identityId: contactId
+        identityId: contactId,
+        note: note,
+        isHidden: isHidden
     )
 }

Based on learnings: Context snippet 1 shows that EstablishedContact provides getAlias(), getNote(), and isHidden() methods that return the contact-specific metadata stored by the SDK.

🤖 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/FriendsView.swift`
around lines 222 - 234, When building contacts from establishedIds inside the
mapping (currently using wallet.getDashPayProfile), also call
managed.getEstablishedContact(contactId:) for each contactId, read
EstablishedContact.getAlias(), getNote(), and isHidden() and use those values to
populate the DashPayContact alias, note, and isHidden fields (falling back to
profile.displayName or the hex short id when alias is empty); ensure you still
set identityId and id as before and handle any nil/throwing calls safely (try?
for both wallet.getDashPayProfile and managed.getEstablishedContact) so filters
and note/alias rendering work correctly.

202-215: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Contact request creation timestamps are not being read from the SDK.

The code maps incomingIds and sentIds directly to DashPayContactRequest structs using only the identity IDs, but never calls managed.getIncomingContactRequest(senderId:) or managed.getSentContactRequest(recipientId:) to fetch the actual ContactRequest objects that hold the real createdAt timestamp.

Impact:

  • Line 409 displays request.createdAt, style: .relative, but createdAt defaults to Date() (current time) at Line 330.
  • All requests incorrectly show "just now" instead of the actual creation time.
  • Request age information is lost.
🔧 Proposed fix to fetch request timestamps
 incomingRequests = incomingIds.map { senderId in
+    let request = try? managed.getIncomingContactRequest(senderId: senderId)
+    let timestamp = (try? request?.getCreatedAt()) ?? 0
+    let createdAt = timestamp > 0 ? Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000) : Date()
+    
     DashPayContactRequest(
         id: "incoming-\(senderId.toHexString())",
         senderId: senderId,
-        recipientId: identity.identityId
+        recipientId: identity.identityId,
+        createdAt: createdAt
     )
 }
 sentRequests = sentIds.map { recipientId in
+    let request = try? managed.getSentContactRequest(recipientId: recipientId)
+    let timestamp = (try? request?.getCreatedAt()) ?? 0
+    let createdAt = timestamp > 0 ? Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000) : Date()
+    
     DashPayContactRequest(
         id: "sent-\(recipientId.toHexString())",
         senderId: identity.identityId,
-        recipientId: recipientId
+        recipientId: recipientId,
+        createdAt: createdAt
     )
 }

Based on learnings: Context snippet 2 shows that ContactRequest.getCreatedAt() returns a UInt64 timestamp (milliseconds since epoch) that must be converted to a Date for display.

🤖 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/FriendsView.swift`
around lines 202 - 215, The mapping that builds DashPayContactRequest in
FriendsView currently uses only IDs and thus loses real timestamps; update the
incomingIds and sentIds transforms to fetch the actual ContactRequest objects
via managed.getIncomingContactRequest(senderId:) and
managed.getSentContactRequest(recipientId:), read the
ContactRequest.getCreatedAt() (a UInt64 milliseconds-since-epoch), convert it to
a Date (Date(timeIntervalSince1970: Double(ms) / 1000.0)), and assign that Date
to DashPayContactRequest.createdAt so the UI shows the real request age; keep
existing id/senderId/recipientId assignments and handle optional/missing
ContactRequest by falling back to Date() or nil as appropriate.
🧹 Nitpick comments (1)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift (1)

290-339: Note: Several struct fields are defined but never populated.

The DashPayContact and DashPayContactRequest structs define fields that are not currently set during initialization in loadFriends():

  • DashPayContact.note — always nil (never fetched from EstablishedContact.getNote())
  • DashPayContact.dpnsName — always nil (DPNS lookup not implemented)
  • DashPayContact.isHidden — always false (never fetched from EstablishedContact.isHidden())
  • DashPayContactRequest.createdAt — always defaults to Date() (never fetched from ContactRequest.getCreatedAt())
  • DashPayContactRequest.senderDisplayName — always nil (sender profile lookup not implemented)

These fields render correctly when populated (Lines 362-377, Line 409), but the initialization logic doesn't call the SDK accessors to retrieve the values. This is addressed by the fixes proposed in the earlier comments.

🤖 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/FriendsView.swift`
around lines 290 - 339, The issue is that several fields on DashPayContact and
DashPayContactRequest are never populated; update the loadFriends() logic where
EstablishedContact and ContactRequest objects are mapped into DashPayContact and
DashPayContactRequest to call the SDK accessors and fill these fields: for
DashPayContact call EstablishedContact.getNote() to set note,
EstablishedContact.isHidden() to set isHidden, and perform the DPNS name lookup
(same logic used elsewhere) to set dpnsName before constructing DashPayContact;
for DashPayContactRequest call ContactRequest.getCreatedAt() to set createdAt
and fetch the sender’s profile/display name to set senderDisplayName before
constructing DashPayContactRequest. Ensure you use the DashPayContact and
DashPayContactRequest initializers (id, displayName, identityId, dpnsName, note,
isHidden) and (id, senderId, recipientId, createdAt, senderDisplayName)
respectively when replacing the current mappings.
🤖 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/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift`:
- Around line 618-643: The delete path still only removes the legacy account
name but writes now use account =
"identity_privkey.\(metadata.walletId).\(derivationPath)"; update the single-key
delete API (the function that deletes by Keychain identifier / the delete method
that currently expects the legacy name) to attempt deletion using both the new
namespaced account string ("identity_privkey.<walletId>.<derivationPath>") and
the legacy form ("identity_privkey.<derivationPath>"), or fall back to the
metadata-scan used by retrieveIdentityPrivateKey(publicKeyHex:) /
retrieveKeyData(identifier:), and ensure
PersistentPublicKey.privateKeyKeychainIdentifier values remain consistent after
upserts.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift`:
- Around line 140-160: pickerEntries currently builds system entries by
filtering out any IDs present in contractsForNetwork, causing DashPay's system
metadata to be dropped when a saved DashPay exists; update the logic in the
pickerEntries computed property so systemContractsAllowingKeyBounds entries are
kept authoritative (i.e., prefer the system entry when IDs collide) — build
system entries first (from Self.systemContractsAllowingKeyBounds) and then
append only saved entries whose id is not present in the system set (derived
from systemContractsAllowingKeyBounds) so BoundsPickerEntry for DashPay retains
the system allowsContractScope and documentTypesAllowingBounds values rather
than being overwritten by the saved contract.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift`:
- Around line 1379-1449: The makeDashpayKeyPair helper writes derived private
keys to Keychain and registers pubkeys without verifying the private key
actually matches preview.publicKeyHex; add a validation step after deriving the
key and before calling KeychainManager.shared.storeIdentityPrivateKey by
invoking KeyValidation.validatePrivateKeyForPublicKey(preview.privateKeyData,
preview.publicKeyHex) (or equivalent) and throw a
PlatformWalletError.walletOperation if validation fails—mirror the check used in
AddIdentityKeyView.submit() so deriveIdentityAuthKeyAtSlot,
preview.publicKeyHex, preview.privateKeyData,
KeyValidation.validatePrivateKeyForPublicKey, and
KeychainManager.shared.storeIdentityPrivateKey are used to locate and implement
the guard.
- Around line 862-877: The submit() path currently appends DashPay keys whenever
addDashPayKeys is true, even for resume/unusedAssetLock flows where
dashpayKeysSection is hidden; update submit() to use the same gate used by the
UI (the condition that controls dashpayKeysSection visibility) before calling
makeDashpayKeyPair and appending keys so you only add keys for the flows that
show the dashpay UI; specifically, guard the block that calls
makeDashpayKeyPair/append to identityPubkeys (and uses Self.defaultKeyCount,
managedWallet, walletId, identityIndex, platformState.currentNetwork) with the
same resume/unusedAssetLock check that prevents dashpayKeysSection from showing,
ensuring resumeIdentityWithAssetLock(...) won’t see an altered key count.

---

Outside diff comments:
In `@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift`:
- Around line 222-234: When building contacts from establishedIds inside the
mapping (currently using wallet.getDashPayProfile), also call
managed.getEstablishedContact(contactId:) for each contactId, read
EstablishedContact.getAlias(), getNote(), and isHidden() and use those values to
populate the DashPayContact alias, note, and isHidden fields (falling back to
profile.displayName or the hex short id when alias is empty); ensure you still
set identityId and id as before and handle any nil/throwing calls safely (try?
for both wallet.getDashPayProfile and managed.getEstablishedContact) so filters
and note/alias rendering work correctly.
- Around line 202-215: The mapping that builds DashPayContactRequest in
FriendsView currently uses only IDs and thus loses real timestamps; update the
incomingIds and sentIds transforms to fetch the actual ContactRequest objects
via managed.getIncomingContactRequest(senderId:) and
managed.getSentContactRequest(recipientId:), read the
ContactRequest.getCreatedAt() (a UInt64 milliseconds-since-epoch), convert it to
a Date (Date(timeIntervalSince1970: Double(ms) / 1000.0)), and assign that Date
to DashPayContactRequest.createdAt so the UI shows the real request age; keep
existing id/senderId/recipientId assignments and handle optional/missing
ContactRequest by falling back to Date() or nil as appropriate.

---

Nitpick comments:
In `@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift`:
- Around line 290-339: The issue is that several fields on DashPayContact and
DashPayContactRequest are never populated; update the loadFriends() logic where
EstablishedContact and ContactRequest objects are mapped into DashPayContact and
DashPayContactRequest to call the SDK accessors and fill these fields: for
DashPayContact call EstablishedContact.getNote() to set note,
EstablishedContact.isHidden() to set isHidden, and perform the DPNS name lookup
(same logic used elsewhere) to set dpnsName before constructing DashPayContact;
for DashPayContactRequest call ContactRequest.getCreatedAt() to set createdAt
and fetch the sender’s profile/display name to set senderDisplayName before
constructing DashPayContactRequest. Ensure you use the DashPayContact and
DashPayContactRequest initializers (id, displayName, identityId, dpnsName, note,
isHidden) and (id, senderId, recipientId, createdAt, senderDisplayName)
respectively when replacing the current mappings.
🪄 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: c6d84bc6-d0e5-4a71-8558-f04615575f46

📥 Commits

Reviewing files that changed from the base of the PR and between 1f66e5d and c05ca3e.

📒 Files selected for processing (24)
  • packages/rs-sdk/src/platform/dpns_usernames/mod.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayService.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsViewStubs.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundFromAssetLockPlatformAddressView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ObservableDashPayService.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
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpIdentityView.swift
💤 Files with no reviewable changes (3)
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ObservableDashPayService.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayService.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsViewStubs.swift

Comment thread packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift Outdated
@codecov
Copy link
Copy Markdown

codecov Bot commented May 28, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.33%. Comparing base (2c9e9af) to head (8f65db2).
⚠️ Report is 2 commits behind head on v3.1-dev.

Additional details and impacted files
@@             Coverage Diff              @@
##           v3.1-dev    #3765      +/-   ##
============================================
+ Coverage     87.17%   87.33%   +0.15%     
============================================
  Files          2607     2590      -17     
  Lines        319589   317082    -2507     
============================================
- Hits         278602   276911    -1691     
+ Misses        40987    40171     -816     
Components Coverage Δ
dpp 87.73% <ø> (ø)
drive 85.95% <ø> (ø)
drive-abci 89.60% <ø> (ø)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.17% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.14% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

PR enables the DashPay iOS flow with focused DPNS, Keychain, and SwiftData fixes. Verified one blocking regression introduced by the new key-health tooling — ECDSA_HASH160 keys are misclassified as orphans because the check compares the 33-byte derived pubkey against the 20-byte HASH160 stored in PersistentPublicKey.publicKeyData (confirmed by AddIdentityKeyView's own HASH160 persistence path and the model's storage shape). Several suggestions follow on the new DashPay key path (lossy ContractBounds round-trip, raw-private-key/WIF surfacing) and on Keychain hygiene (stale deleteIdentityPrivateKey format, orphan delete leaves Keychain bytes).

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

2 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/WalletKeyHealthSheet.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift:194-231: Key-health check misclassifies ECDSA_HASH160 identity keys as orphans
  `runCheck` compares `preview.publicKeyData` (the 33-byte compressed secp256k1 pubkey returned by `deriveIdentityAuthKeyAtSlot`) directly against `row.publicKeyData`. For `ECDSA_HASH160` keys those two values are never equal: `AddIdentityKeyView.swift:582-592` deliberately persists the 20-byte HASH160 as `pubkeyBytesForFFI`, and that 20-byte payload is what flows back through the persister callback into `PersistentPublicKey.publicKeyData` (`PlatformWalletPersistenceHandler.swift:1394`). The check therefore takes the `else` branch and emits `.orphan(...)`, which rolls the entire identity up to `.orphan` and exposes the destructive `Delete this identity` button (`WalletKeyHealthSheet.swift:509-515`) for an identity whose mnemonic-derived key is in fact correct. Because the sheet is reachable from Wallet Info as a repair tool, any wallet containing a HASH160 identity key will see this deterministic false positive. Branch on `keyType`: for `.ecdsaHash160`, compare against `KeychainManager.computePublicKeyHashHex(preview.publicKeyData)` (or its raw 20-byte form) instead of the 33-byte pubkey.
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift:333-340: `deleteOrphan` leaves the orphan identity's private-key bytes in Keychain
  `WalletKeyHealthChecker.deleteOrphan` cascades the SwiftData rows but does not delete the corresponding `identity_privkey.<walletIdHex>.<derivationPath>` (or legacy `identity_privkey.<derivationPath>`) Keychain entries. The user-facing alert (`WalletKeyHealthSheet.swift:424-432`) tells the user "all associated keys" are removed, which sets the wrong expectation. Items are `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`, not biometric-gated. Snapshot `identity.publicKeys` before delete, then `SecItemDelete` rows at both the namespaced and legacy account names, gated on the same `metadata.walletId` check used by `cleanupLegacyKeychainEntry` to avoid clobbering another wallet's bytes.

In `packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift:794-807: `deleteIdentityPrivateKey(derivationPath:)` was not migrated to the walletId-namespaced account
  This PR migrates `storeIdentityPrivateKey` to `identity_privkey.<walletIdHex>.<derivationPath>` (line 642) but leaves the sibling deleter constructing the legacy `identity_privkey.<derivationPath>` account string. `SecItemDelete` therefore never matches any row written by the new path; the helper returns success on `errSecItemNotFound` and silently leaks the secret bytes. Confirmed by grep: this helper has no current callers, so the defect is latent — but the API is still public and is an obvious next stop for any cleanup code that doesn't go through the new `deleteAllIdentityPrivateKeys(forWalletId:)`. Either require the same `(walletId, derivationPath)` pair the writer uses, or remove the helper.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:1369-1396: DashPay keys' `ContractBounds` are dropped on persist round-trip
  `makeDashpayKeyPair` registers the two DashPay keys with `ContractBounds.singleContractDocumentType(id: dashpayContractId, documentTypeName: "contactRequest")` (`CreateIdentityView.swift:1390-1393`), but the persister callback constructs `PersistentPublicKey` without touching `contractBoundsData` and never updates it on subsequent upserts. As a result, every persisted DashPay key resurfaces with `contractBounds == nil` via `toIdentityPublicKey()`. `TransitionDetailView` builds local DPP projections from `identity.identityPublicKeys` across many state transitions, so any local lookup of these keys will see them as unbounded rather than scoped to the contactRequest document type. Even when bounds *are* persisted, `PersistentPublicKey.from` / `toIdentityPublicKey()` only round-trip `[contractId]` → `.singleContract(id:)`, so the `documentTypeName` would still be lost. Carry the contract-bounds payload through the persister callback and extend `PersistentPublicKey` to preserve the document-type variant.

In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift:1406-1432: Default DashPay key path materializes raw private keys + WIFs across the FFI boundary
  `makeDashpayKeyPair` derives the encryption/decryption pair via `ManagedPlatformWallet.deriveIdentityAuthKeyAtSlot(...)`, which crosses the FFI returning both `private_key_bytes` and `private_key_wif` into ARC-managed Swift objects. That is materially weaker than the existing callback-based `IdentityKeyPersister.persist` flow, which keeps the secret short-lived and never serializes it as a WIF string. Because `addDashPayKeys` is now default-on, every wallet-backed identity registration materializes two extra private scalars and two WIF strings on the Swift side. Per `packages/swift-sdk/CLAUDE.md`, this is the kind of "derive + write" pipeline that should live in `platform-wallet` behind a single FFI entry point that returns to Swift only what's needed for the Keychain write.
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift:1395-1436: Pre-registration DashPay key writes leave Keychain orphans with empty `identityId` on failed submit
  `makeDashpayKeyPair` writes private bytes to Keychain with `IdentityPrivateKeyMetadata.identityId = ""` and relies on the post-on-chain persister callback to overwrite the field. If registration aborts between this write and a successful land (network failure, app kill, asset-lock rejection), the two 32-byte secrets stay in Keychain at the namespaced account with an empty `identityId` and no anchoring `PersistentPublicKey` row. They won't show up as orphans in the health sheet either, since the sheet walks identities. Two options: defer the Keychain write until the registration call succeeds (and let the persister callback own it), or sweep the pre-derived entries on submit error.
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift:1406-1411: DashPay encryption/decryption keys derived via the IDENTITY_AUTHENTICATION DIP-9 sub-feature
  `deriveIdentityAuthKeyAtSlot(identityIndex:, keyId:)` hardcodes the base path to `IDENTITY_AUTHENTICATION_PATH_{MAINNET|TESTNET}` regardless of caller purpose; this PR makes that the default DashPay encryption/decryption derivation. Two consequences: (1) the derived bytes are a pure function of `(identity_index, key_index)` with no purpose component, so if any future flow ever derives an *authentication* key at the same `keyId` it produces the same 32-byte secret — ECDH and ECDSA sharing a private key is a textbook reuse hazard, and the `nextKeyId = max + 1` allocator is the only thing keeping the invariant. (2) A standards-compliant DIP-9 wallet restoring from the same mnemonic will look for DashPay keys under the encryption sub-feature path, not under `5'/0'`, and will silently miss them. Per `packages/swift-sdk/CLAUDE.md`, the right fix is in Rust: extend `platform-wallet` so callers can request an encryption-purpose keypair via the proper sub-feature, and call it here.

In `packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift:252-263: Hardcoded `platform_version = 11` for mainnet/testnet will silently break at the next protocol activation
  When mainnet or testnet activates v12+, every SDK consumer using this default will keep emitting v11 `getDocuments` wire shape and start hitting the very `"could not decode data contracts query"` failure this branch is fixing. There is no compile-time link between this constant and `PLATFORM_VERSIONS` in `rs-platform-version`, so a protocol bump won't trigger any indicator. Best fix: probe the active protocol version on init (the auto-discover masternode path is precedent), or expose a Rust-side helper `dash_sdk_default_platform_version_for_network` so the mapping lives next to the version tables. At minimum, add a tracked TODO with a clear trigger.

In `packages/rs-sdk/src/platform/dpns_usernames/mod.rs`:
- [SUGGESTION] packages/rs-sdk/src/platform/dpns_usernames/mod.rs:406-413: `is_dpns_name_available` correctness fix has no automated test
  The semantic change from `documents.is_empty()` to `documents.values().all(|d| d.is_none())` is the central correctness fix in the PR and drives user-visible DPNS registration UX, but is exercised only manually via the iOS app. Commit 2c9e9af692 just relocated DPNS network tests under `tests/`, so there is now a natural home for a positive case (proven-non-existence map → `Ok(true)`) and a negative case (`Some(Document)` entry → `Ok(false)`). Add both so a future change to `Document::fetch_many`'s return shape can't regress the semantics silently.

Comment on lines +1406 to +1432
for (keyId, purpose) in purposes {
let preview = try managedWallet.deriveIdentityAuthKeyAtSlot(
identityIndex: identityIndex,
keyId: keyId,
network: network
)
let pubKeyHashHex = SwiftDashSDK.KeychainManager.computePublicKeyHashHex(
preview.publicKeyData
)
let metadata = IdentityPrivateKeyMetadata(
identityId: identityIdPlaceholder,
keyId: keyId,
walletId: walletIdHex,
identityIndex: identityIndex,
keyIndex: keyId,
derivationPath: preview.derivationPath,
publicKey: preview.publicKeyHex,
publicKeyHash: pubKeyHashHex,
keyType: KeyType.ecdsaSecp256k1.rawValue,
purpose: purpose.rawValue,
securityLevel: SecurityLevel.medium.rawValue
)
guard KeychainManager.shared.storeIdentityPrivateKey(
preview.privateKeyData,
derivationPath: preview.derivationPath,
metadata: metadata
) != nil else {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Default DashPay key path materializes raw private keys + WIFs across the FFI boundary

makeDashpayKeyPair derives the encryption/decryption pair via ManagedPlatformWallet.deriveIdentityAuthKeyAtSlot(...), which crosses the FFI returning both private_key_bytes and private_key_wif into ARC-managed Swift objects. That is materially weaker than the existing callback-based IdentityKeyPersister.persist flow, which keeps the secret short-lived and never serializes it as a WIF string. Because addDashPayKeys is now default-on, every wallet-backed identity registration materializes two extra private scalars and two WIF strings on the Swift side. Per packages/swift-sdk/CLAUDE.md, this is the kind of "derive + write" pipeline that should live in platform-wallet behind a single FFI entry point that returns to Swift only what's needed for the Keychain write.

source: ['codex']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Acknowledged, not addressed in this commit. Migrating makeDashpayKeyPair to the callback-based persister flow (no raw private_key_bytes / private_key_wif across FFI) requires an analog of prePersistIdentityKeysForRegistration that accepts a custom (keyId, purpose, securityLevel, contractBounds) schedule — the existing helper only knows the default auth-key policy. Tracked as a follow-up so addDashPayKeys: true defaults don't materialize secrets ARC-managed on the Swift side. Mitigation in this commit: KeyValidation.validatePrivateKeyForPublicKey runs before the Keychain write so a mis-derived key fails fast.

Comment thread packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift
Comment on lines +1395 to +1436
let identityIdPlaceholder = "" // overwritten by persister callback
// when the identity actually
// lands on-chain; the metadata
// we write here only needs to
// satisfy the keychain
// round-trip lookup by pubkey
// hex.

var rows: [ManagedPlatformWallet.IdentityPubkey] = []
rows.reserveCapacity(purposes.count)

for (keyId, purpose) in purposes {
let preview = try managedWallet.deriveIdentityAuthKeyAtSlot(
identityIndex: identityIndex,
keyId: keyId,
network: network
)
let pubKeyHashHex = SwiftDashSDK.KeychainManager.computePublicKeyHashHex(
preview.publicKeyData
)
let metadata = IdentityPrivateKeyMetadata(
identityId: identityIdPlaceholder,
keyId: keyId,
walletId: walletIdHex,
identityIndex: identityIndex,
keyIndex: keyId,
derivationPath: preview.derivationPath,
publicKey: preview.publicKeyHex,
publicKeyHash: pubKeyHashHex,
keyType: KeyType.ecdsaSecp256k1.rawValue,
purpose: purpose.rawValue,
securityLevel: SecurityLevel.medium.rawValue
)
guard KeychainManager.shared.storeIdentityPrivateKey(
preview.privateKeyData,
derivationPath: preview.derivationPath,
metadata: metadata
) != nil else {
throw PlatformWalletError.walletOperation(
"Could not persist DashPay key (kid \(keyId), purpose \(purpose.name)) to Keychain"
)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Pre-registration DashPay key writes leave Keychain orphans with empty identityId on failed submit

makeDashpayKeyPair writes private bytes to Keychain with IdentityPrivateKeyMetadata.identityId = "" and relies on the post-on-chain persister callback to overwrite the field. If registration aborts between this write and a successful land (network failure, app kill, asset-lock rejection), the two 32-byte secrets stay in Keychain at the namespaced account with an empty identityId and no anchoring PersistentPublicKey row. They won't show up as orphans in the health sheet either, since the sheet walks identities. Two options: defer the Keychain write until the registration call succeeds (and let the persister callback own it), or sweep the pre-derived entries on submit error.

source: ['claude']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Acknowledged, not addressed in this commit. Pre-registration Keychain writes do leave entries with identityId = "" if submit aborts, and the health sheet doesn't currently iterate them (it walks identities). The cleaner fix is to defer the Keychain write into the persister-callback path so it only lands after on-chain success — same end state as the raw-private-keys-across-FFI item above and they're naturally addressed together. Tracked as a follow-up; in the meantime the entries are recoverable via the (existing) wallet-delete flow's deleteAllIdentityPrivateKeys(forWalletId:) sweep.

Comment on lines +1406 to +1411
for (keyId, purpose) in purposes {
let preview = try managedWallet.deriveIdentityAuthKeyAtSlot(
identityIndex: identityIndex,
keyId: keyId,
network: network
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: DashPay encryption/decryption keys derived via the IDENTITY_AUTHENTICATION DIP-9 sub-feature

deriveIdentityAuthKeyAtSlot(identityIndex:, keyId:) hardcodes the base path to IDENTITY_AUTHENTICATION_PATH_{MAINNET|TESTNET} regardless of caller purpose; this PR makes that the default DashPay encryption/decryption derivation. Two consequences: (1) the derived bytes are a pure function of (identity_index, key_index) with no purpose component, so if any future flow ever derives an authentication key at the same keyId it produces the same 32-byte secret — ECDH and ECDSA sharing a private key is a textbook reuse hazard, and the nextKeyId = max + 1 allocator is the only thing keeping the invariant. (2) A standards-compliant DIP-9 wallet restoring from the same mnemonic will look for DashPay keys under the encryption sub-feature path, not under 5'/0', and will silently miss them. Per packages/swift-sdk/CLAUDE.md, the right fix is in Rust: extend platform-wallet so callers can request an encryption-purpose keypair via the proper sub-feature, and call it here.

source: ['claude']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Acknowledged, not addressed in this commit. The fix the reviewer describes (extend platform-wallet with an encryption-purpose sub-feature derivation under the proper DIP-9 path, then route Swift through a single FFI entry point) is the right shape and belongs in Rust per packages/swift-sdk/CLAUDE.md. Filed as a follow-up. The collision hazard the comment flags is real but bounded by nextKeyId = max + 1's strict-monotonic invariant today; landing a DIP-9-compliant path before any external wallet recovers from the same mnemonic is the long-term remediation.

Comment on lines 406 to +413
let documents = Document::fetch_many(self, query).await?;

// If no documents found, the name is available
Ok(documents.is_empty())
// `Document::fetch_many` returns `BTreeMap<Identifier, Option<Document>>`
// — a non-existence proof comes back as a non-empty map whose values are
// all `None`. Checking `documents.is_empty()` would treat a proven
// non-existence as "taken". The name is available iff no entry in the
// map carries an actual document.
Ok(documents.values().all(|d| d.is_none()))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: is_dpns_name_available correctness fix has no automated test

The semantic change from documents.is_empty() to documents.values().all(|d| d.is_none()) is the central correctness fix in the PR and drives user-visible DPNS registration UX, but is exercised only manually via the iOS app. Commit 2c9e9af just relocated DPNS network tests under tests/, so there is now a natural home for a positive case (proven-non-existence map → Ok(true)) and a negative case (Some(Document) entry → Ok(false)). Add both so a future change to Document::fetch_many's return shape can't regress the semantics silently.

source: ['claude']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Acknowledged, not addressed in this commit. An automated test for is_dpns_name_available (positive Ok(true) on proven-non-existence Some(None) map + negative Ok(false) on Some(Document)) is the right gate against silent regressions in Document::fetch_many semantics. Filed as a follow-up — slotting it into the existing tests/ directory is cheap once the iOS PR is in; doing it here would balloon the diff with test-only infra unrelated to the DashPay flow.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 28, 2026

✅ 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: "75e21716cbb2d3b3e37786c6dc56148f33e41c12950061d3644a336a8431ed80"
)

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.

- rs-sdk: fix DPNS availability false-positive — non-existence
  proofs come back as an IndexMap with `None` values, not an empty
  map, so `documents.is_empty()` was reporting available names as
  taken. Switched to `values().all(is_none)`.

- swift-sdk SDK: default `platformVersion` is now network-aware
  (11 for mainnet/testnet, 12 for devnet/regtest) so we don't emit
  the V1 `getDocuments` wire format against pre-v3.1 nodes that
  reject it with "could not decode data contracts query".

- swift-sdk PlatformWalletPersistenceHandler.deleteWalletData:
  switched to a four-phase save (identity-children → identities →
  accounts → wallet) so SwiftData's save-time inverse cleanup
  never has to touch a non-optional inverse on a row in the same
  batch as the parent's delete. Avoids the
  `Cannot remove PersistentX from relationship Y on PersistentZ`
  fatal at the cost of atomicity across phases (acceptable for a
  user-initiated wipe). All model relationships stay non-Optional.

- swift-sdk keychain: namespace identity-private-key keychain
  accounts by walletId (`identity_privkey.<walletIdHex>.<path>`).
  The old per-path scheme collided whenever two wallets had an
  identity at the same `identity_index`, leaving prior rows
  pointing at another wallet's private bytes.

- swift-example-app AddIdentityKeyView: surface DashPay as a
  "(System)" entry in the contract-bounds picker; lock KeyType to
  ECDSA secp256k1 + SecurityLevel to Medium when purpose is
  Encryption/Decryption (DPP enforces both); require the document
  type binding when the contract only declares the bounded-key
  requirement at document-type level (DashPay → contactRequest),
  with auto-fill.

- swift-example-app CreateIdentityView: new default-on toggle
  "Register DashPay keys" that registers 2 extra keys (Encryption +
  Decryption, MEDIUM, ECDSA secp256k1, bound to DashPay →
  contactRequest) alongside the default auth keys. Asset-lock
  minimum auto-scales for the extra `identity_key_in_creation_cost`.

- swift-example-app WalletKeyHealthSheet: new diagnostic in Wallet
  Info — re-derives each `PersistentPublicKey` from the wallet
  mnemonic, classifies healthy / needsRederive / orphan, and
  offers Re-derive (writes the new-format keychain entry + sweeps
  the legacy one) or Delete Identity.

- swift-example-app WalletRowView.platformBalance +
  BalanceCardView.platformBalance: skip identities whose
  `modelContext` is nil to avoid reading invalidated rows during
  a cascade delete.

- Dead code removed: stale `DashPayService` (Swift SDK; hardcoded
  key indices, didn't go through the new FFI), `ObservableDashPayService`
  (empty wrapper, never used), `FriendsViewStubs` (folded real
  types into FriendsView).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@QuantumExplorer QuantumExplorer force-pushed the claude/thirsty-williams-6e9d18 branch from c05ca3e to 2bf062c Compare May 28, 2026 16:01
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (2)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift (2)

1406-1436: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate each derived DashPay key before persisting.

makeDashpayKeyPair writes private keys to Keychain without verifying they match the returned public key. AddIdentityKeyView.submit() performs this validation (lines 506-518), but this helper skips it. A mismatch would register an identity with unusable DashPay keys.

Suggested fix
         for (keyId, purpose) in purposes {
             let preview = try managedWallet.deriveIdentityAuthKeyAtSlot(
                 identityIndex: identityIndex,
                 keyId: keyId,
                 network: network
             )
+            guard KeyValidation.validatePrivateKeyForPublicKey(
+                privateKeyHex: preview.privateKeyData.toHexString(),
+                publicKeyHex: preview.publicKeyHex,
+                keyType: .ecdsaSecp256k1,
+                network: network
+            ) else {
+                throw PlatformWalletError.walletOperation(
+                    "Derived DashPay key didn't match its public key (kid \(keyId), purpose \(purpose.name))"
+                )
+            }
             let pubKeyHashHex = SwiftDashSDK.KeychainManager.computePublicKeyHashHex(
                 preview.publicKeyData
             )

As per coding guidelines, "Always validate private keys match their public keys using KeyValidation.validatePrivateKeyForPublicKey."

🤖 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/CreateIdentityView.swift`
around lines 1406 - 1436, In the for-loop inside CreateIdentityView (where
deriveIdentityAuthKeyAtSlot returns `preview`) validate that the derived private
key matches the returned public key before calling
`KeychainManager.shared.storeIdentityPrivateKey`; specifically call
`KeyValidation.validatePrivateKeyForPublicKey` (the same check used in
`AddIdentityKeyView.submit`) with `preview.privateKeyData` and
`preview.publicKeyHex` and throw a `PlatformWalletError.walletOperation` if
validation fails, then proceed to store only when validation succeeds.

862-877: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Gate addDashPayKeys check to match UI visibility condition.

The UI hides dashpayKeysSection for .unusedAssetLock (resume) flows, but submit() still checks addDashPayKeys unconditionally. If a user toggles DashPay keys on, then switches to resume an existing asset lock, the extra keys are still appended—potentially causing resumeIdentityWithAssetLock to fail against an outpoint funded for the original 3-key registration.

Suggested fix

Add a computed property and use it in both plannedIdentityKeyCount and submit():

+    /// Whether to register DashPay keys for this submission. Matches
+    /// the UI gate so resume/walletless paths never silently add keys.
+    private var shouldRegisterDashPayKeys: Bool {
+        guard case .wallet = walletSelection,
+              fundingSelection != .unusedAssetLock else {
+            return false
+        }
+        return addDashPayKeys
+    }

     private var plannedIdentityKeyCount: UInt32 {
-        Self.defaultKeyCount + (addDashPayKeys ? Self.dashPayExtraKeyCount : 0)
+        Self.defaultKeyCount + (shouldRegisterDashPayKeys ? Self.dashPayExtraKeyCount : 0)
     }
     
     // In submit():
-        if addDashPayKeys {
+        if shouldRegisterDashPayKeys {
             do {
                 identityPubkeys.append(contentsOf: try makeDashpayKeyPair(
🤖 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/CreateIdentityView.swift`
around lines 862 - 877, The submit flow is appending DashPay keys even when the
UI hides dashpayKeysSection for the .unusedAssetLock (resume) flow; add a
computed Bool (e.g., isDashPayVisible or canAddDashPayKeys) that encodes the
same visibility condition used by dashpayKeysSection, then use that property
instead of directly checking addDashPayKeys in both plannedIdentityKeyCount and
submit() so DashPay keys are only added when the UI actually allows them
(affecting makeDashpayKeyPair, addDashPayKeys, plannedIdentityKeyCount, and
resumeIdentityWithAssetLock code paths).
🤖 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 2631-2637: The current flow deletes Core Data identity rows
(backgroundContext.delete(identity) + try backgroundContext.save()) before the
caller runs PlatformWalletManager.deleteWallet(...) keychain cleanup, which can
break retries because identityIdsForWallet becomes empty; fix by ensuring
keychain items are removed before committing identity deletion: either iterate
identityIdsForWallet and call deleteAllKeychainItems(forIdentityId:) for each id
prior to performing backgroundContext.delete(identity) and save(), or
retain/persist the identityIdsForWallet across retries (e.g., return or store
the IDs so deleteAllKeychainItems(forIdentityId:) can be retried safely) so the
caller’s cleanup can always find the identity IDs.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift`:
- Around line 1367-1370: Replace the direct property access in FieldRow so
AccountStorageDetailView uses the shared helper: instead of reading
record.wallet.name or record.wallet.walletId directly, call
walletLabel(record.wallet) (the same approach used in IdentityStorageDetailView)
to build the display string; update the FieldRow with label "Wallet" to use
walletLabel(record.wallet) to centralize formatting and avoid crashes when
record.wallet is nil or invalidated.

---

Duplicate comments:
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift`:
- Around line 1406-1436: In the for-loop inside CreateIdentityView (where
deriveIdentityAuthKeyAtSlot returns `preview`) validate that the derived private
key matches the returned public key before calling
`KeychainManager.shared.storeIdentityPrivateKey`; specifically call
`KeyValidation.validatePrivateKeyForPublicKey` (the same check used in
`AddIdentityKeyView.submit`) with `preview.privateKeyData` and
`preview.publicKeyHex` and throw a `PlatformWalletError.walletOperation` if
validation fails, then proceed to store only when validation succeeds.
- Around line 862-877: The submit flow is appending DashPay keys even when the
UI hides dashpayKeysSection for the .unusedAssetLock (resume) flow; add a
computed Bool (e.g., isDashPayVisible or canAddDashPayKeys) that encodes the
same visibility condition used by dashpayKeysSection, then use that property
instead of directly checking addDashPayKeys in both plannedIdentityKeyCount and
submit() so DashPay keys are only added when the UI actually allows them
(affecting makeDashpayKeyPair, addDashPayKeys, plannedIdentityKeyCount, and
resumeIdentityWithAssetLock code paths).
🪄 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: 1fe33e52-67aa-41d6-804d-4da9e6fd9dd1

📥 Commits

Reviewing files that changed from the base of the PR and between c05ca3e and 2bf062c.

📒 Files selected for processing (15)
  • packages/rs-sdk/src/platform/dpns_usernames/mod.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayService.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsViewStubs.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ObservableDashPayService.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift
💤 Files with no reviewable changes (3)
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsViewStubs.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ObservableDashPayService.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayService.swift
✅ Files skipped from review due to trivial changes (1)
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/rs-sdk/src/platform/dpns_usernames/mod.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Cumulative incremental review at head 2bf062c. The latest delta (c05ca3e..2bf062c) restores non-Optional SwiftData inverses on PersistentAccount/DPNSName/DashpayProfile/DashpayContactRequest and introduces a four-phase save() in PlatformWalletPersistenceHandler.deleteWalletData to avoid the SwiftData inverse-nullify fatal. The four-phase delete in deleteWalletData is internally consistent, but the sibling WalletKeyHealthSheet.deleteOrphan was not given the same treatment AND its doc comment now lies about the relationships being Optional — that is the one new blocking issue in the latest delta. All 10 prior findings from the c05ca3e review remain STILL VALID; the latest delta did not touch any of those code regions. Status reconciliation: 10 prior findings STILL VALID (all carried forward), 0 FIXED, 0 OUTDATED, 0 INTENTIONALLY DEFERRED. New in latest delta: 1 blocking (deleteOrphan stale comment + non-Optional inverse fatal risk) and 1 nitpick (SendTransactionView.platformBalance missing the same mid-cascade-delete guard added to BalanceCardView). Overflow: 2 lower-confidence nitpicks (SendTransactionView guard asymmetry and rederive silently swallowing Keychain failures) dropped from the report to fit the 10-finding budget.

🔴 1 blocking | 🟡 2 suggestion(s)

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

7 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/WalletKeyHealthSheet.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift:327-340: [NEW in latest delta] `deleteOrphan` doc-comment is stale and the single-save path will fatal under the now non-Optional inverses
  The latest delta restores `PersistentDPNSName.identity` (PersistentDPNSName.swift:84) and `PersistentDashpayProfile.identity` (PersistentDashpayProfile.swift:86) to non-Optional, and adds the explicit four-phase delete in `PlatformWalletPersistenceHandler.deleteWalletData` precisely to avoid the SwiftData `Cannot remove PersistentX from relationship Y on PersistentZ because an appropriate default value is not configured` fatal that fires when a save would null out a non-Optional inverse. `deleteOrphan` at 333-340 was not given the same treatment: it still calls `modelContext.delete(identity)` followed by a single `try modelContext.save()`. If the orphan identity has any attached DPNS name, DashPay profile, or contact-request row, that single save will reproduce the exact fatal the PR author called out elsewhere. The path is reachable from the `Delete this identity` button in this sheet — an orphan identity reached on a wallet re-imported from a different mnemonic can plausibly still own DPNS labels or a DashPay profile from the previous wallet. Compounding the risk, the doc comment on lines 328-332 still claims `PersistentDPNSName.identity`, `PersistentDashpayProfile.identity`, and `PersistentDashpayContactRequest.owner` 'are all Optional', which is no longer true — the comment is now actively misleading anyone who reads it to verify the safety claim. Apply the same per-layer-then-save sequencing as `deleteWalletData` (delete `identity.dpnsNames`, `identity.dashpayProfile`, `identity.contactRequests` first, save, then delete the identity and save again) and update the doc comment to reflect the new relationship shape.

In `packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift:794-807: [CARRIED FORWARD — STILL VALID] `deleteIdentityPrivateKey(derivationPath:)` not migrated to the walletId-namespaced account
  Verified at head 2bf062c0 — line 796 still constructs `identity_privkey.\(derivationPath)`, while `storeIdentityPrivateKey` now writes `identity_privkey.\(walletIdHex).\(derivationPath)`. `SecItemDelete` therefore never matches any row written by the new path; the helper returns success on `errSecItemNotFound` and silently leaves the secret bytes in Keychain. No current in-tree callers, so the defect is latent — but the API is still public and is the obvious next stop for any future cleanup that doesn't go through `deleteAllIdentityPrivateKeys(forWalletId:)`. Either require the same `(walletId, derivationPath)` pair the writer uses, or remove the helper.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:1369-1409: [CARRIED FORWARD — STILL VALID] DashPay keys' `ContractBounds` are dropped on persist round-trip
  Still valid. `makeDashpayKeyPair` registers the two DashPay keys with `ContractBounds.singleContractDocumentType(id: dashpayContractId, documentTypeName: "contactRequest")` (CreateIdentityView.swift:1390-1393), but `PersistentPublicKey` serializes bounds as a bare `[contractId]` list and reconstructs them with `.singleContract(id:)` — the `documentTypeName` is dropped entirely. Confirming grep: no reference to `contractBoundsData` or `contract_bounds` in the persister callback or `PersistentPublicKey`. After one persistence round-trip the local `IdentityPublicKey` projection no longer matches the key actually registered on-chain; any local DPP code reading `identity.identityPublicKeys` sees a weaker, semantically different bound than what the PR introduces. Carry the contract-bounds payload through the persister callback and extend `PersistentPublicKey` to preserve the `documentTypeName` variant.

QuantumExplorer and others added 2 commits May 28, 2026 18:30
- WalletKeyHealthSheet: branch HASH160 keys against pubkey-hash hex
  instead of comparing the 33-byte derived pubkey to the 20-byte
  stored hash; report unsupported key types as orphan with a clear
  reason. Rederive returns per-key failures so the sheet can show
  WHICH key is stuck. deleteOrphan also wipes the identity's
  Keychain entries.
- KeychainManager: deleteIdentityPrivateKey now takes walletId
  (symmetric with store) and also sweeps the legacy account.
  Added deleteAllIdentityPrivateKeys(forIdentityIdBase58:).
- AddIdentityKeyView: system contract entries always win over
  user-saved rows that share an ID, so DashPay's bounded document-
  type metadata isn't masked by a parallel saved-contract entry.
- CreateIdentityView: shouldRegisterDashPayKeys computed gate
  mirrors the UI section visibility; submit() and the funding-min
  calc both route through it. KeyValidation.validatePrivateKeyForPublicKey
  added to makeDashpayKeyPair before persisting.
- PlatformWalletManager.deleteWallet: keychain cleanup moved BEFORE
  deleteWalletData so a partial-failure retry can still find the
  identityIds to purge.
- StorageRecordDetailViews: AccountStorageDetailView now uses the
  walletLabel(record.wallet) helper.
- SDK.swift: platform_version=11 pin has an explicit TODO + trigger
  describing when to bump it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… fatal

`PersistentDPNSName.identity`, `PersistentDashpayProfile.identity`,
and `PersistentDashpayContactRequest.owner` are non-Optional, which
makes a single-save `modelContext.delete(identity)` fatal whenever
SwiftData has to null out their inverses in the same save batch.

`WalletKeyHealthChecker.deleteOrphan` now mirrors
`PlatformWalletPersistenceHandler.deleteWalletData`'s per-layer
approach: pre-delete DPNS names + DashPay profile + contact
requests + save, then delete the identity + save. Updated the
stale doc comment that claimed the inverses are Optional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@QuantumExplorer
Copy link
Copy Markdown
Member Author

Self reviewed

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift`:
- Around line 808-825: The loop that unconditionally deletes legacyAccount risks
removing another wallet's key; change the logic so that before calling
SecItemDelete for legacyAccount you perform a SecItemCopyMatching query for the
legacyAccount (using kSecClassGenericPassword, kSecAttrService, kSecAttrAccount
and kSecReturnData) retrieve and decode the stored IdentityPrivateKeyMetadata,
and only call SecItemDelete when the decoded IdentityPrivateKeyMetadata.walletId
equals walletIdHex (mirror the guarded cleanup used in WalletKeyHealthSheet);
keep the unconditional delete for newAccount but gate deletion of legacyAccount
behind this metadata walletId check.
🪄 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: fcb0d9a1-e2d9-4f4f-a252-d5a3ecae7599

📥 Commits

Reviewing files that changed from the base of the PR and between 2bf062c and ea4c16a.

📒 Files selected for processing (7)
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

Actionable comments posted: 0

…tity-key persister

`PersistentPublicKey` was serializing ContractBounds as a bare
`[contractId]` list and reconstructing them as `.singleContract(id:)`,
silently dropping the `documentTypeName` qualifier. DashPay's
encryption/decryption keys register with
`.singleContractDocumentType(id: dashpay, documentTypeName:
"contactRequest")`; after one SwiftData round-trip the local DPP
projection on `identity.identityPublicKeys` was a weaker
`.singleContract` bound that didn't match the on-chain key.

Extended the FFI struct + Swift persistence shape to round-trip
both ContractBounds variants verbatim:

Rust (rs-platform-wallet-ffi):
- IdentityKeyEntryFFI gains contract_bounds_{kind, id, document_type}.
  Kind tag mirrors DPP's `ContractBounds` (0=none, 1=SingleContract,
  2=SingleContractDocumentType); doc-type is a heap CString released
  via free_identity_key_entry_ffi.
- Layout assert bumped (136 → 184 bytes).
- 2 new tests covering both bound variants + idempotent free.

Swift (swift-sdk):
- IdentityKeyEntrySnapshot carries ManagedPlatformWallet.ContractBounds.
- persistIdentityKeysCallback projects the FFI trio into the enum.
- persistIdentityKeys writes both columns on insert AND update.
- PersistentPublicKey grows a contractBoundsDocumentTypeName column;
  toIdentityPublicKey()/from(IdentityPublicKey) round-trip both
  variants. Legacy stores without the new column load cleanly
  (per repo policy — dev stores rebuild on schema changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@QuantumExplorer
Copy link
Copy Markdown
Member Author

Addressed both carry-forward findings flagged in the latest review:

deleteIdentityPrivateKey walletId-namespace (carry-forward at KeychainManager.swift:794-807) — fixed in ea4c16a08f. API now takes (walletId, derivationPath) and sweeps the legacy account.

DashPay ContractBounds round-trip drops documentTypeName (carry-forward at PlatformWalletPersistenceHandler.swift:1369-1409) — fixed in 68f98ad1d1.

  • Extended IdentityKeyEntryFFI with contract_bounds_{kind,id,document_type} (kind tag mirrors DPP's enum: 0=none, 1=SingleContract, 2=SingleContractDocumentType; doc-type is a heap CString released via the existing free fn). Layout assert bumped 136 → 184 bytes. 2 new Rust tests cover both variants + idempotent free.
  • PersistentPublicKey grows a contractBoundsDocumentTypeName column. toIdentityPublicKey() / from(IdentityPublicKey) both round-trip the full variant; the persister callback (insert AND update paths) now writes both columns.
  • Per repo learning, no SwiftData migration concern — dev stores rebuild on schema changes.

deleteOrphan single-save crash under non-Optional inverses (NEW blocking 3319229545) — fixed in b83f9352e2. Mirrors the per-layer save sequence from deleteWalletData: pre-delete dpnsNames / dashpayProfile / contactRequests + save, then delete the identity + save. Stale doc comment updated.

`deleteIdentityPrivateKey(walletId:derivationPath:)` was deleting
`identity_privkey.<path>` (the legacy, non-namespaced account)
unconditionally. The legacy format collided across wallets — that
is the bug we are fixing — so an unconditional delete could
clobber a different wallet's row at the same DIP-9 path.

Promoted `WalletKeyHealthSheet.cleanupLegacyKeychainEntry` (which
already did the right thing — lookup metadata, decode, match
walletId, delete) into a public `KeychainManager` helper
`deleteLegacyKeychainEntryIfOwnedByWallet(walletIdHex:derivationPath:)`.
Both `deleteIdentityPrivateKey` and the post-rederive cleanup in
`WalletKeyHealthSheet` now route through it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Cumulative review at head ea4c16a. The latest delta (2bf062c..ea4c16a) resolves 4 prior findings: ECDSA_HASH160 misclassification is FIXED via the new keyType switch; deleteIdentityPrivateKey is FIXED with the new (walletId, derivationPath) signature; deleteOrphan Keychain wipe is FIXED via the new deleteAllIdentityPrivateKeys(forIdentityIdBase58:) helper; the platform_version hardcode is FIXED at the minimum bar with a tracked TODO. Status of remaining prior findings: 6 STILL VALID and carried forward (Swift-side DashPay derive pipeline materializing raw scalars, encryption keys via auth DIP-9 sub-feature, pre-registration empty-identityId placeholder, ContractBounds documentTypeName drop on round-trip, is_dpns_name_available missing tests, deleteOrphan single-save cascade hazard). New in latest delta: 1 blocking — the new deleteOrphan adopted the Keychain wipe but did NOT adopt the per-layer-save pattern that deleteWalletData (PlatformWalletPersistenceHandler.swift:2594-2650) had to introduce for non-optional inverses, and because the Keychain wipe now runs FIRST, a SwiftData fatal on save leaves the user without private keys AND with the identity row still present. Two new latent suggestions: the legacy-account branch of the new deleteIdentityPrivateKey is not gated on walletId metadata (cross-wallet collision the PR aimed to retire), and the new deleteAllIdentityPrivateKeys(forIdentityIdBase58:) silently skips items with undecodable metadata via try?. Review action: REQUEST_CHANGES on the one blocking concern.

_Note: Inline posting failed (command failed (1): python3 scripts/review_poster.py dashpay/platform 3765 ea4c16a
STDOUT:

STDERR:
Traceback (most recent call last):
File "/Users/claw/.openclaw/workspace/scripts/review_poster.py", line 597, in
result = post_review(repo, pr_number, h), so I posted the same verified findings as a top-level review body._

Reviewed commit: ea4c16a

🔴 1 blocking | 🟡 7 suggestion(s)

Verified findings

blocking: [CARRIED-FORWARD — partially addressed in latest delta] deleteOrphan still single-saves; will fatal when the orphan owns DPNS / DashPay rows, and the Keychain wipe runs FIRST so the user is stranded

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift (line 397)

The latest delta added KeychainManager.shared.deleteAllIdentityPrivateKeys(forIdentityIdBase58:) at line 407 and updated the doc comment, but the SwiftData side still does a single modelContext.delete(identity) + try modelContext.save() (lines 413-414). The relationship inverse PersistentDPNSName.identity is non-optional (PersistentDPNSName.swift:84, with an explicit comment stating SwiftData's non-optional contract is honored), and the same applies to PersistentDashpayProfile.identity and PersistentDashpayContactRequest.owner. The codebase's own PlatformWalletPersistenceHandler.deleteWalletData (PlatformWalletPersistenceHandler.swift:2594-2649) carries a long PHASE 1/2/3 comment explaining that SwiftData fatals with Cannot remove PersistentX from relationship Y on PersistentZ because an appropriate default value is not configured whenever a non-optional inverse is nulled in the same save batch, and works around this with per-layer saves (delete dpnsNames + dashpayProfile + contactRequests, save; delete identity, save). deleteOrphan did not adopt that pattern. Any orphan identity that still owns even one DPNS name, DashPay profile, or contact request will fatal at line 414. Worse, because the Keychain wipe at line 407 now runs FIRST, the fatal leaves the user with the private-key bytes already gone AND the SwiftData identity row still in place — the diagnostic sheet has effectively destroyed the user's keys without finishing the cleanup. An orphan reached by re-importing a different mnemonic is precisely the case this sheet exists to repair, and is plausibly the case where leftover DPNS labels or a DashPay profile from the previous wallet are still present. Apply the same per-layer-then-save sequencing as deleteWalletData, and move the Keychain wipe AFTER the SwiftData saves succeed so a mid-flow failure doesn't strand the user without keys.

suggestion: [NEW in latest delta] deleteIdentityPrivateKey(walletId:derivationPath:) legacy-account branch is not gated on walletId metadata — can clobber a sibling wallet's legacy key

packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift (line 804)

The new (walletId, derivationPath) overload deletes both identity_privkey.<walletIdHex>.<derivationPath> AND identity_privkey.<derivationPath> (legacy), keyed only on the account name for the legacy entry — no kSecAttrGeneric metadata.walletId check. Compare with deleteAllIdentityPrivateKeys(forIdentityIdBase58:) at line 877 and deleteAllIdentityPrivateKeys(forWalletId:) which gate on metadata. The legacy identity_privkey.<path> account is precisely the cross-wallet-colliding name this PR is retiring (PR description: two wallets at identity_index = 0 keep distinct keys in Keychain). If wallet A still has a partially-migrated legacy entry at path X, and any caller invokes deleteIdentityPrivateKey(walletId: B, derivationPath: X), wallet A's legacy bytes are silently deleted. No in-tree callers today (verified), so the defect is latent — but the function is public, the comment positions it as the symmetric counterpart to storeIdentityPrivateKey, and the obvious next step (per-key cleanup outside the wholesale forWalletId: sweep) goes through here. Gate the legacy delete on metadata.walletId == thisWallet (matching cleanupLegacyKeychainEntry's safety property), or drop the legacy branch and require legacy cleanup to go through the metadata-gated helpers.

suggestion: [NEW in latest delta] deleteAllIdentityPrivateKeys(forIdentityIdBase58:) silently skips items with undecodable metadata, leaving orphan private bytes behind

packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift (line 862)

The new helper iterates every identity_privkey.* keychain item, decodes the kSecAttrGeneric metadata blob with try?, and skips items whose decode fails (lines 869-875). It is invoked by the sheet's Delete this identity button (the fix for the prior deleteOrphan finding). Any keychain row whose IdentityPrivateKeyMetadata JSON cannot be decoded — corruption, a partial/aborted write (the pre-registration DashPay-keys path explicitly writes identityId: "" placeholders), or a future schema rename — is silently dropped from the scan. The 32-byte private bytes remain in Keychain after the user is told the orphan was cleaned up. The most likely producers of malformed metadata are exactly the failure modes this PR is repairing, so at minimum log undecodable rows so the developer/user is aware, or treat metadata-decode-failure on an identity_privkey.* row as itself an orphan signal (no matching PersistentPublicKey.pkid == account anywhere) and remove it. The try? swallows both signals today.

suggestion: [CARRIED-FORWARD — STILL VALID] Default DashPay key path materializes raw private keys + WIFs across the FFI, violating the Swift SDK rule

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift (line 1394)

Re-verified at head ea4c16a. makeDashpayKeyPair (CreateIdentityView.swift:1394-1485) still loops (encryption, decryption) and calls managedWallet.deriveIdentityAuthKeyAtSlot(...) per slot, copying preview.privateKeyData (32 raw scalar bytes) plus preview.privateKeyWif into ARC-managed Swift values, then Swift orchestrates the validate-then-Keychain-write pipeline. The latest delta added a KeyValidation.validatePrivateKeyForPublicKey cross-check on the hex'd privkey + pubkey (lines 1435-1446) — sensible defense against marshalling drift, but it cements the FFI contract that delivers raw secrets into Swift for orchestration. The Swift SDK's CLAUDE.md names this exact shape ("orchestrating multi-step derivation pipelines in Swift (mnemonic → seed → path → key → store)") as the anti-precedent and points at platform_wallet_preview_identity_registration_keys as the correct precedent: one FFI entry point that owns the per-purpose policy table, validates internally, and returns the minimum payload Swift needs to write to Keychain. Add a platform_wallet_derive_dashpay_keypair (or extend the existing identity-registration preview to include DashPay-purpose slots) so the cross-check happens on the Rust side and the WIF never crosses the FFI. With this toggle defaulting on, every wallet-backed registration amplifies the surface where raw secrets sit in Swift heap.

suggestion: [CARRIED-FORWARD — STILL VALID] DashPay encryption/decryption keys derived via the IDENTITY_AUTHENTICATION DIP-9 sub-feature path

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift (line 1421)

Re-verified at head ea4c16a. deriveIdentityAuthKeyAtSlot(identityIndex:keyId:network:) resolves on the Rust side via dash_sdk_derive_identity_key_at_slot_with_resolverderive_ecdsa_identity_auth_keypair_from_masteridentity_auth_derivation_path_for_type, whose base is hardcoded to IDENTITY_AUTHENTICATION_PATH_{MAINNET|TESTNET} (DIP-9 m/9'/<coin>'/5'/0'/...). makeDashpayKeyPair calls it for both .encryption (kid firstKeyId) and .decryption (kid firstKeyId + 1). Two consequences: (1) the 32-byte scalar is a pure function of (identity_index, key_index) with no purpose component — any future authentication-key derivation at the same (identity_index, key_id) produces the same private key, and ECDH + ECDSA sharing a scalar is textbook key reuse across cryptosystems. The nextKeyId = max + 1 allocator is the only invariant preventing collision today. The new validatePrivateKeyForPublicKey check at lines 1435-1446 verifies internal pub↔priv consistency, not that the base path was right. (2) A DIP-9-conformant restore tool looking under the encryption sub-feature path will silently miss these keys. Fix on the Rust side: extend platform-wallet with an encryption-purpose keypair derivation under the correct DIP-9 sub-feature, expose it through rs-platform-wallet-ffi, call it here.

suggestion: [CARRIED-FORWARD — STILL VALID] Pre-registration DashPay key writes leave Keychain orphans with empty identityId on failed submit

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift (line 1410)

Re-verified at head ea4c16a. makeDashpayKeyPair writes the two DashPay private bytes to Keychain with IdentityPrivateKeyMetadata.identityId = "" (the explicit identityIdPlaceholder at line 1410) and relies on the post-on-chain persister callback to overwrite the field. If registration aborts between this write and a successful landing (network failure, app kill, asset-lock rejection, user force-close), the two 32-byte secrets stay in Keychain at the namespaced account with empty identityId and no anchoring PersistentPublicKey row. The new deleteAllIdentityPrivateKeys(forIdentityIdBase58:) cleanup added in this delta keys on metadata.identityId, so these placeholder rows are NOT swept by the per-identity cleanup path — they are also invisible to the orphan-scan in the health sheet (which iterates PersistentPublicKey, not Keychain). Defer the Keychain write until registration succeeds, or sweep the pre-derived entries from the submit-error path.

suggestion: [CARRIED-FORWARD — STILL VALID] DashPay keys' ContractBounds.singleContractDocumentType is dropped on persist round-trip

packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift (line 1369)

Re-verified at head ea4c16a. makeDashpayKeyPair registers both DashPay keys with ContractBounds.singleContractDocumentType(id: dashpayContractId, documentTypeName: "contactRequest") (CreateIdentityView.swift:1405-1408). PersistentPublicKey serializes contract bounds as a bare [contractId] list and reconstructs with .singleContract(id:) — the documentTypeName variant is dropped entirely. After one persistence round-trip the local IdentityPublicKey projection no longer matches the key actually registered on-chain; any local DPP code reading identity.identityPublicKeys sees a weaker, semantically different bound than what this PR introduces. Carry the contract-bounds payload through the persister callback and extend PersistentPublicKey to preserve the documentTypeName variant.

suggestion: [CARRIED-FORWARD — STILL VALID] is_dpns_name_available correctness fix has no automated test

packages/rs-sdk/src/platform/dpns_usernames/mod.rs (line 406)

Re-verified at head ea4c16a. The semantic change from documents.is_empty() to documents.values().all(|d| d.is_none()) is the central Rust correctness fix in this PR and drives user-visible DPNS registration UX, but it is exercised only manually via the iOS app. packages/rs-sdk/tests/dpns_usernames.rs and tests/dpns_unit_tests.rs exist (commit 2c9e9af relocated DPNS tests there) but neither covers is_dpns_name_available. Add: positive (proven-non-existence map → Ok(true)), negative (Some(Document)Ok(false)), and explicit empty-map cases so a future change to Document::fetch_many's return shape can't regress the semantics silently.

🤖 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.

- [BLOCKING] In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift`:397-418: [CARRIED-FORWARD — partially addressed in latest delta] deleteOrphan still single-saves; will fatal when the orphan owns DPNS / DashPay rows, and the Keychain wipe runs FIRST so the user is stranded
  The latest delta added `KeychainManager.shared.deleteAllIdentityPrivateKeys(forIdentityIdBase58:)` at line 407 and updated the doc comment, but the SwiftData side still does a single `modelContext.delete(identity)` + `try modelContext.save()` (lines 413-414). The relationship inverse `PersistentDPNSName.identity` is non-optional (PersistentDPNSName.swift:84, with an explicit comment stating SwiftData's non-optional contract is honored), and the same applies to `PersistentDashpayProfile.identity` and `PersistentDashpayContactRequest.owner`. The codebase's own `PlatformWalletPersistenceHandler.deleteWalletData` (PlatformWalletPersistenceHandler.swift:2594-2649) carries a long PHASE 1/2/3 comment explaining that SwiftData fatals with `Cannot remove PersistentX from relationship Y on PersistentZ because an appropriate default value is not configured` whenever a non-optional inverse is nulled in the same save batch, and works around this with per-layer saves (delete dpnsNames + dashpayProfile + contactRequests, save; delete identity, save). `deleteOrphan` did not adopt that pattern. Any orphan identity that still owns even one DPNS name, DashPay profile, or contact request will fatal at line 414. Worse, because the Keychain wipe at line 407 now runs FIRST, the fatal leaves the user with the private-key bytes already gone AND the SwiftData identity row still in place — the diagnostic sheet has effectively destroyed the user's keys without finishing the cleanup. An orphan reached by re-importing a different mnemonic is precisely the case this sheet exists to repair, and is plausibly the case where leftover DPNS labels or a DashPay profile from the previous wallet are still present. Apply the same per-layer-then-save sequencing as `deleteWalletData`, and move the Keychain wipe AFTER the SwiftData saves succeed so a mid-flow failure doesn't strand the user without keys.
- [SUGGESTION] In `packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift`:804-827: [NEW in latest delta] deleteIdentityPrivateKey(walletId:derivationPath:) legacy-account branch is not gated on walletId metadata — can clobber a sibling wallet's legacy key
  The new `(walletId, derivationPath)` overload deletes both `identity_privkey.<walletIdHex>.<derivationPath>` AND `identity_privkey.<derivationPath>` (legacy), keyed only on the account name for the legacy entry — no `kSecAttrGeneric` metadata.walletId check. Compare with `deleteAllIdentityPrivateKeys(forIdentityIdBase58:)` at line 877 and `deleteAllIdentityPrivateKeys(forWalletId:)` which gate on metadata. The legacy `identity_privkey.<path>` account is precisely the cross-wallet-colliding name this PR is retiring (PR description: two wallets at identity_index = 0 keep distinct keys in Keychain). If wallet A still has a partially-migrated legacy entry at path X, and any caller invokes `deleteIdentityPrivateKey(walletId: B, derivationPath: X)`, wallet A's legacy bytes are silently deleted. No in-tree callers today (verified), so the defect is latent — but the function is `public`, the comment positions it as the symmetric counterpart to `storeIdentityPrivateKey`, and the obvious next step (per-key cleanup outside the wholesale `forWalletId:` sweep) goes through here. Gate the legacy delete on `metadata.walletId == thisWallet` (matching `cleanupLegacyKeychainEntry`'s safety property), or drop the legacy branch and require legacy cleanup to go through the metadata-gated helpers.
- [SUGGESTION] In `packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift`:862-881: [NEW in latest delta] deleteAllIdentityPrivateKeys(forIdentityIdBase58:) silently skips items with undecodable metadata, leaving orphan private bytes behind
  The new helper iterates every `identity_privkey.*` keychain item, decodes the `kSecAttrGeneric` metadata blob with `try?`, and skips items whose decode fails (lines 869-875). It is invoked by the sheet's `Delete this identity` button (the fix for the prior `deleteOrphan` finding). Any keychain row whose `IdentityPrivateKeyMetadata` JSON cannot be decoded — corruption, a partial/aborted write (the pre-registration DashPay-keys path explicitly writes `identityId: ""` placeholders), or a future schema rename — is silently dropped from the scan. The 32-byte private bytes remain in Keychain after the user is told the orphan was cleaned up. The most likely producers of malformed metadata are exactly the failure modes this PR is repairing, so at minimum log undecodable rows so the developer/user is aware, or treat metadata-decode-failure on an `identity_privkey.*` row as itself an orphan signal (no matching `PersistentPublicKey.pkid == account` anywhere) and remove it. The `try?` swallows both signals today.
- [SUGGESTION] In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift`:1394-1485: [CARRIED-FORWARD — STILL VALID] Default DashPay key path materializes raw private keys + WIFs across the FFI, violating the Swift SDK rule
  Re-verified at head ea4c16a08. `makeDashpayKeyPair` (CreateIdentityView.swift:1394-1485) still loops `(encryption, decryption)` and calls `managedWallet.deriveIdentityAuthKeyAtSlot(...)` per slot, copying `preview.privateKeyData` (32 raw scalar bytes) plus `preview.privateKeyWif` into ARC-managed Swift values, then Swift orchestrates the validate-then-Keychain-write pipeline. The latest delta added a `KeyValidation.validatePrivateKeyForPublicKey` cross-check on the hex'd privkey + pubkey (lines 1435-1446) — sensible defense against marshalling drift, but it cements the FFI contract that delivers raw secrets into Swift for orchestration. The Swift SDK's `CLAUDE.md` names this exact shape ("orchestrating multi-step derivation pipelines in Swift (mnemonic → seed → path → key → store)") as the anti-precedent and points at `platform_wallet_preview_identity_registration_keys` as the correct precedent: one FFI entry point that owns the per-purpose policy table, validates internally, and returns the minimum payload Swift needs to write to Keychain. Add a `platform_wallet_derive_dashpay_keypair` (or extend the existing identity-registration preview to include DashPay-purpose slots) so the cross-check happens on the Rust side and the WIF never crosses the FFI. With this toggle defaulting on, every wallet-backed registration amplifies the surface where raw secrets sit in Swift heap.
- [SUGGESTION] In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift`:1421-1426: [CARRIED-FORWARD — STILL VALID] DashPay encryption/decryption keys derived via the IDENTITY_AUTHENTICATION DIP-9 sub-feature path
  Re-verified at head ea4c16a08. `deriveIdentityAuthKeyAtSlot(identityIndex:keyId:network:)` resolves on the Rust side via `dash_sdk_derive_identity_key_at_slot_with_resolver` → `derive_ecdsa_identity_auth_keypair_from_master` → `identity_auth_derivation_path_for_type`, whose base is hardcoded to `IDENTITY_AUTHENTICATION_PATH_{MAINNET|TESTNET}` (DIP-9 `m/9'/<coin>'/5'/0'/...`). `makeDashpayKeyPair` calls it for both `.encryption` (kid `firstKeyId`) and `.decryption` (kid `firstKeyId + 1`). Two consequences: (1) the 32-byte scalar is a pure function of `(identity_index, key_index)` with no purpose component — any future authentication-key derivation at the same `(identity_index, key_id)` produces the same private key, and ECDH + ECDSA sharing a scalar is textbook key reuse across cryptosystems. The `nextKeyId = max + 1` allocator is the only invariant preventing collision today. The new `validatePrivateKeyForPublicKey` check at lines 1435-1446 verifies internal pub↔priv consistency, not that the base path was right. (2) A DIP-9-conformant restore tool looking under the encryption sub-feature path will silently miss these keys. Fix on the Rust side: extend `platform-wallet` with an encryption-purpose keypair derivation under the correct DIP-9 sub-feature, expose it through `rs-platform-wallet-ffi`, call it here.
- [SUGGESTION] In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift`:1410-1472: [CARRIED-FORWARD — STILL VALID] Pre-registration DashPay key writes leave Keychain orphans with empty identityId on failed submit
  Re-verified at head ea4c16a08. `makeDashpayKeyPair` writes the two DashPay private bytes to Keychain with `IdentityPrivateKeyMetadata.identityId = ""` (the explicit `identityIdPlaceholder` at line 1410) and relies on the post-on-chain persister callback to overwrite the field. If registration aborts between this write and a successful landing (network failure, app kill, asset-lock rejection, user force-close), the two 32-byte secrets stay in Keychain at the namespaced account with empty `identityId` and no anchoring `PersistentPublicKey` row. The new `deleteAllIdentityPrivateKeys(forIdentityIdBase58:)` cleanup added in this delta keys on `metadata.identityId`, so these placeholder rows are NOT swept by the per-identity cleanup path — they are also invisible to the orphan-scan in the health sheet (which iterates `PersistentPublicKey`, not Keychain). Defer the Keychain write until registration succeeds, or sweep the pre-derived entries from the submit-error path.
- [SUGGESTION] In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:1369-1409: [CARRIED-FORWARD — STILL VALID] DashPay keys' ContractBounds.singleContractDocumentType is dropped on persist round-trip
  Re-verified at head ea4c16a08. `makeDashpayKeyPair` registers both DashPay keys with `ContractBounds.singleContractDocumentType(id: dashpayContractId, documentTypeName: "contactRequest")` (CreateIdentityView.swift:1405-1408). `PersistentPublicKey` serializes contract bounds as a bare `[contractId]` list and reconstructs with `.singleContract(id:)` — the `documentTypeName` variant is dropped entirely. After one persistence round-trip the local `IdentityPublicKey` projection no longer matches the key actually registered on-chain; any local DPP code reading `identity.identityPublicKeys` sees a weaker, semantically different bound than what this PR introduces. Carry the contract-bounds payload through the persister callback and extend `PersistentPublicKey` to preserve the `documentTypeName` variant.
- [SUGGESTION] In `packages/rs-sdk/src/platform/dpns_usernames/mod.rs`:406-413: [CARRIED-FORWARD — STILL VALID] is_dpns_name_available correctness fix has no automated test
  Re-verified at head ea4c16a08. The semantic change from `documents.is_empty()` to `documents.values().all(|d| d.is_none())` is the central Rust correctness fix in this PR and drives user-visible DPNS registration UX, but it is exercised only manually via the iOS app. `packages/rs-sdk/tests/dpns_usernames.rs` and `tests/dpns_unit_tests.rs` exist (commit 2c9e9af692 relocated DPNS tests there) but neither covers `is_dpns_name_available`. Add: positive (proven-non-existence map → `Ok(true)`), negative (`Some(Document)` → `Ok(false)`), and explicit empty-map cases so a future change to `Document::fetch_many`'s return shape can't regress the semantics silently.

deleteAllIdentityPrivateKeys(forIdentityIdBase58:) and the sibling
forWalletId sweep used `try?` to skip rows whose metadata blob
failed to decode, leaving orphan private bytes behind without
telling anyone. Replaced both with explicit do/catch that surface
the skip via os_log under subsystem=dashpay.SwiftDashSDK
category=Keychain — still conservatively NOT deleting rows we
can't prove we own, but the developer/user can now see them in
Console.app instead of inferring silence.

Also applied cargo fmt --all to the ContractBounds FFI extension
to clear the macOS Tests workflow's `cargo fmt --check --all`
gate that failed on commit 7ae00b8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@QuantumExplorer
Copy link
Copy Markdown
Member Author

Latest review was scanned against head ea4c16a08; three of the items are already fixed in commits the review didn't see:

Finding Status Commit
🔴 deleteOrphan single-save fatal under non-Optional inverses Fixed b83f9352e2 — adopted the same per-layer save pattern as deleteWalletData (PHASE 1 deletes dpnsNames + dashpayProfile + contactRequests + save; PHASE 2 deletes the identity + save).
🟡 deleteIdentityPrivateKey legacy branch not gated on metadata.walletId Fixed 7ae00b8a85 — extracted the existing metadata-gated guard into KeychainManager.deleteLegacyKeychainEntryIfOwnedByWallet and routed both call sites through it.
🟡 DashPay ContractBounds.singleContractDocumentType dropped on persist round-trip Fixed 68f98ad1d1 — extended IdentityKeyEntryFFI with contract_bounds_{kind,id,document_type}, PersistentPublicKey grows a contractBoundsDocumentTypeName column; both variants round-trip verbatim.

Newly landed in 03f088f27a:

🟡 deleteAllIdentityPrivateKeys silently skips undecodable-metadata rows — both forIdentityIdBase58 and forWalletId sweeps now surface the skip via os_log (subsystem dashpay.SwiftDashSDK, category Keychain) so a partial/corrupt/legacy row that can't be claim-verified is visible in Console.app rather than leaving silent orphans. Still conservatively NOT deleting rows we can't prove we own — that's the right safety default; the change is observability.

Also clears a cargo fmt --check --all violation on the ContractBounds FFI extension that broke "Rust workspace tests / Tests (macOS)" at 7ae00b8a85.

Carry-forward items deferred as previously discussed: raw private keys + WIFs across FFI, DashPay keys via IDENTITY_AUTHENTICATION DIP-9 path, pre-registration empty-identityId placeholder cleanup, is_dpns_name_available automated test. All require Rust-side work that's out of scope for this iOS-flow PR.

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Cumulative incremental review at head b83f935. The latest delta (single commit: ea4c16a..b83f935) reshapes WalletKeyHealthSheet.deleteOrphan into a four-phase delete (Keychain wipe → PHASE 1 delete DPNS/profile/contact-request children + save → PHASE 2 delete identity + save) that mirrors PlatformWalletPersistenceHandler.deleteWalletData. This FIXES the prior blocking concern about SwiftData fataling on non-Optional inverses during cascade. No new defects introduced by the latest delta. Of 8 prior findings: 1 FIXED (deleteOrphan cascade-fatal), 7 STILL VALID and carried forward unchanged because the latest delta touched only one file. Two of the carried-forward findings sit on the DashPay key-derivation FFI (cross-cryptosystem key reuse via the IDENTITY_AUTHENTICATION DIP-9 path, and pre-registration Keychain writes that can strand secrets with empty identityId on a failed submit); they are the most important remaining items. All findings are suggestions or nitpicks — no in-scope blockers remain after the four-phase fix.

_Note: Inline posting failed (command failed (1): python3 scripts/review_poster.py dashpay/platform 3765 b83f935
STDOUT:

STDERR:
Traceback (most recent call last):
File "/Users/claw/.openclaw/workspace/scripts/review_poster.py", line 597, in
result = post_review(repo, pr_number, h), so I posted the same verified findings as a top-level review body._

Reviewed commit: b83f935

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

Verified findings

suggestion: [CARRIED-FORWARD — STILL VALID] DashPay encryption/decryption keys derived via the IDENTITY_AUTHENTICATION DIP-9 sub-feature path

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift (line 1421)

Verified at head b83f935 — unchanged in the latest delta. makeDashpayKeyPair calls managedWallet.deriveIdentityAuthKeyAtSlot(...) for both .encryption (kid firstKeyId) and .decryption (kid firstKeyId + 1). That helper resolves on the Rust side via dash_sdk_derive_identity_key_at_slot_with_resolverderive_ecdsa_identity_auth_keypair_from_masteridentity_auth_derivation_path_for_type, whose base is hardcoded to IDENTITY_AUTHENTICATION_PATH_{MAINNET|TESTNET} (DIP-9 m/9'/<coin>'/5'/0'/...).

Two consequences: (1) the 32-byte scalar is a pure function of (identity_index, key_index) with no purpose component — any future authentication-key derivation at the same (identity_index, key_id) produces the same private scalar; ECDH (DashPay) + ECDSA (auth signing) sharing a scalar is key reuse across cryptosystems. The nextKeyId = max + 1 allocator is the only invariant preventing collision today, and the new validatePrivateKeyForPublicKey cross-check at lines 1435-1446 verifies internal pub↔priv consistency, not that the base path was right. (2) A DIP-9-conformant restore tool looking under the encryption sub-feature path will silently miss these keys — recovery in any other wallet will not find the DashPay secrets.

Fix on the Rust side per packages/swift-sdk/CLAUDE.md: extend platform-wallet with an encryption-purpose keypair derivation under the correct DIP-9 sub-feature, expose it through rs-platform-wallet-ffi, then call it here.

suggestion: [CARRIED-FORWARD — STILL VALID] DashPay key registration orchestrates raw private keys + WIFs in Swift, violating the Swift SDK boundary rule

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift (line 1394)

Verified at head b83f935 — unchanged. makeDashpayKeyPair loops (encryption, decryption), calls managedWallet.deriveIdentityAuthKeyAtSlot(...) per slot, copies preview.privateKeyData (32 raw scalar bytes) plus preview.privateKeyWif into ARC-managed Swift values, then Swift orchestrates the validate→Keychain-write→FFI-payload pipeline. The added KeyValidation.validatePrivateKeyForPublicKey cross-check (lines 1435-1446) is sensible defense against marshalling drift but cements the FFI contract that delivers raw secrets into Swift for orchestration.

packages/swift-sdk/CLAUDE.md names this exact shape ("orchestrating multi-step derivation pipelines in Swift (mnemonic → seed → path → key → store)") as the anti-precedent, and points at platform_wallet_preview_identity_registration_keys as the correct precedent: one FFI entry point that owns the per-purpose policy table, validates internally, and returns the minimum payload Swift needs to write to Keychain. Add a platform_wallet_derive_dashpay_keypair (or extend the existing identity-registration preview to include DashPay-purpose slots) so the cross-check happens on the Rust side and the WIF never crosses the FFI. With DashPay enabled by default, every wallet-backed registration amplifies the surface where raw secrets sit in the Swift heap.

suggestion: [CARRIED-FORWARD — STILL VALID] Pre-registration DashPay Keychain writes leave orphans with empty identityId on failed submit

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift (line 1410)

Verified at head b83f935 — unchanged. makeDashpayKeyPair writes both DashPay private bytes to Keychain with IdentityPrivateKeyMetadata.identityId = "" (the explicit identityIdPlaceholder at line 1410) and relies on the post-on-chain persister callback to overwrite that field. If registration aborts between the Keychain write and a successful landing (network failure, app kill, asset-lock rejection, force-close), the two 32-byte secrets stay in Keychain at the namespaced account with empty identityId and no anchoring PersistentPublicKey row.

Interaction with finding #4 below: deleteAllIdentityPrivateKeys(forIdentityIdBase58:) keys on metadata.identityId, so these placeholder rows will NOT be swept by the per-identity cleanup path; they are also invisible to WalletKeyHealthSheet's orphan scan (which iterates PersistentPublicKey, not Keychain). Only the wallet-wide deleteAllIdentityPrivateKeys(forWalletId:) reaches them. Defer the Keychain write until registration succeeds, or scrub by walletId in the submit-error path.

suggestion: [CARRIED-FORWARD — STILL VALID] deleteAllIdentityPrivateKeys(forIdentityIdBase58:) silently skips items with undecodable metadata, leaving orphan secret bytes behind

packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift (line 862)

Verified at head b83f935 — unchanged. The helper iterates every identity_privkey.* keychain item, decodes the kSecAttrGeneric metadata blob with try? (lines 869-873), and skips items whose decode fails. It is invoked from WalletKeyHealthChecker.deleteOrphan as PHASE 0 of the new four-phase delete and presented to the user as a definitive cleanup.

Any keychain row whose IdentityPrivateKeyMetadata JSON cannot be decoded — corruption, a partial/aborted write (the pre-registration DashPay-keys path explicitly writes identityId: "" placeholders, see finding above), or a future schema rename — is silently dropped from the scan, and its 32-byte private bytes remain in Keychain after the user is told the orphan was cleaned up. The most likely producers of malformed metadata are exactly the failure modes this PR repairs. At minimum log undecodable rows; better, treat metadata-decode-failure on an identity_privkey.* row as itself an orphan signal (no PersistentPublicKey.pkid == account anywhere in SwiftData) and remove it.

suggestion: [CARRIED-FORWARD — STILL VALID] deleteIdentityPrivateKey(walletId:derivationPath:) legacy-account branch is not gated on walletId metadata — can clobber a sibling wallet's legacy key

packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift (line 804)

Verified at head b83f935 — unchanged. The (walletId, derivationPath) overload deletes both identity_privkey.<walletIdHex>.<derivationPath> AND identity_privkey.<derivationPath> (legacy) keyed only on account name for the legacy entry — no kSecAttrGeneric metadata.walletId check. Compare with deleteAllIdentityPrivateKeys(forIdentityIdBase58:) (line 842+) and deleteAllIdentityPrivateKeys(forWalletId:) (line 886+), which both gate on the decoded metadata.

The legacy identity_privkey.<path> account is precisely the cross-wallet-colliding name this PR retires (PR description: two wallets at identity_index = 0 keep distinct keys in Keychain). If wallet A still holds a partially-migrated legacy entry at path X, and any caller invokes deleteIdentityPrivateKey(walletId: B, derivationPath: X), wallet A's legacy bytes are silently deleted. No in-tree callers today (the defect is latent), but the function is public and positioned as the symmetric counterpart to storeIdentityPrivateKey — the obvious next step (per-key cleanup outside the wholesale forWalletId: sweep) goes through here. Gate the legacy delete on metadata.walletId == walletIdHex, or drop the legacy branch and require legacy cleanup to go through the metadata-gated helpers.

suggestion: [CARRIED-FORWARD — STILL VALID] DashPay keys' ContractBounds.singleContractDocumentType is dropped on persist round-trip

packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift (line 107)

Verified at head b83f935 — unchanged. makeDashpayKeyPair registers both DashPay keys with ContractBounds.singleContractDocumentType(id: dashpayContractId, documentTypeName: "contactRequest") (CreateIdentityView.swift:1405-1408). PersistentPublicKey serializes contract bounds as a bare [contractId] JSON list (line 53, line 138 reads publicKey.contractBounds!.contractId) and reconstructs with .singleContract(id: $0) in toIdentityPublicKey() at line 120 — the documentTypeName variant is dropped entirely.

After one persistence round-trip the local IdentityPublicKey projection no longer matches the key actually registered on-chain; any local DPP code reading identity.identityPublicKeys sees a weaker, semantically different bound than what this PR introduces. Carry the contract-bounds payload through the persister callback and extend PersistentPublicKey to preserve the documentTypeName variant.

nitpick: [CARRIED-FORWARD — STILL VALID] is_dpns_name_available correctness fix has no automated test

packages/rs-sdk/src/platform/dpns_usernames/mod.rs (line 406)

Verified at head b83f935 — unchanged. The semantic change from documents.is_empty() to documents.values().all(|d| d.is_none()) is the central Rust correctness fix in this PR and drives user-visible DPNS registration UX through the FFI, but it is exercised only manually via the iOS app. packages/rs-sdk/tests/dpns_usernames.rs and tests/dpns_unit_tests.rs exist (commit 2c9e9af relocated DPNS tests there) but neither covers is_dpns_name_available — grep confirms only src/platform/dpns_usernames/mod.rs and queries.rs reference the symbol.

Add three cases: positive (proven-non-existence map → Ok(true)), negative (Some(Document)Ok(false)), and empty-map (defines the semantics explicitly) so a future change to Document::fetch_many's return shape can't regress the semantics silently.

🤖 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.

- [SUGGESTION] In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift`:1421-1426: [CARRIED-FORWARD — STILL VALID] DashPay encryption/decryption keys derived via the IDENTITY_AUTHENTICATION DIP-9 sub-feature path
  Verified at head b83f9352 — unchanged in the latest delta. `makeDashpayKeyPair` calls `managedWallet.deriveIdentityAuthKeyAtSlot(...)` for both `.encryption` (kid `firstKeyId`) and `.decryption` (kid `firstKeyId + 1`). That helper resolves on the Rust side via `dash_sdk_derive_identity_key_at_slot_with_resolver` → `derive_ecdsa_identity_auth_keypair_from_master` → `identity_auth_derivation_path_for_type`, whose base is hardcoded to `IDENTITY_AUTHENTICATION_PATH_{MAINNET|TESTNET}` (DIP-9 `m/9'/<coin>'/5'/0'/...`).

Two consequences: (1) the 32-byte scalar is a pure function of `(identity_index, key_index)` with no purpose component — any future authentication-key derivation at the same `(identity_index, key_id)` produces the same private scalar; ECDH (DashPay) + ECDSA (auth signing) sharing a scalar is key reuse across cryptosystems. The `nextKeyId = max + 1` allocator is the only invariant preventing collision today, and the new `validatePrivateKeyForPublicKey` cross-check at lines 1435-1446 verifies internal pub↔priv consistency, not that the base path was right. (2) A DIP-9-conformant restore tool looking under the encryption sub-feature path will silently miss these keys — recovery in any other wallet will not find the DashPay secrets.

Fix on the Rust side per `packages/swift-sdk/CLAUDE.md`: extend `platform-wallet` with an encryption-purpose keypair derivation under the correct DIP-9 sub-feature, expose it through `rs-platform-wallet-ffi`, then call it here.
- [SUGGESTION] In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift`:1394-1485: [CARRIED-FORWARD — STILL VALID] DashPay key registration orchestrates raw private keys + WIFs in Swift, violating the Swift SDK boundary rule
  Verified at head b83f9352 — unchanged. `makeDashpayKeyPair` loops `(encryption, decryption)`, calls `managedWallet.deriveIdentityAuthKeyAtSlot(...)` per slot, copies `preview.privateKeyData` (32 raw scalar bytes) plus `preview.privateKeyWif` into ARC-managed Swift values, then Swift orchestrates the validate→Keychain-write→FFI-payload pipeline. The added `KeyValidation.validatePrivateKeyForPublicKey` cross-check (lines 1435-1446) is sensible defense against marshalling drift but cements the FFI contract that delivers raw secrets into Swift for orchestration.

`packages/swift-sdk/CLAUDE.md` names this exact shape ("orchestrating multi-step derivation pipelines in Swift (mnemonic → seed → path → key → store)") as the anti-precedent, and points at `platform_wallet_preview_identity_registration_keys` as the correct precedent: one FFI entry point that owns the per-purpose policy table, validates internally, and returns the minimum payload Swift needs to write to Keychain. Add a `platform_wallet_derive_dashpay_keypair` (or extend the existing identity-registration preview to include DashPay-purpose slots) so the cross-check happens on the Rust side and the WIF never crosses the FFI. With DashPay enabled by default, every wallet-backed registration amplifies the surface where raw secrets sit in the Swift heap.
- [SUGGESTION] In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift`:1410-1472: [CARRIED-FORWARD — STILL VALID] Pre-registration DashPay Keychain writes leave orphans with empty identityId on failed submit
  Verified at head b83f9352 — unchanged. `makeDashpayKeyPair` writes both DashPay private bytes to Keychain with `IdentityPrivateKeyMetadata.identityId = ""` (the explicit `identityIdPlaceholder` at line 1410) and relies on the post-on-chain persister callback to overwrite that field. If registration aborts between the Keychain write and a successful landing (network failure, app kill, asset-lock rejection, force-close), the two 32-byte secrets stay in Keychain at the namespaced account with empty `identityId` and no anchoring `PersistentPublicKey` row.

Interaction with finding #4 below: `deleteAllIdentityPrivateKeys(forIdentityIdBase58:)` keys on `metadata.identityId`, so these placeholder rows will NOT be swept by the per-identity cleanup path; they are also invisible to `WalletKeyHealthSheet`'s orphan scan (which iterates `PersistentPublicKey`, not Keychain). Only the wallet-wide `deleteAllIdentityPrivateKeys(forWalletId:)` reaches them. Defer the Keychain write until registration succeeds, or scrub by walletId in the submit-error path.
- [SUGGESTION] In `packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift`:862-881: [CARRIED-FORWARD — STILL VALID] deleteAllIdentityPrivateKeys(forIdentityIdBase58:) silently skips items with undecodable metadata, leaving orphan secret bytes behind
  Verified at head b83f9352 — unchanged. The helper iterates every `identity_privkey.*` keychain item, decodes the `kSecAttrGeneric` metadata blob with `try?` (lines 869-873), and skips items whose decode fails. It is invoked from `WalletKeyHealthChecker.deleteOrphan` as PHASE 0 of the new four-phase delete and presented to the user as a definitive cleanup.

Any keychain row whose `IdentityPrivateKeyMetadata` JSON cannot be decoded — corruption, a partial/aborted write (the pre-registration DashPay-keys path explicitly writes `identityId: ""` placeholders, see finding above), or a future schema rename — is silently dropped from the scan, and its 32-byte private bytes remain in Keychain after the user is told the orphan was cleaned up. The most likely producers of malformed metadata are exactly the failure modes this PR repairs. At minimum log undecodable rows; better, treat metadata-decode-failure on an `identity_privkey.*` row as itself an orphan signal (no `PersistentPublicKey.pkid == account` anywhere in SwiftData) and remove it.
- [SUGGESTION] In `packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift`:804-827: [CARRIED-FORWARD — STILL VALID] deleteIdentityPrivateKey(walletId:derivationPath:) legacy-account branch is not gated on walletId metadata — can clobber a sibling wallet's legacy key
  Verified at head b83f9352 — unchanged. The `(walletId, derivationPath)` overload deletes both `identity_privkey.<walletIdHex>.<derivationPath>` AND `identity_privkey.<derivationPath>` (legacy) keyed only on account name for the legacy entry — no `kSecAttrGeneric` metadata.walletId check. Compare with `deleteAllIdentityPrivateKeys(forIdentityIdBase58:)` (line 842+) and `deleteAllIdentityPrivateKeys(forWalletId:)` (line 886+), which both gate on the decoded metadata.

The legacy `identity_privkey.<path>` account is precisely the cross-wallet-colliding name this PR retires (PR description: two wallets at `identity_index = 0` keep distinct keys in Keychain). If wallet A still holds a partially-migrated legacy entry at path X, and any caller invokes `deleteIdentityPrivateKey(walletId: B, derivationPath: X)`, wallet A's legacy bytes are silently deleted. No in-tree callers today (the defect is latent), but the function is `public` and positioned as the symmetric counterpart to `storeIdentityPrivateKey` — the obvious next step (per-key cleanup outside the wholesale `forWalletId:` sweep) goes through here. Gate the legacy delete on `metadata.walletId == walletIdHex`, or drop the legacy branch and require legacy cleanup to go through the metadata-gated helpers.
- [SUGGESTION] In `packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift`:107-140: [CARRIED-FORWARD — STILL VALID] DashPay keys' ContractBounds.singleContractDocumentType is dropped on persist round-trip
  Verified at head b83f9352 — unchanged. `makeDashpayKeyPair` registers both DashPay keys with `ContractBounds.singleContractDocumentType(id: dashpayContractId, documentTypeName: "contactRequest")` (CreateIdentityView.swift:1405-1408). `PersistentPublicKey` serializes contract bounds as a bare `[contractId]` JSON list (line 53, line 138 reads `publicKey.contractBounds!.contractId`) and reconstructs with `.singleContract(id: $0)` in `toIdentityPublicKey()` at line 120 — the `documentTypeName` variant is dropped entirely.

After one persistence round-trip the local `IdentityPublicKey` projection no longer matches the key actually registered on-chain; any local DPP code reading `identity.identityPublicKeys` sees a weaker, semantically different bound than what this PR introduces. Carry the contract-bounds payload through the persister callback and extend `PersistentPublicKey` to preserve the `documentTypeName` variant.
- [NITPICK] In `packages/rs-sdk/src/platform/dpns_usernames/mod.rs`:406-413: [CARRIED-FORWARD — STILL VALID] is_dpns_name_available correctness fix has no automated test
  Verified at head b83f9352 — unchanged. The semantic change from `documents.is_empty()` to `documents.values().all(|d| d.is_none())` is the central Rust correctness fix in this PR and drives user-visible DPNS registration UX through the FFI, but it is exercised only manually via the iOS app. `packages/rs-sdk/tests/dpns_usernames.rs` and `tests/dpns_unit_tests.rs` exist (commit 2c9e9af6 relocated DPNS tests there) but neither covers `is_dpns_name_available` — grep confirms only `src/platform/dpns_usernames/mod.rs` and `queries.rs` reference the symbol.

Add three cases: positive (proven-non-existence map → `Ok(true)`), negative (`Some(Document)` → `Ok(false)`), and empty-map (defines the semantics explicitly) so a future change to `Document::fetch_many`'s return shape can't regress the semantics silently.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

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

⚠️ Outside diff range comments (1)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift (1)

414-416: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle partial-success orphan deletion without leaving stale UI state.

At Line 414, deleteOrphan throws keychainError even after SwiftData deletion succeeds. Then at Line 667, the caller treats it as a full failure and keeps the deleted identity in reports, leaving stale UI/actions.

Suggested localized fix
 private func deleteIdentity(_ report: WalletIdentityKeyHealthReport) {
     do {
         try WalletKeyHealthChecker.deleteOrphan(
             identity: report.identityRow,
             modelContext: modelContext
         )
         actionMessage = "Deleted orphan identity \(report.identityIdBase58.prefix(12))…"
         errorMessage = nil
         // Drop the now-deleted row from the report list so the
         // sheet doesn't try to render it again.
         reports.removeAll { $0.id == report.id }
     } catch {
-        errorMessage = "Delete failed: \(error.localizedDescription)"
+        if report.identityRow.modelContext == nil {
+            reports.removeAll { $0.id == report.id }
+            actionMessage = "Deleted orphan identity \(report.identityIdBase58.prefix(12))…"
+            errorMessage = "Keychain cleanup warning: \(error.localizedDescription)"
+        } else {
+            errorMessage = "Delete failed: \(error.localizedDescription)"
+        }
     }
 }

Also applies to: 656-669

🤖 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/WalletKeyHealthSheet.swift`
around lines 414 - 416, The deleteOrphan flow currently throws keychainError
even when the SwiftData deletion succeeded, causing the caller (which updates
the reports array) to treat the whole operation as a failure and leave stale UI
state; change deleteOrphan so that if SwiftData deletion succeeded but keychain
deletion failed it does not throw away the success—either return a result/flag
or clear the orphan from the in-memory reports and surface the keychain error
separately (e.g., return .partialSuccess(with: keychainError) or set
keychainError on a Result and not throw), update the caller to remove the
deleted identity from reports when SwiftData deletion succeeded and then present
a non-blocking error message for the keychainError instead of preventing report
removal; reference: deleteOrphan, keychainError, and the reports collection.
🤖 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/identity_persistence.rs`:
- Around line 514-526: The branch mapping
ContractBounds::SingleContractDocumentType must not emit contract_bounds_kind =
2 while providing a null contract_bounds_document_type; change the match arm in
the code handling entry.public_key.contract_bounds() so that you attempt
CString::new(document_type_name.as_str()) and only set contract_bounds_kind = 2
and contract_bounds_document_type to the CString pointer on Ok(c). On Err(_)
fall back to treating it as the SingleContract case (set contract_bounds_kind =
1, contract_bounds_id = id.to_buffer(), and contract_bounds_document_type =
ptr::null()) so the discriminant and payload remain consistent; reference the
ContractBounds::SingleContractDocumentType match arm, CString::new, and the
contract_bounds_kind/contract_bounds_document_type variables when making the
change.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift`:
- Around line 29-35: The contractBoundsDocumentTypeName field can become stale
because the contractBounds setter only updates contractBoundsData; update the
contractBounds setter (the property/method named contractBounds) to also set
contractBoundsDocumentTypeName: when the new value is
.singleContractDocumentType(...) assign the document type name into
contractBoundsDocumentTypeName and set contractBoundsData accordingly, and when
the new value is .singleContract(...) or .unbounded set
contractBoundsDocumentTypeName to nil (and update contractBoundsData as before)
so toIdentityPublicKey() reconstructs the correct variant.
- Around line 143-152: The code that builds ContractBounds from
contractBounds?.first must validate that the Data is exactly 32 bytes before
constructing a ContractBounds variant; update the block in PersistentPublicKey
(where ContractBounds is set using contractBounds and
contractBoundsDocumentTypeName) to check id.count == 32 and only create
.singleContract(id:) or .singleContractDocumentType(id:documentTypeName:) when
that condition holds, otherwise treat the value as malformed (e.g., set bounds =
nil or propagate an error) so invalid-length ids are rejected before any FFI
marshalling.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 1350-1371: The persistence code writes contract-bounds into the
new columns but the restore path in buildIdentityRestoreBuffer (and the nearby
logic around lines referenced: 1388-1389, 1410-1416) never marshals those fields
back into the IdentityKeyRestoreFFI, so scoped keys are restored as unbounded;
update buildIdentityRestoreBuffer to read the persisted contractBoundsIds and
contractBoundsDocumentTypeName for a given PersistentPublicKey (the same fields
set where snapshotBoundsIds/snapshotBoundsDocType are computed) and populate the
corresponding contract-bounds field on the IdentityKeyRestoreFFI (building the
same enum/variant shape you used when writing: none, singleContract(id), or
singleContractDocumentType(id,name)) before serializing the restore buffer so
the Rust side receives the restored bounds.

---

Outside diff comments:
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift`:
- Around line 414-416: The deleteOrphan flow currently throws keychainError even
when the SwiftData deletion succeeded, causing the caller (which updates the
reports array) to treat the whole operation as a failure and leave stale UI
state; change deleteOrphan so that if SwiftData deletion succeeded but keychain
deletion failed it does not throw away the success—either return a result/flag
or clear the orphan from the in-memory reports and surface the keychain error
separately (e.g., return .partialSuccess(with: keychainError) or set
keychainError on a Result and not throw), update the caller to remove the
deleted identity from reports when SwiftData deletion succeeded and then present
a non-blocking error message for the keychainError instead of preventing report
removal; reference: deleteOrphan, keychainError, and the reports collection.
🪄 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: edb644b5-b2d5-4283-b9fa-ea0b1c7d4b08

📥 Commits

Reviewing files that changed from the base of the PR and between b83f935 and 03f088f.

📒 Files selected for processing (5)
  • packages/rs-platform-wallet-ffi/src/identity_persistence.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift

Comment thread packages/rs-platform-wallet-ffi/src/identity_persistence.rs Outdated
@QuantumExplorer
Copy link
Copy Markdown
Member Author

Reviewed at head b83f9352; current head is 03f088f27a. Three of the seven findings landed in the two commits the review didn't see:

Finding Status Commit
🟡 deleteAllIdentityPrivateKeys silently skips undecodable-metadata rows Fixed 03f088f27a — both forIdentityIdBase58 and forWalletId sweeps now log skipped rows via os_log (subsystem dashpay.SwiftDashSDK / category Keychain) so they're visible in Console.app instead of inferring silence. Still conservatively NOT deleting rows we can't prove we own.
🟡 deleteIdentityPrivateKey(walletId:derivationPath:) legacy branch ungated Fixed 7ae00b8a85 — extracted the metadata-gated guard into KeychainManager.deleteLegacyKeychainEntryIfOwnedByWallet and routed both call sites through it.
🟡 DashPay keys' ContractBounds.singleContractDocumentType dropped on persist round-trip Fixed 68f98ad1d1 — extended IdentityKeyEntryFFI with contract_bounds_{kind,id,document_type} (kind tag mirrors DPP's enum, doc-type as heap CString); PersistentPublicKey grows a contractBoundsDocumentTypeName column. Both variants round-trip verbatim.

Carry-forwards remain deferred as previously discussed:

  • Raw private keys + WIFs across FFI — needs Rust analog of prePersistIdentityKeysForRegistration that accepts a custom (keyId, purpose, securityLevel, contractBounds) schedule. Out of scope for the iOS-flow PR; mitigated in the interim by the KeyValidation.validatePrivateKeyForPublicKey cross-check before the Keychain write.
  • DashPay keys via IDENTITY_AUTHENTICATION DIP-9 sub-feature path — needs platform-wallet to grow an encryption-purpose keypair derivation under the proper DIP-9 sub-feature, exposed through rs-platform-wallet-ffi. nextKeyId = max + 1 keeps the cross-cryptosystem collision invariant today.
  • Pre-registration empty-identityId placeholder sweep — the cleanest fix bundles with deferring the Keychain write into the persister-callback path (i.e. the raw-private-keys-across-FFI rework above), so they should land together. In the meantime, wallet-delete's deleteAllIdentityPrivateKeys(forWalletId:) does reach those rows.
  • is_dpns_name_available automated test — slots into existing tests/ infra once iOS-flow PR is in; doing it here balloons the diff.

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Latest delta (b83f935 -> 03f088f) is focused hardening that resolves three prior findings: legacy-keychain delete is now gated on metadata.walletId via a shared helper, ContractBounds.singleContractDocumentType now round-trips through both the FFI and SwiftData via a new doc-type column, and both wholesale Keychain sweeps now os_log undecodable rows instead of silently skipping. Four prior findings remain STILL VALID against current head: (1-3) the DashPay registration path still derives encryption/decryption keys from the DIP-9 identity-authentication branch in Rust while Swift orchestrates the validation/Keychain/FFI pipeline with raw 32-byte secrets + WIFs in ARC memory, and still pre-writes those secrets to Keychain with empty identityId before registration finalizes (leaving orphans on aborted submit); (7) the central is_dpns_name_available correctness fix still has no rs-sdk test. Two minor new observations from the delta: the per-key legacy helper silently no-ops on malformed metadata (asymmetric with the wholesale-sweep logging the same delta added) and the FFI CString interior-NUL fallback drops both id and doc-type. No new blocking defects.

🟡 2 suggestion(s) | 💬 2 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift:1394-1485: [CARRIED-FORWARD — STILL VALID] DashPay key registration orchestrates raw private keys + WIFs in Swift, violating the Swift SDK boundary rule
  Re-verified at HEAD 03f088f27 — unchanged. `makeDashpayKeyPair` loops `(encryption, decryption)`, calls `managedWallet.deriveIdentityAuthKeyAtSlot(...)` per slot, copies `preview.privateKeyData` (32 raw scalar bytes) plus `preview.privateKeyWif` into ARC-managed Swift values, then Swift orchestrates the validate->Keychain-write->FFI-payload pipeline. The `KeyValidation.validatePrivateKeyForPublicKey` cross-check is sensible defense against marshalling drift but cements the FFI contract that delivers raw secrets into Swift for orchestration.

  `packages/swift-sdk/CLAUDE.md` names this exact shape ("orchestrating multi-step derivation pipelines in Swift (mnemonic -> seed -> path -> key -> store)") as the anti-precedent, and points at `platform_wallet_preview_identity_registration_keys` as the correct precedent: one FFI entry point that owns the per-purpose policy table, validates internally, and returns the minimum payload Swift needs to write to Keychain. Add a `platform_wallet_derive_dashpay_keypair` (or extend the existing identity-registration preview to include DashPay-purpose slots) so the cross-check happens on the Rust side and the WIF never crosses the FFI. With DashPay enabled by default, every wallet-backed registration amplifies the surface where raw secrets sit in the Swift heap.

In `packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift:947-980: [CARRIED-FORWARD — partially addressed] Identity-scoped Keychain cleanup logs but still leaves undecodable metadata rows behind
  The delta added `Self.log.error(...)` reporting (subsystem `dashpay.SwiftDashSDK`, category `Keychain`) for both the missing-`kSecAttrGeneric`-blob branch and the JSON-decode-failure branch in both `deleteAllIdentityPrivateKeys(forIdentityIdBase58:)` (lines 950-975) and `deleteAllIdentityPrivateKeys(forWalletId:)` — the original "silent skip" half of the finding is now visible to developers in Console.app.

  What remains: the bytes still survive the sweep. The most likely producers of malformed metadata are exactly the failure modes this PR creates — `identityId: ""` placeholder rows from the pre-registration DashPay path (finding #3 above), partial/aborted writes, or a future schema rename. The wholesale identity-scoped wipe presents to the user as definitive (`WalletKeyHealthChecker.deleteOrphan` PHASE 0), but undecodable rows remain. The conservative "don't delete data we can't prove we own" stance is reasonable, but for the `forIdentityIdBase58:` path specifically, undecodable `identity_privkey.*` rows that no `PersistentPublicKey.privateKeyKeychainIdentifier` points at anywhere in SwiftData are safe to treat as orphan signals and delete.

  Reduced severity to `suggestion` since the developer is no longer blind to the leftovers.

Comment on lines +1394 to +1485
private func makeDashpayKeyPair(
managedWallet: ManagedPlatformWallet,
walletId: Data,
identityIndex: UInt32,
firstKeyId: UInt32,
network: Network
) throws -> [ManagedPlatformWallet.IdentityPubkey] {
let purposes: [(keyId: UInt32, purpose: KeyPurpose)] = [
(firstKeyId, .encryption),
(firstKeyId + 1, .decryption),
]
let bounds: ManagedPlatformWallet.ContractBounds = .singleContractDocumentType(
id: Self.dashpayContractId,
documentTypeName: Self.dashpayContactRequestDocumentType
)
let walletIdHex = walletId.toHexString()
let identityIdPlaceholder = "" // overwritten by persister callback
// when the identity actually
// lands on-chain; the metadata
// we write here only needs to
// satisfy the keychain
// round-trip lookup by pubkey
// hex.

var rows: [ManagedPlatformWallet.IdentityPubkey] = []
rows.reserveCapacity(purposes.count)

for (keyId, purpose) in purposes {
let preview = try managedWallet.deriveIdentityAuthKeyAtSlot(
identityIndex: identityIndex,
keyId: keyId,
network: network
)

// Defence against derivation drift / FFI marshalling
// bugs — mirrors `AddIdentityKeyView.submit`'s cross-
// check. A mismatched DashPay key lands on Platform as
// a key the trampoline can't sign with and surfaces as
// an opaque "encrypted xpub" failure on the first
// contact-request flow, which is much harder to debug
// after the fact than failing fast here.
guard
KeyValidation.validatePrivateKeyForPublicKey(
privateKeyHex: preview.privateKeyData.toHexString(),
publicKeyHex: preview.publicKeyHex,
keyType: .ecdsaSecp256k1,
network: network
)
else {
throw PlatformWalletError.walletOperation(
"Derived DashPay key (kid \(keyId), purpose \(purpose.name)) didn't match its public key — refusing to persist"
)
}

let pubKeyHashHex = SwiftDashSDK.KeychainManager.computePublicKeyHashHex(
preview.publicKeyData
)
let metadata = IdentityPrivateKeyMetadata(
identityId: identityIdPlaceholder,
keyId: keyId,
walletId: walletIdHex,
identityIndex: identityIndex,
keyIndex: keyId,
derivationPath: preview.derivationPath,
publicKey: preview.publicKeyHex,
publicKeyHash: pubKeyHashHex,
keyType: KeyType.ecdsaSecp256k1.rawValue,
purpose: purpose.rawValue,
securityLevel: SecurityLevel.medium.rawValue
)
guard KeychainManager.shared.storeIdentityPrivateKey(
preview.privateKeyData,
derivationPath: preview.derivationPath,
metadata: metadata
) != nil else {
throw PlatformWalletError.walletOperation(
"Could not persist DashPay key (kid \(keyId), purpose \(purpose.name)) to Keychain"
)
}
rows.append(
ManagedPlatformWallet.IdentityPubkey(
keyId: keyId,
keyType: .ecdsaSecp256k1,
purpose: purpose,
securityLevel: .medium,
pubkeyBytes: preview.publicKeyData,
contractBounds: bounds
)
)
}
return rows
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: [CARRIED-FORWARD — STILL VALID] DashPay key registration orchestrates raw private keys + WIFs in Swift, violating the Swift SDK boundary rule

Re-verified at HEAD 03f088f — unchanged. makeDashpayKeyPair loops (encryption, decryption), calls managedWallet.deriveIdentityAuthKeyAtSlot(...) per slot, copies preview.privateKeyData (32 raw scalar bytes) plus preview.privateKeyWif into ARC-managed Swift values, then Swift orchestrates the validate->Keychain-write->FFI-payload pipeline. The KeyValidation.validatePrivateKeyForPublicKey cross-check is sensible defense against marshalling drift but cements the FFI contract that delivers raw secrets into Swift for orchestration.

packages/swift-sdk/CLAUDE.md names this exact shape ("orchestrating multi-step derivation pipelines in Swift (mnemonic -> seed -> path -> key -> store)") as the anti-precedent, and points at platform_wallet_preview_identity_registration_keys as the correct precedent: one FFI entry point that owns the per-purpose policy table, validates internally, and returns the minimum payload Swift needs to write to Keychain. Add a platform_wallet_derive_dashpay_keypair (or extend the existing identity-registration preview to include DashPay-purpose slots) so the cross-check happens on the Rust side and the WIF never crosses the FFI. With DashPay enabled by default, every wallet-backed registration amplifies the surface where raw secrets sit in the Swift heap.

source: ['claude', 'codex']

Comment on lines +947 to +980
else {
continue
}
// Surface undecodable-metadata rows via os_log rather
// than silently skipping them. The caller has asked
// for a wholesale identity-scoped wipe, so the safest
// action is still to NOT delete a row we can't prove
// we own — but the developer needs to know that an
// `identity_privkey.*` row exists whose metadata we
// can't read (likely a partial / aborted write, a
// legacy pre-metadata row, or a future schema rename),
// because the user is being told the orphan was
// cleaned up. Visible in Console.app under the
// `dashpay.SwiftDashSDK` subsystem.
guard let metadataData = item[kSecAttrGeneric as String] as? Data else {
Self.log.error(
"Skipping identity_privkey row at account \(account, privacy: .public) — missing kSecAttrGeneric metadata blob; private bytes remain"
)
continue
}
let metadata: IdentityPrivateKeyMetadata
do {
metadata = try decoder.decode(IdentityPrivateKeyMetadata.self, from: metadataData)
} catch {
Self.log.error(
"Skipping identity_privkey row at account \(account, privacy: .public) — metadata JSON decode failed (\(String(describing: error), privacy: .public)); private bytes remain"
)
continue
}
guard metadata.identityId == identityIdBase58 else {
continue
}
try deleteGenericPassword(account: account)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: [CARRIED-FORWARD — partially addressed] Identity-scoped Keychain cleanup logs but still leaves undecodable metadata rows behind

The delta added Self.log.error(...) reporting (subsystem dashpay.SwiftDashSDK, category Keychain) for both the missing-kSecAttrGeneric-blob branch and the JSON-decode-failure branch in both deleteAllIdentityPrivateKeys(forIdentityIdBase58:) (lines 950-975) and deleteAllIdentityPrivateKeys(forWalletId:) — the original "silent skip" half of the finding is now visible to developers in Console.app.

What remains: the bytes still survive the sweep. The most likely producers of malformed metadata are exactly the failure modes this PR creates — identityId: "" placeholder rows from the pre-registration DashPay path (finding #3 above), partial/aborted writes, or a future schema rename. The wholesale identity-scoped wipe presents to the user as definitive (WalletKeyHealthChecker.deleteOrphan PHASE 0), but undecodable rows remain. The conservative "don't delete data we can't prove we own" stance is reasonable, but for the forIdentityIdBase58: path specifically, undecodable identity_privkey.* rows that no PersistentPublicKey.privateKeyKeychainIdentifier points at anywhere in SwiftData are safe to treat as orphan signals and delete.

Reduced severity to suggestion since the developer is no longer blind to the leftovers.

source: ['claude', 'codex']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Partially: stays with the conservative "don't delete data we can't prove we own" stance for now — the os_log surfacing landed in 03f088f so the residue isn't silent anymore. Your suggested refinement (delete undecodable rows in the forIdentityIdBase58: path when no PersistentPublicKey.privateKeyKeychainIdentifier references the account anywhere in SwiftData) is sound but requires the helper to take a ModelContext, which tightens KeychainManager's coupling to the persistence layer. Tracking as a follow-up; the most likely producer (the pre-registration identityId: "" placeholder rows) is itself a carry-forward whose root-cause fix would eliminate the leftover class entirely.

Comment thread packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift Outdated
Comment thread packages/rs-platform-wallet-ffi/src/identity_persistence.rs
QuantumExplorer and others added 2 commits May 28, 2026 19:38
…sub-fixes

PR #3765 follow-up review found five issues from the prior round.
Addressed in this commit:

1. Rust FFI `IdentityKeyEntryFFI::from_entry` was emitting kind=2 with a
   null doc-type pointer on `CString::new` failure (interior-NUL — DPP
   rejects them upstream so unreachable today, but the discriminant + payload
   were inconsistent). Now demotes to kind=1 (`SingleContract { id }`) so the
   contract id is preserved and the Swift consumer sees a coherent variant.

2. PersistentPublicKey.contractBounds setter cleared only `contractBoundsData`,
   leaving the new `contractBoundsDocumentTypeName` column stale across
   variant changes. Setter now also nils the doc-type column; the persister
   continues to write it explicitly after the setter, atomically.

3. `toIdentityPublicKey()` could construct a `ContractBounds` from a corrupt
   short / over-long id; downstream FFI marshalling hard-asserts 32 bytes
   and would crash on the NEXT call. Added an `id.count == 32` gate; a
   wrong-length id drops the bounds projection but keeps the key.

4. Persist↔restore round-trip closure. `IdentityKeyRestoreFFI` was missing
   contract-bounds fields, so cold-restart loaded scoped DashPay keys as
   unbounded. Extended the Rust struct with `contract_bounds_{kind,id,
   document_type}` (mirrors the persist-side encoding); `build_identity_public_keys`
   reconstructs the variant; Swift's `buildIdentityRestoreBuffer` reads from
   the persisted columns and emits the same trio (CString allocation reuses
   `cStringBuffers` for release).

5. `WalletKeyHealthChecker.deleteOrphan` was throwing `keychainError` even
   when SwiftData succeeded, so the caller (`deleteIdentity` in the sheet)
   treated it as a full failure and kept the deleted row in `reports` —
   stale UI. Returns `OrphanDeleteOutcome { swiftDataDeleted, keychainError }`
   instead; caller drops the row on SwiftData success and surfaces the
   keychain side as a non-blocking warning.

Plus a follow-on consistency fix found in the same review:
`KeychainManager.deleteLegacyKeychainEntryIfOwnedByWallet` was silently
no-op'ing on malformed metadata while the wholesale sweeps log via
`os_log`. Added matching log lines so all three "skip, can't verify
ownership" branches surface in Console.app uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…act_bounds_document_type

Per coderabbit feedback on 3319620994 — `free_identity_key_entry_ffi`
is idempotent, but a copied-then-double-freed struct value would still
trigger UB. Spell out the supported consumption pattern (Swift binding
copies the doc-type into an owned String inside the callback) so the
constraint is explicit for future maintainers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@QuantumExplorer QuantumExplorer merged commit c01269d into v3.1-dev May 28, 2026
4 checks passed
@QuantumExplorer QuantumExplorer deleted the claude/thirsty-williams-6e9d18 branch May 28, 2026 17:42
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Latest delta 03f088f→03c54053 is a focused hardening pass that closes the persist↔restore ContractBounds round-trip and resolves both prior NEW findings (per-key legacy keychain helper now logs malformed-metadata skips symmetric to the wholesale sweeps; CString interior-NUL fallback now demotes to SingleContract kind=1 preserving the contract id rather than dropping it). Five prior findings remain STILL VALID at HEAD and are unchanged in this delta: (#1) DashPay encryption/decryption keys derived via the DIP-9 IDENTITY_AUTHENTICATION sub-feature path, (#2) Swift orchestrates raw 32-byte scalars + WIFs across the FFI for DashPay registration, (#3) pre-registration Keychain writes with placeholder identityId leak on aborted submit, (#4) identity-scoped Keychain sweep now logs but still leaves undecodable rows behind (partially addressed → suggestion), (#5) is_dpns_name_available correctness fix has no rs-sdk test. Two new latest-delta observations: PersistentPublicKey.contractBounds setter unconditionally nulls contractBoundsDocumentTypeName (statement-ordering footgun confirmed at PlatformWalletPersistenceHandler.swift:1415-1416), and the new restore-path contract_bounds projection silently demotes scoped keys to 'no bounds' on a wrong-length id rather than refusing the row. No blocking issues; recommending COMMENT.

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

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

5 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/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift:89-107: [NEW in latest delta] `contractBounds` setter unconditionally nulls `contractBoundsDocumentTypeName` — statement-ordering footgun
  Confirmed at HEAD: the setter at lines 89-107 always clears `contractBoundsDocumentTypeName` whenever the ids change through it. The doc comment correctly explains why (ids and doc-type are paired), but the contract this creates is asymmetric and fragile:

  * `from(_:identityId:)` is fine because it uses the designated initializer (line 70), bypassing the setter.
  * `PlatformWalletPersistenceHandler.upsertPublicKeyRow` at lines 1415-1416 writes `row.contractBounds = snapshotBoundsIds` (clears the doc-type) immediately followed by `row.contractBoundsDocumentTypeName = snapshotBoundsDocType` (restores it). This works ONLY because of strict statement ordering. Reordering, or any new caller that sets `contractBoundsDocumentTypeName` first and `contractBounds` second, silently degrades a `.singleContractDocumentType` row to `.singleContract` on the next `toIdentityPublicKey()` call — exactly the DashPay-specific failure mode this PR exists to fix.

  Less foot-gunny shapes: (a) make the setter accept `(ids, docType)` atomically (or expose only `from(_:identityId:)` for external writes); (b) drop the side-effect from the setter and have the persister handler null the doc-type explicitly when it wants to.

Comment on lines +3809 to +3825
if let id = pk.contractBounds?.first, id.count == 32 {
withUnsafeMutableBytes(of: &row.contract_bounds_id) { dst in
id.copyBytes(to: dst.bindMemory(to: UInt8.self).baseAddress!, count: 32)
}
if let docType = pk.contractBoundsDocumentTypeName, !docType.isEmpty {
row.contract_bounds_kind = 2
row.contract_bounds_document_type = UnsafePointer(
duplicateCString(docType, allocation: allocation)
)
} else {
row.contract_bounds_kind = 1
row.contract_bounds_document_type = nil
}
} else {
row.contract_bounds_kind = 0
row.contract_bounds_document_type = nil
}
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: [NEW in latest delta] Restore-path silently demotes scoped DashPay keys to 'no bounds' on wrong-length id

The new contract_bounds mirror in the restore-row builder gates on pk.contractBounds?.first?.count == 32 and falls through to row.contract_bounds_kind = 0 (unbounded) when the id is malformed/wrong-length. The Rust receiver in build_identity_public_keys then constructs IdentityPublicKeyV0 with contract_bounds: None. This is internally self-consistent with the persist-side comment ('rather than crashing FFI marshalling on the Rust side'), but has a scope-broadening effect: a DashPay encryption key persisted as .singleContractDocumentType(dashpay, contactRequest) that becomes corrupted on disk would, after cold restart, be presented to the local signer/UI as an unbounded key. The on-chain key still carries the bound (Drive will reject any out-of-scope state transition), so the only attack surface is wasted asset-lock / processing fees on a guaranteed-reject transition — not a consensus break.

Prefer either: (a) propagate the length error to the caller and drop the entire key from the restore set; (b) at minimum os_log the wrong-length-id case via the same dashpay.SwiftDashSDK subsystem. The parallel branch in PersistentPublicKey.toIdentityPublicKey() (line 164-171) swallows the length mismatch silently as well.

source: ['codex']

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Cumulative incremental review of PR #3765 at HEAD 8f65db2. The latest delta (03c5405→8f65db24) is documentation-only: a 10-line ownership/lifetime comment on IdentityKeyEntryFFI::contract_bounds_document_type clarifying the single-owner invariant for Swift consumers. No new defects introduced. Prior findings status: (1) DashPay encryption keys derived via IDENTITY_AUTHENTICATION DIP-9 sub-feature path — STILL VALID; (2) DashPay key registration orchestrates raw private keys + WIFs in Swift — STILL VALID; (3) Pre-registration DashPay Keychain writes leave orphans with empty identityId on failed submit — STILL VALID; (4) Identity-scoped Keychain cleanup logs but still leaves undecodable rows behind — STILL VALID (partially addressed); (5) is_dpns_name_available correctness fix has no automated test — STILL VALID; (6) contractBounds setter unconditionally nulls contractBoundsDocumentTypeName — STILL VALID; (7) Restore-path silently demotes scoped DashPay keys to no-bounds on wrong-length id — STILL VALID; (8) deleteLegacyKeychainEntryIfOwnedByWallet silent skip on malformed metadata — FIXED at 03c5405; (9) CString interior-NUL fallback dropped contract id — FIXED at 03c5405. No new latest-delta findings. Recommending COMMENT.

🟡 1 suggestion(s)

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

6 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/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift:89-107: [CARRIED-FORWARD — STILL VALID] `contractBounds` setter unconditionally nulls `contractBoundsDocumentTypeName` — statement-ordering footgun
  Re-verified at HEAD 8f65db24 — unchanged. The setter at lines 89-107 always clears `contractBoundsDocumentTypeName` whenever the ids change through it. The doc comment correctly explains why (ids and doc-type are paired), but the contract this creates is asymmetric and fragile: `from(_:identityId:)` is fine because it uses the designated initializer, bypassing the setter; `PlatformWalletPersistenceHandler.upsertPublicKeyRow` writes `row.contractBounds = snapshotBoundsIds` (clears the doc-type) immediately followed by `row.contractBoundsDocumentTypeName = snapshotBoundsDocType` (restores it). This works ONLY because of strict statement ordering. Reordering, or any new caller that sets `contractBoundsDocumentTypeName` first and `contractBounds` second, silently degrades a `.singleContractDocumentType` row to `.singleContract` on the next `toIdentityPublicKey()` call — exactly the DashPay-specific failure mode this PR exists to fix. Less foot-gunny shapes: (a) make the setter accept `(ids, docType)` atomically (or expose only `from(_:identityId:)` for external writes); (b) drop the side-effect from the setter and have the persister handler null the doc-type explicitly when it wants to.

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