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:
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"