diff --git a/.platform-ref b/.platform-ref index 5923f8590d..e602b9076c 100644 --- a/.platform-ref +++ b/.platform-ref @@ -1 +1 @@ -e72022ab45fc0f30a286104714ae6241bc540901 +1721ca7a8bc962c5c1e1559f65ff433454f57619 diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 3df5b29e71..55e83030db 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ E012FFCC6C3142B4B5869B72 /* ShieldedRecoveryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2ADF29244E649D8BE05FD93 /* ShieldedRecoveryController.swift */; }; 4C9AC6D2EC13450DAC68FC90 /* ShieldedBalanceControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 930BFA9B069540D4A5CAD79A /* ShieldedBalanceControllerTests.swift */; }; 525045ECC4BF30BBCB09E13C /* PlatformBalanceReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17921340EDF4455B62461AF8 /* PlatformBalanceReaderTests.swift */; }; + 7A34000230A5000000000002 /* DashModelContainer+Testing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A34000130A5000000000001 /* DashModelContainer+Testing.swift */; }; C693C63758228F630D39F00E /* HomeBalancePresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F9884169298187E321C8804 /* HomeBalancePresentationTests.swift */; }; BCC98012E278206FC08FA8E3 /* PlatformBalanceControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71029F5975636F1DCFFFF85 /* PlatformBalanceControllerTests.swift */; }; FA87A306BF0E476385DA1C66 /* ShieldedBalanceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5A428A4E24408592C674A0 /* ShieldedBalanceController.swift */; }; @@ -1024,6 +1025,7 @@ 7A32000230A3000000000002 /* PooledSendableBalanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A32000130A3000000000001 /* PooledSendableBalanceTests.swift */; }; 7A33000230A3000000000002 /* ContactPaymentReviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A33000130A3000000000001 /* ContactPaymentReviewTests.swift */; }; 7A30002230A2000000000022 /* CrowdNodeOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A30002130A2000000000021 /* CrowdNodeOwnershipTests.swift */; }; + 7A33000230A4000000000002 /* KeyToolsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A33000130A4000000000001 /* KeyToolsTests.swift */; }; 7A31000230A2000000000002 /* CoinJoinMoveDestinationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A31000130A2000000000001 /* CoinJoinMoveDestinationPolicyTests.swift */; }; 7A30001230A1000000000012 /* StuckAssetLockRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A30001130A1000000000011 /* StuckAssetLockRetryTests.swift */; }; BABA1000000000000000001F /* AssetLockDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BABA1000000000000000001E /* AssetLockDiagnosticsTests.swift */; }; @@ -2253,6 +2255,7 @@ F2ADF29244E649D8BE05FD93 /* ShieldedRecoveryController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShieldedRecoveryController.swift; sourceTree = ""; }; 930BFA9B069540D4A5CAD79A /* ShieldedBalanceControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShieldedBalanceControllerTests.swift; sourceTree = ""; }; 17921340EDF4455B62461AF8 /* PlatformBalanceReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformBalanceReaderTests.swift; sourceTree = ""; }; + 7A34000130A5000000000001 /* DashModelContainer+Testing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DashModelContainer+Testing.swift"; sourceTree = ""; }; 6F9884169298187E321C8804 /* HomeBalancePresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeBalancePresentationTests.swift; sourceTree = ""; }; D71029F5975636F1DCFFFF85 /* PlatformBalanceControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformBalanceControllerTests.swift; sourceTree = ""; }; 9F5A428A4E24408592C674A0 /* ShieldedBalanceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShieldedBalanceController.swift; sourceTree = ""; }; @@ -3334,6 +3337,7 @@ 7A32000130A3000000000001 /* PooledSendableBalanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PooledSendableBalanceTests.swift; sourceTree = ""; }; 7A33000130A3000000000001 /* ContactPaymentReviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPaymentReviewTests.swift; sourceTree = ""; }; 7A30002130A2000000000021 /* CrowdNodeOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrowdNodeOwnershipTests.swift; sourceTree = ""; }; + 7A33000130A4000000000001 /* KeyToolsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyToolsTests.swift; sourceTree = ""; }; 7A31000130A2000000000001 /* CoinJoinMoveDestinationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinJoinMoveDestinationPolicyTests.swift; sourceTree = ""; }; 7A30001130A1000000000011 /* StuckAssetLockRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StuckAssetLockRetryTests.swift; sourceTree = ""; }; BABA1000000000000000001E /* AssetLockDiagnosticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetLockDiagnosticsTests.swift; sourceTree = ""; }; @@ -7682,6 +7686,7 @@ B7C072AA26C7000000000002 /* DWContestedNameStatusServiceTests.swift */, 930BFA9B069540D4A5CAD79A /* ShieldedBalanceControllerTests.swift */, 17921340EDF4455B62461AF8 /* PlatformBalanceReaderTests.swift */, + 7A34000130A5000000000001 /* DashModelContainer+Testing.swift */, 6F9884169298187E321C8804 /* HomeBalancePresentationTests.swift */, D71029F5975636F1DCFFFF85 /* PlatformBalanceControllerTests.swift */, 5798C63088F44013AEC5D85B /* ShieldedRecoveryControllerTests.swift */, @@ -7697,6 +7702,7 @@ 7A32000130A3000000000001 /* PooledSendableBalanceTests.swift */, 7A33000130A3000000000001 /* ContactPaymentReviewTests.swift */, 7A30002130A2000000000021 /* CrowdNodeOwnershipTests.swift */, + 7A33000130A4000000000001 /* KeyToolsTests.swift */, 7A31000130A2000000000001 /* CoinJoinMoveDestinationPolicyTests.swift */, 7A30001130A1000000000011 /* StuckAssetLockRetryTests.swift */, BABA1000000000000000001E /* AssetLockDiagnosticsTests.swift */, @@ -11056,6 +11062,7 @@ B7C072AA26C7000000000001 /* DWContestedNameStatusServiceTests.swift in Sources */, 4C9AC6D2EC13450DAC68FC90 /* ShieldedBalanceControllerTests.swift in Sources */, 525045ECC4BF30BBCB09E13C /* PlatformBalanceReaderTests.swift in Sources */, + 7A34000230A5000000000002 /* DashModelContainer+Testing.swift in Sources */, C693C63758228F630D39F00E /* HomeBalancePresentationTests.swift in Sources */, BCC98012E278206FC08FA8E3 /* PlatformBalanceControllerTests.swift in Sources */, F0CAB696BC154E7EB3EC221D /* ShieldedRecoveryControllerTests.swift in Sources */, @@ -11071,6 +11078,7 @@ 7A32000230A3000000000002 /* PooledSendableBalanceTests.swift in Sources */, 7A33000230A3000000000002 /* ContactPaymentReviewTests.swift in Sources */, 7A30002230A2000000000022 /* CrowdNodeOwnershipTests.swift in Sources */, + 7A33000230A4000000000002 /* KeyToolsTests.swift in Sources */, 7A31000230A2000000000002 /* CoinJoinMoveDestinationPolicyTests.swift in Sources */, 7A30001230A1000000000012 /* StuckAssetLockRetryTests.swift in Sources */, BABA1000000000000000001F /* AssetLockDiagnosticsTests.swift in Sources */, diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift index 29e35b3bd5..cde4ab53ad 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift @@ -593,31 +593,6 @@ final class SwiftDashSDKHost { return persistedMnemonics().contains { Mnemonic.normalizePhrase($0.mnemonic) == typed } } - /// Throwaway key-wallet stack (manager + wallet) for path-based key - /// derivation, built from the persisted mnemonic of the host's active - /// wallet. Separate from the running `PlatformWalletManager` β€” key - /// derivation lives in the `WalletManager`/`Wallet` FFI surface, which - /// the runtime handles don't expose. The instances derive keys locally - /// (no networking, no persistence); drop them when done. - /// - /// Consumers: the masternode Owner/Voting key deriver and CrowdNode - /// message signing. Returns nil when no wallet is bound/running or the - /// mnemonic can't be read (logged). - func derivationWallet() -> (manager: WalletManager, wallet: Wallet, walletId: Data)? { - guard let hostWalletId = wallet?.walletId, let network = runningNetwork else { - Self.logger.warning("πŸͺΊ HOST :: derivationWallet β€” no bound wallet/network") - return nil - } - guard let mnemonic = try? WalletStorage().retrieveMnemonic(for: hostWalletId), - let manager = try? WalletManager(network: network), - let walletId = try? manager.addWallet(mnemonic: mnemonic), - let derivationWallet = (try? manager.getWallet(id: walletId)) ?? nil else { - Self.logger.error("πŸͺΊ HOST :: derivationWallet β€” key-wallet bootstrap failed") - return nil - } - return (manager: manager, wallet: derivationWallet, walletId: walletId) - } - // MARK: - Lifecycle enum HostError: LocalizedError { @@ -709,14 +684,7 @@ final class SwiftDashSDKHost { case WalletStorageError.mnemonicNotFound: return (.notFound, nil) case SwiftDashSDKWalletDeletionError.invalidMnemonic, - SwiftDashSDKWalletDeletionError.unrecognizedWalletNetwork, - KeyWalletError.invalidMnemonic, - KeyWalletError.invalidInput: - // The resolver derives ids by constructing a wallet from the - // stored phrase; these two `KeyWalletError` cases are that - // construction refusing material that passed word validation. - // Its other cases (allocation, network, state) say nothing about - // the keys and stay a store failure. + SwiftDashSDKWalletDeletionError.unrecognizedWalletNetwork: return (.unclassifiable, nil) case HostError.mnemonicRoundTripMismatch: return (.idMismatch, nil) @@ -1114,7 +1082,7 @@ final class SwiftDashSDKHost { do { // This is the same deterministic id contract used by addWallet's // duplicate guard and PlatformWalletManager.createWallet. - walletId = try Wallet(mnemonic: mnemonic, network: network).id + walletId = try Mnemonic.walletId(for: mnemonic, network: network) } catch { Self.logger.error("πŸͺΊ HOST :: walletId derivation failed: \(String(describing: error), privacy: .public)") throw HostError.walletCreationFailed(error) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift index 7decebe335..fb4c9100ea 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift @@ -273,13 +273,6 @@ final class SwiftDashSDKKeyMigrator: NSObject { } do { - let seed = try Mnemonic.toSeed(mnemonic: mnemonic) - guard seed.count == 64, try Mnemonic.toSeed(mnemonic: mnemonic) == seed else { - logger.error("πŸ”‘ KEYMIG :: \(walletID, privacy: .public) seed sanity check failed") - failed += 1 - hadFailure = true - continue - } let sdkWalletId = try createWalletOnHost( mnemonic: mnemonic, network: network, diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKPhraseRepairer.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKPhraseRepairer.swift index 1b3deb4260..77a8efbcb8 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKPhraseRepairer.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKPhraseRepairer.swift @@ -7,8 +7,8 @@ // `findLastPotentialWordsOfMnemonicForPassphrase:…`, // DSBIP39Mnemonic.m:436–589), composed from SwiftDashSDK primitives: // `Mnemonic.wordList` (candidate enumeration), `Mnemonic.validate` -// (BIP-39 checksum), `Mnemonic.toSeed` + `key_wallet_derive_address_from_seed` -// (candidate β†’ first BIP-44 mainnet address), plus `InsightClient` +// (BIP-39 checksum), `Mnemonic.firstReceiveAddress` (candidate β†’ first +// BIP-44 mainnet address), plus `InsightClient` // (on-chain address-existence) and `DamerauLevenshtein` (typo backup). // // Algorithm (single missing/incorrect word of a 12-word phrase): @@ -30,10 +30,8 @@ // DashSync's per-language decode; extra candidates are Insight-gated. // 4. Two-missing concurrency is 4 (DashSync: CPUβˆ’1) β€” gentler on Insight. // 5. Distance backup ranks ALL checksum-valid words, not only those whose -// seed derivation succeeded. Identical for English; for non-English -// phrases the current key-wallet FFI (`mnemonic_to_seed`) is -// English-hardcoded, so derivation fails and on-chain confirmation is -// unavailable β€” the distance backup still works there. +// address derivation succeeded. Derivation accepts every SDK language, +// so this differs from DashSync only if a derivation fails. // 6. A stopped scan still completes (with whatever was accumulated): // keeps the stop-flag box's lifetime airtight; the VC has already // dismissed itself on cancel, so the extra completion is invisible. @@ -159,13 +157,12 @@ enum PhraseRepairEngine { typealias ProgressTick = (Float) async -> Bool /// Seam for unit tests; `.default` wires the real Insight client and - /// the SwiftDashSDK/FFI derivation chain. + /// the SwiftDashSDK address derivation. struct Dependencies { var insight: InsightAddressQuerying /// Checksum-valid candidate phrase β†’ first BIP-44 external address - /// (account 0, mainnet). `nil` when derivation fails β€” today that - /// means a non-English phrase (the FFI's `mnemonic_to_seed` is - /// English-hardcoded); such candidates skip on-chain confirmation. + /// (account 0, mainnet). `nil` when derivation fails. Such candidates + /// skip on-chain confirmation. var deriveFirstAddress: (String) -> String? static let `default` = Dependencies(insight: InsightClient(), @@ -442,39 +439,12 @@ enum PhraseRepairEngine { return found } - // MARK: Address derivation (SDK + FFI) + // MARK: Address derivation (SDK) - /// m/44'/5'/0'/0/0 β€” first external address of account 0, mainnet + /// Checksum-valid phrase β†’ its first receive address on mainnet /// (hardcoded, parity with `[DSChain mainnet]` in DSBIP39Mnemonic.m:542). - private static let firstAddressPath: String? = - try? KeyDerivation.getBIP44PaymentPath(network: .mainnet, - accountIndex: 0, - isChange: false, - addressIndex: 0) - - /// Checksum-valid phrase β†’ BIP-39 seed (nil passphrase) β†’ address via - /// the `key_wallet_derive_address_from_seed` C FFI (re-exported through - /// SwiftDashSDK; result freed with `string_free`, its documented pair). - /// Returns nil on any failure β€” notably non-English phrases, which the - /// FFI's English-hardcoded `mnemonic_to_seed` rejects. + /// Returns nil on any failure. static func defaultDeriveFirstAddress(phrase: String) -> String? { - guard let path = firstAddressPath, - let seed = try? Mnemonic.toSeed(mnemonic: phrase), - seed.count == 64 else { - return nil - } - - var address: String? - seed.withUnsafeBytes { (rawBuffer: UnsafeRawBufferPointer) in - guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } - path.withCString { cPath in - guard let cAddress = key_wallet_derive_address_from_seed(base, - Network.mainnet.ffiValue, - cPath) else { return } - address = String(cString: cAddress) - string_free(cAddress) - } - } - return address + try? Mnemonic.firstReceiveAddress(for: phrase, network: .mainnet) } } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletCreator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletCreator.swift index 8321d820f5..a093205239 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletCreator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletCreator.swift @@ -133,13 +133,6 @@ final class SwiftDashSDKWalletCreator: NSObject { } do { - // Determinism + length sanity check (matches migrator). - let seed = try Mnemonic.toSeed(mnemonic: mnemonic) - guard seed.count == 64 else { - logger.error("\(label, privacy: .public): seed length invalid: \(seed.count, privacy: .public)") - return - } - let walletId = try createWalletOnHost( mnemonic: mnemonic, network: appNetwork, diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift index 6bf2bccee5..88aa0554e3 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift @@ -33,10 +33,7 @@ import SwiftDashSDK // MARK: - WalletBalance -/// Wallet balance snapshot. Defined locally rather than re-exporting -/// SwiftDashSDK's `KeyWalletTypes.Balance` because that struct references -/// an internal `FFIBalance` type, which makes it unusable as a `public` -/// property type from this module. The four fields map directly to +/// Wallet balance snapshot. The four fields map directly to /// `SPVWalletEventsHandler.onBalanceUpdated`'s callback parameters. public struct WalletBalance: Equatable, Sendable { public let confirmed: UInt64 diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift index 0bd03f559d..c8c9732aab 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift @@ -130,10 +130,11 @@ enum SwiftDashSDKStoredWalletNetworkResolver { } var ids: [Network: Data] = [:] for network in storableNetworks { - ids[network] = try SwiftDashSDK.Wallet( - mnemonic: mnemonic, - network: network - ).id + do { + ids[network] = try Mnemonic.walletId(for: mnemonic, network: network) + } catch { + throw SwiftDashSDKWalletDeletionError.invalidMnemonic + } } return ids } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift index 100c6af4ce..89e5c50431 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift @@ -93,10 +93,10 @@ final class MasternodeVoterRegistry { /// Outcome of a votable-node resolution. /// /// `mayBeIncomplete` says the answer cannot be trusted as exhaustive: the - /// voting address pool was read from the degraded fallback (gap-limit - /// deep) *and* at least one active registration went unmatched, so a node - /// this wallet really can vote with may be missing from `nodes`. The UI - /// says so rather than presenting a short list as the full picture. + /// running wallet has no provider voting account *and* at least one active + /// registration went unmatched, so a node this wallet really can vote with + /// may be missing from `nodes`. The UI says so rather than presenting a + /// short list as the full picture. struct Resolution { let nodes: [VoterNode] let mayBeIncomplete: Bool @@ -163,11 +163,11 @@ final class MasternodeVoterRegistry { keySource: .walletIndex(index)) } - // Only a degraded pool read can hide a node we own; if every active - // registration matched, depth did not matter. - let mayBeIncomplete = !resolution.poolIsLive && nodes.count < eligible.count + // Only a missing provider voting account can hide a node we own. If + // every active registration matched, the account did not matter. + let mayBeIncomplete = !resolution.hasProviderPool && nodes.count < eligible.count Self.logger.info( - "πŸ—³οΈ VOTING :: votable nodes=\(nodes.count, privacy: .public) of \(eligible.count, privacy: .public) active registrations livePool=\(resolution.poolIsLive, privacy: .public)") + "πŸ—³οΈ VOTING :: votable nodes=\(nodes.count, privacy: .public) of \(eligible.count, privacy: .public) active registrations providerPool=\(resolution.hasProviderPool, privacy: .public)") // Exclusion is by the wallet's ELIGIBLE set (not just the votable // subset): a wallet-registered node stays out of the tracked list // even when its derived key didn't resolve. diff --git a/DashWallet/Sources/Models/CrowdNode/CrowdNode.swift b/DashWallet/Sources/Models/CrowdNode/CrowdNode.swift index ff48d3d4a9..1889e6794c 100644 --- a/DashWallet/Sources/Models/CrowdNode/CrowdNode.swift +++ b/DashWallet/Sources/Models/CrowdNode/CrowdNode.swift @@ -452,13 +452,13 @@ extension CrowdNode { /// without standing an SDK wallet up: the whole point of ticket 32026 is /// that `unproven` must behave like neither of the other two. enum StoredAccountVerdict: Equatable { - /// The scan found the address among this wallet's keys β€” the online - /// restore may activate the stored account on it. + /// The bounded scan found the address among this wallet's keys, so the + /// online restore may activate the stored account on it. case trusted - /// Ownership is unknown (wallet not up, or the address sits beyond - /// the scan bound). Keep the stored data, but never activate an - /// account on it: the legacy per-wallet key seeding can have copied - /// it from another wallet on the same network. + /// Ownership is unknown (wallet not up, or the bounded scan did not find + /// the address). Keep the stored data, but never activate an account on + /// it: the legacy per-wallet key seeding can have copied it from another + /// wallet on the same network. case unproven /// The address cannot be this wallet's β€” the account is reset. /// @@ -571,12 +571,12 @@ extension CrowdNode { private func validatePrefs() -> CrowdNodeMessageSigner.Ownership? { guard let accountAddress = prefs.accountAddress else { return nil } - // SDK-native ownership check (BIP44 acct-0 scan β€” the same one the - // CrowdNode signer uses). Tri-state: nil β‡’ unknown (the SDK wallet - // isn't up yet, or the address sits beyond the bounded scan) β€” keep - // the stored account rather than resetting it. false is returned only - // for an address that cannot belong to this wallet (not a P2PKH - // address of the running network). + // SDK-native ownership check: the same bounded scan the CrowdNode + // signer uses. Tri-state: nil β‡’ unknown (the SDK wallet isn't up yet, + // or the scan did not find the address), so keep the stored account + // rather than resetting it. false is returned only for an address that + // cannot belong to this wallet (not a P2PKH address of the running + // network). let owns: CrowdNodeMessageSigner.Ownership if Thread.isMainThread { owns = MainActor.assumeIsolated { CrowdNodeMessageSigner.ownership(of: accountAddress) } @@ -1290,7 +1290,7 @@ extension CrowdNode { return TransactionObserver.fetchObserved().contains { filter.matches($0) } } - /// - Parameter trustStoredAddress: whether the ownership scan proved the + /// - Parameter trustStoredAddress: whether the ownership check proved the /// stored address belongs to the active wallet. When false the stored /// address is ignored and the address must come from this wallet's own /// API-confirmation transaction, so an unproven address β€” including one diff --git a/DashWallet/Sources/UI/CrowdNode/CrowdNodeModel.swift b/DashWallet/Sources/UI/CrowdNode/CrowdNodeModel.swift index 01b209062f..3b6a108e79 100644 --- a/DashWallet/Sources/UI/CrowdNode/CrowdNodeModel.swift +++ b/DashWallet/Sources/UI/CrowdNode/CrowdNodeModel.swift @@ -372,29 +372,26 @@ extension CrowdNodeModel { /// `DSKeyManager.signMesasageDigest` + `magicDigest`), which reads a /// wallet that has no keys for SDK-created wallets. /// -/// The CrowdNode account address is a BIP44 receive address of account 0; -/// the SDK exposes no addressβ†’derivation-index lookup, so the signer -/// re-derives public keys along `m/44'/'/0'//` and -/// matches the address's hash160. The scan is bounded, and the two callers -/// treat exhaustion differently: `sign` fails closed with nil (never a -/// wrong-key signature), while the ownership check answers "not found within -/// the bound" β€” the scan can miss an address the wallet owns (ticket 32026; -/// whether through the index bound or the set of derivation paths it walks is -/// not established), so exhaustion is not evidence the address is foreign. +/// The CrowdNode account address is a BIP44 receive address of account 0. +/// The SDK wallet finds it in Rust with a bounded scan: BIP44 account 0, +/// external chain then internal, indexes 0 to 299. `sign` takes the key at +/// that path, which Rust derives without handing the phrase to Swift, and the +/// ownership check takes the path alone, which needs no phrase. The two +/// callers treat a miss differently. `sign` fails closed with nil, never a +/// wrong-key signature. The ownership check answers "not found within the +/// bound", because the search can miss an address the wallet owns (ticket +/// 32026). Whether it misses through the index bound or through the set of +/// derivation paths it walks is not established, so a miss is not evidence +/// the address is foreign. @MainActor enum CrowdNodeMessageSigner { - private static let scanLimit: UInt32 = 300 - /// 65-byte compact recoverable signature over the DarkCoin-framed /// message (`DarkCoinMessage.framed`; the signer FFI SHA256d-hashes /// internally), or nil when the wallet/keys can't be resolved. static func sign(message: String, forAddress address: String) -> Data? { guard let network = SwiftDashSDKHost.shared.runningNetwork, - let targetHash160 = hash160(ofAddress: address, network: network), - let (_, wallet, _) = SwiftDashSDKHost.shared.derivationWallet(), - let path = derivationPath(ofHash160: targetHash160, wallet: wallet, network: network), - let wif = try? wallet.derivePrivateKey(path: path), - let privateKey = WIFParser.parseWIF(wif) else { + let key = try? SwiftDashSDKHost.shared.wallet?.bip44AddressPrivateKey(address: address), + let privateKey = Data(hex: key.hex) else { DWLogger.log("CrowdNode: message signing failed β€” no key resolved for account address") return nil } @@ -402,33 +399,37 @@ enum CrowdNodeMessageSigner { data: DarkCoinMessage.framed(message), privateKey: privateKey, network: network) } - /// What the ownership check needs from the running wallet: an - /// address β†’ hash160 decode for the running network, and the bounded - /// BIP44 scan over that wallet's keys. `liveOwnership()` returns nil - /// when the SDK wallet or the network isn't up yet. + /// What the ownership check needs from the running wallet: whether an + /// address is a P2PKH address of the running network, and the wallet's + /// lookup of an address's derivation path, which throws when the wallet + /// can't answer. `liveOwnership()` returns nil when the SDK wallet or the + /// network isn't up yet. struct OwnershipLookup { - let hash160OfAddress: (String) -> String? - let derivationPath: (String) -> String? + let isP2PKH: (String) -> Bool + let derivationPath: (String) throws -> String? } /// Why the ownership check answered what it did. /// /// `ownsAddress`'s `Bool?` collapsed two very different unknowns into - /// `nil`: the wallet not being up yet, and the bounded scan running out. - /// They deserve different handling β€” a scan that ran out is a durable + /// `nil`: the wallet not being up yet, and the bounded search running out. + /// They deserve different handling β€” a search that ran out is a durable /// verdict for this wallet, while a wallet that is not up means the check /// never ran, and a restore must not memoize a pass built on it as /// fruitless. enum Ownership: Equatable { - /// The scan found the key: this address is the active wallet's. + /// The wallet found the address's path: this address is the active + /// wallet's. case owned /// The address cannot be this wallet's β€” not a P2PKH address of the /// running network. The only answer that justifies destroying prefs. case foreign - /// The SDK wallet or the network is not up, so nothing was checked. + /// The SDK wallet or the network is not up, or the wallet could not + /// answer, so nothing was checked. case walletUnavailable - /// The scan ran to its bound without finding the address. Not evidence - /// the address is foreign (ticket 32026) β€” the bound exists for cost. + /// The wallet's search ran to its bound without finding the address. + /// Not evidence the address is foreign (ticket 32026) β€” the bound + /// exists for cost. case notFoundWithinBound /// Whether this verdict came from a check that actually ran. @@ -443,24 +444,29 @@ enum CrowdNodeMessageSigner { /// The verdict rule on its own, with the wallet reduced to two closures so /// it can be pinned without an SDK host: a nil `lookup` is "the wallet - /// isn't up". + /// isn't up", and a throwing lookup is "the wallet could not answer". The + /// path is asked for only for a P2PKH address of the running network. static func ownership(of address: String, using lookup: OwnershipLookup?) -> Ownership { guard let lookup else { return .walletUnavailable } - guard let targetHash160 = lookup.hash160OfAddress(address) else { + guard lookup.isP2PKH(address) else { return .foreign // not a P2PKH address of this network β‡’ not ours } - return lookup.derivationPath(targetHash160) != nil ? .owned : .notFoundWithinBound + do { + return try lookup.derivationPath(address) != nil ? .owned : .notFoundWithinBound + } catch { + return .walletUnavailable + } } /// Tri-state wallet-ownership check for a persisted CrowdNode account - /// address (a BIP44 acct-0 receive address, so the same bounded scan the - /// signer uses can find it): `true` when the scan finds the key; `false` + /// address (a BIP44 acct-0 receive address, so the wallet's bounded + /// search finds it): `true` when the wallet finds its path; `false` /// only when the address cannot be this wallet's (not a P2PKH address of /// the running network); `nil` when ownership is unknown β€” the /// wallet/network isn't up yet (a relaunch validates prefs before the SDK - /// starts), or the scan ran out before reaching the address's index + /// starts), or the search ran out before reaching the address's index /// (ticket 32026). The caller must NOT treat `nil` as "alien address" β€” - /// unlike signing, where exhaustion safely fails closed, answering + /// unlike signing, where a miss safely fails closed, answering /// `false` here triggers a destructive reset of the CrowdNode link. static func ownsAddress(_ address: String) -> Bool? { ownsAddress(address, using: liveOwnership()) @@ -479,41 +485,20 @@ enum CrowdNodeMessageSigner { private static func liveOwnership() -> OwnershipLookup? { guard let network = SwiftDashSDKHost.shared.runningNetwork, - let (_, wallet, _) = SwiftDashSDKHost.shared.derivationWallet() else { + let wallet = SwiftDashSDKHost.shared.wallet else { return nil } return OwnershipLookup( - hash160OfAddress: { hash160(ofAddress: $0, network: network) }, - derivationPath: { derivationPath(ofHash160: $0, wallet: wallet, network: network) }) + isP2PKH: { isP2PKH($0, network: network) }, + derivationPath: { try wallet.findBIP44AddressPath(address: $0) }) } - /// Base58Check-decoded P2PKH hash160 of `address` as lowercase hex - /// (the format `computePublicKeyHashHex` emits). Nil for anything that is - /// not a P2PKH address of `network` β€” the one input that lets `ownsAddress` - /// answer `false`, so it is exercised directly by the tests. - static func hash160(ofAddress address: String, network: Network) -> String? { + /// Whether `address` is a P2PKH address of `network`. Anything else is the + /// one input that lets `ownsAddress` answer `false`, so it is exercised + /// directly by the tests. + static func isP2PKH(_ address: String, network: Network) -> Bool { let paymentNetwork: PaymentNetwork = network == .mainnet ? .mainnet : .testnet - guard let script = ScriptAddressCodec.scriptPubKey(forAddress: address, network: paymentNetwork), - script.count == 25 else { return nil } // P2PKH only; P2SH can't be message-signed - return script.subdata(in: 3 ..< 23).hexEncodedString() - } - - /// Scan the BIP44 external chain (then internal, defensively) for the - /// key whose hash160 matches. ~50 Β΅s per derive; worst case β‰ˆ 600 - /// derives per withdraw/email action or startup prefs validation. Nil - /// means "not found within the bound", not "absent from the wallet". - private static func derivationPath(ofHash160 target: String, wallet: Wallet, network: Network) -> String? { - let coin = network == .mainnet ? "5'" : "1'" - for chain in [0, 1] { - for index in 0 ..< scanLimit { - let path = "m/44'/\(coin)/0'/\(chain)/\(index)" - guard let pubHex = try? wallet.derivePublicKey(path: path), - let pubKey = Data(hex: pubHex) else { continue } - if KeychainManager.computePublicKeyHashHex(pubKey) == target { - return path - } - } - } - return nil + // A P2SH script is 23 bytes, and P2SH can't be message-signed. + return ScriptAddressCodec.scriptPubKey(forAddress: address, network: paymentNetwork)?.count == 25 } } diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index 8e85ad7324..a1f78941bf 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -2984,7 +2984,7 @@ class SwiftDashSDKWalletSource: TransactionSource { if producing.typedKind == .coinJoin || ownerType == coinJoinAccountType { kindOrDepositMixing.insert(producing.txid) } - if ownerType == standardAccountType { + if ownerType == PersistentAccount.standardAccountType { depositsToStandard.insert(producing.txid) } } @@ -3059,7 +3059,6 @@ class SwiftDashSDKWalletSource: TransactionSource { // PersistentAccount.accountType discriminants (stable across releases). private static let coinJoinAccountType: UInt32 = 1 // 0=Standard(BIP44/BIP32), 1=CoinJoin - private static let standardAccountType: UInt32 = 0 /// CoinJoin-mixing classification cache for the per-row (scoped) fetch /// paths, keyed by txid and stamped with the row's `lastUpdated` β€” the @@ -3126,11 +3125,16 @@ class SwiftDashSDKWalletSource: TransactionSource { } let spendsCoinJoin = p.inputs.contains { ownerAccountType($0) == coinJoinAccountType } guard spendsCoinJoin else { return false } - let depositsToStandard = p.outputs.contains { ownerAccountType($0) == standardAccountType } + let depositsToStandard = p.outputs.contains { ownerAccountType($0) == PersistentAccount.standardAccountType } return !depositsToStandard } } +extension PersistentAccount { + /// `accountType` of a Standard (BIP44 or BIP32) account. Stable across releases. + static let standardAccountType: UInt32 = 0 +} + #if DEBUG private struct HomeViewModelPreviewTransactionSource: TransactionSource { var allTransactions: [Transaction] { [] } diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift index cb6fc2cbf8..3f1651a43a 100644 --- a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift @@ -399,10 +399,9 @@ final class WalletsViewModel: ObservableObject { guard Mnemonic.validate(trimmed) else { return false } guard let network = WalletEnvironment.network else { return false } do { - // `Wallet(mnemonic:network:)` derives keys locally in the key-wallet - // FFI and persists nothing (same read-only surface `SwiftDashSDKHost - // .derivationWallet` uses); its `id` is the deterministic walletId. - let derivedId = try Wallet(mnemonic: trimmed, network: network).id + // `Mnemonic.walletId(for:network:)` derives the deterministic + // walletId without creating or persisting a wallet. + let derivedId = try Mnemonic.walletId(for: trimmed, network: network) return derivedId == expected } catch { Self.logger.error("walletId derivation from mnemonic failed: \(String(describing: error), privacy: .public)") diff --git a/DashWallet/Sources/UI/Menu/Tools/ExtendedKeys/ExtendedPublicKeysModel.swift b/DashWallet/Sources/UI/Menu/Tools/ExtendedKeys/ExtendedPublicKeysModel.swift index 83bf089479..1ec720402e 100644 --- a/DashWallet/Sources/UI/Menu/Tools/ExtendedKeys/ExtendedPublicKeysModel.swift +++ b/DashWallet/Sources/UI/Menu/Tools/ExtendedKeys/ExtendedPublicKeysModel.swift @@ -16,21 +16,49 @@ // import Foundation +import SwiftData +import SwiftDashSDK // MARK: - ExtendedPublicKeysModel @MainActor final class ExtendedPublicKeysModel { /// The SDK wallet's BIP44 account-0 extended public key (xpub/tpub - /// base58check, serialized by the SDK), or `nil` when the wallet runtime - /// isn't up or the derivation fails β€” the sheet shows "Not available". + /// base58check), rendered from the account bytes the wallet persisted. It + /// is `nil` when the wallet runtime isn't up or the bytes are missing. The + /// sheet then shows "Not available". let bip44AccountXpub: String? + /// The `standardTag` of a Standard account's BIP44 variant. + private static let bip44StandardTag: UInt8 = 0 + init() { - guard let (_, wallet, _) = SwiftDashSDKHost.shared.derivationWallet() else { + let host = SwiftDashSDKHost.shared + guard let walletId = host.wallet?.walletId, + let context = host.modelContainer?.mainContext else { bip44AccountXpub = nil return } - bip44AccountXpub = try? wallet.getAccountXpub(accountIndex: 0) + bip44AccountXpub = Self.loadBIP44AccountXpub(context: context, walletId: walletId) + } + + /// Renders the xpub from the persisted BIP-44 account 0 bytes of + /// `walletId`, or nil when the row or its bytes are missing. + static func loadBIP44AccountXpub(context: ModelContext, walletId: Data) -> String? { + let standardType = PersistentAccount.standardAccountType + let bip44Tag = bip44StandardTag + let accountIndex: UInt32 = 0 + var descriptor = FetchDescriptor( + predicate: #Predicate { + $0.wallet.walletId == walletId && + $0.accountType == standardType && + $0.standardTag == bip44Tag && + $0.accountIndex == accountIndex + }) + descriptor.fetchLimit = 1 + guard let bytes = (try? context.fetch(descriptor))?.first?.accountExtendedPubKeyBytes else { + return nil + } + return PlatformWalletManager.accountExtendedPubKeyString(bytes: bytes) } } diff --git a/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/DerivationPathKeys/Models/DerivationPathKeysModel.swift b/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/DerivationPathKeys/Models/DerivationPathKeysModel.swift index 586a6a5469..79fe44f785 100644 --- a/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/DerivationPathKeys/Models/DerivationPathKeysModel.swift +++ b/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/DerivationPathKeys/Models/DerivationPathKeysModel.swift @@ -185,11 +185,11 @@ extension DerivationPathKeysModel { /// Derives masternode provider Owner/Voting keys (ECDSA) from SwiftDashSDK, /// replacing DashSync's `DSAuthenticationKeysDerivationPath`. /// -/// The DIP-3 paths β€” voting `m/9'/'/3'/1'`, owner `m/9'/'/3'/2'` -/// (ECDSA, fully hardened account path, soft key index; coin = 5' mainnet / -/// 1' testnet) β€” are resolved Rust-side by `providerKeyAtIndex`. They are NOT -/// rebuilt here: composing them app-side is what let the account path be -/// applied twice and produced keys off the intended branch. +/// The DIP-3 voting and owner account paths (ECDSA, fully hardened account +/// path, soft key index) are resolved Rust-side: `providerKeyAtIndex` derives +/// every private key, and the addresses come from the running wallet's pool. +/// Composing the account path app-side is what let it be applied twice and +/// produced keys off the intended branch. /// /// Internal (not private): `MasternodeKeyUsage` reuses `address(at:)` for /// its owner/voting address join. @@ -206,32 +206,17 @@ final class MasternodeProviderKeyDeriver { /// resolver the operator/platform families use β€” not a second copy of it. private let keyResolver: ProviderKeyResolver - /// Index β†’ base58 address of the provider pool, snapshotted once at init. - /// Sourced from the running wallet (`loadLiveAddresses`), falling back to - /// the derivation wallet's own pool (`loadDerivationAddresses`) when the - /// running wallet has no provider account yet β€” e.g. a just-imported - /// wallet whose registration hasn't completed. Empty only when neither - /// source has a pool, in which case there are genuinely no addresses to - /// join against. + /// Index β†’ base58 address of the running wallet's provider pool, + /// snapshotted once at init. Empty when the wallet has no provider account + /// yet, such as a just-imported wallet whose registration hasn't completed. private let poolAddresses: [UInt32: String] init?(key: MNKey) { - // The network itself is no longer read here β€” it selected the coin - // type when this class composed the DIP-3 path app-side. The resolver - // owns that now; the check remains because a host with no running - // network has no wallet to derive from either. - guard SwiftDashSDKHost.shared.runningNetwork != nil else { - return nil - } - - let type: AccountType let kind: ManagedPlatformWallet.ProviderKeyKind switch key { case .voting: - type = .providerVotingKeys kind = .votingECDSA case .owner: - type = .providerOwnerKeys kind = .ownerECDSA case .operator, .evonodeOperator: // BLS / Ed25519 families have no address rows, so they use @@ -243,42 +228,17 @@ final class MasternodeProviderKeyDeriver { return nil } - // The derivation stack is a THROWAWAY key-wallet built from the - // mnemonic (see `SwiftDashSDKHost.derivationWallet`) β€” it has never - // processed a transaction, so its pools sit at the freshly-created - // `DEFAULT_SPECIAL_GAP_LIMIT` depth. It backs ONLY the fallback - // address pool below; private keys come from `keyResolver`. Nothing - // outlives this initializer: the wallet is owned by `derivationManager`, - // which is released on return, so holding either past the pool read - // would leave a reference into a freed graph. - guard let (derivationManager, wallet, derivationWalletId) = - SwiftDashSDKHost.shared.derivationWallet() else { - return nil - } - - // Ensure the provider account exists so the fallback pool read below - // can resolve it. - _ = try? wallet.getAccount(type: type) - + self.poolAddresses = Self.loadLiveAddresses(for: key) self.keyResolver = keyResolver - - let live = Self.loadLiveAddresses(for: key) - self.poolIsLive = !live.isEmpty - self.poolAddresses = live.isEmpty - ? Self.loadDerivationAddresses( - key: key, manager: derivationManager, walletId: derivationWalletId) - : live } - /// `true` when ``poolAddresses`` came from the running wallet's live pool. + /// Whether the running wallet has a provider account of this kind. /// - /// `false` means the degraded derivation-wallet fallback, which only ever - /// holds `DEFAULT_SPECIAL_GAP_LIMIT` entries. Consumers that merely *look - /// up* an index are unaffected, but consumers that **enumerate** the pool - /// to decide what exists β€” the owner/voting address joins β€” can silently - /// under-report, so they must surface the incompleteness rather than - /// present a short list as the whole truth. - let poolIsLive: Bool + /// Without one ``poolAddresses`` is empty and `providerKeyAtIndex` throws. + /// Consumers that **enumerate** the pool to decide what exists, the + /// owner/voting address joins, must say so rather than present an empty + /// list as the whole truth. + var hasProviderPool: Bool { !poolAddresses.isEmpty } /// Snapshot the running wallet's provider address pool β€” the one SPV /// extends as ProRegTx/ProUpRegTx matches mark indexes used, and the one @@ -306,47 +266,6 @@ final class MasternodeProviderKeyDeriver { return addresses } - /// Degraded-path pool read from the derivation wallet itself, used when - /// the running wallet can't vend the account. This stack never processes - /// transactions, so its pool is only ever the freshly-created - /// `DEFAULT_SPECIAL_GAP_LIMIT` depth β€” enough to keep the low indexes - /// resolvable rather than showing nothing at all, but it is NOT a - /// substitute for the live pool (that shallowness is precisely the bug - /// this class's live read exists to fix). - private static func loadDerivationAddresses( - key: MNKey, - manager: WalletManager, - walletId: Data - ) -> [UInt32: String] { - guard let collection = manager.getManagedAccountCollection(walletId: walletId) else { - return [:] - } - let account: ManagedAccount? - switch key { - case .voting: account = collection.getProviderVotingKeysAccount() - case .owner: account = collection.getProviderOwnerKeysAccount() - case .operator, .evonodeOperator: account = nil - } - guard let pool = account?.getAddressPool(type: .single) - ?? account?.getExternalAddressPool() else { - return [:] - } - - // The pool exposes no count, so walk until it stops vending. The cap - // is a runaway guard, not a semantic window: this pool is created at - // gap-limit depth and never grows here. - var addresses: [UInt32: String] = [:] - for index in 0.. String? { keyResolver.key(at: index)?.privateKeyWIF } @@ -355,11 +274,7 @@ final class MasternodeProviderKeyDeriver { keyResolver.key(at: index)?.privateKeyHex } - /// The pool address at `index`. Backed by the running wallet's live pool - /// whenever it's available: reading the throwaway derivation wallet as - /// the primary source would cap every consumer at that stack's initial - /// 5-entry pool, hiding every masternode whose owner/voting key sits at - /// a deeper index. + /// The address at `index` of the running wallet's provider pool. func address(at index: UInt32) -> String? { poolAddresses[index] } diff --git a/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/Overview/Model/WalletKeysOverviewModel.swift b/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/Overview/Model/WalletKeysOverviewModel.swift index dce88bc525..f20fb1d3b7 100644 --- a/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/Overview/Model/WalletKeysOverviewModel.swift +++ b/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/Overview/Model/WalletKeysOverviewModel.swift @@ -163,17 +163,17 @@ struct MasternodeKeyUsage { resolveAddressIndexes(family: family, targets: targets).map } - /// The address join plus whether it was made against the complete pool. + /// The address join plus whether the wallet has a provider account of + /// this family. /// - /// `poolIsLive == false` means the derivation-wallet fallback answered, - /// and that pool is only gap-limit deep β€” so an address that is genuinely - /// ours but sits at a deeper index is simply not in `map`. Callers that - /// present the result as "these are all of them" must say so; a caller - /// that only labels known rows can ignore it. + /// `hasProviderPool == false` means the running wallet has no provider + /// account to join against, so an address that is genuinely ours is + /// missing from `map`. Callers that present the result as "these are all + /// of them" must say so. A caller that only labels known rows can ignore it. static func resolveAddressIndexes( family: MNKey, targets: Set - ) -> (map: [String: UInt32], poolIsLive: Bool) { + ) -> (map: [String: UInt32], hasProviderPool: Bool) { guard !targets.isEmpty, let deriver = MasternodeProviderKeyDeriver(key: family) else { return ([:], true) } var map: [String: UInt32] = [:] @@ -183,6 +183,6 @@ struct MasternodeKeyUsage { targets.contains(address) else { continue } map[address] = index } - return (map, deriver.poolIsLive) + return (map, deriver.hasProviderPool) } } diff --git a/DashWalletTests/CrowdNodeOwnershipTests.swift b/DashWalletTests/CrowdNodeOwnershipTests.swift index 5fa7bf39df..0fd49b6877 100644 --- a/DashWalletTests/CrowdNodeOwnershipTests.swift +++ b/DashWalletTests/CrowdNodeOwnershipTests.swift @@ -17,26 +17,17 @@ import XCTest @MainActor final class CrowdNodeOwnershipTests: XCTestCase { + private struct LookupFailure: Error {} // Fixtures: the same hash160 encoded for two networks, plus a mainnet // P2SH β€” the address forms a real wallet can meet. private let mainnetAddress = "XhpXm8bjSKVGaXAKeGRNHDRg9W1o22PLJG" - /// The 20 payload bytes of `mainnetAddress`, spelled out rather than as a - /// hex literal: a bare 40-character hex string trips the repo's secret - /// scanner, and a hash160 of a throwaway test address is not a secret. - private let mainnetHash160Bytes: [UInt8] = [ - 0x4e, 0x3d, 0x1f, 0x8b, 0x2c, 0x9a, 0x0e, 0x7d, 0x5b, 0x6a, - 0x3c, 0x1f, 0x8e, 0x2d, 0x4b, 0x6a, 0x9c, 0x0f, 0x7e, 0x3d, - ] - private var mainnetHash160: String { - mainnetHash160Bytes.map { String(format: "%02x", $0) }.joined() - } private let mainnetP2SHAddress = "7h9dhRvmgdg3cUng5kAsT3sn8mdd9eYYCz" private let testnetAddress = "yTT8n5gAss9LvG5sD7jmKEr2RnWAXScspP" - private func lookup(hash160: String?, path: String?) -> CrowdNodeMessageSigner.OwnershipLookup { + private func lookup(isP2PKH: Bool, path: String?) -> CrowdNodeMessageSigner.OwnershipLookup { CrowdNodeMessageSigner.OwnershipLookup( - hash160OfAddress: { _ in hash160 }, + isP2PKH: { _ in isP2PKH }, derivationPath: { _ in path }) } @@ -45,7 +36,7 @@ final class CrowdNodeOwnershipTests: XCTestCase { func testScanFindingTheKeyProvesOwnership() { let owns = CrowdNodeMessageSigner.ownsAddress( mainnetAddress, - using: lookup(hash160: mainnetHash160, path: "m/44'/5'/0'/0/7")) + using: lookup(isP2PKH: true, path: "m/44'/5'/0'/0/7")) XCTAssertEqual(owns, true) } @@ -59,7 +50,7 @@ final class CrowdNodeOwnershipTests: XCTestCase { // answering `false` tore a live link down. let owns = CrowdNodeMessageSigner.ownsAddress( mainnetAddress, - using: lookup(hash160: mainnetHash160, path: nil)) + using: lookup(isP2PKH: true, path: nil)) XCTAssertNil(owns) } @@ -73,35 +64,32 @@ final class CrowdNodeOwnershipTests: XCTestCase { // the running network, so no derivation index could ever produce it. let owns = CrowdNodeMessageSigner.ownsAddress( mainnetP2SHAddress, - using: lookup(hash160: nil, path: "m/44'/5'/0'/0/7")) + using: lookup(isP2PKH: false, path: "m/44'/5'/0'/0/7")) XCTAssertEqual(owns, false) } // MARK: which addresses reach the scan at all - func testMainnetP2PKHAddressDecodesToItsHash160() { - XCTAssertEqual( - CrowdNodeMessageSigner.hash160(ofAddress: mainnetAddress, network: .mainnet), - mainnetHash160) + func testMainnetP2PKHAddressIsAccepted() { + XCTAssertTrue(CrowdNodeMessageSigner.isP2PKH(mainnetAddress, network: .mainnet)) } func testP2SHAddressIsRejectedOutright() { // CrowdNode account addresses are BIP44 receive addresses; a P2SH // address is not one and cannot be message-signed either. - XCTAssertNil(CrowdNodeMessageSigner.hash160(ofAddress: mainnetP2SHAddress, network: .mainnet)) + XCTAssertFalse(CrowdNodeMessageSigner.isP2PKH(mainnetP2SHAddress, network: .mainnet)) } func testAddressOfAnotherNetworkIsRejected() { - XCTAssertNil(CrowdNodeMessageSigner.hash160(ofAddress: testnetAddress, network: .mainnet)) - XCTAssertNil(CrowdNodeMessageSigner.hash160(ofAddress: mainnetAddress, network: .testnet)) + XCTAssertFalse(CrowdNodeMessageSigner.isP2PKH(testnetAddress, network: .mainnet)) + XCTAssertFalse(CrowdNodeMessageSigner.isP2PKH(mainnetAddress, network: .testnet)) } func testMalformedAddressIsRejected() { - XCTAssertNil(CrowdNodeMessageSigner.hash160(ofAddress: "", network: .mainnet)) - XCTAssertNil(CrowdNodeMessageSigner.hash160(ofAddress: "not an address", network: .mainnet)) + XCTAssertFalse(CrowdNodeMessageSigner.isP2PKH("", network: .mainnet)) + XCTAssertFalse(CrowdNodeMessageSigner.isP2PKH("not an address", network: .mainnet)) // Valid base58, broken checksum. - XCTAssertNil(CrowdNodeMessageSigner.hash160( - ofAddress: "XhpXm8bjSKVGaXAKeGRNHDRg9W1o22PLJH", network: .mainnet)) + XCTAssertFalse(CrowdNodeMessageSigner.isP2PKH("XhpXm8bjSKVGaXAKeGRNHDRg9W1o22PLJH", network: .mainnet)) } // MARK: what the restore does with each verdict @@ -186,10 +174,29 @@ final class CrowdNodeOwnershipTests: XCTestCase { XCTAssertEqual( CrowdNodeMessageSigner.ownership( of: mainnetAddress, - using: lookup(hash160: mainnetHash160, path: nil)), + using: lookup(isP2PKH: true, path: nil)), .notFoundWithinBound) } + func testAWalletThatCannotAnswerIsUnavailableNotExhausted() { + let failing = CrowdNodeMessageSigner.OwnershipLookup( + isP2PKH: { _ in true }, + derivationPath: { _ in throw LookupFailure() }) + XCTAssertEqual( + CrowdNodeMessageSigner.ownership(of: mainnetAddress, using: failing), + .walletUnavailable) + } + + func testForeignAddressIsRefusedBeforeTheWalletLookup() { + let lookup = CrowdNodeMessageSigner.OwnershipLookup( + isP2PKH: { _ in false }, + derivationPath: { _ in + XCTFail("a foreign address must not reach the wallet's lookup") + return nil + }) + XCTAssertEqual(CrowdNodeMessageSigner.ownership(of: mainnetP2SHAddress, using: lookup), .foreign) + } + func testOnlyAWalletThatWasUpProducesACheckedVerdict() { XCTAssertFalse(CrowdNodeMessageSigner.Ownership.walletUnavailable.wasChecked, "a pass built on this must not be memoized as fruitless") @@ -203,11 +210,11 @@ final class CrowdNodeOwnershipTests: XCTestCase { // about trust or about destruction. XCTAssertEqual( CrowdNodeMessageSigner.ownership( - of: mainnetAddress, using: lookup(hash160: mainnetHash160, path: "m/44'/5'/0'/0/7")), + of: mainnetAddress, using: lookup(isP2PKH: true, path: "m/44'/5'/0'/0/7")), .owned) XCTAssertEqual( CrowdNodeMessageSigner.ownsAddress( - mainnetAddress, using: lookup(hash160: mainnetHash160, path: "m/44'/5'/0'/0/7")), + mainnetAddress, using: lookup(isP2PKH: true, path: "m/44'/5'/0'/0/7")), true) XCTAssertNil(CrowdNodeMessageSigner.ownsAddress(mainnetAddress, using: nil)) } diff --git a/DashWalletTests/DashModelContainer+Testing.swift b/DashWalletTests/DashModelContainer+Testing.swift new file mode 100644 index 0000000000..7cfdc4ddef --- /dev/null +++ b/DashWalletTests/DashModelContainer+Testing.swift @@ -0,0 +1,16 @@ +import SwiftData +import SwiftDashSDK + +extension DashModelContainer { + /// `DashModelContainer.createInMemory()` leaves `cloudKitDatabase` at `.automatic`, + /// which the dashpay host app's iCloud entitlement turns into a CloudKit-backed + /// store that refuses the schema's non-optional relationships. Tests only + /// need the models, so they build the in-memory container without CloudKit. + static func inMemoryWithoutCloudKit() throws -> ModelContainer { + let configuration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: true, + cloudKitDatabase: .none) + return try ModelContainer(for: schema, configurations: [configuration]) + } +} diff --git a/DashWalletTests/HostRecoveryTests.swift b/DashWalletTests/HostRecoveryTests.swift index 2bb66c18bf..9b813c9e80 100644 --- a/DashWalletTests/HostRecoveryTests.swift +++ b/DashWalletTests/HostRecoveryTests.swift @@ -437,16 +437,15 @@ final class HostRecoveryTests: XCTestCase { let resolver = SwiftDashSDKHost.preparationFailure( forStartError: HostError.walletBootstrapFailed(SwiftDashSDKWalletDeletionError.unrecognizedWalletNetwork)) XCTAssertEqual(resolver?.codes, ["Keychain:unclassifiable"]) - // The resolver constructs a wallet from the stored phrase; its refusal - // of material that passed word validation is a keys failure too. - for material in [KeyWalletError.invalidMnemonic("bad"), .invalidInput("bad")] { - let construction = SwiftDashSDKHost.preparationFailure( - forStartError: HostError.walletBootstrapFailed(material)) - XCTAssertEqual(construction?.codes, ["Keychain:unclassifiable"]) - } - // Its other cases say nothing about the stored material. + // The resolver reports a stored phrase whose wallet id cannot be derived + // as `.invalidMnemonic`, a keys failure too. + let derivation = SwiftDashSDKHost.preparationFailure( + forStartError: HostError.walletBootstrapFailed(SwiftDashSDKWalletDeletionError.invalidMnemonic)) + XCTAssertEqual(derivation?.codes, ["Keychain:unclassifiable"]) + // A `PlatformWalletError` says nothing about the stored material: store + // calls throw it as well. XCTAssertEqual(SwiftDashSDKHost.preparationFailure( - forStartError: HostError.walletBootstrapFailed(KeyWalletError.invalidState("ffi")))?.kind, .database) + forStartError: HostError.walletBootstrapFailed(PlatformWalletError.invalidParameter("ffi")))?.kind, .database) XCTAssertNil(SwiftDashSDKHost.preparationFailure(forStartError: HostError.sdkInitFailed(swiftData))) XCTAssertNil(SwiftDashSDKHost.keysFailure(in: swiftData)) } diff --git a/DashWalletTests/KeyToolsTests.swift b/DashWalletTests/KeyToolsTests.swift new file mode 100644 index 0000000000..85320bde4f --- /dev/null +++ b/DashWalletTests/KeyToolsTests.swift @@ -0,0 +1,88 @@ +// +// KeyToolsTests.swift +// DashWalletTests +// +// Copyright Β© 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftData +import SwiftDashSDK +import XCTest +@testable import dashpay + +@MainActor +final class KeyToolsTests: XCTestCase { + // MARK: BIP44 account xpub + + // Bincode `ExtendedPubKey` encodings of the accounts of the all-`abandon` + // test phrase, the form the SDK persists in `accountExtendedPubKeyBytes`, + // from rust-dashcore key-wallet. + private let testnetBIP44Account0 = + "010306b037f60100028b42cd4776376c82791b494155151f56c2d7b471e0c7a526a7ce60dd872e38676b22c5123ba10adeaf4bfcbb45d1a02d828f25bf8646957a98d06287c4e2b850" + private let testnetBIP44Account0Xpub = + "tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba" + private let testnetBIP44Account1 = + "010306b037f6010102e33ff6cf05a02b7e86a0c642467a81eca94378e8ecb894a86aa61cd4bb6cfcaea09288f3a8d81cae308122fc4f188e263763a0f283a4624a04d8354ce88aa4a4" + private let testnetBIP32Account0 = + "010173c5da0a0100027f1d87730e460e921b382242911565bf93daf2081ed685b2edd1d01176b2c13cf1c03f5ff97108912fd56761d3fada8879e4173aba45f10da4bbd94b1c497160" + private let testnetCoinJoinAccount0 = + "0104ce779a78010003d34f244d2ba069e010860f4029fe2a69fa9f97b76003a42b8035a0bb9d4427ef87da1f77dccd9010219927dc01faa168bb51851f07ede6df86139fb518bb2481" + private let mainnetBIP44Account0 = + "000378bb198e01000380df7b326343660535342c6bd254207a8ebf994adf5fd98f3dfc67f9d678e126ee470b7c9fe2cae4719f9a0732c0863083d75b48158e7dbd843f2a77a1a23da0" + + private let walletId = Data(repeating: 1, count: 32) + + @discardableResult + private func addAccount( + to wallet: PersistentWallet, + in context: ModelContext, + type: UInt32, + standardTag: UInt8 = 0, + index: UInt32, + xpubHex: String? + ) throws -> PersistentAccount { + let account = PersistentAccount(wallet: wallet, accountType: type, accountIndex: index, accountTypeName: "test") + account.standardTag = standardTag + account.accountExtendedPubKeyBytes = try xpubHex.map { try XCTUnwrap(Data(hex: $0)) } + context.insert(account) + return account + } + + func testXpubComesFromTheBIP44AccountZeroRow() throws { + let container = try DashModelContainer.inMemoryWithoutCloudKit() + let context = container.mainContext + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + let other = PersistentWallet(walletId: Data(repeating: 2, count: 32), network: .mainnet) + context.insert(wallet) + context.insert(other) + // Each row differs from the BIP44 account 0 row of `wallet` in one field. + try addAccount(to: wallet, in: context, type: 0, index: 1, xpubHex: testnetBIP44Account1) + try addAccount(to: wallet, in: context, type: 0, standardTag: 1, index: 0, xpubHex: testnetBIP32Account0) + try addAccount(to: wallet, in: context, type: 1, index: 0, xpubHex: testnetCoinJoinAccount0) + try addAccount(to: other, in: context, type: 0, index: 0, xpubHex: mainnetBIP44Account0) + try context.save() + XCTAssertNil(ExtendedPublicKeysModel.loadBIP44AccountXpub(context: context, walletId: walletId)) + + let account = try addAccount(to: wallet, in: context, type: 0, index: 0, xpubHex: nil) + try context.save() + XCTAssertNil(ExtendedPublicKeysModel.loadBIP44AccountXpub(context: context, walletId: walletId)) + + account.accountExtendedPubKeyBytes = try XCTUnwrap(Data(hex: testnetBIP44Account0)) + try context.save() + XCTAssertEqual( + ExtendedPublicKeysModel.loadBIP44AccountXpub(context: context, walletId: walletId), + testnetBIP44Account0Xpub) + } +} diff --git a/DashWalletTests/PhraseRepairEngineTests.swift b/DashWalletTests/PhraseRepairEngineTests.swift index 307bcd3d77..8a0e243366 100644 --- a/DashWalletTests/PhraseRepairEngineTests.swift +++ b/DashWalletTests/PhraseRepairEngineTests.swift @@ -59,7 +59,7 @@ final class PhraseRepairCandidateScanTests: XCTestCase { // reports 136 checksum-valid words: the 128 English ones plus 8 that pass another // wordlist's checksum. The engine needs a language-scoped validate before this // invariant can be asserted again. - throw XCTSkip("FFI mnemonic_validate is not language-scoped; scan yields 136, not 128") + throw XCTSkip("`Mnemonic.validate` is not language-scoped, so the scan yields 136, not 128") let scan = await PhraseRepairEngine.scanCandidates( remaining: abandon11, insertIndex: 11, @@ -85,7 +85,7 @@ final class PhraseRepairCandidateScanTests: XCTestCase { let phrase = (abandon11 + ["about"]).joined(separator: " ") let first = PhraseRepairEngine.defaultDeriveFirstAddress(phrase: phrase) let second = PhraseRepairEngine.defaultDeriveFirstAddress(phrase: phrase) - XCTAssertNotNil(first) + XCTAssertEqual(first, "XoJA8qE3N2Y3jMLEtZ3vcN42qseZ8LvFf5") XCTAssertEqual(first, second) } @@ -252,8 +252,7 @@ final class PhraseRepairEngineTests: XCTestCase { XCTAssertEqual(result, ["about": 0]) } - /// Non-derivable candidates (today: non-English phrases through the - /// English-hardcoded FFI) still reach the distance backup (deviation 5). + /// Non-derivable candidates still reach the distance backup (deviation 5). func testDerivationFailureStillAllowsDistanceBackup() async { let phrase = (abandon11 + ["abouz"]).joined(separator: " ") let result = await PhraseRepairEngine.findPotentialWords( diff --git a/DashWalletTests/PlatformBalanceReaderTests.swift b/DashWalletTests/PlatformBalanceReaderTests.swift index 024c75f7dc..b176e9db0a 100644 --- a/DashWalletTests/PlatformBalanceReaderTests.swift +++ b/DashWalletTests/PlatformBalanceReaderTests.swift @@ -8,18 +8,6 @@ import XCTest final class PlatformBalanceReaderTests: XCTestCase { private let walletId = Data(repeating: 1, count: 32) - /// `DashModelContainer.createInMemory()` leaves `cloudKitDatabase` at `.automatic`, - /// which the dashpay host app's iCloud entitlement turns into a CloudKit-backed - /// store that refuses the schema's non-optional relationships. The reader only - /// needs the models, so build the in-memory container without CloudKit here. - private func makeContainer() throws -> ModelContainer { - let configuration = ModelConfiguration( - schema: DashModelContainer.schema, - isStoredInMemoryOnly: true, - cloudKitDatabase: .none) - return try ModelContainer(for: DashModelContainer.schema, configurations: [configuration]) - } - private func addWallet(to container: ModelContainer, id: Data, network: Network = .testnet, hasAccount: Bool = true) -> PersistentWallet { let wallet = PersistentWallet(walletId: id, network: network) container.mainContext.insert(wallet) @@ -42,7 +30,7 @@ final class PlatformBalanceReaderTests: XCTestCase { } func testReadsOnlySelectedWalletAndIncludesZeroRows() throws { - let container = try makeContainer() + let container = try DashModelContainer.inMemoryWithoutCloudKit() let selected = addWallet(to: container, id: walletId) let other = addWallet(to: container, id: Data(repeating: 2, count: 32)) addAddress(to: container, wallet: selected, index: 2, credits: 70) @@ -57,7 +45,7 @@ final class PlatformBalanceReaderTests: XCTestCase { } func testMissingNetworkMetadataDoesNotHidePersistedBalanceOrKnownZero() throws { - let container = try makeContainer() + let container = try DashModelContainer.inMemoryWithoutCloudKit() let wallet = addWallet(to: container, id: walletId) wallet.networkRaw = nil addAddress(to: container, wallet: wallet, index: 0, credits: 42) @@ -70,7 +58,7 @@ final class PlatformBalanceReaderTests: XCTestCase { } func testEmptyAccountIsKnownZero() throws { - let container = try makeContainer() + let container = try DashModelContainer.inMemoryWithoutCloudKit() _ = addWallet(to: container, id: walletId) let snapshot = try XCTUnwrap(PlatformBalanceReader.read(container: container, walletId: walletId, network: .testnet)) XCTAssertEqual(snapshot.credits, 0) @@ -78,14 +66,14 @@ final class PlatformBalanceReaderTests: XCTestCase { } func testMissingWalletOrAccountIsUnavailable() throws { - let container = try makeContainer() + let container = try DashModelContainer.inMemoryWithoutCloudKit() XCTAssertNil(try PlatformBalanceReader.read(container: container, walletId: walletId, network: .testnet)) _ = addWallet(to: container, id: walletId, hasAccount: false) XCTAssertNil(try PlatformBalanceReader.read(container: container, walletId: walletId, network: .testnet)) } func testSameWalletIdWithWrongNetworkIsUnavailable() throws { - let container = try makeContainer() + let container = try DashModelContainer.inMemoryWithoutCloudKit() let wallet = addWallet(to: container, id: walletId) addAddress(to: container, wallet: wallet, index: 0, credits: 99) XCTAssertNil(try PlatformBalanceReader.read(container: container, walletId: walletId, network: .mainnet)) @@ -93,8 +81,8 @@ final class PlatformBalanceReaderTests: XCTestCase { func testSameWalletIdAcrossNetworkContainersKeepsAmountsAndAddressesSeparate() throws { // SwiftDashSDKHost uses a separate DashModel.sqlite per network. - let mainnet = try makeContainer() - let testnet = try makeContainer() + let mainnet = try DashModelContainer.inMemoryWithoutCloudKit() + let testnet = try DashModelContainer.inMemoryWithoutCloudKit() let mainWallet = addWallet(to: mainnet, id: walletId, network: .mainnet) let testWallet = addWallet(to: testnet, id: walletId, network: .testnet) addAddress(to: mainnet, wallet: mainWallet, index: 1, credits: 100) @@ -112,7 +100,7 @@ final class PlatformBalanceReaderTests: XCTestCase { } func testUpdatedPersistedZeroReplacesPreviousAmount() throws { - let container = try makeContainer() + let container = try DashModelContainer.inMemoryWithoutCloudKit() let wallet = addWallet(to: container, id: walletId) addAddress(to: container, wallet: wallet, index: 0, credits: 99) let first = try XCTUnwrap(PlatformBalanceReader.read(container: container, walletId: walletId, network: .testnet)) @@ -123,14 +111,14 @@ final class PlatformBalanceReaderTests: XCTestCase { } func testOverflowFailsInsteadOfPublishingWrappedAmount() throws { - let container = try makeContainer() + let container = try DashModelContainer.inMemoryWithoutCloudKit() let wallet = addWallet(to: container, id: walletId) addAddress(to: container, wallet: wallet, index: 0, credits: UInt64.max) addAddress(to: container, wallet: wallet, index: 1, credits: 1) XCTAssertThrowsError(try PlatformBalanceReader.read(container: container, walletId: walletId, network: .testnet)) } func testIncompleteLocalAccountDoesNotClaimZeroBeforeDiscovery() throws { - let container = try makeContainer() + let container = try DashModelContainer.inMemoryWithoutCloudKit() let wallet = addWallet(to: container, id: walletId, hasAccount: false) // A wallet row can precede account discovery/persistence. Its absence // is not evidence that the imported seed has no Platform funds.