Skip to content

fix(swift-sdk): gate the ordered bring-up on the seed actually owning the wallet - #4368

Merged
QuantumExplorer merged 3 commits into
v4.2-devfrom
fix/verify-only-seed-binding-api
Aug 11, 2026
Merged

fix(swift-sdk): gate the ordered bring-up on the seed actually owning the wallet#4368
QuantumExplorer merged 3 commits into
v4.2-devfrom
fix/verify-only-seed-binding-api

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

startWalletSubsystems (#4359) builds its own MnemonicResolver and KeychainSigner, and did no binding check — so it never inherited the wrong-seed gate unlockWalletFromKeychain applies. On a wallet whose identity is already known it skips discovery and goes straight to the contact sync and drain, deriving DIP-15 contact accounts from a mnemonic the wallet has already rejected.

That damage does not heal. register_contact_account keys its existence check on (index, owner, friend) and never on the xpub, and unlike the external-account side there is no rotation sweep for receiving accounts. A wrong xpub is written once and every later correct-seed pass no-ops over it, so the wallet watches addresses nobody pays to — with no symptom except payments that never arrive.

The other two drain branches are narrower, and worth stating so the fix is not over-scoped:

  • RegisterExternal is self-defending — a wrong-seed ECDH product fails the 69/78/107-byte xpub format parse, the channel is marked broken, and the existing rotation sweep rebuilds it once the correct seed is present.
  • The auto-accept leg cannot broadcast at all: KeychainSigner passes the on-chain public key as a binding and the signer rejects a wrong-seed key before producing a signature.

So nothing reaches Platform under a mismatched seed. What breaks is local state, silently and permanently.

Reported by review on dashpay/dashwallet-ios#961, which is the first client of this call and is blocked on this.

What was done?

PlatformWalletManager.verifySeedBinding(_:) — step 1 of unlockWalletFromKeychain, extracted verbatim and made public. Returns .verified / .watchOnly, throws on a mismatch. unlockWalletFromKeychain is now that call plus its drain, so no existing caller changes behaviour.

startWalletSubsystems calls it before anything else, so every client is protected rather than every client having to remember.

Why a new API rather than calling the unlock

unlockWalletFromKeychain is not a verification primitive. When the queue is non-empty it also starts a detached drain and sets the Swift-side draining guard. Using it as a preflight would leave this call racing a second drain over a second snapshot — duplicated network and ECDH work, and competing channel-broken / auto-accept writes. That is precisely what the existing guard exists to prevent, and it is reachable both from a persisted pending queue and from work the inline contact sync queues while the startup drain runs.

Why in the SDK rather than in each host

Two reasons, and the second is the load-bearing one:

  1. A host that has to remember to gate this call is a host that will eventually forget.
  2. The published seedMismatch flag cannot serve as that gate. Hosts commonly kick the unlock off asynchronously — iOS schedules it in a detached, un-awaited Task and returns the wallet immediately, then goes straight into the bring-up — so the flag is racing the very call it is meant to guard, and on the launch where the seed is wrong it may not be set yet. A verification performed inside the call is ordered with respect to the work it protects; a flag read outside it is not.

Cost on the common path is a string comparison: the verify is marker-cached, and a match never touches the Keychain.

Considered and rejected

Putting the gate in rs-platform-wallet's start_wallet_subsystems instead. That is the stronger home — a future JNI client inherits it — but the marker/stamp cache that keeps the check free is persisted host-side, so Rust would need both threaded through the FFI plus a new terminal SeedMismatch status, i.e. an ABI addition. Worth doing when a second client appears; not worth blocking a security fix behind today.

How Has This Been Tested?

xcodebuild -scheme SwiftDashSDK -sdk iphonesimulator   # clean

Plus a clean dashpay build of dashwallet-ios#961 against this branch, on the iOS 26.5 simulator — that PR drops its own app-side workaround in favour of this gate.

Not covered: no automated test. Exercising it needs a wallet whose persisted account-0 xpub disagrees with the Keychain mnemonic, which the SwiftDashSDK test targets have no fixture for today; the underlying verification itself is already covered Rust-side in seed_binding.rs (verify_seed_binds_rejects_wrong_signer).

Breaking Changes

None to existing callers — unlockWalletFromKeychain keeps its signature and behaviour.

One new failure mode for adopters of startWalletSubsystems: it now throws when the stored seed does not bind to the wallet, where it previously proceeded. That is the point of the change, and the documented contract already says Core sync must start regardless — the iOS call site catches, logs, and starts SPV.

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

Summary by CodeRabbit

  • Security

    • Added seed-to-wallet binding verification before unlocking or starting wallet services.
    • Prevented wallet startup when stored key material does not match the selected wallet.
    • Improved watch-only wallet handling without attempting identity-key setup.
  • Bug Fixes

    • Deferred contact encryption processing now occurs only after successful wallet verification.
    • Unlock operations correctly report unsuccessful results for watch-only wallets.

… the wallet

`startWalletSubsystems` built its own `MnemonicResolver` and
`KeychainSigner` and did no binding check, so it did not inherit the
wrong-seed gate `unlockWalletFromKeychain` applies. On a wallet whose
identity is already known it skips discovery and goes straight to the
contact sync and drain — deriving DIP-15 contact accounts from a
mnemonic the wallet has already rejected.

That damage does not heal. `register_contact_account` keys its existence
check on `(index, owner, friend)` and never on the xpub, and unlike the
external-account side there is no rotation sweep for receiving accounts,
so a wrong xpub is written once and every later correct-seed pass no-ops
over it. The wallet watches addresses nobody pays to, with no symptom
beyond payments that never arrive. The two other drain branches are
narrower: a wrong-seed ECDH product fails `RegisterExternal`'s xpub
format parse and the rotation sweep rebuilds it, and the auto-accept leg
cannot broadcast at all because `KeychainSigner` binds the on-chain
public key and rejects a wrong-seed key before signing.

**Why a new API rather than calling the unlock.** `unlockWalletFromKeychain`
is not a verification primitive: when the queue is non-empty it also
starts a detached drain and sets the `draining` guard. Using it as a
preflight would have this call race a second drain over a second
snapshot — duplicated network and ECDH work, and competing
channel-broken and auto-accept writes, which is exactly what that guard
exists to prevent. So step 1 is now `verifySeedBinding`, public and
side-effect-free, and the unlock is that call plus the drain. No
behaviour change for existing unlock callers.

**Why in the SDK rather than in each host.** A host that has to remember
to gate this call will eventually forget, and the published
`seedMismatch` flag cannot serve as that gate: hosts commonly kick the
unlock off asynchronously (iOS schedules it in a detached task and
returns), so the flag races the very call it is supposed to guard.
Verification performed inside the call is ordered with respect to the
work it protects; a flag read outside it is not.

Cost is a string comparison on the common path — the verify is
marker-cached, and a match never touches the Keychain.

Reported on dashpay/dashwallet-ios#961, whose call site this unblocks.

Verified: `xcodebuild -scheme SwiftDashSDK -sdk iphonesimulator` clean.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 1 minute

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d97c074-5019-4008-9661-acab7ad9dc6d

📥 Commits

Reviewing files that changed from the base of the PR and between bc4989c and b869720.

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

Walkthrough

The PR adds a public seed-binding verification API. Wallet unlock and subsystem startup call it before creating signers or draining deferred contact cryptography. Watch-only wallets return a distinct result.

Changes

Seed binding verification flow

Layer / File(s) Summary
Verification API and unlock flow
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
Adds SeedBindingCheck and verifySeedBinding. unlockWalletFromKeychain now verifies first, returns false for watch-only wallets, and delays signer creation and contact-crypto draining until verification succeeds.
Startup verification gate
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerStartup.swift
startWalletSubsystems verifies the seed binding before resolver and signer creation. Seed-binding mismatches now throw before startup work continues.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WalletSubsystems
  participant PlatformWalletManager
  participant SeedBindingCheck
  participant ResolverSigner
  WalletSubsystems->>PlatformWalletManager: startWalletSubsystems(wallet)
  PlatformWalletManager->>SeedBindingCheck: verifySeedBinding(wallet)
  SeedBindingCheck-->>PlatformWalletManager: .verified or mismatch error
  PlatformWalletManager->>ResolverSigner: create signer after verification
Loading

Possibly related PRs

Suggested reviewers: llbartekll, shumkov, quantumexplorer

🚥 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 clearly summarizes the main change: gating ordered wallet bring-up on seed ownership verification.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/verify-only-seed-binding-api

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

🕓 Ready for review — next in queue (commit b869720)
Queue position: 1/1
ETA: start ~11:00 UTC · complete ~11:11 UTC (median 11m across 30 recent reviews; 2 slots)
Queued 2m ago · Last checked: 2026-08-11 11:00 UTC

romchornyi pushed a commit to dashpay/dashwallet-ios that referenced this pull request Aug 11, 2026
From review on #961.

The previous commit used `unlockWalletFromKeychain` as the preflight,
which is not a verification primitive: when the contact-crypto queue is
non-empty it also starts a detached drain, so the bring-up's own drain
would race a second one over a second snapshot — duplicated network and
ECDH work, and competing channel-broken / auto-accept writes.

There was no verify-only API to call instead, so the gate moved into the
SDK, where it belongs anyway: dashpay/platform#4368 adds
`PlatformWalletManager.verifySeedBinding` and has `startWalletSubsystems`
call it before touching any key material. Every client is now protected,
not just this call site.

This file goes back to what it was: the call site and its logging. A
mismatch now surfaces as a throw from the bring-up, which the existing
catch already handles the right way — log it and start Core SPV, because
a wallet without a balance is worse than a wallet without DashPay state.

Requires platform#4368; the SDK is a local path dependency.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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/PlatformWalletManager.swift`:
- Line 750: Update the watch-only return path in the wallet verification method
to call setDashPaySeedMismatch(walletId, false) before returning .watchOnly,
ensuring any previously published seed mismatch is cleared when the mnemonic is
removed.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerStartup.swift`:
- Line 144: Update the startup flow around verifySeedBinding(wallet) to call an
internal storage-aware overload with the caller-provided storage, ensuring
verification uses the same WalletStorage as the resolver and persistence logic.
Preserve the public verifySeedBinding overload as a default-storage convenience
API, and have the new overload perform the existing verification behavior
against the supplied storage.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 95235abe-08fb-4ab0-8c60-b5dfb9a4b370

📥 Commits

Reviewing files that changed from the base of the PR and between 08edcfd and bc4989c.

📒 Files selected for processing (2)
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerStartup.swift

jeanpierreroma and others added 2 commits August 11, 2026 13:49
…from

Both from review on #4368.

**The gate could check a different Keychain than the work uses.**
`startWalletSubsystems` takes a `storage` parameter and builds its
resolver from it, but the verify I added constructed its own default
`WalletStorage`. Where those differ — tests, or any host that injects a
store — verification would approve one mnemonic while the derivation ran
on another, which is the exact failure this gate exists to prevent, just
harder to see. The check now takes the store as a parameter and passes it
to the resolver too; the public no-argument form is the default-store
convenience and is what the unlock path still uses.

**A removed mnemonic left the banner up.** The watch-only early return
came before `setDashPaySeedMismatch(walletId, false)`, so a wallet whose
seed failed to bind and whose Keychain item was then deleted kept
publishing a mismatch for a seed that no longer exists. No mnemonic is
not a mismatch.

Verified: `xcodebuild -scheme SwiftDashSDK -sdk iphonesimulator` clean.
@QuantumExplorer
QuantumExplorer merged commit e87142b into v4.2-dev Aug 11, 2026
16 checks passed
@QuantumExplorer
QuantumExplorer deleted the fix/verify-only-seed-binding-api branch August 11, 2026 11:05
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.

4 participants