diff --git a/LDKNodeMonday.xcodeproj/project.pbxproj b/LDKNodeMonday.xcodeproj/project.pbxproj index 8efac00..64e71c3 100644 --- a/LDKNodeMonday.xcodeproj/project.pbxproj +++ b/LDKNodeMonday.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -32,7 +32,6 @@ AE17E8E129A402E40058C9C9 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AE17E8E029A402E40058C9C9 /* Preview Assets.xcassets */; }; AE17E90D29A42D430058C9C9 /* LightningNodeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE17E90C29A42D430058C9C9 /* LightningNodeService.swift */; }; AE186B8E2A1540B700338463 /* StartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE186B8D2A1540B700338463 /* StartView.swift */; }; - AE1AED452C25C94D00B467EF /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = AE1AED442C25C94D00B467EF /* LDKNode */; }; AE1D9BEC2B2A1FFD00620748 /* BitcoinUI in Frameworks */ = {isa = PBXBuildFile; productRef = AE1D9BEB2B2A1FFD00620748 /* BitcoinUI */; }; AE1D9C0B2B2A251500620748 /* ChannelDetails+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE1D9C0A2B2A251500620748 /* ChannelDetails+Extensions.swift */; }; AE3815362B6A9705006B2952 /* LightningNodesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3815352B6A9705006B2952 /* LightningNodesService.swift */; }; @@ -70,6 +69,9 @@ AE7D3FAD2A4263C100EAE730 /* PaymentsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7D3FAC2A4263C100EAE730 /* PaymentsViewModel.swift */; }; AE80116B29A59976009B9967 /* NodeIDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE80116A29A59976009B9967 /* NodeIDView.swift */; }; AE80116D29A59AF4009B9967 /* ChannelAddView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE80116C29A59AF4009B9967 /* ChannelAddView.swift */; }; + AE80C2002C4AB360006E7193 /* BIP21View.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE80C1FF2C4AB360006E7193 /* BIP21View.swift */; }; + AE80C2022C4AB38D006E7193 /* BIP21ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE80C2012C4AB38D006E7193 /* BIP21ViewModel.swift */; }; + AE80C2052C4AB5E4006E7193 /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = AE80C2042C4AB5E4006E7193 /* LDKNode */; }; AE94226A2A007D6C007E4F12 /* ChannelsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE9422692A007D6C007E4F12 /* ChannelsListView.swift */; }; AEA057E92B912C3C00DB1096 /* ZeroInvoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA057E82B912C3C00DB1096 /* ZeroInvoiceView.swift */; }; AEA057EB2B912E1800DB1096 /* AmountInvoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA057EA2B912E1800DB1096 /* AmountInvoiceView.swift */; }; @@ -154,6 +156,8 @@ AE7D3FAC2A4263C100EAE730 /* PaymentsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentsViewModel.swift; sourceTree = ""; }; AE80116A29A59976009B9967 /* NodeIDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeIDView.swift; sourceTree = ""; }; AE80116C29A59AF4009B9967 /* ChannelAddView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelAddView.swift; sourceTree = ""; }; + AE80C1FF2C4AB360006E7193 /* BIP21View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BIP21View.swift; sourceTree = ""; }; + AE80C2012C4AB38D006E7193 /* BIP21ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BIP21ViewModel.swift; sourceTree = ""; }; AE8D877429B145F100F2B918 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; AE9422692A007D6C007E4F12 /* ChannelsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelsListView.swift; sourceTree = ""; }; AEA057E82B912C3C00DB1096 /* ZeroInvoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZeroInvoiceView.swift; sourceTree = ""; }; @@ -189,7 +193,7 @@ AEE5B7652A09B1FC001E5E59 /* CodeScanner in Frameworks */, AE51BEFD2B37A5DC00BAE452 /* SimpleToast in Frameworks */, AE7C4A082B406D590061189D /* SimpleToast in Frameworks */, - AE1AED452C25C94D00B467EF /* LDKNode in Frameworks */, + AE80C2052C4AB5E4006E7193 /* LDKNode in Frameworks */, AE01C5B02AB3BEED00F28C7E /* KeychainAccess in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -283,6 +287,7 @@ AEAAD9D22C23399700765F5B /* Bolt12ZeroInvoiceViewModel.swift */, AE5BFE852C0B9D7B003B467C /* Bolt12InvoiceViewModel.swift */, AE028A272B96325700B336E7 /* AmountInvoiceViewModel.swift */, + AE80C2012C4AB38D006E7193 /* BIP21ViewModel.swift */, AE028A292B96328600B336E7 /* JITInvoiceViewModel.swift */, AE49E8552A253674002623E8 /* AddressViewModel.swift */, ); @@ -378,6 +383,7 @@ AE5BFE832C0B9D06003B467C /* Bolt12InvoiceView.swift */, AEAAD9D02C23394D00765F5B /* Bolt12ZeroInvoiceView.swift */, AEA057EA2B912E1800DB1096 /* AmountInvoiceView.swift */, + AE80C1FF2C4AB360006E7193 /* BIP21View.swift */, AEA057EC2B912FEA00DB1096 /* JITInvoiceView.swift */, AE17E8DB29A402E30058C9C9 /* AddressView.swift */, ); @@ -555,7 +561,7 @@ AE51BEFC2B37A5DC00BAE452 /* SimpleToast */, AE7C4A072B406D590061189D /* SimpleToast */, AE060C372C051B59006724F1 /* LDKNode */, - AE1AED442C25C94D00B467EF /* LDKNode */, + AE80C2042C4AB5E4006E7193 /* LDKNode */, ); productName = LDKNodeMonday; productReference = AE17E8D629A402E30058C9C9 /* LDKNodeMonday.app */; @@ -590,7 +596,7 @@ AE01C5AE2AB3BEED00F28C7E /* XCRemoteSwiftPackageReference "KeychainAccess" */, AE1D9BEA2B2A1FFD00620748 /* XCRemoteSwiftPackageReference "BitcoinUI" */, AE7C4A062B406D590061189D /* XCRemoteSwiftPackageReference "SimpleToast" */, - AE1AED432C25C94D00B467EF /* XCRemoteSwiftPackageReference "ldk-node" */, + AE80C2032C4AB5E4006E7193 /* XCLocalSwiftPackageReference "../ldk-node/bindings/swift" */, ); productRefGroup = AE17E8D729A402E30058C9C9 /* Products */; projectDirPath = ""; @@ -626,6 +632,7 @@ AE0055162B4A0E0100100797 /* Logger+Extensions.swift in Sources */, AEA057EB2B912E1800DB1096 /* AmountInvoiceView.swift in Sources */, AE70963C2B5C22270038BE56 /* CurrencyCode.swift in Sources */, + AE80C2002C4AB360006E7193 /* BIP21View.swift in Sources */, AE01C5B22AB3BF3C00F28C7E /* KeyService.swift in Sources */, AE49E8642A2537B3002623E8 /* PeersListViewModel.swift in Sources */, AE49E8542A253647002623E8 /* BitcoinViewModel.swift in Sources */, @@ -685,6 +692,7 @@ AE6BB56F2A008CBA009E16E3 /* UInt64+Extensions.swift in Sources */, AE49E85A2A2536D4002623E8 /* ChannelAddViewModel.swift in Sources */, AE0055142B4895B500100797 /* SeedViewModel.swift in Sources */, + AE80C2022C4AB38D006E7193 /* BIP21ViewModel.swift in Sources */, AE7D3FAB2A4263AE00EAE730 /* PaymentsView.swift in Sources */, AE80116D29A59AF4009B9967 /* ChannelAddView.swift in Sources */, AE551D472B8ECE7D0034B61E /* Payment.swift in Sources */, @@ -906,6 +914,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + AE80C2032C4AB5E4006E7193 /* XCLocalSwiftPackageReference "../ldk-node/bindings/swift" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../ldk-node/bindings/swift"; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ AE01C5AE2AB3BEED00F28C7E /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { isa = XCRemoteSwiftPackageReference; @@ -915,19 +930,11 @@ version = 4.2.2; }; }; - AE1AED432C25C94D00B467EF /* XCRemoteSwiftPackageReference "ldk-node" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/lightningdevkit/ldk-node.git"; - requirement = { - kind = exactVersion; - version = 0.3.0; - }; - }; AE1D9BEA2B2A1FFD00620748 /* XCRemoteSwiftPackageReference "BitcoinUI" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/reez/BitcoinUI.git"; requirement = { - branch = 1.0.6; + branch = main; kind = branch; }; }; @@ -959,11 +966,6 @@ isa = XCSwiftPackageProductDependency; productName = LDKNode; }; - AE1AED442C25C94D00B467EF /* LDKNode */ = { - isa = XCSwiftPackageProductDependency; - package = AE1AED432C25C94D00B467EF /* XCRemoteSwiftPackageReference "ldk-node" */; - productName = LDKNode; - }; AE1D9BEB2B2A1FFD00620748 /* BitcoinUI */ = { isa = XCSwiftPackageProductDependency; package = AE1D9BEA2B2A1FFD00620748 /* XCRemoteSwiftPackageReference "BitcoinUI" */; @@ -978,6 +980,10 @@ package = AE7C4A062B406D590061189D /* XCRemoteSwiftPackageReference "SimpleToast" */; productName = SimpleToast; }; + AE80C2042C4AB5E4006E7193 /* LDKNode */ = { + isa = XCSwiftPackageProductDependency; + productName = LDKNode; + }; AEE5B7642A09B1FC001E5E59 /* CodeScanner */ = { isa = XCSwiftPackageProductDependency; package = AEE5B7632A09B1FC001E5E59 /* XCRemoteSwiftPackageReference "CodeScanner" */; diff --git a/LDKNodeMonday.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LDKNodeMonday.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c0a4157..2522a59 100644 --- a/LDKNodeMonday.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LDKNodeMonday.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "3c4f831e3e2d84e3d749572be35ef446cf04910008de67451a51af6ca58e22b3", + "originHash" : "205ed5286a50854d994301c2ba51d3a38173557dcfa21e1c3da1ccccb0730a15", "pins" : [ { "identity" : "bitcoinui", "kind" : "remoteSourceControl", "location" : "https://github.com/reez/BitcoinUI.git", "state" : { - "branch" : "1.0.6", - "revision" : "2f4f1a49a869ebd19c08718ade319ffdf59e6da1" + "branch" : "main", + "revision" : "22ea27e2495aac9035c39a1b09165a802d90bdde" } }, { @@ -28,15 +28,6 @@ "version" : "4.2.2" } }, - { - "identity" : "ldk-node", - "kind" : "remoteSourceControl", - "location" : "https://github.com/lightningdevkit/ldk-node.git", - "state" : { - "revision" : "bd9bd683201c1597d8930ac4118fa950cedfd56c", - "version" : "0.3.0" - } - }, { "identity" : "simpletoast", "kind" : "remoteSourceControl", diff --git a/LDKNodeMonday/Extensions/String+Extensions.swift b/LDKNodeMonday/Extensions/String+Extensions.swift index 2ebc5dd..d8161b9 100644 --- a/LDKNodeMonday/Extensions/String+Extensions.swift +++ b/LDKNodeMonday/Extensions/String+Extensions.swift @@ -10,44 +10,47 @@ import Foundation extension String { func bolt11amount() -> String? { - let regex = try! NSRegularExpression(pattern: "ln.*?(\\d+)([munp]?)", options: []) + let regex = try! NSRegularExpression( + pattern: "ln(?:bc|tb|tbs)(?\\d+)(?[munp]?)", + options: [.caseInsensitive] + ) + if let match = regex.firstMatch( in: self, options: [], range: NSRange(location: 0, length: self.utf16.count) ) { - let amountRange = match.range(at: 1) - let multiplierRange = match.range(at: 2) - - if let amountSwiftRange = Range(amountRange, in: self), - let multiplierSwiftRange = Range(multiplierRange, in: self) - { - - let amountString = self[amountSwiftRange] - let multiplierString = self[multiplierSwiftRange] - let numberFormatter = NumberFormatter() - - if let amount = numberFormatter.number(from: String(amountString))?.doubleValue { - var conversion = amount - - switch multiplierString { - case "m": - conversion *= 0.001 - case "u": - conversion *= 0.000001 - case "n": - conversion *= 0.000000001 - case "p": - conversion *= 0.000000000001 - default: - break - } - - let convertedAmount = conversion * 100_000_000 - let formattedAmount = String(format: "%.0f", convertedAmount) - return formattedAmount - } + + guard let amountRange = Range(match.range(withName: "amount"), in: self), + let multiplierRange = Range(match.range(withName: "multiplier"), in: self) + else { + return nil + } + + let amountString = String(self[amountRange]) + let multiplierString = String(self[multiplierRange]) + + guard let amount = Int(amountString) else { + return nil } + + var conversion = Double(amount) + switch multiplierString.lowercased() { + case "m": + conversion *= 0.001 + case "u": + conversion *= 0.000001 + case "n": + conversion *= 0.000000001 + case "p": + conversion *= 0.000000000001 + default: + break + } + + let convertedAmount = conversion * 100_000_000 + let formattedAmount = String(format: "%.0f", convertedAmount) + return formattedAmount } return nil @@ -158,14 +161,51 @@ extension String { return params } + func processBIP21(_ input: String, spendableBalance: UInt64) -> (String, String, Payment) { + guard let url = URL(string: input), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { + return ("", "0", .isNone) + } + + let bitcoinAddress = url.path + var amount = "0" + var bolt12Offer: String? + var bolt11Invoice: String? + + for item in components.queryItems ?? [] { + switch item.name.lowercased() { + case "amount": + if let value = item.value, let btcAmount = Double(value) { + amount = String(format: "%.0f", btcAmount * 100_000_000) + } + case "lightning": + bolt11Invoice = item.value + case "lno": + bolt12Offer = item.value + default: + break + } + } + + if let offer = bolt12Offer { + return processLightningAddress(offer) + } + if let invoice = bolt11Invoice { + return processLightningAddress(invoice) + } + return (bitcoinAddress, amount, .isBitcoin) + } + func extractPaymentInfo(spendableBalance: UInt64) -> ( address: String, amount: String, payment: Payment ) { - let queryParams = self.queryParameters() - - if let lightningAddress = queryParams["lightning"], !lightningAddress.isEmpty { - return processLightningAddress(lightningAddress) - } else if self.isLightningAddress && !self.starts(with: "lnurl") { + if self.lowercased().starts(with: "bitcoin:") && self.contains("?") { + return processBIP21(self, spendableBalance: spendableBalance) + } else if self.lowercased().starts(with: "lightning:") { + let invoice = String(self.dropFirst(10)) // Remove "lightning:" prefix + return processLightningAddress(invoice) + } else if self.lowercased().starts(with: "lnbc") || self.lowercased().starts(with: "lntb") { return processLightningAddress(self) } else if self.isBitcoinAddress { return processBitcoinAddress(spendableBalance) @@ -191,7 +231,7 @@ extension String { private func processLightningAddress(_ address: String) -> (String, String, Payment) { let sanitizedAddress = address.replacingOccurrences(of: "lightning:", with: "") - if sanitizedAddress.starts(with: "lno") { + if sanitizedAddress.lowercased().starts(with: "lno") { return (sanitizedAddress, "0", .isLightning) } else { let amount = sanitizedAddress.bolt11amount() ?? "0" @@ -202,6 +242,9 @@ extension String { private func extractBitcoinAddress() -> String { if self.lowercased().hasPrefix("bitcoin:") { let address = self.replacingOccurrences(of: "bitcoin:", with: "") + if let addressEnd = address.range(of: "?")?.lowerBound { + return String(address[.. QrPaymentResult { + let qrPaymentResult = try ldkNode.unifiedQrPayment().send(uriStr: uriStr) + return qrPaymentResult + } + func sendUsingAmount(bolt11Invoice: Bolt11Invoice, amountMsat: UInt64) async throws -> PaymentHash { @@ -227,6 +233,16 @@ class LightningNodeService { return offer } + // Generates a BIP21 URI string with an on the address and BOLT11 invoice. + func receive(amountSat: UInt64, message: String, expirySec: UInt32) async throws -> String { + let bip21UriString = try ldkNode.unifiedQrPayment().receive( + amountSats: amountSat, + message: message, + expirySec: expirySec + ) + return bip21UriString + } + func receiveVariableAmount(description: String, expirySecs: UInt32) async throws -> Bolt11Invoice { diff --git a/LDKNodeMonday/Service/Lightning Service/LightningServiceError.swift b/LDKNodeMonday/Service/Lightning Service/LightningServiceError.swift index 1cc85a8..323b624 100644 --- a/LDKNodeMonday/Service/Lightning Service/LightningServiceError.swift +++ b/LDKNodeMonday/Service/Lightning Service/LightningServiceError.swift @@ -155,6 +155,12 @@ func handleNodeError(_ error: NodeError) -> MondayError { case .GossipUpdateTimeout(let message): return .init(title: "GossipUpdateTimeout", detail: message) + case .UriParameterParsingFailed(let message): + return .init(title: "UriParameterParsingFailed", detail: message) + + case .InvalidUri(let message): + return .init(title: "InvalidUri", detail: message) + } } diff --git a/LDKNodeMonday/View Model/Home/AmountViewModel.swift b/LDKNodeMonday/View Model/Home/AmountViewModel.swift index 1bfcc9a..f856905 100644 --- a/LDKNodeMonday/View Model/Home/AmountViewModel.swift +++ b/LDKNodeMonday/View Model/Home/AmountViewModel.swift @@ -90,7 +90,7 @@ class AmountViewModel { func sendPaymentBolt12(invoice: Bolt12Invoice) async { do { - try await LightningNodeService.shared.send(bolt12Invoice: invoice) + let _ = try await LightningNodeService.shared.send(bolt12Invoice: invoice) } catch let error as NodeError { NotificationCenter.default.post(name: .ldkErrorReceived, object: error) let errorString = handleNodeError(error) @@ -118,7 +118,7 @@ class AmountViewModel { } func handleLightningPayment(address: String, numpadAmount: String) async { - if address.starts(with: "lno") { + if address.lowercased().starts(with: "lno") { await sendPaymentBolt12(invoice: address) } else if address.bolt11amount() == "0" { if let amountSats = UInt64(numpadAmount) { diff --git a/LDKNodeMonday/View Model/Home/Receive/BIP21ViewModel.swift b/LDKNodeMonday/View Model/Home/Receive/BIP21ViewModel.swift new file mode 100644 index 0000000..b3f0173 --- /dev/null +++ b/LDKNodeMonday/View Model/Home/Receive/BIP21ViewModel.swift @@ -0,0 +1,55 @@ +// +// BIP21ViewModel.swift +// LDKNodeMonday +// +// Created by Matthew Ramsden on 7/19/24. +// + +import BitcoinUI +import Foundation +import LDKNode +import SwiftUI + +class BIP21ViewModel: ObservableObject { + @Published var unified: String = "" + @Published var receiveViewError: MondayError? + @Published var networkColor = Color.gray + @Published var amountSat: String = "" + + func receivePayment(amountSat: UInt64, message: String, expirySecs: UInt32) async { + do { + let unified = try await LightningNodeService.shared.receive( + amountSat: amountSat, + message: message, + expirySec: expirySecs + ) + DispatchQueue.main.async { + self.unified = unified + } + } catch let error as NodeError { + let errorString = handleNodeError(error) + DispatchQueue.main.async { + self.receiveViewError = .init(title: errorString.title, detail: errorString.detail) + } + } catch { + DispatchQueue.main.async { + self.receiveViewError = .init( + title: "Unexpected error", + detail: error.localizedDescription + ) + } + } + } + + func clearInvoice() { + self.unified = "" + } + + func getColor() { + let color = LightningNodeService.shared.networkColor + DispatchQueue.main.async { + self.networkColor = color + } + } + +} diff --git a/LDKNodeMonday/View/Home/Receive/BIP21View.swift b/LDKNodeMonday/View/Home/Receive/BIP21View.swift new file mode 100644 index 0000000..ea1c1e1 --- /dev/null +++ b/LDKNodeMonday/View/Home/Receive/BIP21View.swift @@ -0,0 +1,320 @@ +// +// BIP21View.swift +// LDKNodeMonday +// +// Created by Matthew Ramsden on 7/19/24. +// + +import BitcoinUI +import LDKNode +import SwiftUI + +struct BIP21View: View { + @ObservedObject var viewModel: BIP21ViewModel + @State private var onchainIsCopied = false + @State private var onchainShowCheckmark = false + @State private var bolt11IsCopied = false + @State private var bolt11ShowCheckmark = false + @State private var bolt12IsCopied = false + @State private var bolt12ShowCheckmark = false + + @State private var showingReceiveViewErrorAlert = false + @State private var isKeyboardVisible = false + + var body: some View { + + VStack { + + if viewModel.unified == "" { + + VStack(alignment: .leading) { + + Text("Sats") + .bold() + .padding(.horizontal) + + ZStack { + TextField( + "0", + text: $viewModel.amountSat + ) + .keyboardType(.numberPad) + .submitLabel(.done) + .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 32)) + + if !viewModel.amountSat.isEmpty { + HStack { + Spacer() + Button { + self.viewModel.amountSat = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .padding(.trailing, 8) + } + } + } + .padding(.horizontal) + + } + .padding() + + Button { + Task { + let amountSat = (UInt64(viewModel.amountSat) ?? 0) + await viewModel.receivePayment( + amountSat: amountSat, + message: "Monday Wallet", + expirySecs: UInt32(3600) + ) + } + } label: { + Text("Create Amount Invoice") + } + + } else { + + QRCodeView(qrCodeType: .bip21(viewModel.unified)) + + VStack(spacing: 5.0) { + + HStack(alignment: .center) { + + VStack(alignment: .leading, spacing: 5.0) { + Text("On Chain") + .bold() + if let components = parseUnifiedQR(viewModel.unified) { + Text(components.onchain) + .truncationMode(.middle) + .lineLimit(1) + .foregroundColor(.secondary) + .redacted( + reason: viewModel.unified.isEmpty ? .placeholder : [] + ) + } + } + .font(.caption2) + + Spacer() + + if let components = parseUnifiedQR(viewModel.unified) { + Button { + UIPasteboard.general.string = components.onchain + onchainIsCopied = true + onchainShowCheckmark = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + onchainIsCopied = false + onchainShowCheckmark = false + } + } label: { + HStack { + withAnimation { + Image( + systemName: onchainShowCheckmark + ? "checkmark" : "doc.on.doc" + ) + .font(.title3) + .minimumScaleFactor(0.5) + } + } + .bold() + .foregroundColor(viewModel.networkColor) + } + .font(.caption2) + + } + + } + .padding(.horizontal) + + HStack(alignment: .center) { + + VStack(alignment: .leading, spacing: 5.0) { + Text("Bolt 11") + .bold() + if let components = parseUnifiedQR(viewModel.unified) { + Text(components.bolt11) + .truncationMode(.middle) + .lineLimit(1) + .foregroundColor(.secondary) + .redacted( + reason: viewModel.unified.isEmpty ? .placeholder : [] + ) + } + } + .font(.caption2) + + Spacer() + + if let components = parseUnifiedQR(viewModel.unified) { + Button { + UIPasteboard.general.string = components.bolt11 + bolt11IsCopied = true + bolt11ShowCheckmark = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + bolt11IsCopied = false + bolt11ShowCheckmark = false + } + } label: { + HStack { + withAnimation { + Image( + systemName: bolt11ShowCheckmark + ? "checkmark" : "doc.on.doc" + ) + .font(.title3) + .minimumScaleFactor(0.5) + } + } + .bold() + .foregroundColor(viewModel.networkColor) + } + .font(.caption2) + + } + + } + .padding(.horizontal) + + HStack(alignment: .center) { + + VStack(alignment: .leading, spacing: 5.0) { + Text("Bolt 12") + .bold() + if let components = parseUnifiedQR(viewModel.unified) { + Text(components.bolt12) + .truncationMode(.middle) + .lineLimit(1) + .foregroundColor(.secondary) + .redacted( + reason: viewModel.unified.isEmpty ? .placeholder : [] + ) + } + } + .font(.caption2) + + Spacer() + + if let components = parseUnifiedQR(viewModel.unified) { + Button { + UIPasteboard.general.string = components.bolt12 + bolt12IsCopied = true + bolt12ShowCheckmark = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + bolt12IsCopied = false + bolt12ShowCheckmark = false + } + } label: { + HStack { + withAnimation { + Image( + systemName: bolt12ShowCheckmark + ? "checkmark" : "doc.on.doc" + ) + .font(.title3) + .minimumScaleFactor(0.5) + } + } + .bold() + .foregroundColor(viewModel.networkColor) + } + .font(.caption2) + + } + + } + .padding(.horizontal) + + Button("Clear Invoice") { + viewModel.clearInvoice() + } + .buttonBorderShape(.capsule) + .buttonStyle(.bordered) + .tint(viewModel.networkColor) + .padding() + + } + + } + + } + .onAppear { + viewModel.getColor() + } + .onReceive(viewModel.$receiveViewError) { errorMessage in + if errorMessage != nil { + showingReceiveViewErrorAlert = true + } + } + .onReceive( + NotificationCenter.default.publisher( + for: UIResponder.keyboardWillShowNotification + ) + ) { _ in + isKeyboardVisible = true + } + .onReceive( + NotificationCenter.default.publisher( + for: UIResponder.keyboardWillHideNotification + ) + ) { _ in + isKeyboardVisible = false + } + .alert(isPresented: $showingReceiveViewErrorAlert) { + Alert( + title: Text(viewModel.receiveViewError?.title ?? "Unknown"), + message: Text(viewModel.receiveViewError?.detail ?? ""), + dismissButton: .default(Text("OK")) { + viewModel.receiveViewError = nil + } + ) + } + + } + +} + +struct UnifiedQRComponents { + let onchain: String + let bolt11: String + let bolt12: String +} + +func parseUnifiedQR(_ unifiedQR: String) -> UnifiedQRComponents? { + // Split the string by '?' + let components = unifiedQR.components(separatedBy: "?") + + guard components.count > 1 else { return nil } + + // Extract onchain (everything before the first '?') and remove the "BITCOIN:" prefix + var onchain = components[0] + if onchain.lowercased().hasPrefix("bitcoin:") { + onchain = String(onchain.dropFirst(8)) // Remove "BITCOIN:" + } + + // Join the rest of the components back together + let remainingString = components.dropFirst().joined(separator: "?") + + // Split the remaining string by '&' + let params = remainingString.components(separatedBy: "&") + + var bolt11: String? + var bolt12: String? + + for param in params { + if param.starts(with: "lightning=") { + bolt11 = String(param.dropFirst("lightning=".count)) + } else if param.starts(with: "lno=") { + bolt12 = String(param.dropFirst("lno=".count)) + } + } + + guard let bolt11 = bolt11, let bolt12 = bolt12 else { return nil } + + return UnifiedQRComponents(onchain: onchain, bolt11: bolt11, bolt12: bolt12) +} + +#Preview { + BIP21View(viewModel: .init()) +} diff --git a/LDKNodeMonday/View/Home/Receive/ReceiveView.swift b/LDKNodeMonday/View/Home/Receive/ReceiveView.swift index 2d9e3d9..113c7c1 100644 --- a/LDKNodeMonday/View/Home/Receive/ReceiveView.swift +++ b/LDKNodeMonday/View/Home/Receive/ReceiveView.swift @@ -8,7 +8,7 @@ import SwiftUI struct ReceiveView: View { - @State private var selectedOption: ReceiveOption = .bolt11Zero + @State private var selectedOption: ReceiveOption = .bip21 var body: some View { @@ -19,18 +19,10 @@ struct ReceiveView: View { Spacer() switch selectedOption { - case .bolt11Zero: - ZeroInvoiceView(viewModel: .init()) - case .bolt11: - AmountInvoiceView(viewModel: .init()) case .bolt11JIT: JITInvoiceView(viewModel: .init()) - // case .bolt12Zero: - // Bolt12ZeroInvoiceView(viewModel: .init()) - case .bolt12: - Bolt12InvoiceView(viewModel: .init()) - case .bitcoin: - AddressView(viewModel: .init()) + case .bip21: + BIP21View(viewModel: .init()) } } @@ -52,12 +44,10 @@ struct CustomSegmentedPicker: View { }) { VStack { Image(systemName: option.systemImageName) - .font(.system(size: 6)) Text(option.rawValue) - .font(.system(size: 6)) } .padding() - .font(.caption2) + .font(.body) .foregroundColor( self.selectedOption == option ? Color.primary : Color.secondary ) @@ -74,5 +64,5 @@ struct CustomSegmentedPicker: View { } #Preview { - CustomSegmentedPicker(options: ReceiveOption.allCases, selectedOption: .constant(.bolt11Zero)) + CustomSegmentedPicker(options: ReceiveOption.allCases, selectedOption: .constant(.bip21)) } diff --git a/README.md b/README.md index 8fbc46a..9df1f60 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A lightning node on your iPhone with the ability to send & receive on-chain & li ## Swift Packages - LDK Node via [ldk-node](https://github.com/lightningdevkit/ldk-node) + *Note: Sometimes, a [local build of LDK Node](https://github.com/lightningdevkit/ldk-node/blob/main/scripts/uniffi_bindgen_generate_swift.sh) is used instead of the remote Swift package.* - Bitcoin UI Kit via [BitcoinUI](https://github.com/reez/BitcoinUI)