Skip to content

feat(platform-wallet): skip startup mnemonic touches via seed-binding marker + gated contact-crypto drain#4125

Merged
QuantumExplorer merged 3 commits into
v4.1-devfrom
feat/startup-mnemonic-touch-removal
Jul 14, 2026
Merged

feat(platform-wallet): skip startup mnemonic touches via seed-binding marker + gated contact-crypto drain#4125
QuantumExplorer merged 3 commits into
v4.1-devfrom
feat/startup-mnemonic-touch-removal

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jul 14, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Two unconditional mnemonic/FFI touches ran on the app-launch unlock path for every restored wallet, every launch:

  1. platform_wallet_verify_seed_binds_to_wallet pulled the mnemonic from the Keychain through the resolver and derived mnemonic → seed → master xpriv → BIP44 account-0 xpub, just to compare against the persisted xpub. The result is a pure function of (mnemonic, network) vs a fixed persisted xpub — it cannot change between launches, and the sign-time pubkey binding remains the ultimate guard.
  2. A background platform_wallet_drain_pending_contact_crypto was scheduled unconditionally per wallet, costing a detached task + FFI round-trips (and a resolver-driven contactInfo refresh) even when nothing actionable was queued.

What was done?

Seed-bind verification marker (decision in Rust, storage in Swift):

  • rs-platform-wallet: new PlatformWallet::verify_seed_binds_with_marker — the marker binds the previously verified account-0 xpub to an identity stamp of the mnemonic Keychain item (its creation+modification dates, read attribute-only — the secret is never materialized). A match proves the binding was verified on an earlier launch AND the Keychain item is untouched since, and only then is the signer skipped. A missing/stale marker (first launch, wallet re-import) or a changed stamp (any rewrite/re-creation of the mnemonic item, including storeMnemonic's delete-then-add) falls through to the full derive-and-compare; a missing stamp disables the cache entirely (full check every launch — fail-safe, never bypassed). verify_seed_binds now delegates to it. New SeedBindingVerification outcome enum.
  • rs-platform-wallet-ffi: new platform_wallet_verify_seed_binds_to_wallet_cached taking the nullable marker + nullable Keychain stamp; *out_marker is set (host must persist) only when a full verification ran, bound, and a stamp was supplied. SeedMismatch still maps to ErrorInvalidParameter exactly like the non-cached entry point, which is kept.
  • swift-sdk: new optional PersistentWallet.seedBindingVerifiedMarker column plus load/store accessors on the persistence handler (mirroring setWalletName); new WalletStorage.mnemonicKeychainStamp(for:) attribute-only reader; unlockWalletFromKeychain round-trips marker + stamp through the cached FFI. Swift only loads/stores — match-vs-verify is decided in Rust.

Gated contact-crypto drain:

  • The drain is now scheduled only when the signerless probe reports queued work, using a new total drainable count (platform_wallet_drainable_contact_crypto_count / drainable_contact_crypto_count) that includes ContactInfoDecrypt — so cross-device contact metadata (alias/note) still applies at launch and metadata-only queues are never stranded. The filtered pending_contact_crypto_count keeps its existing semantics for the user-facing "waiting to finish setup" banner. On a probe failure the drain is scheduled anyway (old behavior — always safe).

Observability: the resolver's Swift vtable entry (MnemonicResolver.resolve), the unlock outcome, the marker persist, and the drain decision now log via NSLog so "what touched the seed at launch" is auditable off-Xcode.

How Has This Been Tested?

  • cargo test -p platform-wallet -p platform-wallet-ffi — green. New library tests pin the marker decision: no marker → full check + returns the stamped marker; matching marker+stamp → MarkerMatched proven to skip the signer (a wrong-seed signer passes untouched); changed Keychain stamp → full check re-runs (replaced mnemonic rejected with SeedMismatch, same mnemonic re-verifies with the new stamp); no stamp → never matches, nothing cached; stale marker → falls through (wrong signer still SeedMismatch). New FFI tests cover the null-pointer / unknown-handle marshalling contract of the cached entry point.
  • ./build_ios.sh --target sim — Rust xcframework + SwiftDashSDK + SwiftExampleApp all build.
  • On the booted simulator (5 pre-existing wallets: 4 testnet + 1 mainnet):
    • First launch after the change: all 5 wallet rows gained a persisted marker (account-0 tpub…/xpub…). Wiping one wallet's marker and relaunching produced exactly one full verify + marker re-persist for that wallet.
    • Subsequent launch: all 5 wallets unlocked via the marker path in ~600 ms combined — 5× "wallet unlock … seed verified", full verify, zero resolver calls on the unlock path. The only resolver calls in the session belonged to other startup paths (Orchard shielded bind — separate track) and to signing.
    • Signing still resolves: a 0.001 tDASH Core self-send from a testnet wallet fired the resolver once at the Send tap and broadcast successfully ("Payment sent", balance updated).
    • Drain gating: all wallets logged "contact-crypto drain skipped — no pending ops". No wallet on the test devices had queued ops, so the count>0 branch wasn't driven live; it is the pre-change scheduling path unchanged. The gate now counts the full queue (ContactInfoDecrypt included), so any queued metadata refresh schedules the drain at launch exactly as before this PR. The DashPay banner Unlock flow is unaffected.
  • Review-fix re-verification on a second, clean simulator (fresh install, wallet created in-app): launch 1 after restore ran exactly one full verify (🔑 resolve + 🔐 marker persisted, marker persisted as tpub…|c<created>-m<modified>); launch 2 unlocked via the marker path with zero resolver calls and no re-persist — proving the Keychain stamp is stable across launches (a drifting stamp would have silently disabled the cache).

Breaking Changes

None. The marker column is optional/nullable (pre-release, no migration needed); rows without it simply re-verify once. The non-cached FFI entry point is unchanged and retained.

Checklist:

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

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added seed-binding verification caching to speed up wallet unlocks when a previously verified marker remains valid.
    • Persisted verification markers across wallet sessions, with automatic full verification when markers are missing or outdated.
    • Added public APIs for reading and updating seed-verification status.
  • Performance

    • Avoided unnecessary signer derivation during cached verification.
    • Skipped background contact-crypto processing when no pending operations require action.
  • Bug Fixes

    • Improved validation and error handling for invalid wallets, seeds, markers, and output values.

… marker + gated contact-crypto drain

The unlock path ran platform_wallet_verify_seed_binds_to_wallet for every
restored wallet at every launch, resolving the Keychain mnemonic and
re-deriving the BIP44 account-0 xpub even though the outcome is a pure
function of (mnemonic, network) vs the persisted xpub. Verify once, persist
the verified xpub as a marker on the wallet row, and let Rust skip the
resolver on later launches while the marker still matches (re-import
invalidates it and re-runs the full check).

Also gate the per-wallet background contact-crypto drain on the signerless
pending-ops probe so an empty queue schedules nothing at unlock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added this to the v4.1.0 milestone Jul 14, 2026
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@QuantumExplorer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f86378d8-4bbc-4e39-9469-37c5c5d8a3a0

📥 Commits

Reviewing files that changed from the base of the PR and between fbe538c and 8a2b541.

📒 Files selected for processing (2)
  • packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
📝 Walkthrough

Walkthrough

The change adds marker-aware seed-binding verification across Rust, FFI, and Swift. Swift persists markers between unlocks, passes them to the cached FFI entry point, stores newly returned markers, and skips contact-crypto draining when no operations are pending.

Changes

Seed-binding verification cache

Layer / File(s) Summary
Rust marker-aware verification
packages/rs-platform-wallet/src/wallet/identity/network/..., packages/rs-platform-wallet/src/lib.rs
Adds marker-aware verification outcomes, marker comparison and fallback behavior, public re-exports, drain probing, and focused tests.
Cached FFI entry point
packages/rs-platform-wallet-ffi/src/dashpay.rs
Adds the cached verification entry point with pointer validation, marker allocation, error mapping, and marshalling tests.
Swift marker persistence
packages/swift-sdk/Sources/SwiftDashSDK/Persistence/..., packages/swift-sdk/Sources/SwiftDashSDK/Core/...
Adds the persisted marker model property, persistence-handler APIs, and a keychain-derived mnemonic stamp.
Cached unlock integration
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/..., packages/swift-sdk/Sources/SwiftDashSDK/FFI/...
Uses persisted markers during unlock, stores newly returned markers, skips empty contact-crypto drains, and adds related logging.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PlatformWalletManager
  participant PlatformWalletPersistenceHandler
  participant WalletStorage
  participant CachedFFI as platform_wallet_verify_seed_binds_to_wallet_cached
  participant PlatformWallet
  participant ContactCryptoProvider

  PlatformWalletManager->>PlatformWalletPersistenceHandler: Read seed-binding marker
  PlatformWalletManager->>WalletStorage: Read mnemonic keychain stamp
  PlatformWalletManager->>CachedFFI: Verify wallet with marker and stamp
  CachedFFI->>PlatformWallet: Call verify_seed_binds_with_marker
  PlatformWallet->>ContactCryptoProvider: Derive account-0 xpub when marker is stale or absent
  ContactCryptoProvider-->>PlatformWallet: Return derived xpub
  PlatformWallet-->>CachedFFI: Return verification outcome and marker
  CachedFFI-->>PlatformWalletManager: Return status and optional marker
  PlatformWalletManager->>PlatformWalletPersistenceHandler: Persist newly returned marker
Loading

Suggested reviewers: shumkov, zocolini, lklimek, llbartekll

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main startup optimizations: seed-binding marker caching and gated contact-crypto drain.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/startup-mnemonic-touch-removal

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.

@thepastaclaw

thepastaclaw commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Sonnet deferred (commit 8a2b541)
Canonical validated blockers: 1

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/rs-platform-wallet-ffi/src/dashpay.rs (1)

1020-1024: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider wrapping CStr::from_ptr(verified_marker) in an explicit unsafe block.
It keeps the unsafe scope consistent with the rest of this function and makes the FFI boundary easier to audit.

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

In `@packages/rs-platform-wallet-ffi/src/dashpay.rs` around lines 1020 - 1024,
Update the marker conversion in the verified_marker handling to wrap the
CStr::from_ptr call in an explicit unsafe block. Keep the existing null check
and string conversion behavior unchanged, limiting the unsafe scope to the FFI
pointer dereference.

Source: Coding guidelines

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

Nitpick comments:
In `@packages/rs-platform-wallet-ffi/src/dashpay.rs`:
- Around line 1020-1024: Update the marker conversion in the verified_marker
handling to wrap the CStr::from_ptr call in an explicit unsafe block. Keep the
existing null check and string conversion behavior unchanged, limiting the
unsafe scope to the FFI pointer dereference.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 97c011e7-a514-438f-8a30-d91b601482d5

📥 Commits

Reviewing files that changed from the base of the PR and between 7010103 and 92c977e.

📒 Files selected for processing (8)
  • packages/rs-platform-wallet-ffi/src/dashpay.rs
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Preliminary review — Codex only

The seed-binding cache is not bound to the current Keychain mnemonic, so it bypasses the wrong-slot gate after that mnemonic changes. The drain gate also treats ContactInfoDecrypt-only queues as empty, leaving cross-device contact metadata unapplied unless a payment later triggers a drain. Both are blocking regressions.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol/high — general (failed), gpt-5.6-sol/high — security-auditor (failed), gpt-5.6-sol/high — ffi-engineer (failed), gpt-5.6-sol — general (failed), gpt-5.6-sol — security-auditor (failed), gpt-5.6-sol — ffi-engineer (failed), gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs:87-90: Cached xpub marker bypasses current-seed verification
  `expected_marker` is only the wallet's persisted account-0 xpub, and a match returns before consulting `crypto`. Swift persists that marker independently of the mutable Keychain entry, while the public `WalletStorage.storeMnemonic(_:for:)` API can replace the mnemonic for an existing wallet ID without invalidating it. After one successful verification, a replacement or mis-mapped mnemonic therefore passes warm unlock, clears `seedMismatch`, and is used by later resolver-backed cryptography. Bind the marker to a Keychain-item identity or generation that is atomically invalidated on every mnemonic write and deletion, or continue deriving the resolver xpub before trusting the seed.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:689-699: Drain gate strands queued contact metadata indefinitely
  This early return uses `platform_wallet_pending_contact_crypto_count`, whose Rust implementation deliberately excludes `ContactInfoDecrypt`. Seedless recurring sync enqueues that operation but cannot decrypt or apply its alias, note, and hidden metadata. The only other production path that calls `drain_pending_contact_crypto` is payment sending, and the unlock banner uses the same filtered count, so users who do not send a payment retain stale cross-device metadata across subsequent syncs and launches. Gate on any drainable work, including `ContactInfoDecrypt`, or provide another guaranteed signer-present drain for those entries.

Comment thread packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs Outdated
QuantumExplorer and others added 2 commits July 14, 2026 21:53
… + gate drain on total drainable count

Review follow-ups (PR #4125):
- The cached verification marker is now bound to an identity stamp of the
  mnemonic Keychain item (creation+modification dates, attribute-only read)
  in addition to the account-0 xpub. Any rewrite or re-creation of the item
  changes the stamp and forces the full resolver check, so a replaced or
  mis-mapped mnemonic can never coast on an old verification. No stamp →
  cache disabled (full check every launch), never bypassed.
- The unlock drain gate now uses a new total drainable count that includes
  ContactInfoDecrypt, so cross-device contact metadata still applies at
  launch; the filtered count remains for the user-facing banner.
- Wrap CStr::from_ptr in explicit unsafe blocks in the cached verify FFI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fy resolver audit line

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer
QuantumExplorer merged commit 58543d3 into v4.1-dev Jul 14, 2026
17 checks passed
@QuantumExplorer
QuantumExplorer deleted the feat/startup-mnemonic-touch-removal branch July 14, 2026 15:58

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Preliminary review — Codex only

The contact-crypto drain blocker is fixed: the unlock path now counts all queued operations, including ContactInfoDecrypt, before deciding whether to schedule the drain. The seed-binding cache remains unsafe on supported native macOS because its generation stamp uses second-resolution legacy Keychain timestamps, allowing a rapid mnemonic replacement to retain the cached marker and bypass current-seed verification.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (failed), gpt-5.6-sol — general (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking

🤖 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/Core/Wallet/WalletStorage.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift:186-190: Cached xpub marker bypasses current-seed verification
  The Swift package supports native macOS15, but these Keychain operations omit `kSecUseDataProtectionKeychain`, so SecItem defaults to the file-based Keychain on macOS ([Apple TN3137](https://developer.apple.com/documentation/Technotes/tn3137-on-mac-keychains)). Its creation and modification attributes are whole-second `YYYYMMDDhhmmSSZ` values ([Apple Security documentation](https://developer.apple.com/documentation/security/secitemattr/creationdateitemattr)); converting the resulting Date to milliseconds only appends zeroes. Because public `storeMnemonic` deletes and re-adds the same item, a correct mnemonic can be verified and then replaced by a different mnemonic within the same second without changing this stamp. The persisted `xpub|stamp` marker is not cleared, and `seed_binding.rs` returns `MarkerMatched` before invoking the resolver, so the wrong-seed bypass remains. Store a random generation value alongside the Keychain item and rotate it on every mnemonic write, or invalidate the persisted marker whenever `storeMnemonic` writes.

Comment on lines +186 to +190
let created = attrs[kSecAttrCreationDate as String] as? Date ?? modified
// Millisecond precision; both dates so delete-then-add and in-place
// update are each guaranteed to change the stamp.
return "c\(Int64(created.timeIntervalSince1970 * 1000))"
+ "-m\(Int64(modified.timeIntervalSince1970 * 1000))"

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.

🔴 Blocking: Cached xpub marker bypasses current-seed verification

The Swift package supports native macOS15, but these Keychain operations omit kSecUseDataProtectionKeychain, so SecItem defaults to the file-based Keychain on macOS (Apple TN3137). Its creation and modification attributes are whole-second YYYYMMDDhhmmSSZ values (Apple Security documentation); converting the resulting Date to milliseconds only appends zeroes. Because public storeMnemonic deletes and re-adds the same item, a correct mnemonic can be verified and then replaced by a different mnemonic within the same second without changing this stamp. The persisted xpub|stamp marker is not cleared, and seed_binding.rs returns MarkerMatched before invoking the resolver, so the wrong-seed bypass remains. Store a random generation value alongside the Keychain item and rotate it on every mnemonic write, or invalidate the persisted marker whenever storeMnemonic writes.

source: ['codex']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants