lnlink

iOS app for connecting to lightning nodes
git clone git://jb55.com/lnlink
Log | Files | Refs | Submodules | README | LICENSE

commit f9c4c56990d36c583f6be4923405fd22d02504ee
parent da3d69f4f15b2c932760b404bc91eebfac365cb7
Author: William Casarin <jb55@jb55.com>
Date:   Sat, 12 Mar 2022 20:48:23 -0800

initial lnurl and lnaddress support

still needs to:

- check description hashes
- show metadata

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
M.gitignore | 1+
MTODO | 2+-
Mlightninglink-c/lightninglink.h | 1+
Mlightninglink.xcodeproj/project.pbxproj | 18++++++++++++++++--
Mlightninglink.xcodeproj/xcuserdata/jb55.xcuserdatad/xcschemes/xcschememanagement.plist | 5+++++
Mlightninglink/Invoice.swift | 21+++++++++++++--------
Alightninglink/LNUrl.swift | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alightninglink/QR.swift | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlightninglink/Views/ContentView.swift | 75+++++++++++++++++++++++++--------------------------------------------------
Mlightninglink/Views/PayView.swift | 363++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
MlightninglinkTests/lightninglinkTests.swift | 10++++++++++
11 files changed, 608 insertions(+), 145 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -2,3 +2,4 @@ *xcshareddata* TODO.bak assets/logo.png +.DS_Store diff --git a/TODO b/TODO @@ -1,5 +1,4 @@ (B) receive funds / create invoice view -(A) parse lnurl make lnurl request create offer subscriptions @@ -9,3 +8,4 @@ deterministic nodeid for more secure tokens with id restrictions @security bolt12 invoice request (C) fiat-denominated custom amount input buttons (C) refresh contentview when switching to app +(B) pay bolt12 with payer note diff --git a/lightninglink-c/lightninglink.h b/lightninglink-c/lightninglink.h @@ -10,6 +10,7 @@ #include "lnsocket.h" #include "commando.h" +#include "bech32.h" void fd_do_zero(fd_set *); void fd_do_set(int, fd_set *); diff --git a/lightninglink.xcodeproj/project.pbxproj b/lightninglink.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 4C035A0027AEF90000FF92CE /* PayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0359FF27AEF90000FF92CE /* PayView.swift */; }; 4C035A0427AEFD2F00FF92CE /* Invoice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C035A0327AEFD2F00FF92CE /* Invoice.swift */; }; 4C3DC97127D4829A00773021 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3DC97027D4829A00773021 /* SettingsView.swift */; }; + 4C4D7A5627DD84BA005A4F7F /* LNUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4D7A5527DD84BA005A4F7F /* LNUrl.swift */; }; 4C641D192788FF2F002A36C9 /* lightninglinkApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C641D182788FF2F002A36C9 /* lightninglinkApp.swift */; }; 4C641D1B2788FF2F002A36C9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C641D1A2788FF2F002A36C9 /* ContentView.swift */; }; 4C641D1D2788FF30002A36C9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4C641D1C2788FF30002A36C9 /* Assets.xcassets */; }; @@ -18,6 +19,7 @@ 4C641D342788FF31002A36C9 /* lightninglinkUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C641D332788FF31002A36C9 /* lightninglinkUITests.swift */; }; 4C641D362788FF31002A36C9 /* lightninglinkUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C641D352788FF31002A36C9 /* lightninglinkUITestsLaunchTests.swift */; }; 4C641D492789083E002A36C9 /* lightninglink.c in Sources */ = {isa = PBXBuildFile; fileRef = 4C641D482789083E002A36C9 /* lightninglink.c */; }; + 4C72DB2527DD9D9B00834545 /* QR.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C72DB2427DD9D9B00834545 /* QR.swift */; }; 4C873FCF27A62DC1008C972C /* lnsocket.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C873FCE27A62DC1008C972C /* lnsocket.a */; }; 4C873FD127A62DE7008C972C /* libsodium.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C873FD027A62DE7008C972C /* libsodium.a */; }; 4C873FD327A62DF5008C972C /* libsecp256k1.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C873FD227A62DF5008C972C /* libsecp256k1.a */; }; @@ -52,6 +54,7 @@ 4C0359FF27AEF90000FF92CE /* PayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayView.swift; sourceTree = "<group>"; }; 4C035A0327AEFD2F00FF92CE /* Invoice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Invoice.swift; sourceTree = "<group>"; }; 4C3DC97027D4829A00773021 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; + 4C4D7A5527DD84BA005A4F7F /* LNUrl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LNUrl.swift; sourceTree = "<group>"; }; 4C641D152788FF2F002A36C9 /* lightninglink.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = lightninglink.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4C641D182788FF2F002A36C9 /* lightninglinkApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = lightninglinkApp.swift; sourceTree = "<group>"; }; 4C641D1A2788FF2F002A36C9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; @@ -66,6 +69,7 @@ 4C641D472789083E002A36C9 /* lightninglink.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = lightninglink.h; sourceTree = "<group>"; }; 4C641D482789083E002A36C9 /* lightninglink.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = lightninglink.c; sourceTree = "<group>"; }; 4C641D4A279CFA32002A36C9 /* lnsocket */ = {isa = PBXFileReference; lastKnownFileType = folder; path = lnsocket; sourceTree = "<group>"; }; + 4C72DB2427DD9D9B00834545 /* QR.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QR.swift; sourceTree = "<group>"; }; 4C873FCC27A62DA8008C972C /* ios */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ios; path = lnsocket/target/ios; sourceTree = "<group>"; }; 4C873FCE27A62DC1008C972C /* lnsocket.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = lnsocket.a; path = lnsocket/target/ios/lnsocket.a; sourceTree = "<group>"; }; 4C873FD027A62DE7008C972C /* libsodium.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libsodium.a; path = lnsocket/target/ios/libsodium.a; sourceTree = "<group>"; }; @@ -143,6 +147,8 @@ 4C873FD627A6F1F5008C972C /* RPC.swift */, 4C035A0327AEFD2F00FF92CE /* Invoice.swift */, 4C8B289227B44EAF00DF3372 /* LNLink.swift */, + 4C4D7A5527DD84BA005A4F7F /* LNUrl.swift */, + 4C72DB2427DD9D9B00834545 /* QR.swift */, ); path = lightninglink; sourceTree = "<group>"; @@ -353,11 +359,13 @@ 4C641D492789083E002A36C9 /* lightninglink.c in Sources */, 4CCB0E2627C979F30026461C /* CodeScanner.swift in Sources */, 4CCB0E2827C979F30026461C /* ScannerViewController.swift in Sources */, + 4C72DB2527DD9D9B00834545 /* QR.swift in Sources */, 4C035A0427AEFD2F00FF92CE /* Invoice.swift in Sources */, 4C641D192788FF2F002A36C9 /* lightninglinkApp.swift in Sources */, 4CCB0E2B27CA71CA0026461C /* SetupView.swift in Sources */, 4C873FD727A6F1F5008C972C /* RPC.swift in Sources */, 4C3DC97127D4829A00773021 /* SettingsView.swift in Sources */, + 4C4D7A5627DD84BA005A4F7F /* LNUrl.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -553,6 +561,7 @@ "$(inherited)", "$(PROJECT_DIR)/lnsocket/target/ios", ); + MACOSX_DEPLOYMENT_TARGET = 12.2; MARKETING_VERSION = 0.2; PRODUCT_BUNDLE_IDENTIFIER = com.jb55.lightninglink; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -593,6 +602,7 @@ "$(inherited)", "$(PROJECT_DIR)/lnsocket/target/ios", ); + MACOSX_DEPLOYMENT_TARGET = 12.2; MARKETING_VERSION = 0.2; PRODUCT_BUNDLE_IDENTIFIER = com.jb55.lightninglink; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -610,7 +620,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5VWAH67C65; + DEVELOPMENT_TEAM = XK7H4JAB3D; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -618,9 +628,11 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 12.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.jb55.lightninglinkTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -635,7 +647,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5VWAH67C65; + DEVELOPMENT_TEAM = XK7H4JAB3D; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -643,9 +655,11 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 12.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.jb55.lightninglinkTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/lightninglink.xcodeproj/xcuserdata/jb55.xcuserdatad/xcschemes/xcschememanagement.plist b/lightninglink.xcodeproj/xcuserdata/jb55.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,11 @@ <key>orderHint</key> <integer>0</integer> </dict> + <key>lightninglinkTests.xcscheme_^#shared#^_</key> + <dict> + <key>orderHint</key> + <integer>1</integer> + </dict> </dict> <key>SuppressBuildableAutocreation</key> <dict> diff --git a/lightninglink/Invoice.swift b/lightninglink/Invoice.swift @@ -9,13 +9,15 @@ import Foundation public enum DecodeType { - case offer - case invoice(InvoiceAmount) + case offer(String) + case invoice(InvoiceAmount, String) + case lnurl(URL) } public enum InvoiceAmount { case amount(Int64) case min(Int64) + case range(Int64, Int64) case any } @@ -25,7 +27,7 @@ public func parseInvoiceString(_ invoice: String) -> DecodeType? let inv = invoice.lowercased() if inv.starts(with: "lno1") { - return .offer + return .offer(inv) } let is_bolt11 = inv.starts(with: "lnbc") @@ -55,7 +57,7 @@ public func parseInvoiceString(_ invoice: String) -> DecodeType? num = String(inv[start_ind..<end_ind]) if sep != "1" { - return .invoice(.any) + return .invoice(.any, inv) } break @@ -71,14 +73,17 @@ public func parseInvoiceString(_ invoice: String) -> DecodeType? } switch scale { - case "m": return .invoice(.amount(Int64(n * 100000000))); - case "u": return .invoice(.amount(Int64(n * 100000))); - case "n": return .invoice(.amount(Int64(n * 100))); - case "p": return .invoice(.amount(Int64(n * 1))); + case "m": return .invoice(.amount(Int64(n * 100000000)), inv); + case "u": return .invoice(.amount(Int64(n * 100000)), inv); + case "n": return .invoice(.amount(Int64(n * 100)), inv); + case "p": return .invoice(.amount(Int64(n * 1)), inv); default: return nil } } +public func lnurl_decode(_ str: String) { +} + /* public func parseInvoice(_ str: String) -> Invoice? { diff --git a/lightninglink/LNUrl.swift b/lightninglink/LNUrl.swift @@ -0,0 +1,157 @@ +// +// LNUrl.swift +// lightninglink +// +// Created by William Casarin on 2022-03-12. +// + +import Foundation + +public struct LNUrlDecode { + let encoded: Bech32 +} + +public enum Bech32Type { + case bech32 + case bech32m +} + +public struct LNUrlError: Decodable { + let status: String? + let reason: String? + + public init (reason: String) { + self.status = "ERROR" + self.reason = reason + } +} + +public struct LNUrlPay: Decodable { + let callback: URL + let maxSendable: UInt64? + let minSendable: UInt64? + let metadata: String + let tag: String +} + +public struct LNUrlPayInvoice: Decodable { + let pr: String +} + +public enum LNUrl { + case payRequest(LNUrlPay) +} + +public struct Bech32 { + let hrp: String + let dat: Data + let type: Bech32Type +} + +func decode_bech32(_ str: String) -> Bech32? { + let hrp_buf = UnsafeMutableBufferPointer<CChar>.allocate(capacity: str.count) + let bits_buf = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: str.count) + let data_buf = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: str.count) + var bitslen: Int = 0 + var datalen: Int = 0 + var m_hrp_str: String? = nil + var m_data: Data? = nil + var typ: bech32_encoding = BECH32_ENCODING_NONE + + hrp_buf.withMemoryRebound(to: UInt8.self) { hrp_ptr in + str.withCString { input in + typ = bech32_decode(hrp_ptr.baseAddress, bits_buf.baseAddress, &bitslen, input, str.count) + bech32_convert_bits(data_buf.baseAddress, &datalen, 8, bits_buf.baseAddress, bitslen, 5, 0) + m_data = Data(buffer: data_buf)[...(datalen-1)] + m_hrp_str = String(cString: hrp_ptr.baseAddress!) + } + } + + guard let hrp = m_hrp_str else { + return nil + } + + guard let data = m_data else { + return nil + } + + var m_type: Bech32Type? = nil + if typ == BECH32_ENCODING_BECH32 { + m_type = .bech32 + } else if typ == BECH32_ENCODING_BECH32M { + m_type = .bech32m + } + + guard let type = m_type else { + return nil + } + + return Bech32(hrp: hrp, dat: data, type: type) +} + +func decode_lnurl(_ data: Data) -> LNUrl? { + let lnurlp: LNUrlPay? = decode_data(data) + return lnurlp.map { .payRequest($0) } +} + +func decode_lnurl_pay(_ data: Data) -> LNUrlPayInvoice? { + return decode_data(data) +} + +func decode_data<T: Decodable>(_ data: Data) -> T? { + let decoder = JSONDecoder() + do { + return try decoder.decode(T.self, from: data) + } catch { + print("decode_data failed for \(T.self): \(error)") + } + + return nil +} + +func lnurl_fetchinvoice(lnurlp: LNUrlPay, amount: Int64, completion: @escaping (Either<LNUrlError, LNUrlPayInvoice>) -> ()) { + guard let url = URL(string: lnurlp.callback.absoluteString + "?amount=\(amount)") else { + completion(.left(LNUrlError(reason: "Invalid lnurl callback"))) + return + } + handle_lnurl_request(url, completion: completion) +} + +func handle_lnurl_request<T: Decodable>(_ url: URL, completion: @escaping (Either<LNUrlError, T>) -> ()) { + let task = URLSession.shared.dataTask(with: url) { (mdata, response, error) in + guard let data = mdata else { + completion(.left(LNUrlError(reason: "Request failed: \(error.debugDescription)"))) + return + } + + if let merr: LNUrlError = decode_data(data) { + if merr.status == "ERROR" { + completion(.left(merr)) + } + } + + guard let t: T = decode_data(data) else { + completion(.left(LNUrlError(reason: "Failed when decoding \(T.self)"))) + return + } + + completion(.right(t)) + } + + task.resume() +} + +func handle_lnurl(_ url: URL, completion: @escaping (LNUrl?) -> ()) { + let task = URLSession.shared.dataTask(with: url) { (mdata, response, error) in + guard let data = mdata else { + let lnurl: LNUrl? = nil + completion(lnurl) + return + } + + completion(decode_lnurl(data)) + } + + task.resume() +} + diff --git a/lightninglink/QR.swift b/lightninglink/QR.swift @@ -0,0 +1,100 @@ +// +// QR.swift +// lightninglink +// +// Created by William Casarin on 2022-03-12. +// + +import Foundation + + +public enum LNScanResult { + case lightning(DecodeType) + case lnlink(LNLink) + case lnurl(URL) +} + + +func handle_qrcode(_ qr: String) -> Either<String, LNScanResult> { + let invstr = qr.trimmingCharacters(in: .whitespacesAndNewlines) + var lowered = invstr.lowercased() + + if lowered.starts(with: "lightning:") { + let index = invstr.index(invstr.startIndex, offsetBy: 10) + lowered = String(lowered[index...]) + } + + if lowered.starts(with: "lnlink:") { + switch parse_auth_qr(invstr) { + case .left(let err): + return .left(err) + case .right(let lnlink): + return .right(.lnlink(lnlink)) + } + } + + if lowered.starts(with: "lnurl") { + guard let bech32 = decode_bech32(lowered) else { + return .left("Invalid lnurl bech32 encoding") + } + + let url = String(decoding: bech32.dat, as: UTF8.self) + if let email = parse_email(str: url) { + guard let lnurl = make_lnaddress(email: email) else { + return .left("Couldn't make lnaddress from email") + } + return .right(.lnurl(lnurl)) + } + + guard let lnurl = URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)) else { + return .left("Couldn't make lnurl from qr") + } + + return .right(.lnurl(lnurl)) + } + + if let email = parse_email(str: lowered) { + guard let lnurl = make_lnaddress(email: email) else { + return .left("Couldn't make lnaddress from email") + } + return .right(.lnurl(lnurl)) + } + + guard let parsed = parseInvoiceString(invstr) else { + return .left("Failed to parse invoice") + } + + return .right(.lightning(parsed)) +} + + +struct Email { + let name: String + let host: String +} + +func parse_email(str: String) -> Email? { + let parts = str.split(separator: "@") + + guard parts.count == 2 else { + return nil + } + + if parts[0].contains(":") { + return nil + } + + if !parts[1].contains(".") { + return nil + } + + let name = String(parts[0]) + let host = String(parts[1]) + + return Email(name: name, host: host) +} + + +func make_lnaddress(email: Email) -> URL? { + return URL(string: "https://\(email.host)/.well-known/lnurlp/\(email.name)") +} diff --git a/lightninglink/Views/ContentView.swift b/lightninglink/Views/ContentView.swift @@ -30,7 +30,7 @@ enum ActiveAlert: Identifiable { } } - case pay(DecodeType, String) + case pay(DecodeType) } public enum ActiveSheet: Identifiable { @@ -44,7 +44,7 @@ public enum ActiveSheet: Identifiable { } case qr - case pay(DecodeType, String) + case pay(DecodeType) } struct Funds { @@ -77,18 +77,18 @@ let SCAN_TYPES: [AVMetadataObject.ObjectType] = [.qr] struct ContentView: View { @State private var active_sheet: ActiveSheet? @State private var active_alert: ActiveAlert? - @State private var has_alert: Bool + @State private var has_alert: Bool = false @State private var last_pay: Pay? - @State private var dashboard: Dashboard - @State private var funds: Funds + @State private var dashboard: Dashboard = .empty + @State private var funds: Funds = .empty @State private var is_reset: Bool = false @State private var scan_invoice: String? private let lnlink: LNLink init(dashboard: Dashboard, lnlink: LNLink, scan_invoice: String?) { - self.dashboard = dashboard self.lnlink = lnlink + self.dashboard = dashboard self.has_alert = false self.scan_invoice = scan_invoice self.funds = Funds.from_listfunds(fs: dashboard.funds) @@ -113,14 +113,14 @@ struct ContentView: View { } func check_pay() { - guard let (amt, inv) = get_clipboard_invoice() else { + guard let decode = get_clipboard_invoice() else { self.active_sheet = .qr self.has_alert = false return } self.active_sheet = nil - self.active_alert = .pay(amt, inv) + self.active_alert = .pay(decode) self.has_alert = true } @@ -150,7 +150,7 @@ struct ContentView: View { Spacer() } - } + } .padding() Spacer() @@ -167,6 +167,7 @@ struct ContentView: View { } Spacer() + HStack { Spacer() Button("Pay", action: check_pay) @@ -184,8 +185,8 @@ struct ContentView: View { self.has_alert = false self.active_alert = nil switch alert { - case .pay(let amt, let inv): - self.active_sheet = .pay(amt, inv) + case .pay(let decode): + self.active_sheet = .pay(decode) } } } @@ -204,8 +205,8 @@ struct ContentView: View { } - case .pay(let decode_type, let raw): - PayView(invoice_str: raw, decode_type: decode_type, lnlink: self.lnlink) + case .pay(let decode): + PayView(decode: decode, lnlink: self.lnlink) } } .onReceive(NotificationCenter.default.publisher(for: .sentPayment)) { payment in @@ -217,12 +218,14 @@ struct ContentView: View { self.is_reset = true } .onReceive(NotificationCenter.default.publisher(for: .donate)) { _ in - self.active_sheet = .pay(.offer, "lno1pfsycnjvd9hxkgrfwvsxvun9v5s8xmmxw3mkzun9yysyyateypkk2grpyrcflrd6ypek7gzfyp3kzm3qvdhkuarfde6k2grd0ysxzmrrda5x7mrfwdkj6en4v4kx2epqvdhkg6twvusxzerkv4h8gatjv4eju9q2d3hxc6twdvhxzursrcs08sggen2ndwzjdpqlpfw9sgfth8n9sjs7kjfssrnurnp5lqk66u0sgr32zxwrh0kmxnvmt5hyn0my534209573mp9ck5ekvywvugm5x3kq8ztex8yumafeft0arh6dke04jqgckmdzekqxegxzhecl23lurrj") + let offer: DecodeType = .offer("lno1pfsycnjvd9hxkgrfwvsxvun9v5s8xmmxw3mkzun9yysyyateypkk2grpyrcflrd6ypek7gzfyp3kzm3qvdhkuarfde6k2grd0ysxzmrrda5x7mrfwdkj6en4v4kx2epqvdhkg6twvusxzerkv4h8gatjv4eju9q2d3hxc6twdvhxzursrcs08sggen2ndwzjdpqlpfw9sgfth8n9sjs7kjfssrnurnp5lqk66u0sgr32zxwrh0kmxnvmt5hyn0my534209573mp9ck5ekvywvugm5x3kq8ztex8yumafeft0arh6dke04jqgckmdzekqxegxzhecl23lurrj") + self.active_sheet = .pay(offer) } .onOpenURL() { url in handle_scan(url.absoluteString) } .onAppear() { + refresh_funds() if scan_invoice != nil { handle_scan(scan_invoice!) scan_invoice = nil @@ -234,7 +237,7 @@ struct ContentView: View { } } - + func handle_scan(_ str: String) { switch handle_qrcode(str) { case .left(let err): @@ -242,11 +245,14 @@ struct ContentView: View { break case .right(let scanres): switch scanres { - case .lightning(let decode, let invstr): - self.active_sheet = .pay(decode, invstr) + case .lightning(let decode): + self.active_sheet = .pay(decode) case .lnlink: print("got a lnlink, not an invoice") // TODO: report that this is an lnlink, not an invoice + case .lnurl(let lnurl): + let decode: DecodeType = .lnurl(lnurl) + self.active_sheet = .pay(decode) } } } @@ -270,39 +276,8 @@ struct ContentView_Previews: PreviewProvider { } */ -public enum LNScanResult { - case lightning(DecodeType, String) - case lnlink(LNLink) -} - - -func handle_qrcode(_ qr: String) -> Either<String, LNScanResult> { - var invstr = qr.trimmingCharacters(in: .whitespacesAndNewlines) - let lowered = invstr.lowercased() - - if lowered.starts(with: "lnlink:") { - switch parse_auth_qr(invstr) { - case .left(let err): - return .left(err) - case .right(let lnlink): - return .right(.lnlink(lnlink)) - } - } - - if lowered.starts(with: "lightning:") { - let index = invstr.index(invstr.startIndex, offsetBy: 10) - invstr = String(lowered[index...]) - } - - guard let parsed = parseInvoiceString(invstr) else { - return .left("Failed to parse invoice") - } - - return .right(.lightning(parsed, invstr)) -} - -func get_clipboard_invoice() -> (DecodeType, String)? { +func get_clipboard_invoice() -> DecodeType? { guard let inv = UIPasteboard.general.string else { return nil } @@ -311,5 +286,5 @@ func get_clipboard_invoice() -> (DecodeType, String)? { return nil } - return (amt, inv) + return amt } diff --git a/lightninglink/Views/PayView.swift b/lightninglink/Views/PayView.swift @@ -8,14 +8,52 @@ import SwiftUI import Combine -public struct ReadyInvoice { - let invoice: String +public struct Offer { + let offer: String + let amount: InvoiceAmount + let decoded: Decode +} + +public struct Invoice { + let invstr: String let amount: InvoiceAmount } +public enum ReadyInvoice { + case requested(RequestInvoice) + case direct(Invoice) + + func amount() -> InvoiceAmount { + switch self { + case .direct(let inv): + return inv.amount + case .requested(let invreq): + return invreq.amount() + } + } +} + +public enum RequestInvoice { + case lnurl(LNUrlPay) + case offer(Offer) + + func amount() -> InvoiceAmount { + switch self { + case .lnurl(let lnurlp): + return lnurl_pay_invoice_amount(lnurlp) + case .offer(let offer): + return offer.amount + } + } +} + public struct PayAmount { let tip: Int64? let amount: Int64 + + func total() -> Int64 { + return amount + (tip ?? 0) + } } public struct FetchInvoiceReq { @@ -34,16 +72,15 @@ public enum TipSelection { public enum PayState { case initial - case decoding(LNSocket?, String) + case decoding(LNSocket?, DecodeType) case decoded(DecodeType) - case ready(ReadyInvoice) - case offer_input(ReadyInvoice, Decode) + case ready(Invoice) + case invoice_request(RequestInvoice) } struct PayView: View { let init_decode_type: DecodeType let lnlink: LNLink - let init_invoice_str: String let expiry_timer = Timer.publish(every: 1, on: .main, in: .default).autoconnect() @@ -59,9 +96,8 @@ struct PayView: View { @Environment(\.presentationMode) var presentationMode - init(invoice_str: String, decode_type: DecodeType, lnlink: LNLink) { - self.init_invoice_str = invoice_str - self.init_decode_type = decode_type + init(decode: DecodeType, lnlink: LNLink) { + self.init_decode_type = decode self.lnlink = lnlink } @@ -83,7 +119,7 @@ struct PayView: View { } var body: some View { - main_view() + MainView() } func progress_color() -> Color { @@ -100,8 +136,8 @@ struct PayView: View { return Color.green } - func main_view() -> some View { - return VStack() { + func MainView() -> some View { + return VStack { Text("Confirm Payment") .font(.largeTitle) .padding() @@ -131,8 +167,9 @@ struct PayView: View { // Middle area let ready_invoice = is_ready(state) if ready_invoice != nil { - amount_view_inv(ready_invoice!.amount) + amount_view_inv(ready_invoice!.amount()) } + Text("\(self.error ?? "")") .foregroundColor(Color.red) @@ -153,7 +190,7 @@ struct PayView: View { .font(.title) Spacer() - if should_show_confirmation(ready_invoice?.amount) { + if should_show_confirmation(ready_invoice?.amount()) { Button("Confirm") { handle_confirm(ln: nil) } @@ -274,27 +311,37 @@ struct PayView: View { tip_view() } - case .any: + case .range(let min_amt, let max_amt): if self.paying { let amt = self.custom_amount_msats Text("\(render_amount_msats(amt))") .font(.title) } else { - Form { - Section { - HStack(alignment: .lastTextBaseline) { - TextField("10,000", text: $custom_amount_input) - .font(.title) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .onReceive(Just(self.custom_amount_input)) { - handle_custom_receive($0) - } - Text("sats") + InputView { + handle_custom_receive($0) + if self.custom_amount_input != "" { + if self.custom_amount_msats < min_amt { + self.error = "Amount not allowed, must be higher than \(render_amount_msats(min_amt))" + } else if self.custom_amount_msats > max_amt { + self.error = "Amount not allowed, must be lower than \(render_amount_msats(max_amt))" + } else { + if self.error != nil && self.error!.starts(with: "Amount not allowed") { + self.error = nil + } } } } - .frame(height: 100) + } + + case .any: + if self.paying { + let amt = self.custom_amount_msats + Text("\(render_amount_msats(amt))") + .font(.title) + } else { + InputView { + handle_custom_receive($0) + } } case .amount(let amt): @@ -304,12 +351,31 @@ struct PayView: View { } } + func InputView(onReceive: @escaping (String) -> ()) -> some View { + // TODO remove from class, pass input binding? + return Form { + Section { + HStack(alignment: .lastTextBaseline) { + TextField("10,000", text: $custom_amount_input) + .font(.title) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .onReceive(Just(self.custom_amount_input)) { + onReceive($0) + } + Text("sats") + } + } + } + .frame(height: 100) + } + func confirm_pay(ln: LNSocket?, inv: String, pay_amt: PayAmount?) { let res = confirm_payment(ln: ln, lnlink: self.lnlink, bolt11: inv, pay_amt: pay_amt) switch res { case .left(let err): self.paying = false - self.error = "Error: \(err)" + self.error = err case .right(let pay): print(pay) @@ -321,59 +387,102 @@ struct PayView: View { } func get_pay_amount(_ amt: InvoiceAmount) -> PayAmount? { - switch amt { - case .min(let min_amt): - let tip = self.custom_amount_msats - return PayAmount(tip: tip, amount: min_amt) - case .any: - return PayAmount(tip: 0, amount: custom_amount_msats) - case .amount: - return nil - } + return get_pay_amount_from_input(amt, input_amount: self.custom_amount_msats) } - func handle_confirm(ln mln: LNSocket?) { - switch self.state { - case .offer_input(let inv, let decoded): - guard let pay_amt = get_pay_amount(inv.amount) else { - self.error = "Expected payment amount for bolt12" - return - } - let req = fetchinvoice_req_from_offer( - offer: decoded, - offer_str: inv.invoice, - pay_amt: pay_amt) - switch req { + func handle_confirm_lnurl(ln mln: LNSocket?, lnurlp: LNUrlPay) { + let lnurl_amt = lnurl_pay_invoice_amount(lnurlp) + guard let pay_amt = get_pay_amount(lnurl_amt) else { + self.error = "Invalid payment amount for lnurl" + return + } + self.paying = true + + lnurl_fetchinvoice(lnurlp: lnurlp, amount: pay_amt.amount) { + switch $0 { case .left(let err): - self.error = err - case .right(let req): - let token = self.lnlink.token - self.paying = true - DispatchQueue.global(qos: .background).async { - let ln = mln ?? LNSocket() - if mln == nil { - guard ln.connect_and_init(node_id: self.lnlink.node_id, host: self.lnlink.host) else { - self.paying = false - self.error = "Connection failed when fetching invoice" - return - } + self.error = err.reason + self.paying = false + case .right(let lnurl_invoice): + guard let ret_inv = parseInvoiceString(lnurl_invoice.pr) else { + self.error = "Invalid lnurl invoice" + self.paying = false + return + } + switch ret_inv { + case .invoice(let amt, let invstr): + if !pay_amount_matches(pay_amt: pay_amt, invoice_amount: amt) { + self.error = "Returned lnurl invoice doesn't match expected amount" + self.paying = false + return } - switch rpc_fetchinvoice(ln: ln, token: token, req: req) { - case .failure(let err): + + DispatchQueue.global(qos: .background).async { + confirm_pay(ln: mln, inv: invstr, pay_amt: nil) + } + case .offer: + self.error = "Got an offer from a lnurl pay request? What?" + self.paying = false + return + case .lnurl: + self.error = "Got another lnurl from an lnurl pay request? What?" + self.paying = false + return + } + } + } + } + + func handle_confirm_offer(ln mln: LNSocket?, offer: Offer) { + guard let pay_amt = get_pay_amount(offer.amount) else { + self.error = "Expected payment amount for bolt12" + return + } + let req = fetchinvoice_req_from_offer( + offer: offer.decoded, + offer_str: offer.offer, + pay_amt: pay_amt) + switch req { + case .left(let err): + self.error = err + case .right(let req): + let token = self.lnlink.token + self.paying = true + DispatchQueue.global(qos: .background).async { + let ln = mln ?? LNSocket() + if mln == nil { + guard ln.connect_and_init(node_id: self.lnlink.node_id, host: self.lnlink.host) else { self.paying = false - self.error = err.description - case .success(let fetch_invoice): - confirm_pay(ln: ln, inv: fetch_invoice.invoice, pay_amt: nil) + self.error = "Connection failed when fetching invoice" + return } } + switch rpc_fetchinvoice(ln: ln, token: token, req: req) { + case .failure(let err): + self.paying = false + self.error = err.description + case .success(let fetch_invoice): + confirm_pay(ln: ln, inv: fetch_invoice.invoice, pay_amt: nil) + } } + } + } + func handle_confirm(ln mln: LNSocket?) { + switch self.state { + case .invoice_request(let reqinv): + switch reqinv { + case .offer(let offer): + return handle_confirm_offer(ln: mln, offer: offer) + case .lnurl(let lnurlp): + return handle_confirm_lnurl(ln: mln, lnurlp: lnurlp) + } - case .ready(let ready_invoice): - let pay_amt = get_pay_amount(ready_invoice.amount) + case .ready(let invoice): + let pay_amt = get_pay_amount(invoice.amount) self.paying = true DispatchQueue.global(qos: .background).async { - confirm_pay(ln: mln, inv: ready_invoice.invoice, pay_amt: pay_amt) + confirm_pay(ln: mln, inv: invoice.invstr, pay_amt: pay_amt) } case .initial: fallthrough @@ -396,13 +505,13 @@ struct PayView: View { switch self.state { case .ready: break - case .offer_input: + case .invoice_request: break case .initial: - switch_state(.decoding(nil, self.init_invoice_str)) - case .decoding(let ln, let inv): + switch_state(.decoding(nil, self.init_decode_type)) + case .decoding(let ln, let decode): DispatchQueue.global(qos: .background).async { - self.handle_decode(ln, inv: inv) + self.handle_decode(ln, decode: decode) } case .decoded: break @@ -420,7 +529,11 @@ struct PayView: View { } } - func handle_decode(_ oldln: LNSocket?, inv: String) { + func handle_lnurl_payview(ln: LNSocket?, lnurlp: LNUrlPay) { + switch_state(.invoice_request(.lnurl(lnurlp))) + } + + func handle_decode(_ oldln: LNSocket?, decode: DecodeType) { let ln = oldln ?? LNSocket() if oldln == nil { guard ln.connect_and_init(node_id: self.lnlink.node_id, host: self.lnlink.host) else { @@ -428,6 +541,25 @@ struct PayView: View { } } + var inv = "" + switch decode { + case .offer(let s): + inv = s + case .invoice(_, let s): + inv = s + case .lnurl(let lnurl): + handle_lnurl(lnurl) { lnurl in + switch lnurl { + case .payRequest(let pay): + self.handle_lnurl_payview(ln: ln, lnurlp: pay) + return + case .none: + self.error = "Invalid lnurl" + } + } + return + } + switch rpc_decode(ln: ln, token: self.lnlink.token, inv: inv) { case .failure(let fail): self.error = fail.description @@ -446,7 +578,7 @@ struct PayView: View { amount = .amount(amt) } - self.state = .ready(ReadyInvoice(invoice: inv, amount: amount)) + self.state = .ready(Invoice(invstr: inv, amount: amount)) self.invoice = decoded update_expiry_percent() } else { @@ -518,6 +650,15 @@ func parse_msat(_ s: String) -> Int64? { public enum Either<L, R> { case left(L) case right(R) + + func mapError<L2>(mapper: (L) -> L2) -> Either<L2, R> { + switch self { + case .left(let l1): + return .left(mapper(l1)) + case .right(let r): + return .right(r) + } + } } func confirm_payment(ln mln: LNSocket?, lnlink: LNLink, bolt11: String, pay_amt: PayAmount?) -> Either<String, Pay> { @@ -553,10 +694,10 @@ func confirm_payment(ln mln: LNSocket?, lnlink: LNLink, bolt11: String, pay_amt: func is_ready(_ state: PayState) -> ReadyInvoice? { switch state { - case .ready(let ready_invoice): - return ready_invoice - case .offer_input(let ready_invoice, _): - return ready_invoice + case .ready(let invoice): + return .direct(invoice) + case .invoice_request(let invreq): + return .requested(invreq) case .initial: fallthrough case .decoding: fallthrough case .decoded: @@ -569,6 +710,8 @@ func render_amount(_ amt: InvoiceAmount) -> String { switch amt { case .any: return "Enter amount" + case .range(let min_amt, let max_amt): + return "\(render_amount_msats(min_amt)) to \(render_amount_msats(max_amt))" case .amount(let amt): return "\(render_amount_msats(amt))?" case .min(let min): @@ -604,11 +747,11 @@ func handle_bolt12_offer(ln: LNSocket, decoded: Decode, inv: String) -> Either<S guard let min_amt = parse_msat(decoded.amount_msat!) else { return .left("Error parsing amount_msat: '\(decoded.amount_msat!)'") } - let ready = ReadyInvoice(invoice: inv, amount: .min(min_amt)) - return .right(.offer_input(ready, decoded)) + let offer = Offer(offer: inv, amount: .min(min_amt), decoded: decoded) + return .right(.invoice_request(.offer(offer))) } else { - let ready = ReadyInvoice(invoice: inv, amount: .any) - return .right(.offer_input(ready, decoded)) + let offer = Offer(offer: inv, amount: .any, decoded: decoded) + return .right(.invoice_request(.offer(offer))) } } @@ -616,7 +759,7 @@ func handle_bolt12_offer(ln: LNSocket, decoded: Decode, inv: String) -> Either<S func should_show_confirm(_ state: PayState) -> Bool { switch state { case .ready: fallthrough - case .offer_input: + case .invoice_request: return true case .decoded: fallthrough @@ -644,3 +787,55 @@ func is_any_amount(_ amt: InvoiceAmount) -> Bool { return false } } + +func lnurl_pay_invoice_amount(_ lnurlp: LNUrlPay) -> InvoiceAmount { + let min_amt = Int64(lnurlp.minSendable ?? 1) + let max_amt = Int64(lnurlp.maxSendable ?? 2100000000000000000) + return .range(min_amt, max_amt) +} + +func get_pay_amount_from_input(_ amt: InvoiceAmount, input_amount: Int64) -> PayAmount? { + switch amt { + case .min(let min_amt): + return PayAmount(tip: input_amount, amount: min_amt) + case .range: + return PayAmount(tip: 0, amount: input_amount) + case .any: + return PayAmount(tip: 0, amount: input_amount) + case .amount: + return nil + } +} + + +func pay_amount_matches(pay_amt: PayAmount, invoice_amount: InvoiceAmount) -> Bool +{ + switch invoice_amount { + case .amount(let amt): + if pay_amt.total() == amt { + return true + } + case .range(let min_amt, let max_amt): + if pay_amt.total() < min_amt { + return false + } + + if pay_amt.total() > max_amt { + return false + } + + return true + case .min(let min): + if pay_amt.total() < min { + return false + } + + return true + + case .any: + + return true + } + + return false +} diff --git a/lightninglinkTests/lightninglinkTests.swift b/lightninglinkTests/lightninglinkTests.swift @@ -18,6 +18,16 @@ class lightninglinkTests: XCTestCase { override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } + + func test_parse_lnurl() throws { + let lnurl = "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS" + + let m_decode = decode_bech32(lnurl) + XCTAssert(m_decode != nil) + let decode = m_decode! + + XCTAssert(decode.hrp == "LNURL") + } func testAnyAmountParsesOk() throws { let inv = "lnbc1p3psxjypp5335lq3qyr4vaexez53yxac5jfatdavwyq5eskkkvnrx6yw9j75vsdqvw3jhxarpdeusxqyjw5qcqpjsp5z65t0t70q4e6yp0t2rcajwslkz6uqmaw2eu5s3fkdfgaf5sdm7vsrzjqv7cv43pj3u8qy38rxwt6mm8qv6u34qg4y4w3zuk93yafhqws0sz2z2z0yqq40qqqqqqqqlgqqqqqeqqjq9qyyssqd432fhw3shf0l3zy0l3ku3xv8re6lhaayeyr8u0ayfcy46348vrzjsa46j7prz70l34wklyennpk7dzsw8eqacde74z92jylvevvdhgpzcxhyn"