diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index edaca8956..ac05ca573 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -19,12 +19,20 @@ 9613018C2C5022D700878183 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 9613018B2C5022D700878183 /* SQLite */; }; 9613018E2C50288900878183 /* LdkMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9613018D2C50288900878183 /* LdkMigration.swift */; }; 9613018F2C5028CC00878183 /* MigrationsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 961301872C50202A00878183 /* MigrationsService.swift */; }; + 962B921E2C5A20EF00B21057 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962B921D2C5A20EF00B21057 /* WelcomeView.swift */; }; + 962B92212C5A217400B21057 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962B92202C5A217400B21057 /* HomeView.swift */; }; + 962B92232C5A327000B21057 /* StartupHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962B92222C5A327000B21057 /* StartupHandler.swift */; }; + 962B92252C5A4F5D00B21057 /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962B92242C5A4F5D00B21057 /* ViewModel.swift */; }; 9637E6D32C32CE79004A92FC /* Env.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9637E6D22C32CE79004A92FC /* Env.swift */; }; 9637E6D52C32D811004A92FC /* OnChainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9637E6D42C32D811004A92FC /* OnChainService.swift */; }; 9637E6D82C32D8A7004A92FC /* BitcoinDevKit in Frameworks */ = {isa = PBXBuildFile; productRef = 9637E6D72C32D8A7004A92FC /* BitcoinDevKit */; }; 9637E6DA2C32E573004A92FC /* OnChainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9637E6D92C32E573004A92FC /* OnChainViewModel.swift */; }; 9637E6DD2C32EAA8004A92FC /* WalletNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9637E6DC2C32EAA8004A92FC /* WalletNetwork.swift */; }; 9637E6DF2C32ED7B004A92FC /* LnPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9637E6DE2C32ED7B004A92FC /* LnPeer.swift */; }; + 963DE7932C578D9900A2AA1D /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 963DE7922C578D9900A2AA1D /* Keychain.swift */; }; + 963DE7942C578D9900A2AA1D /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 963DE7922C578D9900A2AA1D /* Keychain.swift */; }; + 963DE7962C57969000A2AA1D /* KeychainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 963DE7952C57969000A2AA1D /* KeychainTests.swift */; }; + 963DE7972C57BC8D00A2AA1D /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 963DE7922C578D9900A2AA1D /* Keychain.swift */; }; 966DE6612C502C7E00A7B0EF /* Env.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9637E6D22C32CE79004A92FC /* Env.swift */; }; 966DE6622C502C8600A7B0EF /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F261352C369D2400167439 /* Errors.swift */; }; 966DE6632C502C8600A7B0EF /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96FE5A1A2C46A4DD00860ADC /* Logger.swift */; }; @@ -102,11 +110,17 @@ 961058E02C355B5500E1F1D8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 961301872C50202A00878183 /* MigrationsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationsService.swift; sourceTree = ""; }; 9613018D2C50288900878183 /* LdkMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LdkMigration.swift; sourceTree = ""; }; + 962B921D2C5A20EF00B21057 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; + 962B92202C5A217400B21057 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + 962B92222C5A327000B21057 /* StartupHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupHandler.swift; sourceTree = ""; }; + 962B92242C5A4F5D00B21057 /* ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModel.swift; sourceTree = ""; }; 9637E6D22C32CE79004A92FC /* Env.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Env.swift; sourceTree = ""; }; 9637E6D42C32D811004A92FC /* OnChainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnChainService.swift; sourceTree = ""; }; 9637E6D92C32E573004A92FC /* OnChainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnChainViewModel.swift; sourceTree = ""; }; 9637E6DC2C32EAA8004A92FC /* WalletNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletNetwork.swift; sourceTree = ""; }; 9637E6DE2C32ED7B004A92FC /* LnPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LnPeer.swift; sourceTree = ""; }; + 963DE7922C578D9900A2AA1D /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; + 963DE7952C57969000A2AA1D /* KeychainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTests.swift; sourceTree = ""; }; 966DE66A2C50FA6A00A7B0EF /* channel_manager.bin */ = {isa = PBXFileReference; lastKnownFileType = archive.macbinary; path = channel_manager.bin; sourceTree = ""; }; 966DE66C2C50FA7600A7B0EF /* adb1de43b448b04b3fdde638155929cd2163d2c53d36bb40b517d7acc44d1630.bin */ = {isa = PBXFileReference; lastKnownFileType = archive.macbinary; path = adb1de43b448b04b3fdde638155929cd2163d2c53d36bb40b517d7acc44d1630.bin; sourceTree = ""; }; 966DE6712C512C1E00A7B0EF /* seed.bin */ = {isa = PBXFileReference; lastKnownFileType = archive.macbinary; path = seed.bin; sourceTree = ""; }; @@ -194,6 +208,30 @@ path = Extensions; sourceTree = ""; }; + 962B921B2C5A208200B21057 /* Onboarding */ = { + isa = PBXGroup; + children = ( + 962B921D2C5A20EF00B21057 /* WelcomeView.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; + 962B921C2C5A209400B21057 /* Settings */ = { + isa = PBXGroup; + children = ( + 96FE5A182C46594500860ADC /* LogView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 962B921F2C5A216200B21057 /* Wallets */ = { + isa = PBXGroup; + children = ( + 962B92202C5A217400B21057 /* HomeView.swift */, + ); + path = Wallets; + sourceTree = ""; + }; 9637E6D12C32CE65004A92FC /* Constants */ = { isa = PBXGroup; children = ( @@ -224,6 +262,7 @@ 96B129FE2C2EC0ED00DD07B0 /* ViewModels */ = { isa = PBXGroup; children = ( + 962B92242C5A4F5D00B21057 /* ViewModel.swift */, 96B129FF2C2EC37B00DD07B0 /* LightningViewModel.swift */, 9637E6D92C32E573004A92FC /* OnChainViewModel.swift */, ); @@ -246,6 +285,8 @@ children = ( 96F261352C369D2400167439 /* Errors.swift */, 96FE5A1A2C46A4DD00860ADC /* Logger.swift */, + 963DE7922C578D9900A2AA1D /* Keychain.swift */, + 962B92222C5A327000B21057 /* StartupHandler.swift */, ); path = Utilities; sourceTree = ""; @@ -306,6 +347,7 @@ 966DE6692C50FA5800A7B0EF /* ldk-backup */, 96FE1F762C2DE6AC006D0C8B /* BitkitTests.swift */, 9613018D2C50288900878183 /* LdkMigration.swift */, + 963DE7952C57969000A2AA1D /* KeychainTests.swift */, ); path = BitkitTests; sourceTree = ""; @@ -322,7 +364,9 @@ 96FE5A172C46592800860ADC /* Views */ = { isa = PBXGroup; children = ( - 96FE5A182C46594500860ADC /* LogView.swift */, + 962B921F2C5A216200B21057 /* Wallets */, + 962B921B2C5A208200B21057 /* Onboarding */, + 962B921C2C5A209400B21057 /* Settings */, ); path = Views; sourceTree = ""; @@ -511,6 +555,7 @@ 961058EA2C35793000E1F1D8 /* LnPeer.swift in Sources */, 96F261332C369C2100167439 /* ServiceQueue.swift in Sources */, 961058EB2C35793000E1F1D8 /* WalletNetwork.swift in Sources */, + 963DE7972C57BC8D00A2AA1D /* Keychain.swift in Sources */, 961058DF2C355B5500E1F1D8 /* NotificationService.swift in Sources */, 96F261372C369D2400167439 /* Errors.swift in Sources */, 96FE5A1C2C46A4E100860ADC /* Logger.swift in Sources */, @@ -526,14 +571,19 @@ 961301882C50202A00878183 /* MigrationsService.swift in Sources */, 96B12A002C2EC37B00DD07B0 /* LightningViewModel.swift in Sources */, 9637E6DD2C32EAA8004A92FC /* WalletNetwork.swift in Sources */, + 962B921E2C5A20EF00B21057 /* WelcomeView.swift in Sources */, + 963DE7932C578D9900A2AA1D /* Keychain.swift in Sources */, 96F261362C369D2400167439 /* Errors.swift in Sources */, 9637E6DA2C32E573004A92FC /* OnChainViewModel.swift in Sources */, + 962B92212C5A217400B21057 /* HomeView.swift in Sources */, + 962B92252C5A4F5D00B21057 /* ViewModel.swift in Sources */, 96F261322C369C2100167439 /* ServiceQueue.swift in Sources */, 9637E6DF2C32ED7B004A92FC /* LnPeer.swift in Sources */, 96FE1F672C2DE6AA006D0C8B /* ContentView.swift in Sources */, 96FE5A1B2C46A4DD00860ADC /* Logger.swift in Sources */, 96B12A032C2EC65000DD07B0 /* LightningService.swift in Sources */, 966DE6742C512C9300A7B0EF /* HexBytes.swift in Sources */, + 962B92232C5A327000B21057 /* StartupHandler.swift in Sources */, 9637E6D52C32D811004A92FC /* OnChainService.swift in Sources */, 96FE1F652C2DE6AA006D0C8B /* BitkitApp.swift in Sources */, ); @@ -545,6 +595,7 @@ files = ( 966DE6622C502C8600A7B0EF /* Errors.swift in Sources */, 966DE6652C502CAD00A7B0EF /* WalletNetwork.swift in Sources */, + 963DE7942C578D9900A2AA1D /* Keychain.swift in Sources */, 966DE6672C50372600A7B0EF /* LightningService.swift in Sources */, 966DE6632C502C8600A7B0EF /* Logger.swift in Sources */, 9613018E2C50288900878183 /* LdkMigration.swift in Sources */, @@ -555,6 +606,7 @@ 966DE6752C512FD100A7B0EF /* HexBytes.swift in Sources */, 96FE1F772C2DE6AC006D0C8B /* BitkitTests.swift in Sources */, 966DE6682C50372900A7B0EF /* OnChainService.swift in Sources */, + 963DE7962C57969000A2AA1D /* KeychainTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcuserdata/jason.xcuserdatad/UserInterfaceState.xcuserstate b/Bitkit.xcodeproj/project.xcworkspace/xcuserdata/jason.xcuserdatad/UserInterfaceState.xcuserstate index 0eed74087..34d487fbb 100644 Binary files a/Bitkit.xcodeproj/project.xcworkspace/xcuserdata/jason.xcuserdatad/UserInterfaceState.xcuserstate and b/Bitkit.xcodeproj/project.xcworkspace/xcuserdata/jason.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Bitkit/Bitkit.entitlements b/Bitkit/Bitkit.entitlements index 76584c8e6..d2a6d546d 100644 --- a/Bitkit/Bitkit.entitlements +++ b/Bitkit/Bitkit.entitlements @@ -14,5 +14,9 @@ com.apple.security.files.user-selected.read-only + keychain-access-groups + + $(AppIdentifierPrefix)to.bitkit + diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index e2b34f70f..e1e7ae575 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -6,12 +6,16 @@ // import Foundation +import BitcoinDevKit struct Env { static let isPreview = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" static let isTestFlight = Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" static let isUnitTest = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + //{Team ID}.{Keychain Group} + static let keychainGroup = "KYH47R284B.to.bitkit" + #if targetEnvironment(simulator) static let isSim = true #else @@ -26,6 +30,8 @@ struct Env { //MARK: wallet services static let network: WalletNetwork = .regtest + static let defaultWalletWordCount: WordCount = .words12 + static let onchainWalletStopGap = UInt64(20) static var esploraServerUrl: String { switch network { case .regtest: @@ -54,10 +60,12 @@ struct Env { return documentsDirectory } - static var ldkStorage: URL { + static func ldkStorage(walletIndex: Int) -> URL { switch network { case .regtest: - return appStorageUrl.appendingPathComponent("regtest").appendingPathComponent("ldk") + return appStorageUrl + .appendingPathComponent("regtest") + .appendingPathComponent("wallet\(walletIndex)/ldk") case .bitcoin: fatalError("Bitcoin network not implemented") case .testnet: @@ -67,10 +75,12 @@ struct Env { } } - static var bdkStorage: URL { + static func bdkStorage(walletIndex: Int) -> URL { switch network { case .regtest: - return appStorageUrl.appendingPathComponent("regtest").appendingPathComponent("bdk") + return appStorageUrl + .appendingPathComponent("regtest") + .appendingPathComponent("wallet\(walletIndex)/bdk") case .bitcoin: fatalError("Bitcoin network not implemented") case .testnet: @@ -108,5 +118,5 @@ struct Env { } } - static let testMnemonic = "pool curve feature leader elite dilemma exile toast smile couch crane public" +// static let testMnemonic = "pool curve feature leader elite dilemma exile toast smile couch crane public" } diff --git a/Bitkit/ContentView.swift b/Bitkit/ContentView.swift index a8990bc79..b2ebade89 100644 --- a/Bitkit/ContentView.swift +++ b/Bitkit/ContentView.swift @@ -8,189 +8,26 @@ import SwiftUI struct ContentView: View { - @StateObject var lnViewModel = LightningViewModel() - @StateObject var onChainViewModel = OnChainViewModel() - - @Environment(\.scenePhase) var scenePhase - - @State var showLogs = false + @StateObject var viewModel = ViewModel.shared var body: some View { - List { - Section { - Text(lnViewModel.status?.debugState ?? "No LDK State") - - if let nodeId = lnViewModel.nodeId { - Text("LN Node ID: \(nodeId)") - .font(.caption) - .onTapGesture { - UIPasteboard.general.string = nodeId - } - } - } - - Section("Balances") { - if let lnBalance = lnViewModel.balance { - Text("Lightning \(lnBalance.totalLightningBalanceSats)") - Text("Lightning onchain \(lnBalance.totalOnchainBalanceSats)") - } - - if let onchainBalance = onChainViewModel.balance { - Text("On Chain \(onchainBalance.total)") - } - } - - if let peers = lnViewModel.peers { - Section("Peers") { - ForEach(peers, id: \.nodeId) { peer in - HStack { - Text("\(peer.nodeId)@\(peer.address)") - .font(.caption2) - Spacer() - Text(peer.isConnected ? "✅" : "❌") - } - } - } - } - - if let channels = lnViewModel.channels { - Section("Channels") { - ForEach(channels, id: \.channelId) { channel in - VStack { - Text(channel.counterpartyNodeId).font(.caption2) - .multilineTextAlignment(.leading) - HStack { - Text("Out: \(channel.outboundCapacityMsat)") - Spacer() - Text("In: \(channel.inboundCapacityMsat)") - Text(channel.isChannelReady ? "🟢" : "🔴") - Text(channel.isUsable ? "🟢" : "🔴") - } - - } - .onLongPressGesture { - Task { - do { - try await LightningService.shared.closeChannel(userChannelId: channel.userChannelId, counterpartyNodeId: channel.counterpartyNodeId) - Logger.info("Channel closed") - try await lnViewModel.sync() - } catch { - - } - } - } - } - - Button("Copy open channel command") { - let cmd = "lncli openchannel --node_key=\(lnViewModel.nodeId ?? "") --local_amt=200000 --push_amt=10000 --private=true --zero_conf --channel_type=anchors" - // let cmd = "lncli openchannel --node_key=\(lnViewModel.nodeId ?? "") --local_amt=200000 --push_amt=10000 --min_confs=3" - UIPasteboard.general.string = cmd - } - } - } - - if let receiveAddress = onChainViewModel.address { - Text("Receive Address: \(receiveAddress)") - .onTapGesture { - UIPasteboard.general.string = receiveAddress - } - } - - Button("New Receive Address") { - Task { - try await onChainViewModel.newReceiveAddress() - } - } - - Button("Create bolt11") { - Task { - let invoice = try await LightningService.shared.receive(amountSats: 123, description: "paymeplz") - Logger.info(invoice, context: "Created invoice") - UIPasteboard.general.string = invoice - } + VStack { + if viewModel.walletExists == nil { + ProgressView() + } else if viewModel.walletExists == true { + HomeView() + } else { + WelcomeView() } - - Button("Pay bolt11") { - Task { - if let invoice = UIPasteboard.general.string { - let _ = try? await LightningService.shared.send(bolt11: invoice) - } - } - } - - Button("Show Logs") { - showLogs = true - } - - Section("Transactions") { - if let payments = lnViewModel.payments { - ForEach(payments, id: \.id) { payment in - HStack { - Text("\(payment.direction == .inbound ? "⬇️" : "⬆️")") - Text("\(payment.status)") - Spacer() - Text("\(payment.amountMsat ?? 0)") - } - } - } - } - } - .refreshable { - do { - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { try await lnViewModel.sync() } - group.addTask { try await onChainViewModel.sync() } - try await group.waitForAll() - } - } catch { - //TODO show an error - } - } - .sheet(isPresented: $showLogs) { - LogView() } - .onAppear { - Logger.debug("App appeared, spinning up services...") - Task { - do { - try await lnViewModel.start() - try await lnViewModel.sync() - - try await onChainViewModel.start() - try await onChainViewModel.sync() - } catch { - Logger.error(error, context: "Failed to start wallet services") - } + .onChange(of: viewModel.walletExists) { _ in + Logger.info("Wallet exists state changed: \(viewModel.walletExists?.description ?? "nil")") + if viewModel.walletExists == true { + StartupHandler.startAllServices() } } - .onChange(of: scenePhase) { newPhase in - if newPhase == .background { - if lnViewModel.status?.isRunning == true { - Logger.debug("App backgrounded, stopping LN service...") - Task { - do { - try await lnViewModel.stop() - } catch { - Logger.error(error, context: "Failed to stop LN") - } - } - } - return - } - - if newPhase == .active { - if lnViewModel.status?.isRunning == false { - Logger.debug("App active, starting LN service...") - Task { - do { - try await lnViewModel.start() - try await lnViewModel.sync() - } catch { - Logger.error(error, context: "Failed to start LN") - } - } - } - } + .onAppear { + viewModel.setWalletExistsState() } } } diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 7ab7430f5..3b985056e 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -12,15 +12,25 @@ import LDKNode class LightningService { private var node: Node? + var currentWalletIndex: Int = 0 static var shared = LightningService() private init() {} - func setup(mnemonic: String, passphrase: String?) async throws { + func setup(walletIndex: Int) async throws { + guard var mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + throw CustomServiceError.mnemonicNotFound + } + + var passphrase = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + + currentWalletIndex = walletIndex + var config = defaultConfig() - config.storageDirPath = Env.ldkStorage.path - config.logDirPath = Env.ldkStorage.path + let ldkStoragePath = Env.ldkStorage(walletIndex: walletIndex).path + config.storageDirPath = ldkStoragePath + config.logDirPath = ldkStoragePath config.network = Env.network.ldkNetwork config.logLevel = .trace @@ -39,9 +49,9 @@ class LightningService { builder.setGossipSourceP2p() } - builder.setEntropyBip39Mnemonic(mnemonic: mnemonic, passphrase: nil) + builder.setEntropyBip39Mnemonic(mnemonic: mnemonic, passphrase: passphrase) - Logger.debug(Env.ldkStorage.path, context: "LDK storage path") + Logger.debug(ldkStoragePath, context: "LDK storage path") Logger.debug("Building node...") @@ -50,6 +60,10 @@ class LightningService { } Logger.info("LDK node setup") + + //Clear memory + mnemonic = "" + passphrase = nil } /// Pass onEvent when being used in the background to listen for payments, channels, closes, etc @@ -83,6 +97,22 @@ class LightningService { Logger.info("Node stopped") } + func wipeStorage(walletIndex: Int) async throws { + guard node == nil else { + throw AppError(serviceError: .nodeStillRunning) + } + + let directory = Env.ldkStorage(walletIndex: walletIndex) + guard FileManager.default.fileExists(atPath: directory.path) else { + Logger.warn("No directory found to wipe: \(directory.path)") + return + } + + Logger.warn("Wiping on lighting wallet...") + try FileManager.default.removeItem(at: directory) + Logger.info("Lightning wallet wiped") + } + private func connectToTrustedPeers() async throws { guard let node else { throw AppError(serviceError: .nodeNotStarted) diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index d6f347284..9f4588d06 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -17,17 +17,17 @@ class MigrationsService { //MARK: Migrations for RN Bitkit to Swift Bitkit extension MigrationsService { - func ldkToLdkNode(seed: Data, manager: Data, monitors: [Data]) throws { + func ldkToLdkNode(walletIndex: Int, seed: Data, manager: Data, monitors: [Data]) throws { Logger.info("Migrating LDK to LDKNode") - let storagePath = Env.ldkStorage.path - let sqlFilePath = Env.ldkStorage.appendingPathComponent("ldk_node_data.sqlite").path + let ldkStorage = Env.ldkStorage(walletIndex: walletIndex) + let sqlFilePath = ldkStorage.appendingPathComponent("ldk_node_data.sqlite").path //Create path if doesn't exist let fileManager = FileManager.default var isDir: ObjCBool = true - if !fileManager.fileExists(atPath: storagePath, isDirectory: &isDir) { - try fileManager.createDirectory(atPath: storagePath, withIntermediateDirectories: true, attributes: nil) - Logger.debug("Directory created at path: \(storagePath)") + if !fileManager.fileExists(atPath: ldkStorage.path, isDirectory: &isDir) { + try fileManager.createDirectory(atPath: ldkStorage.path, withIntermediateDirectories: true, attributes: nil) + Logger.debug("Directory created at path: \(ldkStorage.path)") } Logger.debug(sqlFilePath, context: "SQLIte file path") diff --git a/Bitkit/Services/OnChainService.swift b/Bitkit/Services/OnChainService.swift index f772e6522..c5a82173d 100644 --- a/Bitkit/Services/OnChainService.swift +++ b/Bitkit/Services/OnChainService.swift @@ -10,31 +10,35 @@ import BitcoinDevKit class OnChainService { private var wallet: Wallet? - private var blockchainConfig: BlockchainConfig? + var currentWalletIndex: Int = 0 - static var shared = OnChainService() - - private init() {} - - func setup() throws { - //TODO maybe better as a lazy var + private var blockchainConfig: BlockchainConfig { let esploraConfig = EsploraConfig( baseUrl: Env.esploraServerUrl, proxy: nil, concurrency: nil, - stopGap: UInt64(20), + stopGap: Env.onchainWalletStopGap, timeout: nil ) - blockchainConfig = BlockchainConfig.esplora(config: esploraConfig) + return BlockchainConfig.esplora(config: esploraConfig) } - func createWallet(mnemonic: String, passphrase: String?) async throws { - let mnemonic = try Mnemonic.fromString(mnemonic: "\(mnemonic)\(passphrase == nil ? "" : " \(passphrase!)")") + static var shared = OnChainService() + private init() {} + + func setup(walletIndex: Int) async throws { + guard var mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + throw CustomServiceError.mnemonicNotFound + } + + var passphrase = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + currentWalletIndex = walletIndex + let secretKey = DescriptorSecretKey( network: Env.network.bdkNetwork, - mnemonic: mnemonic, + mnemonic: try Mnemonic.fromString(mnemonic: mnemonic), password: passphrase ) @@ -54,21 +58,52 @@ class OnChainService { Logger.debug("Creating onchain wallet...") + let bdkStorage = Env.bdkStorage(walletIndex: walletIndex) + try FileManager.default.createDirectory(at: bdkStorage, withIntermediateDirectories: true, attributes: nil) + + let dbConfig = DatabaseConfig.sqlite(config: .init(path: bdkStorage.appendingPathComponent("db.sqlite").path)) + try await ServiceQueue.background(.bdk) { self.wallet = try Wallet( descriptor: descriptor, changeDescriptor: changeDescriptor, network: Env.network.bdkNetwork, - databaseConfig: .sled(config: .init(path: Env.bdkStorage.path, treeName: "")) + databaseConfig: dbConfig ) } Logger.info("Onchain wallet created") + + //Clear memory + mnemonic = "" + passphrase = nil + } + + func stop() { + Logger.debug("Stopping on chain wallet...") + self.wallet = nil + Logger.info("On chain wallet stopped") + } + + func wipeStorage(walletIndex: Int) async throws { + guard wallet == nil else { + throw AppError(serviceError: .onchainWalletStillRunning) + } + + let directory = Env.bdkStorage(walletIndex: walletIndex) + guard FileManager.default.fileExists(atPath: directory.path) else { + Logger.warn("No directory found to wipe: \(directory.path)") + return + } + + Logger.warn("Wiping on chain wallet...") + try FileManager.default.removeItem(at: directory) + Logger.info("On chain wallet wiped") } func getAddress() async throws -> String { guard let wallet else { - throw AppError(serviceError: .onchainWalletNotCreated) + throw AppError(serviceError: .onchainWalletNotInitialized) } return try await ServiceQueue.background(.bdk) { @@ -78,8 +113,8 @@ class OnChainService { } func sync() async throws { - guard let wallet, let blockchainConfig else { - throw AppError(serviceError: .onchainWalletNotCreated) + guard let wallet else { + throw AppError(serviceError: .onchainWalletNotInitialized) } Logger.debug("Syncing BDK...") diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index 62e066a6f..92edec0b0 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -11,9 +11,20 @@ import BitcoinDevKit enum CustomServiceError: Error { case nodeNotStarted - case onchainWalletNotCreated + case onchainWalletNotInitialized case ldkNodeSqliteAlreadyExists case ldkToLdkNodeMigration + case mnemonicNotFound + case nodeStillRunning + case onchainWalletStillRunning +} + +enum KeychainError: Error { + case failedToSave + case failedToSaveAlreadyExists + case failedToDelete + case failedToLoad + case keychainWipeNotAllowed } /// Translates LDK and BDK error messages into translated messages that can be displayed to end users @@ -56,7 +67,7 @@ struct AppError: LocalizedError { case .nodeNotStarted: message = "Node is not started" debugMessage = nil - case .onchainWalletNotCreated: + case .onchainWalletNotInitialized: message = "Onchain wallet not created" debugMessage = nil case .ldkNodeSqliteAlreadyExists: @@ -65,6 +76,15 @@ struct AppError: LocalizedError { case .ldkToLdkNodeMigration: message = "LDK to LDK-node migration issue" debugMessage = nil + case .mnemonicNotFound: + message = "Mnemonic not found" + debugMessage = nil + case .nodeStillRunning: + message = "Node is still running" + debugMessage = nil + case .onchainWalletStillRunning: + message = "Onchain wallet is still running" + debugMessage = nil } Logger.error("\(message) [\(debugMessage ?? "")]", context: "service error") diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift new file mode 100644 index 000000000..709bf60ae --- /dev/null +++ b/Bitkit/Utilities/Keychain.swift @@ -0,0 +1,181 @@ +// +// Keychain.swift +// Bitkit +// +// Created by Jason van den Berg on 2024/07/29. +// + +import Foundation +import Security + +enum KeychainEntryType { + case bip39Mnemonic(index: Int) + case bip39Passphrase(index: Int) + + //TODO: allow for reading keychain entries from RN wallet and then migrate them if needed + + var storageKey: String { + switch self { + case .bip39Mnemonic(let index): + return "bip39_mnemonic_\(index)" + case .bip39Passphrase(index: let index): + return "bip39_passphrase_\(index)" + } + } +} + +class Keychain { + class func save(key: KeychainEntryType, data: Data) throws { + Logger.debug("Saving \(key.storageKey)", context: "Keychain") + + let query = [ + kSecClass as String: kSecClassGenericPassword as String, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock as String, + kSecAttrAccount as String: key.storageKey, + kSecValueData as String: data, + kSecAttrAccessGroup as String: Env.keychainGroup + ] as [String : Any] + + //Don't allow accidentally overwriting keys + guard try load(key: key) == nil else { + Logger.error("Key \(key.storageKey) already exists in keychain. Explicity delete key before attempting to update value.", context: "Keychain") + throw KeychainError.failedToSaveAlreadyExists + } + + let status = SecItemAdd(query as CFDictionary, nil) + + if status != noErr { + Logger.error("Failed to save \(key.storageKey) to keychain. \(status.description)", context: "Keychain") + throw KeychainError.failedToSave + } + + //Sanity check on save + guard var storedValue = try load(key: key) else { + Logger.error("Failed to load \(key.storageKey) after saving", context: "Keychain") + throw KeychainError.failedToSave + } + + guard storedValue == data else { + Logger.error("Saved \(key.storageKey) does not match loaded value", context: "Keychain") + throw KeychainError.failedToSave + } + storedValue = Data() //Clear memory + + Logger.info("Saved \(key.storageKey)", context: "Keychain") + } + + class func saveString(key: KeychainEntryType, str: String) throws { + guard let data = str.data(using: .utf8) else { + throw KeychainError.failedToSave + } + + try save(key: key, data: data) + } + + class func delete(key: KeychainEntryType) throws { + let query = [ + kSecClass as String: kSecClassGenericPassword as String, + kSecAttrAccount as String: key.storageKey, + kSecAttrAccessGroup as String: Env.keychainGroup + ] as [String : Any] + + let status = SecItemDelete(query as CFDictionary) + + if status != noErr { + Logger.error("Failed to delete \(key.storageKey) from keychain. \(status.description)", context: "Keychain") + throw KeychainError.failedToDelete + } + + Logger.debug("Deleted \(key.storageKey)", context: "Keychain") + } + + class func exists(key: KeychainEntryType) throws -> Bool { + var value = try load(key: key) + let exists = value != nil + value = Data() //Clear memory + return exists + } + + //TODO throws if fails but return nil if not found + class func load(key: KeychainEntryType) throws -> Data? { + let query = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key.storageKey, + kSecReturnData as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecAttrAccessGroup as String: Env.keychainGroup + ] as [String : Any] + + var dataTypeRef: AnyObject? = nil + + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + if status == errSecItemNotFound { + Logger.debug("\(key.storageKey) not found in keychain") + return nil + } + + if status != noErr { + Logger.error("Failed to load \(key.storageKey) from keychain. \(status.description)", context: "Keychain") + throw KeychainError.failedToLoad + } + + Logger.debug("\(key.storageKey) loaded from keychain") + return dataTypeRef as! Data? + } + + class func loadString(key: KeychainEntryType) throws -> String? { + if let data = try load(key: key), let str = String(data: data, encoding: .utf8) { + return str + } + + return nil + } + + class func getAllKeyChainStorageKeys() -> [String] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecReturnData as String: kCFBooleanTrue!, + kSecReturnAttributes as String: kCFBooleanTrue!, + kSecReturnRef as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + var result: AnyObject? + let lastResultCode = withUnsafeMutablePointer(to: &result) { + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) + } + + var storageKeys = [String]() + if lastResultCode == noErr { + let array = result as? Array> + for item in array! { + if let key = item[kSecAttrAccount as String] as? String { + storageKeys.append(key) + } + } + } + + return storageKeys + } + + class func wipeEntireKeychain() throws { + //TODO remove check in the future when safe to do so or required by the UI + guard (Env.isDebug || Env.isUnitTest) && Env.network == .regtest else { + Logger.error("Wiping keychain is only allowed in debug mode for regtest", context: "Keychain") + throw KeychainError.keychainWipeNotAllowed + } + + let keys = getAllKeyChainStorageKeys() + for key in keys { + let query = [ + kSecClass as String: kSecClassGenericPassword as String, + kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: Env.keychainGroup + ] as [String : Any] + SecItemDelete(query as CFDictionary) + + Logger.info("Deleted \(key) from keychain") + } + } +} diff --git a/Bitkit/Utilities/StartupHandler.swift b/Bitkit/Utilities/StartupHandler.swift new file mode 100644 index 000000000..77b15f40d --- /dev/null +++ b/Bitkit/Utilities/StartupHandler.swift @@ -0,0 +1,64 @@ +// +// Startup.swift +// Bitkit +// +// Created by Jason van den Berg on 2024/07/31. +// + +import Foundation +import BitcoinDevKit + +class StartupHandler { + private init() {} + + static func startAllServices() { + Logger.debug("Spinning up services...") + Task { + do { + try await OnChainViewModel.shared.start() + } catch { + Logger.error(error, context: "Failed to start on chain service") + } + } + + Task { + do { + try await LightningViewModel.shared.start() + } catch { + Logger.error(error, context: "Failed to start lightning service") + } + } + } + + /// Creates a new mnemonic and saves it to the keychain + /// - Parameters: + /// - bip39Passphrase: optional bip39 passphrase + /// - walletIndex: wallet index, defaults to zero for first entry + /// - Returns: The generated mnemonic + static func createNewWallet(bip39Passphrase: String?, walletIndex: Int = 0) throws -> String { + let mnemonic = Mnemonic(wordCount: Env.defaultWalletWordCount).asString() + + try Keychain.saveString(key: .bip39Mnemonic(index: walletIndex), str: mnemonic) + if let bip39Passphrase { + try Keychain.saveString(key: .bip39Passphrase(index: walletIndex), str: bip39Passphrase) + } + + return mnemonic + } + + /// Restores a wallet from a mnemoni and, saves it to the keychain + /// - Parameters: + /// - mnemonic: 12 or 24 word mnemonic + /// - bip39Passphrase: optional bip39 passphrase + /// - walletIndex: wallet index, defaults to zero for first + static func restoreWallet(mnemonic: String, bip39Passphrase: String?, walletIndex: Int = 0) throws { + _ = try Mnemonic.fromString(mnemonic: mnemonic) //Check it's valid + + //TODO validate word count also? + + try Keychain.saveString(key: .bip39Mnemonic(index: walletIndex), str: mnemonic) + if let bip39Passphrase { + try Keychain.saveString(key: .bip39Passphrase(index: walletIndex), str: bip39Passphrase) + } + } +} diff --git a/Bitkit/ViewModels/LightningViewModel.swift b/Bitkit/ViewModels/LightningViewModel.swift index 270f49426..f171e0625 100644 --- a/Bitkit/ViewModels/LightningViewModel.swift +++ b/Bitkit/ViewModels/LightningViewModel.swift @@ -18,20 +18,24 @@ class LightningViewModel: ObservableObject { @Published var channels: [ChannelDetails]? @Published var payments: [PaymentDetails]? - func start() async throws { - let mnemonic = Env.testMnemonic // = generateEntropyMnemonic() - let passphrase: String? = nil - + private init() {} + public static var shared = LightningViewModel() + + func start(walletIndex: Int = 0) async throws { syncState() - try await LightningService.shared.setup(mnemonic: mnemonic, passphrase: passphrase) + try await LightningService.shared.setup(walletIndex: walletIndex) try await LightningService.shared.start(onEvent: { _ in + //On every lightning event just sync UI Task { @MainActor in self.syncState() } }) syncState() - - //TODO listen on LDK events to sync UI state + + //Always sync on start but don't need to wait for this + Task { @MainActor in + try await sync() + } } func stop() async throws { @@ -39,6 +43,11 @@ class LightningViewModel: ObservableObject { syncState() } + func wipeWallet() async throws { + try await stop() + try await LightningService.shared.wipeStorage(walletIndex: 0) + } + func sync() async throws { isSyncing = true syncState() diff --git a/Bitkit/ViewModels/OnChainViewModel.swift b/Bitkit/ViewModels/OnChainViewModel.swift index 19d96d26c..0a8177172 100644 --- a/Bitkit/ViewModels/OnChainViewModel.swift +++ b/Bitkit/ViewModels/OnChainViewModel.swift @@ -14,15 +14,29 @@ class OnChainViewModel: ObservableObject { @Published var balance: Balance? @Published var address: String? - func start() async throws { - let mnemonic = Env.testMnemonic // = generateEntropyMnemonic() - let passphrase: String? = nil + private init() {} + public static var shared = OnChainViewModel() + + func start(walletIndex: Int = 0) async throws { + try await OnChainService.shared.setup(walletIndex: walletIndex) + syncState() - try OnChainService.shared.setup() - try await OnChainService.shared.createWallet(mnemonic: mnemonic, passphrase: passphrase) + //Always sync on start but don't need to wait for this + Task { @MainActor in + try await sync() + } + } + + func stop() throws { + OnChainService.shared.stop() syncState() } + func wipeWallet() async throws { + try stop() + try await OnChainService.shared.wipeStorage(walletIndex: 0) + } + func newReceiveAddress() async throws { address = try await OnChainService.shared.getAddress() } diff --git a/Bitkit/ViewModels/ViewModel.swift b/Bitkit/ViewModels/ViewModel.swift new file mode 100644 index 000000000..fa6b0f589 --- /dev/null +++ b/Bitkit/ViewModels/ViewModel.swift @@ -0,0 +1,25 @@ +// +// ViewModel.swift +// Bitkit +// +// Created by Jason van den Berg on 2024/07/31. +// + +import SwiftUI + +@MainActor +class ViewModel: ObservableObject { + @Published var walletExists: Bool? = nil + + private init() {} + public static var shared = ViewModel() + + func setWalletExistsState() { + do { + walletExists = try Keychain.exists(key: .bip39Mnemonic(index: 0)) + } catch { + //TODO show error + Logger.error(error) + } + } +} diff --git a/Bitkit/Views/Onboarding/WelcomeView.swift b/Bitkit/Views/Onboarding/WelcomeView.swift new file mode 100644 index 000000000..c31e437f9 --- /dev/null +++ b/Bitkit/Views/Onboarding/WelcomeView.swift @@ -0,0 +1,50 @@ +// +// WelcomeView.swift +// Bitkit +// +// Created by Jason van den Berg on 2024/07/31. +// + +import SwiftUI + +struct WelcomeView: View { + @StateObject var viewModel = ViewModel.shared + @State var bip39Passphrase: String? + + var body: some View { + VStack { + Text("Welcome") + .font(.largeTitle) + + Form { + TextField("BIP39 Passphrase", text: Binding( + get: { bip39Passphrase ?? "" }, + set: { bip39Passphrase = $0.isEmpty ? nil : $0 } + )) + } + + HStack { + Button("Create Wallet") { + do { + _ = try StartupHandler.createNewWallet(bip39Passphrase: bip39Passphrase) + viewModel.setWalletExistsState() + } catch { + //TODO: show a error to user + Logger.error(error) + } + } + .padding() + +// Button("Restore Wallet") { +// //TODO +// } +// .padding() + } + .padding() + } + } +} + +#Preview { + WelcomeView() +} diff --git a/Bitkit/Views/LogView.swift b/Bitkit/Views/Settings/LogView.swift similarity index 91% rename from Bitkit/Views/LogView.swift rename to Bitkit/Views/Settings/LogView.swift index def2dd355..8903b5f45 100644 --- a/Bitkit/Views/LogView.swift +++ b/Bitkit/Views/Settings/LogView.swift @@ -27,7 +27,7 @@ struct LogView: View { } func loadLog() { - let dir = Env.ldkStorage + let dir = Env.ldkStorage(walletIndex: LightningService.shared.currentWalletIndex) let fileURL = dir.appendingPathComponent("ldk_node_latest.log") do { diff --git a/Bitkit/Views/Wallets/HomeView.swift b/Bitkit/Views/Wallets/HomeView.swift new file mode 100644 index 000000000..40cde2a51 --- /dev/null +++ b/Bitkit/Views/Wallets/HomeView.swift @@ -0,0 +1,206 @@ +// +// HomeView.swift +// Bitkit +// +// Created by Jason van den Berg on 2024/07/31. +// + +import SwiftUI + +struct HomeView: View { + @ObservedObject var lnViewModel = LightningViewModel.shared + @ObservedObject var onChainViewModel = OnChainViewModel.shared + @StateObject var viewModel = ViewModel.shared + + @Environment(\.scenePhase) var scenePhase + + @State var showLogs = false + + var body: some View { + List { + Section { + Text(lnViewModel.status?.debugState ?? "No LDK State") + + if let nodeId = lnViewModel.nodeId { + Text("LN Node ID: \(nodeId)") + .font(.caption) + .onTapGesture { + UIPasteboard.general.string = nodeId + } + } + } + + Section("Balances") { + if let lnBalance = lnViewModel.balance { + Text("Lightning \(lnBalance.totalLightningBalanceSats)") + Text("Lightning onchain \(lnBalance.totalOnchainBalanceSats)") + } + + if let onchainBalance = onChainViewModel.balance { + Text("On Chain \(onchainBalance.total)") + } + } + + if let peers = lnViewModel.peers { + Section("Peers") { + ForEach(peers, id: \.nodeId) { peer in + HStack { + Text("\(peer.nodeId)@\(peer.address)") + .font(.caption2) + Spacer() + Text(peer.isConnected ? "✅" : "❌") + } + } + } + } + + if let channels = lnViewModel.channels { + Section("Channels") { + ForEach(channels, id: \.channelId) { channel in + VStack { + Text(channel.counterpartyNodeId).font(.caption2) + .multilineTextAlignment(.leading) + HStack { + Text("Out: \(channel.outboundCapacityMsat)") + Spacer() + Text("In: \(channel.inboundCapacityMsat)") + Text(channel.isChannelReady ? "🟢" : "🔴") + Text(channel.isUsable ? "🟢" : "🔴") + } + + } + .onLongPressGesture { + Task { + do { + try await LightningService.shared.closeChannel(userChannelId: channel.userChannelId, counterpartyNodeId: channel.counterpartyNodeId) + Logger.info("Channel closed") + try await lnViewModel.sync() + } catch { + + } + } + } + } + + Button("Copy open channel command") { + let cmd = "lncli openchannel --node_key=\(lnViewModel.nodeId ?? "") --local_amt=200000 --push_amt=10000 --private=true --zero_conf --channel_type=anchors" + // let cmd = "lncli openchannel --node_key=\(lnViewModel.nodeId ?? "") --local_amt=200000 --push_amt=10000 --min_confs=3" + UIPasteboard.general.string = cmd + } + } + } + + if let receiveAddress = onChainViewModel.address { + Text("Receive Address: \(receiveAddress)") + .onTapGesture { + UIPasteboard.general.string = receiveAddress + } + } + + Button("New Receive Address") { + Task { + try await onChainViewModel.newReceiveAddress() + } + } + + Button("Create bolt11") { + Task { + let invoice = try await LightningService.shared.receive(amountSats: 123, description: "paymeplz") + Logger.info(invoice, context: "Created invoice") + UIPasteboard.general.string = invoice + } + } + + Button("Pay bolt11") { + Task { + if let invoice = UIPasteboard.general.string { + let _ = try? await LightningService.shared.send(bolt11: invoice) + } + } + } + + Button("Show Logs") { + showLogs = true + } + + Button("NUKE") { + Task { + guard Env.network == .regtest else { + Logger.error("Can only nuke on regtest") + return + } + do { + //Delete storage (for current wallet only) + try await onChainViewModel.wipeWallet() + try await lnViewModel.wipeWallet() + //Delete entire keychain + try Keychain.wipeEntireKeychain() + viewModel.setWalletExistsState() + } catch { + Logger.error(error, context: "Nuke") + } + } + } + + Section("LN Transactions") { + if let payments = lnViewModel.payments { + ForEach(payments, id: \.id) { payment in + HStack { + Text("\(payment.direction == .inbound ? "⬇️" : "⬆️")") + Text("\(payment.status)") + Spacer() + Text("\(payment.amountMsat ?? 0)") + } + } + } + } + } + .refreshable { + do { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { try await onChainViewModel.sync() } + group.addTask { try await lnViewModel.sync() } + try await group.waitForAll() + } + } catch { + //TODO show an error + } + } + .sheet(isPresented: $showLogs) { + LogView() + } + .onChange(of: scenePhase) { newPhase in + if newPhase == .background { + if lnViewModel.status?.isRunning == true { + Logger.debug("App backgrounded, stopping LN service...") + Task { + do { + try await lnViewModel.stop() + } catch { + Logger.error(error, context: "Failed to stop LN") + } + } + } + return + } + + if newPhase == .active { + if lnViewModel.status?.isRunning == false { + Logger.debug("App active, starting LN service...") + Task { + do { + try await lnViewModel.start() + try await lnViewModel.sync() + } catch { + Logger.error(error, context: "Failed to start LN") + } + } + } + } + } + } +} + +#Preview { + HomeView() +} diff --git a/BitkitNotification/BitkitNotification.entitlements b/BitkitNotification/BitkitNotification.entitlements index 4fca2ce32..a9b567fb3 100644 --- a/BitkitNotification/BitkitNotification.entitlements +++ b/BitkitNotification/BitkitNotification.entitlements @@ -6,5 +6,9 @@ group.bitkit + keychain-access-groups + + $(AppIdentifierPrefix)to.bitkit + diff --git a/BitkitNotification/NotificationService.swift b/BitkitNotification/NotificationService.swift index 7efff6bb0..a651ab373 100644 --- a/BitkitNotification/NotificationService.swift +++ b/BitkitNotification/NotificationService.swift @@ -9,6 +9,7 @@ import UserNotifications import LDKNode class NotificationService: UNNotificationServiceExtension { + let walletIndex = 0 //Assume first wallet for now var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? @@ -19,10 +20,7 @@ class NotificationService: UNNotificationServiceExtension { Task { do { - let mnemonic = Env.testMnemonic // = generateEntropyMnemonic() - let passphrase: String? = nil - - try await LightningService.shared.setup(mnemonic: mnemonic, passphrase: passphrase) + try await LightningService.shared.setup(walletIndex: walletIndex) //Assume first wallet for now try await LightningService.shared.start { event in self.handleLdkEvent(event: event) @@ -86,7 +84,7 @@ class NotificationService: UNNotificationServiceExtension { } func dumpLdkLogs() { - let dir = Env.ldkStorage + let dir = Env.ldkStorage(walletIndex: walletIndex) let fileURL = dir.appendingPathComponent("ldk_node_latest.log") do { diff --git a/BitkitTests/KeychainTests.swift b/BitkitTests/KeychainTests.swift new file mode 100644 index 000000000..6f3d4e67e --- /dev/null +++ b/BitkitTests/KeychainTests.swift @@ -0,0 +1,67 @@ +// +// Keychain.swift +// BitkitTests +// +// Created by Jason van den Berg on 2024/07/29. +// + +import XCTest + +final class KeychainTests: XCTestCase { + + override func setUpWithError() throws { + try Keychain.wipeEntireKeychain() + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testKeychain() throws { + let testMnemonic = "test\(Int.random(in: 0...99999)) test\(Int.random(in: 0...99999)) test\(Int.random(in: 0...99999)) test\(Int.random(in: 0...99999)) test\(Int.random(in: 0...99999)) test\(Int.random(in: 0...99999)) test\(Int.random(in: 0...99999)) test\(Int.random(in: 0...99999)) test\(Int.random(in: 0...99999)) test\(Int.random(in: 0...99999)) test\(Int.random(in: 0...99999)) test\(Int.random(in: 0...99999))" + let testPassphrase = "testpasshrase\(Int.random(in: 0...99999))" + + //Write + try Keychain.saveString(key: .bip39Mnemonic(index: 0), str: testMnemonic) + try Keychain.saveString(key: .bip39Passphrase(index: 0), str: testPassphrase) + + //Read + XCTAssertEqual(try Keychain.loadString(key: .bip39Mnemonic(index: 0)), testMnemonic) + XCTAssertEqual(try Keychain.loadString(key: .bip39Passphrase(index: 0)), testPassphrase) + + //Not allowed to overwrite existing key + XCTAssertThrowsError(try Keychain.saveString(key: .bip39Mnemonic(index: 0), str: testMnemonic)) + XCTAssertThrowsError(try Keychain.saveString(key: .bip39Passphrase(index: 0), str: testMnemonic)) + + //Test deleting + try Keychain.delete(key: .bip39Mnemonic(index: 0)) + try Keychain.delete(key: .bip39Passphrase(index: 0)) + + //Write multiple wallets + for i in 0...5 { + try Keychain.saveString(key: .bip39Mnemonic(index: i), str: "\(testMnemonic) index\(i)") + try Keychain.saveString(key: .bip39Passphrase(index: i), str: "\(testPassphrase) index\(i)") + } + + //Check all keys are saved correctly + let listedKeys = Keychain.getAllKeyChainStorageKeys() + XCTAssertEqual(listedKeys.count, 12) + for i in 0...5 { + XCTAssertTrue(listedKeys.contains("bip39_mnemonic_\(i)")) + XCTAssertTrue(listedKeys.contains("bip39_passphrase_\(i)")) + } + + //Check each value + for i in 0...5 { + XCTAssertEqual(try Keychain.loadString(key: .bip39Mnemonic(index: i)), "\(testMnemonic) index\(i)") + XCTAssertEqual(try Keychain.loadString(key: .bip39Passphrase(index: i)), "\(testPassphrase) index\(i)") + } + + //Wipe + try Keychain.wipeEntireKeychain() + + //Check all keys are gone + let listedKeysAfterWipe = Keychain.getAllKeyChainStorageKeys() + XCTAssertEqual(listedKeysAfterWipe.count, 0) + } +} diff --git a/BitkitTests/LdkMigration.swift b/BitkitTests/LdkMigration.swift index 8cb15b9ce..104d6802b 100644 --- a/BitkitTests/LdkMigration.swift +++ b/BitkitTests/LdkMigration.swift @@ -7,10 +7,11 @@ import XCTest -final class LdkMigration: XCTestCase { +final class LdkMigrationTests: XCTestCase { + let walletIndex = 0 override func setUpWithError() throws { - try? FileManager.default.removeItem(at: Env.appStorageUrl) //Removes 'unit-test' directory + } override func tearDownWithError() throws { @@ -18,6 +19,9 @@ final class LdkMigration: XCTestCase { } func testLdkToLdkNode() async throws { + try Keychain.wipeEntireKeychain() + try await LightningService.shared.wipeStorage(walletIndex: walletIndex) + guard let seedFile = Bundle(for: type(of: self)).url(forResource: "seed", withExtension: "bin") else { XCTFail("Missing file: seed.bin") return @@ -33,7 +37,12 @@ final class LdkMigration: XCTestCase { return } + let testMnemonic = "pool curve feature leader elite dilemma exile toast smile couch crane public" + + try Keychain.saveString(key: .bip39Mnemonic(index: 0), str: testMnemonic) + try MigrationsService.shared.ldkToLdkNode( + walletIndex: 0, seed: try Data(contentsOf: seedFile), manager: try Data(contentsOf: managerFile), monitors: [ @@ -41,7 +50,8 @@ final class LdkMigration: XCTestCase { ] ) - try await LightningService.shared.setup(mnemonic: Env.testMnemonic, passphrase: nil) + //TODO restore first from words + try await LightningService.shared.setup(walletIndex: walletIndex) try await LightningService.shared.start() XCTAssertEqual(LightningService.shared.nodeId, "02cd08b7b375e4263849121f9f0ffb2732a0b88d0fb74487575ac539b374f45a55") @@ -53,7 +63,7 @@ final class LdkMigration: XCTestCase { } func dumpLdkLogs() { - let dir = Env.ldkStorage + let dir = Env.ldkStorage(walletIndex: walletIndex) let fileURL = dir.appendingPathComponent("ldk_node_latest.log") guard let text = try? String(contentsOf: fileURL, encoding: .utf8) else { @@ -66,11 +76,4 @@ final class LdkMigration: XCTestCase { } print("*****END LOG******") } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } } diff --git a/README.md b/README.md index 99f2b839e..32b95e1b4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -## Error handling +## How to build -- Don't pass exceptions up to the UI -- Translations -- Known lightning errors +1. Open Bitkit.xcodeproj in XCode +2. Build