Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions DashWallet.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,10 @@
752F81992E30F55E00ADA76D /* SecurityMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752F81972E30F55E00ADA76D /* SecurityMenuViewModel.swift */; };
752F819A2E30F55E00ADA76D /* SecurityMenuScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752F81962E30F55E00ADA76D /* SecurityMenuScreen.swift */; };
752F819B2E30F55E00ADA76D /* SecurityMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752F81972E30F55E00ADA76D /* SecurityMenuViewModel.swift */; };
7DA5000000000000000A0011 /* WalletsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DA5000000000000000A0001 /* WalletsScreen.swift */; };
7DA5000000000000000A0012 /* WalletsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DA5000000000000000A0001 /* WalletsScreen.swift */; };
7DA5000000000000000A0021 /* WalletsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DA5000000000000000A0002 /* WalletsViewModel.swift */; };
7DA5000000000000000A0022 /* WalletsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DA5000000000000000A0002 /* WalletsViewModel.swift */; };
752F81A92E323FDB00ADA76D /* ToolsMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752F81A82E323FD000ADA76D /* ToolsMenuViewModel.swift */; };
752F81AA2E323FDB00ADA76D /* ToolsMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752F81A82E323FD000ADA76D /* ToolsMenuViewModel.swift */; };
75303FE52AE7B70500870D8B /* CrowdNode.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 75303FE42AE7B70500870D8B /* CrowdNode.storyboard */; };
Expand Down Expand Up @@ -2509,6 +2513,8 @@
752D03A82E2F758B00B88784 /* MainMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenuViewModel.swift; sourceTree = "<group>"; };
752D03B12E2F7B3C00B88784 /* MainMenuViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenuViewControllerDelegate.swift; sourceTree = "<group>"; };
752F81962E30F55E00ADA76D /* SecurityMenuScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityMenuScreen.swift; sourceTree = "<group>"; };
7DA5000000000000000A0001 /* WalletsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WalletsScreen.swift; path = Wallets/WalletsScreen.swift; sourceTree = "<group>"; };
7DA5000000000000000A0002 /* WalletsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WalletsViewModel.swift; path = Wallets/WalletsViewModel.swift; sourceTree = "<group>"; };
752F81972E30F55E00ADA76D /* SecurityMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityMenuViewModel.swift; sourceTree = "<group>"; };
752F81A82E323FD000ADA76D /* ToolsMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolsMenuViewModel.swift; sourceTree = "<group>"; };
75303FE42AE7B70500870D8B /* CrowdNode.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = CrowdNode.storyboard; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4194,6 +4200,8 @@
children = (
2A7F3B18238C643D00DEA3EF /* Advanced Security */,
2A10EB3F2358D29500C38B61 /* ResetWalletInfo */,
7DA5000000000000000A0001 /* WalletsScreen.swift */,
7DA5000000000000000A0002 /* WalletsViewModel.swift */,
752F81962E30F55E00ADA76D /* SecurityMenuScreen.swift */,
752F81972E30F55E00ADA76D /* SecurityMenuViewModel.swift */,
);
Expand Down Expand Up @@ -8927,6 +8935,8 @@
472D13E3299E23B7006903F1 /* BalanceNotifier.swift in Sources */,
752F819A2E30F55E00ADA76D /* SecurityMenuScreen.swift in Sources */,
752F819B2E30F55E00ADA76D /* SecurityMenuViewModel.swift in Sources */,
7DA5000000000000000A0011 /* WalletsScreen.swift in Sources */,
7DA5000000000000000A0021 /* WalletsViewModel.swift in Sources */,
2AD1CE6422D9127600C99324 /* DWSeedWordModel.m in Sources */,
7592AA7C2B9B08C000417F9E /* SupportedTopperPaymentMethods.swift in Sources */,
75AA33CC2BF9C82700F12465 /* ModalDialog.swift in Sources */,
Expand Down Expand Up @@ -9571,6 +9581,8 @@
C9D2C7C42A320AA000D15901 /* CurrencyExchanger_Objc.m in Sources */,
752F81982E30F55E00ADA76D /* SecurityMenuScreen.swift in Sources */,
752F81992E30F55E00ADA76D /* SecurityMenuViewModel.swift in Sources */,
7DA5000000000000000A0012 /* WalletsScreen.swift in Sources */,
7DA5000000000000000A0022 /* WalletsViewModel.swift in Sources */,
7573C2E12B01103900F4C347 /* VotingFilterItemSelectableCell.swift in Sources */,
C943B32B2A408CED00AF23C5 /* DWAvatarExternalLoadingView.m in Sources */,
C943B5972A40EDDA00AF23C5 /* DWConfirmUsernameViewController.m in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ final class SwiftDashSDKContactsService: ObservableObject {

private let authorizer = DWIdentityAuthorizer()
private var saveObserverCancellable: AnyCancellable?
private var activeWalletCancellable: AnyCancellable?

/// Last run of the payments projection (see
/// `refreshPaymentsProjection`). Throttles the piggyback call in
Expand All @@ -124,6 +125,20 @@ final class SwiftDashSDKContactsService: ObservableObject {
.sink { [weak self] _ in
self?.refresh()
}
// The ownerId (current identity id) changes with the active wallet, so
// a runtime wallet switch invalidates every published snapshot. Rebuild
// against the new wallet's ownerId. `refresh` reads the ownerId from
// `DWCurrentUserIdentityInfo`, whose cache the same notification also
// invalidates — but NotificationCenter delivery order between the two
// observers isn't guaranteed, so force the identity snapshot fresh here
// (idempotent revision bump) before reading it.
activeWalletCancellable = NotificationCenter.default
.publisher(for: SwiftDashSDKWalletState.activeWalletDidChangeNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
DWCurrentUserIdentityInfo.shared.refreshFromSDK()
self?.refresh()
}
refresh()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ public final class DWCurrentUserIdentityInfo: NSObject {
selector: #selector(handleInvalidationNotification(_:)),
name: DWIdentityRegistrationBridge.stateChangedNotification,
object: nil)
// A runtime wallet switch rebinds the host to a different wallet whose
// identity/username is entirely different (or absent). Invalidate so
// the next read rebuilds the snapshot from the new wallet's
// `PersistentIdentity` rows instead of serving the old wallet's cache.
center.addObserver(
self,
selector: #selector(handleInvalidationNotification(_:)),
name: SwiftDashSDKWalletState.activeWalletDidChangeNotification,
object: nil)
}

// MARK: - Obj-C / Swift read API
Expand Down
164 changes: 150 additions & 14 deletions DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,15 @@ final class SwiftDashSDKHost {
return (handles.manager, resolvedWallet)
}

/// Create or import a wallet as the active managed platform wallet. This is
/// the only path that writes new wallet identity into SwiftData and stores
/// its mnemonic in `WalletStorage`.
/// Create or import a wallet as the SOLE active managed platform wallet.
/// This is the fresh-install / recover path: it rebuilds the runtime from
/// scratch (`buildRuntime` tears down any running manager), creates the
/// wallet, stores its mnemonic, pins it active in the registry, and
/// publishes it as bound. Onboarding's first wallet uses this.
///
/// For adding a wallet ALONGSIDE existing ones without rebinding the
/// active wallet, use `addWallet(mnemonic:)` instead — this path replaces
/// the running runtime and is not additive.
@discardableResult
func createOrImportWallet(
mnemonic: String,
Expand All @@ -247,9 +253,102 @@ final class SwiftDashSDKHost {
let handles = try buildRuntime(for: network)
let createdWallet: ManagedPlatformWallet
do {
createdWallet = try handles.manager.createWallet(
createdWallet = try createAndPersist(
mnemonic: mnemonic,
manager: handles.manager,
network: handles.network,
modelContainer: handles.modelContainer)
} catch {
// `createOrImportWallet` owns a freshly-built (not yet published)
// runtime, so tear it down on failure. `createAndPersist` has
// already rolled back the wallet row + mnemonic it wrote.
stop()
throw error
}

if let kind = registryNetworkKind(for: network) {
WalletEnvironment.setActiveWalletId(createdWallet.walletId, for: kind)
}
publish(handles: handles, wallet: createdWallet)

let origin = isImported ? "imported" : "created"
Self.logger.info("🪺 HOST :: \(origin, privacy: .public) managed wallet for \(network.rawValue, privacy: .public)")
return createdWallet
}

/// Outcome of `addWallet(mnemonic:)`.
enum AddWalletResult {
/// The wallet was created and its mnemonic persisted; the running
/// runtime is unchanged (the caller switches to it explicitly).
case added(walletId: Data)
/// A wallet deriving this walletId already has a persisted mnemonic on
/// this device — nothing was written. The caller offers switching to it.
case alreadyExists(walletId: Data)
}

/// Add a wallet from `mnemonic` ADDITIVELY: create it in the already-running
/// manager and persist its mnemonic, WITHOUT tearing down the runtime,
/// touching the active-wallet registry, or rebinding the published active
/// wallet. The caller (Wallets screen "Add Wallet") switches to the new
/// wallet afterward via `SwiftDashSDKWalletRuntime.switchWallet`.
///
/// Requires a running host (a bound active wallet already exists — adding
/// is only reachable from the Wallets screen). Returns `.alreadyExists`
/// without writing anything when a mnemonic for the derived walletId is
/// already persisted (`manager.createWallet` is idempotent by walletId, so
/// re-adding would silently no-op — the caller surfaces this instead).
///
/// Shares the create-then-persist-with-rollback body with
/// `createOrImportWallet` (`createAndPersist`); differs only in that it
/// uses the LIVE manager and does not publish or set-active.
@discardableResult
func addWallet(mnemonic: String) throws -> AddWalletResult {
guard !mnemonic.isEmpty, Mnemonic.validate(mnemonic) else {
throw HostError.invalidMnemonic
}
guard let manager = manager,
let modelContainer = modelContainer,
let network = runningNetwork else {
throw HostError.walletNotFound(runningNetwork ?? .mainnet)
}

// Idempotence guard: `createWallet` is keyed by the deterministic
// walletId, so adding an already-present wallet would no-op. Detect it
// from the persisted-mnemonic set (the switchable-wallet source of
// truth) and report `.alreadyExists` rather than a fabricated success.
let derivedId = try Wallet(mnemonic: mnemonic, network: network).id
if Self.persistedMnemonics().contains(where: { $0.walletId == derivedId }) {
Self.logger.info("🪺 HOST :: addWallet — walletId already persisted; not re-adding")
return .alreadyExists(walletId: derivedId)
}

let createdWallet = try createAndPersist(
mnemonic: mnemonic,
manager: manager,
network: network,
modelContainer: modelContainer)

Self.logger.info("🪺 HOST :: added managed wallet for \(network.rawValue, privacy: .public) (additive)")
return .added(walletId: createdWallet.walletId)
}

/// Create a wallet in `manager` and persist its mnemonic in `WalletStorage`,
/// verifying the round-trip. On any failure, rolls back both the wallet row
/// (from `modelContainer`) and the mnemonic, then rethrows a typed
/// `HostError`. Does NOT touch the registry, publish, or stop the host —
/// runtime bookkeeping is the caller's (so this body is shared by the
/// rebuild path `createOrImportWallet` and the additive `addWallet`).
private func createAndPersist(
mnemonic: String,
manager: PlatformWalletManager,
network: Network,
modelContainer: ModelContainer
) throws -> ManagedPlatformWallet {
let createdWallet: ManagedPlatformWallet
do {
createdWallet = try manager.createWallet(
mnemonic: mnemonic,
network: network,
name: "dashwallet",
createDefaultAccounts: true)
} catch {
Expand All @@ -267,18 +366,16 @@ final class SwiftDashSDKHost {
} catch {
Self.logger.error("🪺 HOST :: mnemonic persistence failed: \(String(describing: error), privacy: .public)")
try? storage.deleteMnemonic(for: createdWallet.walletId)
deletePersistedWallet(walletId: createdWallet.walletId, in: handles.modelContainer)
stop()
deletePersistedWallet(walletId: createdWallet.walletId, in: modelContainer)
// Also drop the just-created wallet from the live manager so an
// additive add doesn't leave an orphan in `manager.wallets`.
try? manager.deleteWallet(walletId: createdWallet.walletId)
if let hostError = error as? HostError {
throw hostError
}
throw HostError.mnemonicPersistenceFailed(error)
}

publish(handles: handles, wallet: createdWallet)

let origin = isImported ? "imported" : "created"
Self.logger.info("🪺 HOST :: \(origin, privacy: .public) managed wallet for \(network.rawValue, privacy: .public)")
return createdWallet
}

Expand Down Expand Up @@ -360,14 +457,53 @@ final class SwiftDashSDKHost {
network: Network
) throws -> ManagedPlatformWallet {
let restored = try manager.loadFromPersistor()
if let first = manager.firstWallet {
if let resolved = resolveActiveWallet(in: manager, network: network) {
Self.logger.info("🪺 HOST :: reusing persisted wallet; restored=\(restored.count, privacy: .public)")
return first
return resolved
}

throw HostError.walletNotFound(network)
}

/// `WalletEnvironment.NetworkKind` for the SDK `Network` — the app-side
/// key the active-wallet registry is scoped by. Only `.mainnet` /
/// `.testnet` reach the registry; `.devnet`/`.regtest` don't run a
/// persisted wallet (`buildRuntime` rejects `.regtest`), so they map to
/// `nil` and the resolver falls back to `firstWallet` without touching
/// the registry.
private func registryNetworkKind(for network: Network) -> WalletEnvironment.NetworkKind? {
switch network {
case .mainnet: return .mainnet
case .testnet: return .testnet
default: return nil
}
}

/// Resolve which loaded wallet is active for `network`: the persisted
/// `WalletEnvironment.activeWalletId` when it names a wallet the manager
/// currently holds, otherwise `firstWallet` (unset registry, or the
/// recorded wallet is gone). The resolved walletId is written back to the
/// registry so it's concrete after the first launch — including the
/// fallback, which pins the arbitrary-but-deterministic `firstWallet` as
/// the active choice going forward. Returns `nil` only when the manager
/// holds no wallets at all.
private func resolveActiveWallet(
in manager: PlatformWalletManager,
network: Network
) -> ManagedPlatformWallet? {
let kind = registryNetworkKind(for: network)
if let kind,
let activeId = WalletEnvironment.activeWalletId(for: kind),
let active = manager.wallets[activeId] {
return active
}
guard let fallback = manager.firstWallet else { return nil }
if let kind {
WalletEnvironment.setActiveWalletId(fallback.walletId, for: kind)
}
return fallback
}

/// `walletNotFound` retry: re-create wallet rows from the keychain
/// mnemonics (`persistedMnemonics`). One attempt per entry, no retry loop —
/// `createWallet` is idempotent by walletId (`Wallet(mnemonic:network:).id`),
Expand Down Expand Up @@ -401,9 +537,9 @@ final class SwiftDashSDKHost {
}
}

guard let first = handles.manager.firstWallet else { return nil }
guard let resolved = resolveActiveWallet(in: handles.manager, network: handles.network) else { return nil }
Self.logger.info("🪺 HOST :: recovered persisted wallet from keychain mnemonic(s); entries=\(entries.count, privacy: .public)")
return first
return resolved
}

private func publish(handles: RuntimeHandles, wallet resolvedWallet: ManagedPlatformWallet) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,10 @@ final class SwiftDashSDKReceiveAddressReader: NSObject {
@objc
static func isAddressUsed(_ address: String) -> Bool {
onMain {
guard let container = SwiftDashSDKHost.shared.modelContainer else { return false }
guard let container = SwiftDashSDKHost.shared.modelContainer,
let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { return false }
var descriptor = FetchDescriptor<PersistentTxo>(
predicate: #Predicate { $0.address == address })
predicate: #Predicate { $0.address == address && $0.walletId == walletId })
descriptor.fetchLimit = 1
let rows = (try? container.mainContext.fetch(descriptor)) ?? []
return !rows.isEmpty
Expand All @@ -85,8 +86,23 @@ final class SwiftDashSDKReceiveAddressReader: NSObject {
@objc(receivedTotalExcludingAddress:)
static func receivedTotal(excludingAddress address: String) -> UInt64 {
onMain {
guard let container = SwiftDashSDKHost.shared.modelContainer else { return 0 }
let descriptor = FetchDescriptor<PersistentTransaction>()
guard let container = SwiftDashSDKHost.shared.modelContainer,
let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { return 0 }
// Scope to the active wallet via the TXO join: `PersistentTransaction`
// carries no walletId, so gather the wallet's txids from its
// walletId-scoped TXO rows (producing + spending sides) and sum
// only those transactions' own outputs.
let txoDescriptor = FetchDescriptor<PersistentTxo>(
predicate: #Predicate { $0.walletId == walletId })
guard let txos = try? container.mainContext.fetch(txoDescriptor) else { return 0 }
var txids = Set<Data>()
for txo in txos {
if let producing = txo.transaction { txids.insert(producing.txid) }
if let spending = txo.spendingTransaction { txids.insert(spending.txid) }
}
guard !txids.isEmpty else { return 0 }
let descriptor = FetchDescriptor<PersistentTransaction>(
predicate: #Predicate { txids.contains($0.txid) })
guard let rows = try? container.mainContext.fetch(descriptor) else { return 0 }

var total: UInt64 = 0
Expand Down
Loading
Loading