Skip to content

DashSync migration part 1 - #1

Closed
podkovyrin wants to merge 8 commits into
developfrom
feature/dashsync-migration-part1
Closed

DashSync migration part 1#1
podkovyrin wants to merge 8 commits into
developfrom
feature/dashsync-migration-part1

Conversation

@podkovyrin

Copy link
Copy Markdown
Contributor

No description provided.

@podkovyrin podkovyrin closed this Nov 13, 2018
@podkovyrin
podkovyrin deleted the feature/dashsync-migration-part1 branch November 13, 2018 09:18
podkovyrin added a commit that referenced this pull request Aug 28, 2020
bfoss765 added a commit that referenced this pull request Nov 13, 2025
…rized commits

Updated both CLAUDE.md and AI-DEVELOPMENT-GUIDE.md with much stronger and clearer
language about the critical requirement to NEVER commit or push without explicit
user permission.

Changes include:
- Added prominent visual markers (emojis, bold text) to catch attention
- Declared this the #1 rule and #1 user complaint
- Provided concrete examples of violations vs correct behavior
- Listed specific phrases that grant permission
- Made it absolutely clear there are NO EXCEPTIONS to this rule

This addresses the recurring issue where AI assistants commit changes
without waiting for user permission.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
llbartekll added a commit that referenced this pull request Apr 8, 2026
The SDK's `ModelContainerHelper.createContainer()` is unusable in
dashwallet-ios for two compounding reasons that the SDK author never
hit because `SwiftExampleApp/` has neither App Group nor iCloud
entitlements:

1. **CloudKit auto-validation rejects the SDK's schema.** dashwallet-ios
   has both an App Group entitlement (Watch app + Today extension data
   sharing) and an iCloud entitlement. SwiftData's default
   `cloudKitDatabase: .automatic` and `groupContainer: .automatic`
   auto-detect both and try to register the container as a CloudKit
   store under the App Group container. The validator then enumerates
   ~120 violations across the SDK's 10 `@Model` types — non-optional
   attributes without defaults, non-optional relationships, missing
   relationship inverses (PersistentIdentity↔PersistentTokenBalance),
   and ubiquitous `@Attribute(.unique)` constraints which CloudKit
   doesn't support at all. The whole container init throws
   `SwiftDataError #1` (loadIssueModelContainer).

2. **Threading.** Even with the CloudKit issue worked around, the SDK
   helper's runtime schema registration only succeeds reliably when
   called from main on iOS 17. The SDK example app uses a
   `@MainActor`-isolated `UnifiedAppState.init()` to enforce this
   (`SwiftExampleApp/UnifiedAppState.swift:64`). Our migrator,
   coordinator, wallet creator, and wallet wiper all dispatched to
   `DispatchQueue.global(qos: .userInitiated)` and called the helper
   off-main, which would race even if CloudKit weren't an issue.

Fix: introduce `SwiftDashSDKContainer.swift`, a shared
`@objc(DWSwiftDashSDKContainer)` singleton that owns a
`ModelContainer` constructed once on the main thread. The container is
built directly via `ModelConfiguration(...)` with explicit
`cloudKitDatabase: .none`, an explicit store URL under
`Library/Application Support/SwiftDashSDKLocal/HDWallet.store`, and a
minimal `Schema([HDWallet.self])` — bypassing the SDK helper entirely.
The 9 platform `Persistent*` models are unused by dashwallet-ios so
omitting them from the schema is harmless.

`AppDelegate.application:didFinishLaunchingWithOptions:` calls
`[DWSwiftDashSDKContainer warmUp]` BEFORE the seed migrator, the SPV
coordinator, the wallet creator, and the wallet wiper. The warmUp is
synchronous, runs on main (precondition-enforced), and creates the
container exactly once per app launch.

All four downstream callers now read `SwiftDashSDKContainer.modelContainer`
from their background queues and construct their own background
`ModelContext` against it, which Apple documents as the supported
pattern. Reads from background queues are safe because the
`dispatch_async` calls that schedule the background work establish a
happens-before edge with the warmUp write. Each caller handles the
nil case (warmUp failed on main) by logging a clear error and either
rolling back any partial state (migrator/creator) or refusing to
proceed (coordinator/wiper).

Also bumps the SPV coordinator's seed-migrator polling timeout from
30s to 120s — covers slow simulator boot + the migrator's heavy work
+ a generous buffer for first-launch SwiftData store creation.

Verified end-to-end: imported a wallet via the recover flow (which
calls `WalletCreator.importWallet`), the wallet creator wrote an
HDWallet record to the new sandbox store, and the SPV coordinator
read it back and started syncing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
llbartekll added a commit that referenced this pull request Apr 8, 2026
…ause

The fix in 0b70276 is structurally correct, but the explanatory
comments around it were written from an early hypothesis that turned
out to be wrong. An exploration agent's research suggested the SDK's
ModelContainerHelper.createContainer() was racing schema registration
when invoked from a background queue on iOS 17, and the SDK's example
app uses a @MainActor-isolated init for the same reason. I implemented
the shared-container fix on that basis.

Two rounds of testing later we discovered the actual root cause from
a CoreData log dump: dashwallet-ios has both an iCloud entitlement and
an App Group entitlement. SwiftData's defaults `cloudKitDatabase:
.automatic` and `groupContainer: .automatic` auto-detect both, register
the SDK's 10-model schema as a CloudKit-backed store under the App
Group container, and then fail validation because the SDK's
`Persistent*` models violate ~120 CloudKit rules (non-optional
attributes without defaults, unique constraints, missing relationship
inverses, non-optional relationships).

The bypass — minimal `Schema([HDWallet.self])`, explicit
`cloudKitDatabase: .none`, explicit store URL under
Library/Application Support/SwiftDashSDKLocal — works end-to-end and
is verified by an actual SPV sync running against a wallet imported
via the recover flow. But the comments still tell the iOS-17 threading
story.

Cleanup, six edits across five files plus one numeric constant:

  - AppDelegate.m: reframe the warmUp block comment from "races
    schema registration on iOS 17" → "fails CloudKit validation on
    iCloud + App Group entitled apps".

  - SwiftDashSDKContainer.swift: fix the file-header bullet that
    claimed we set `groupContainer: .none` (we don't — the explicit
    `url:` overrides any group-container default, which is correct
    behavior, but the comment lied about the mechanism). Soften the
    `warmUp()` doc to say the main-thread requirement is defensive
    practice matching the SDK example app, not load-bearing for
    SwiftData. The Thread.isMainThread precondition stays — it's free
    belt-and-suspenders against any future SwiftData regression.

  - SwiftDashSDKKeyMigrator.swift,
    SwiftDashSDKWalletCreator.swift,
    SwiftDashSDKWalletWiper.swift: drop the "throws SwiftDataError #1
    on iOS 17" attribution from each background-queue context comment.
    Replace with "the SDK helper fails CloudKit validation on entitled
    apps; see SwiftDashSDKContainer.swift for the full rationale".

  - SwiftDashSDKKeyMigrator.swift, SwiftDashSDKSPVCoordinator.swift:
    drop "main-thread warmUp must have failed" from error logs and
    the user-visible error string. warmUp() can fail for filesystem
    or schema reasons too, not just threading. New phrasing is
    "warmUp() failed; check Console.app for `📦 SDKBOX` logs".

  - SwiftDashSDKSPVCoordinator.swift: revert
    `seedMigratorWaitTimeout` from 120.0 → 30.0. The bump to 120s
    came in during the diagnostic round with a rationale ("buffer
    for first-launch SwiftData store creation") that's now obsolete
    because the store is created upfront in `warmUp()` BEFORE the
    coordinator's polling loop even starts. The migrator's heavy
    work is ~300-500 ms; 30 s was always plenty.

Pure documentation hygiene plus one numeric constant. Zero behavior
changes. Both dashwallet and dashpay targets build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
llbartekll added a commit that referenced this pull request Apr 9, 2026
M6 stopped DashSync's SPV but left BalanceModel reading from
DSWallet.balance, which is now frozen at whatever DashSync had cached
the moment M6 ran. This commit unfreezes the home screen balance by
sourcing it from SwiftDashSDK via a new SwiftDashSDKWalletState
singleton.

A new SwiftDashSDKWalletState class is the right home for wallet-side
@published state: SPV chain sync (handled by SwiftDashSDKSPVCoordinator)
and wallet state (balance, transactions, addresses) are different
concerns even though the FFI couples their event delivery. Future
follow-ups for transactions (#6), addresses (#1), and identities (#16)
will live alongside `balance` in this class instead of bloating the
SPV coordinator.

- Add SwiftDashSDKWalletState singleton with @published var balance,
  applyBalance(_:), seedInitialBalance(walletManager:walletId:), and
  clearBalance() methods. Holds the WalletBalance struct (4 UInt64
  fields, with `total` and `spendable` computed). 💰 WALLET :: log tag.
- SwiftDashSDKSPVCoordinator: WalletEventsHandler.onBalanceUpdated
  becomes a thin forwarder to SwiftDashSDKWalletState.shared.applyBalance.
  performStart calls SwiftDashSDKWalletState.shared.seedInitialBalance
  after walletManager.importWallet succeeds. Coordinator no longer
  owns any wallet-side @published state.
- BalanceModel subscribes to SwiftDashSDKWalletState.shared.\$balance
  via Combine and reads from .balance?.total instead of
  DWEnvironment.sharedInstance().currentWallet.balance.
- SwiftDashSDKWalletWiper calls SwiftDashSDKWalletState.shared.clearBalance()
  after the SwiftData wipe so post-wipe state doesn't show the
  previous wallet's balance.
- Register the new file in both dashwallet and dashpay targets in
  project.pbxproj (UUID family A5D5DD000000000000010C/D/E).

Other DashSync balance consumers (BalanceNotifier, SendAmountModel,
DWPhoneWCSessionManager, DashPay/CrowdNode/CoinJoin/DashSpendPay) still
read from DSWallet and remain stale. Each gets its own follow-up commit.
Aim of this commit is the smallest change that fixes the most visible
part of the M6 regression — the home screen number — with the right
architectural shape so #6 and beyond can build on it cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
llbartekll added a commit that referenced this pull request Apr 17, 2026
Migrates the receive-address read path from DSAccount.receiveAddress
to WalletManager.getReceiveAddress(walletId:) via the new thin shim
SwiftDashSDKReceiveAddressReader, mirroring the AddressValidator
pattern. Eleven production call sites switched in one cut, with
DWReceiveModel re-fetching on transactionsDidChange so the displayed
address advances as SPV catches up after first launch post-migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
llbartekll added a commit that referenced this pull request May 14, 2026
The `transactions` @published array and its `applyTransactions` /
`seedTransactions` / `currentTransactionCount` / `clearTransactions`
helpers were left dangling when upstream gated
`managed_core_account_get_transactions` behind the
`keep-finalized-transactions` Cargo feature. With no live producer for
the bridge, `HomeViewModel` already reads `PersistentTransaction` rows
directly from SwiftData via `SwiftDashSDKWalletSource`. Drop the
unreachable state and its `transactionsDidChangeNotification`.

`DWReceiveModel` was the only external observer of that notification;
point it at `NSManagedObjectContextDidSaveNotification` instead — the
same SwiftData "tx written" signal HomeViewModel uses — so the
displayed receive address advances when SPV processes a payment.
Balance state on `SwiftDashSDKWalletState` is untouched.

Refresh DASHSYNC_MIGRATION.md rows #1 / #6 / table row 6 to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
llbartekll added a commit that referenced this pull request Jul 2, 2026
SwiftDashSDKReceiveAddressReader ignored its DSChain argument (it resolves the
wallet via SwiftDashSDKHost.shared), so every call site fetched
DWEnvironment.currentChain / account.wallet.chain purely to feed a discarded
value. Drop the parameter: receiveAddress(on:) -> receiveAddress(),
@objc(receiveAddressOnChain:) -> plain @objc (auto selector `receiveAddress`).

- 10 Swift sites + the BIP70 provider drop the arg; 8 shed a now-dead
  `let chain` binding.
- 5 ObjC sites become a selector swap; DWURLRequestHandler sheds a dead
  `account` local. Sites that still need chain/account for DSPaymentRequest /
  Apple Watch balance keep them (those belong to #22 / #5-#6).
- The reader file is now completely DashSync-free (zero DS* symbols); kept as a
  permanent SDK-only helper (same shape as #2 / #21).

Flips migration item #1 (receive address) from Solo to Done. dashpay scheme
builds clean on iPhone 17 sim (arm64).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
llbartekll added a commit that referenced this pull request Jul 6, 2026
… monitor

The Settings "Rescan Blockchain" actions set isResyncingWallet=true and
invoked dead DashSync rescans (whose disconnected*Rescan side effect was
also the last thing that could start DashSync networking). The send gates
in SendAmountModel/DashSpendPayViewModel then required the DashSync
syncPhase to reach .synced — which never happens post-M6 — so tapping
Rescan permanently blocked sending until the next launch's sync-done
transition. Live user-facing bug from the teardown research (Bug #1).

- Settings: the Rescan menu row, its two alerts, the .rescan destination,
  and the three dead view-model actions are removed (no SDK rescan API
  exists yet; the runtime stop/restart path has the known main-thread
  block_on deadlock, so no restart-backed rescan is wired). The
  onDidRescan init parameter stays for the frozen MainMenuViewController
  call site.
- Send gates now check SyncingActivityMonitor.shared.state == .syncDone
  (the documented invariant); the isResyncingWallet disjunct stays to
  defend installs with a stale persisted flag, which the monitor clears
  on the next sync-done transition.
- SyncingHeaderView drops its frozen peerManager.connected clause.
- AppDelegate's migration-crash path no longer calls the dead
  masternodeListAndBlocksRescan (TODO(teardown C1) records the ignored
  parameter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
llbartekll added a commit that referenced this pull request Jul 8, 2026
withdraw() and signAndSendEmail() signed their API messages through the
frozen DashSync stack (wallet.privateKey(forAddress:fromSeed:) +
DSKeyManager.signMesasageDigest + magicDigest) — silently broken for
SDK-created wallets, whose keys DashSync never sees. The path is now
SwiftDashSDK end to end:

- DarkCoinMessage.framed (new pure-Foundation
  Models/CrowdNode/Services/DarkCoinMessageFraming.swift, both targets
  in pbxproj): Bitcoin CompactSize framing of the DarkCoin magic +
  message. Golden-verified byte-exact against an independent Python
  port of DashSync's magicDigest framing, incl. a 300-byte message
  exercising the 0xFD compact-size arm (ProtoWriter's LEB128 varint is
  NOT this — documented in-file).
- CrowdNodeMessageSigner (CrowdNodeModel.swift): resolves the account
  address's key by scanning m/44'/coin'/0'/{0,1}/{0..299} pubkey
  hash160s (the SDK has no address->index lookup), derives the WIF via
  Wallet.derivePrivateKey(path:), and signs the framed bytes with the
  new swift-sdk RawKeySigner (SHA256d inside the FFI; 65-byte compact
  recoverable sig, same wire format DSKeyManager produced). Fails
  closed with nil — never a wrong-key signature.
- Auth: wallet.seed(withPrompt:) replaced by the shared
  AuthenticationGate; the withdraw message is String(amount), byte-
  identical to the .value (KituraContracts String(describing:)) the
  web service still sends as the API parameter.
- SwiftDashSDKHost.derivationWallet(): the throwaway key-wallet
  bootstrap promoted out of MasternodeProviderKeyDeriver (guardrail #1)
  — the deriver and the signer now share it.

Companion swift-sdk commit: platform 47a83cf6d0 (public RawKeySigner).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
llbartekll added a commit that referenced this pull request Jul 8, 2026
The non-presenting half of the app-side auth stack, byte-compatible
with DashSync's keychain so users' PINs carry over with zero migration:

- PinStore: codec + I/O over the six org.dashfoundation.dash accounts.
  The byte contract (UTF-8 pin, 8-byte little-endian int64s, cku/aku
  accessibility split, add-or-update write shape) is frozen as goldens
  captured from a live DashSync-written keychain (PIN "1111" ->
  31313131; USES_AUTHENTICATION -> 0100000000000000 as the endianness
  witness) in scripts/auth_keychain_goldens/. PinCodec + LockoutPolicy
  are pure and swiftc-harness-tested (21 checks: fixtures both
  directions + the 6^(n-3)*60 lockout table). The migrator's keychain
  read primitive is promoted into PinStore (guardrail #1).
- LockoutPolicy: the pod's formula as pure functions; DW_LOCKOUT_SCALE
  env hook for second-scale lockout smokes (env-gated, not #if DEBUG —
  the dashpay target defines no DEBUG Swift condition).
- AuthenticationService (@objc DWAuthenticationService, protocol seam):
  session didAuthenticate + failedPins dedupe, verifyPin/precheck
  ported line-for-line (unique-attempt pre-increment, failHeight
  secure-time ratchet, permanent lock at 8), setupNewPin/removePin,
  biometric spending-limit policy incl. the legacy NSUserDefaults
  migrate-on-read fallback. Interactive prompt lands with C7.3.
- DEBUG launch parity assertion (AppDelegate, TODO(C7-final: remove)):
  PinStore reads vs live DSAuthenticationManager getters. It already
  earned its keep: the pod's lockoutWaitTime getter turns out to return
  inf below 3 failures (uint64 underflow into pow()) — never consumed
  there; our policy returns 0, and the comparison is scoped to the
  defined range.

Verified: dashpay arm64-sim build; goldens harness 21/21; sim launch
logs 'C7 PARITY :: PinStore agrees with DSAuthenticationManager
(pinSet=true failCount=0)' against the DashSync-written wallet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
QuantumExplorer added a commit that referenced this pull request Jul 9, 2026
… a Wallets screen (#790)

* feat(wallet): active-wallet registry + walletId-scoped data reads (switch-wallet phase 0)

The app assumed a single SDK wallet and arbitrarily bound
`PlatformWalletManager.firstWallet`, while every wallet-data read scanned
all persisted rows across all wallets. Phase 0 makes the app honest about
which wallet is active and scopes reads to it — no UI, no behavior change
for existing single-wallet installs.

Active-wallet registry: `WalletEnvironment` gains per-network
`activeWalletId(for:)` / `setActiveWalletId(_:for:)` backed by a
per-network UserDefaults key holding the raw walletId `Data` (nil = unset),
following the file's existing stateless-static-namespace pattern (no new
singleton). `SwiftDashSDKHost` resolves the persisted active walletId among
the manager's loaded `wallets`, falling back to `firstWallet` when unset or
missing, and writes the resolved id back on every bind (fallback included,
and after `createOrImportWallet`) so the registry is concrete after first
launch. For a single-wallet install `firstWallet` == the only wallet, so
the resolved wallet is unchanged.

Scoped reads: `PersistentTransaction` deliberately carries no walletId (one
row is shared across wallets), so per-wallet membership is recovered by
joining through `PersistentTxo.walletId` — gather the active wallet's txids
from its walletId-scoped TXO rows (union of the producing `transaction` and
the spending `spendingTransaction`) and match transactions in that set.
Scoped sites: the home tx list and point-lookup (HomeViewModel), the
receive-address used/received-total reads (SwiftDashSDKReceiveAddressReader),
ZenLedger address export, and the CrowdNode TransactionObserver scan. The
TXO join is one walletId-scoped fetch per reload (not per transaction), so
the home timeline stays a single indexed scan on large wallets. Readers
that run before the host binds a wallet return empty, which is correct.

Co-Authored-By: Claude Opus <noreply@anthropic.com>

* feat(wallet): runtime wallet switching + activeWalletDidChange (switch-wallet phase 1)

Phase 0 made the app honest about which wallet is active (per-network
registry + walletId-scoped reads) but left no way to change the active
wallet at runtime. Phase 1 adds that engine. No UI (Phase 2 owns UI).

Reuse the network-switch sequence, don't duplicate it. `switchWallet(to:)`
on SwiftDashSDKWalletRuntime validates the target, repoints the per-network
active-wallet registry, then runs the exact stop -> clear -> load -> start
sequence `refresh` already owns for a network switch — the only difference
is the network is unchanged, so the host's Phase-0 `resolveActiveWallet`
binds the newly-recorded wallet when it rebuilds. Added an awaitable variant
of the serial-chain `enqueue` so the switch can block until its rebuild (and
every op queued before it) completes, then verify the bind and post the
change signal. A new `.walletDidChange` refresh trigger never elides the
rebuild (same network would otherwise skip it). Validation is synchronous on
the main actor before any teardown; failures surface as `SwitchError`
(unsupportedNetwork / unknownWallet / bindFailed) rather than
logged-and-swallowed. Switching to the already-active wallet is a no-op.

Typed change signal: `SwiftDashSDKWalletState.activeWalletDidChangeNotification`
(new name, not a re-emission of any DS* name nor of balanceDidChange —
guardrail #5), posted on the main thread after the new wallet is bound and
its balance seeded.

Consumer audit (wired vs. already-covered):
- DWCurrentUserIdentityInfo: WIRED — added the notification to its lazy-
  invalidation observers so the next read rebuilds from the new wallet's
  identity rows.
- SwiftDashSDKContactsService: WIRED — ownerId changes with the wallet;
  observes and refreshes. Forces the identity snapshot fresh first because
  NotificationCenter delivery order between the two observers isn't
  guaranteed.
- HomeViewModel: WIRED — treats the switch like a network change
  (clearCachedData) because the per-hash tx cache belongs to the old wallet
  and a same-total balance event wouldn't clear it.
- MainTabbarController: WIRED via a new unconditional rebuild. The existing
  reconfigureDashPayTabsIfNeeded only ADDS tabs (early-returns without an
  identity), so switching to a wallet without an identity needs a rebuild
  that also REMOVES the DashPay tabs; selection resets to Home.
- BalanceModel / BalanceNotifier / DWPhoneWCSessionManager: NOT wired —
  they follow SwiftDashSDKWalletState.$balance / balanceDidChange, which the
  switch sequence already drives (clearAllState clear + the SPV re-seed).

Registry hygiene on wipe: SwiftDashSDKWalletWiper clears the active-wallet
registry for both registry-backed networks. The wipe removes all wallets
(mnemonics are network-agnostic), so every recorded active id now points at
nothing; Phase 0's fallback would mask a stale id, but clearing it keeps the
registry honest.

Co-Authored-By: Claude Opus <noreply@anthropic.com>

* feat(wallet): Wallets screen — switch, rename, remove (switch-wallet phase 2)

Replaces the Security menu's destructive "Reset Wallet" entry with a
"Wallets" screen listing the on-device SwiftDashSDK wallets for the
current network, one marked active, with switch-on-tap, rename, and
per-wallet remove. Add Wallet is Phase 3 and is intentionally absent
(no disabled controls, no stubs).

Menu wiring: `SecurityMenuViewModel`'s "Reset Wallet" item becomes a
"Wallets" item; `SecurityMenuScreen` pushes a `UIHostingController`
wrapping the new SwiftUI screen (matching the sibling push pattern).
`DWResetWalletInfoViewController` is kept — the Wallets screen still
presents it for the last-wallet remove path.

WalletsScreen + WalletsViewModel (SwiftUI + @mainactor VM, SwiftUI-first):
- List source: `SwiftDashSDKHost.shared.manager?.wallets` intersected
  with the walletIds that have a persisted mnemonic
  (`SwiftDashSDKHost.persistedMnemonics()`) — a wallet without a mnemonic
  isn't switchable. Per row: display name (`PersistentWallet.name` else
  "Wallet <prefix>…"), balance (`ManagedPlatformWallet.balance().total`
  via the app's `formattedDashAmount`), DPNS username (read from the
  wallet's pinned `PersistentIdentity` row directly — target-neutral SDK
  model, so no DASHPAY gate), and an active checkmark
  (`WalletEnvironment.activeWalletId(for:)`, network via `isTestnet`).
- Switch: confirmation alert → `SwiftDashSDKWalletRuntime.switchWallet`
  with a blocking progress overlay; refreshes on
  `activeWalletDidChangeNotification`; surfaces `SwitchError` in an alert
  on failure (never claims success on failure).
- Rename: alert with TextField → writes `PersistentWallet.name` via the
  host `modelContainer` mainContext (empty clears to nil).
- Remove: recovery-phrase confirmation sheet spelling out that only this
  device's copy is destroyed (funds recoverable only with the phrase).
  Verified read-only by deriving the walletId from the entered mnemonic
  (`Wallet(mnemonic:network:).id`, which persists nothing) and comparing.
  Removing the active wallet auto-switches to another wallet first; the
  last wallet routes to the existing full reset flow.

Guardrail #1 (no copy-then-adapt): the per-wallet deletion body is
promoted out of `SwiftDashSDKWalletWiper` into an internal
`deleteWalletFromSDK(_:)` (manager `deleteWallet` + mnemonic delete),
reused by both the full wipe loop and the new remove flow.

Both `dashpay` and `dashwallet` targets are registered in project.pbxproj
(this UI is not DashPay-gated). `dashpay` builds green; the new code also
compiles clean under `dashwallet` (its remaining failures are the
pre-existing DASHPAY-only breakage this branch documents).

Co-Authored-By: Claude Opus <noreply@anthropic.com>

* feat(wallet): add-wallet create/import + per-wallet backup flags (switch-wallet phase 3)

Phase 2 shipped the Wallets screen (switch / rename / remove) but left Add
Wallet for Phase 3. This adds it and makes the two genuinely per-wallet
DWGlobalOptions flags wallet-scoped.

Add Wallet (WalletsScreen + WalletsViewModel):
A "+" in the screen header presents a sheet with two paths.
- Create New Wallet: generate a 12-word mnemonic via SwiftDashSDK's
  `Mnemonic.generate(wordCount: 12)`, show it in a clean SwiftUI phrase grid
  (DWPreviewSeedPhraseModel is too DashSync-entangled to reuse — it dual-writes
  to DSWallet — so a new grid, no new UIKit), and require an explicit "I have
  written it down" toggle before creating.
- Import from Phrase: multiline entry, `Mnemonic.validate` gate. If the derived
  walletId is already on device, a typed `.alreadyOnDevice` state offers
  switching to it instead of fabricating a success.
Both paths add additively then `switchWallet(to:)` the new wallet (awaited,
progress state), dismissing on success. All SDK/mnemonic work lives in the VM.

Additive host path (SwiftDashSDKHost):
`createOrImportWallet` is NOT additive — it rebuilds the runtime (`buildRuntime`
calls `stop()`), pins the new wallet active in the registry, and publishes it as
bound. That is correct for onboarding's first/sole wallet but would unbind the
active wallet when adding a second. New `addWallet(mnemonic:)` uses the LIVE
running manager, persists the mnemonic, and does NOT touch the registry or
publish (the UI switches explicitly). The create-then-persist-with-rollback body
is factored into a shared `createAndPersist` used by both paths (no duplication).
`addWallet` detects the idempotent "already exists" case up front by deriving the
walletId (`Wallet(mnemonic:network:).id`) and checking `persistedMnemonics()`,
returning `.alreadyExists` rather than silently no-opping. Neither path touches
DashSync.

Per-wallet flags (DWGlobalOptions):
`walletNeedsBackup` and `userHasBalance` describe a per-wallet fact but were
app-global. Their getters/setters now resolve `DW_WALLET_NEEDS_BACKUP_<hex>` /
`DW_WALLET_HAS_BALANCE_<hex>` scoped by the active walletId
(`DWWalletEnvironment.activeWalletIdHex`, a new @objc accessor over the same
per-network registry — one place owns it). One-time migration: on first read
with no per-wallet key, seed from the legacy `DW_GLOB_*` key's effective value
(which carries the registered default, e.g. needs-backup = YES), then the legacy
key goes dormant (not deleted) and remains the fallback while no wallet is active
yet (onboarding sets these before a walletId is resolved). The 30+ existing call
sites keep the property names as the active-wallet view, unchanged. `shortcuts`
stays global (a UI preference). New wallets: created → needs-backup TRUE (phrase
shown, not verified — matches onboarding), imported → FALSE (user holds the
phrase — matches recover).

DSDynamicOptions reads/writes UserDefaults live on every accessor (no in-memory
cache), so a wallet switch needs no cache invalidation — the next read simply
resolves the new active wallet's key. B2 has nothing to wire.

dashpay builds green (ARCHS=arm64). Nothing added references DASHPAY-only symbols
un-gated: the per-wallet flags live outside any `#ifdef DASHPAY`, and the UI/host
code is target-neutral SDK surface. No files added, so no pbxproj/plist changes.

Co-Authored-By: Claude Opus <noreply@anthropic.com>

* feat(wallet): per-wallet CrowdNode + withdrawal state (switch-wallet phase 4)

CrowdNode state describes ONE wallet's CrowdNode account (bound to a
funding address in a specific wallet), but was stored app-global — so a
wallet switch would show wallet A's CrowdNode balance/account under
wallet B (money-display-adjacent wrongness).

Scope the per-wallet keys the same way DWGlobalOptions phase 3 scoped its
per-wallet flags: effective key = `<legacyKey>_<activeWalletIdHex>`,
resolved live per access via a single `resolvedKey(_:)` seam in
CrowdNodeDefaults (and CoinJoinWithdrawalStore), seeded once from the
legacy key on first read, with the legacy key kept dormant as the
no-active-wallet fallback. Per-wallet: account/primary address, online
account state, last known balance, confirmation-dialog / online-info
shown, signed-email message id, pending confirmed-notification, last
withdrawal block, and the CoinJoin withdrawal txid tag set. Kept global:
the CrowdNode info / withdrawal-limits education flags (user-level) and
the withdrawal limits / fee (API service parameters).

CrowdNodeDefaults caches values in in-memory `_`-backed fields, so a
wallet switch also invalidates that cache; CrowdNode.shared caches the
active wallet's published state, so it observes
activeWalletDidChangeNotification (mirroring SwiftDashSDKContactsService)
and reloads from the now-active wallet's defaults without wiping them.

resetForWipe (both stores) now clears EVERY wallet's per-wallet keys by
prefix enumeration plus the dormant legacy keys — the wipe destroys every
wallet. The shared per-wallet deletion primitive deleteWalletFromSDK now
also clears the removed wallet's per-wallet CrowdNode + withdrawal keys,
so the Wallets-screen Remove flow drops them without touching the UI.

Co-Authored-By: Claude Opus <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus <noreply@anthropic.com>
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