lnlink

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

commit dce24dd0f721a807e39281ce122d0001782141c6
parent 8b307f8a359f39bc58b78d874c8a09e8d3460a0c
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 25 Feb 2022 11:22:39 -0800

detect invoices in clipboard

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

Diffstat:
Mlightninglink/ContentView.swift | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mlightninglink/Invoice.swift | 26+++++++++++++++++++++-----
Mlightninglink/PayView.swift | 85++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
MlightninglinkTests/lightninglinkTests.swift | 16+++++++++++++++-
4 files changed, 154 insertions(+), 42 deletions(-)

diff --git a/lightninglink/ContentView.swift b/lightninglink/ContentView.swift @@ -13,6 +13,17 @@ extension Notification.Name { } } +enum ActiveAlert: Identifiable { + var id: String { + switch self { + case .pay: + return "pay" + } + } + + case pay(InvoiceAmount, String) +} + enum ActiveSheet: Identifiable { var id: String { switch self { @@ -24,7 +35,7 @@ enum ActiveSheet: Identifiable { } case qr - case pay(Int64, String) + case pay(InvoiceAmount, String) } struct Funds { @@ -51,7 +62,9 @@ struct Funds { struct ContentView: View { @State private var info: GetInfo - @State private var activeSheet: ActiveSheet? + @State private var active_sheet: ActiveSheet? + @State private var active_alert: ActiveAlert? + @State private var has_alert: Bool @State private var last_pay: Pay? @State private var funds: Funds @@ -60,6 +73,7 @@ struct ContentView: View { init(info: GetInfo, lnlink: LNLink, funds: ListFunds) { self.info = info self.lnlink = lnlink + self.has_alert = false self.funds = Funds.from_listfunds(fs: funds) } @@ -86,6 +100,17 @@ struct ContentView: View { return "-\(pay.msatoshi) msats (\(pay.msatoshi_sent) msats sent)" } + func check_pay() { + guard let (amt, inv) = get_clipboard_invoice() else { + self.active_sheet = .qr + self.has_alert = false + return + } + + self.has_alert = true + self.active_alert = .pay(amt, inv) + } + var body: some View { VStack { Group { @@ -106,13 +131,29 @@ struct ContentView: View { Spacer() HStack { Spacer() - Button("Pay", - action: { self.activeSheet = .qr }) + Button("Pay", action: check_pay) .font(.title) .padding() } } - .sheet(item: $activeSheet) { sheet in + .alert("Invoice found in clipboard", isPresented: $has_alert, presenting: active_alert, actions: { alert in + Button("Cancel") { + self.has_alert = false + } + Button("Use QR Instead") { + self.active_sheet = .qr + } + Button("Yes") { + self.active_alert = nil + switch alert { + case .pay(let amt, let inv): + self.active_sheet = .pay(amt, inv) + } + } + }, message: { alert in + Text("There is an invoice in your clipboard, should we use that for payment?") + }) + .sheet(item: $active_sheet) { sheet in switch sheet { case .qr: QRScanner() { code in @@ -125,15 +166,16 @@ struct ContentView: View { guard let parsed = m_parsed else { return } - self.activeSheet = .pay(parsed, invstr) + self.active_sheet = .pay(parsed, invstr) } case .pay(let amt, let raw): PayView(invoice_str: raw, amount: amt, lnlink: self.lnlink) } } - .onReceive(NotificationCenter.default.publisher(for: .sentPayment)) { payment in + .onReceive(NotificationCenter.default.publisher(for: .sentPayment)) { payment in last_pay = payment.object as! Pay + self.active_sheet = nil refresh_funds() } } @@ -148,3 +190,16 @@ struct ContentView_Previews: PreviewProvider { } } */ + + +func get_clipboard_invoice() -> (InvoiceAmount, String)? { + guard let inv = UIPasteboard.general.string else { + return nil + } + + guard let amt = parseInvoiceAmount(inv) else { + return nil + } + + return (amt, inv) +} diff --git a/lightninglink/Invoice.swift b/lightninglink/Invoice.swift @@ -8,8 +8,16 @@ import Foundation -public func parseInvoiceAmount(_ inv: String) -> Int64? +public enum InvoiceAmount { + case amount(Int64) + case any +} + +// this is just a quick stopgap before we have full invoice parsing +public func parseInvoiceAmount(_ invoice: String) -> InvoiceAmount? { + let inv = invoice.lowercased() + if !inv.starts(with: "lnbc") { return nil } @@ -17,6 +25,7 @@ public func parseInvoiceAmount(_ inv: String) -> Int64? var ind = 4 var num: String = "" var scale: Character = Character("p") + var sep: Character // number part while true { @@ -28,8 +37,15 @@ public func parseInvoiceAmount(_ inv: String) -> Int64? } else { let start_ind = inv.index(inv.startIndex, offsetBy: 4) let end_ind = inv.index(inv.startIndex, offsetBy: ind - 1) + scale = inv[inv.index(inv.startIndex, offsetBy: ind - 1)] + sep = inv[inv.index(inv.startIndex, offsetBy: ind)] num = String(inv[start_ind..<end_ind]) + + if sep != "1" { + return .any + } + break } } @@ -43,10 +59,10 @@ public func parseInvoiceAmount(_ inv: String) -> Int64? } switch scale { - case "m": return Int64(n * 100000000); - case "u": return Int64(n * 100000); - case "n": return Int64(n * 100); - case "p": return Int64(n * 1); + case "m": return .amount(Int64(n * 100000000)); + case "u": return .amount(Int64(n * 100000)); + case "n": return .amount(Int64(n * 100)); + case "p": return .amount(Int64(n * 1)); default: return nil } } diff --git a/lightninglink/PayView.swift b/lightninglink/PayView.swift @@ -7,24 +7,18 @@ import SwiftUI -func render_amount(_ amount: Int64) -> String { - if amount < 1000 { - return "\(amount) msats" - } - - return "\(amount / 1000) sats" -} struct PayView: View { var invoice_str: String - var amount: Int64 + var amount: InvoiceAmount var lnlink: LNLink + @State var pay_result: Pay? @State var error: String? @Environment(\.presentationMode) var presentationMode - init(invoice_str: String, amount: Int64, lnlink: LNLink) { + init(invoice_str: String, amount: InvoiceAmount, lnlink: LNLink) { self.invoice_str = invoice_str self.amount = amount self.lnlink = lnlink @@ -53,7 +47,7 @@ struct PayView: View { .font(.largeTitle) Spacer() Text("Pay") - Text("\(render_amount(self.amount))?") + Text("\(render_amount(self.amount))") .font(.title) Text("\(self.error ?? "")") Spacer() @@ -66,32 +60,18 @@ struct PayView: View { Spacer() Button("Confirm") { - // do a fresh connection for each payment - let ln = LNSocket() - - guard ln.connect_and_init(node_id: self.lnlink.node_id, host: self.lnlink.host) else { - self.error = "Failed to connect, please try again!" - return - } - - let res = rpc_pay( - ln: ln, - token: lnlink.token, - bolt11: self.invoice_str, - amount_msat: nil) + let res = confirm_payment(bolt11: self.invoice_str, lnlink: self.lnlink) switch res { - case .failure(let req_err): - // handle error - self.error = req_err.description + case .left(let err): + self.error = err - case .success(let pay): - self.error = nil + case .right(let pay): print(pay) self.dismiss() NotificationCenter.default.post(name: .sentPayment, object: pay) } - } + } .font(.title) } } @@ -110,3 +90,50 @@ struct PayView_Previews: PreviewProvider { */ + +public enum Either<L, R> { + case left(L) + case right(R) +} + +func confirm_payment(bolt11: String, lnlink: LNLink) -> Either<String, Pay> { + // do a fresh connection for each payment + let ln = LNSocket() + + guard ln.connect_and_init(node_id: lnlink.node_id, host: lnlink.host) else { + return .left("Failed to connect, please try again!") + } + + let res = rpc_pay( + ln: ln, + token: lnlink.token, + bolt11: bolt11, + amount_msat: nil) + + switch res { + case .failure(let req_err): + // handle error + return .left(req_err.description) + + case .success(let pay): + return .right(pay) + } +} + + +func render_amount(_ amt: InvoiceAmount) -> String { + switch amt { + case .any: + return "Enter amount" + case .amount(let amt): + return "\(render_amount_msats(amt))?" + } +} + +func render_amount_msats(_ amount: Int64) -> String { + if amount < 1000 { + return "\(amount) msats" + } + + return "\(amount / 1000) sats" +} diff --git a/lightninglinkTests/lightninglinkTests.swift b/lightninglinkTests/lightninglinkTests.swift @@ -12,13 +12,27 @@ class lightninglinkTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. + XCTAssert(false) } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } - func testExample() throws { + func testAnyAmountParsesOk() throws { + let inv = "lnbc1p3psxjypp5335lq3qyr4vaexez53yxac5jfatdavwyq5eskkkvnrx6yw9j75vsdqvw3jhxarpdeusxqyjw5qcqpjsp5z65t0t70q4e6yp0t2rcajwslkz6uqmaw2eu5s3fkdfgaf5sdm7vsrzjqv7cv43pj3u8qy38rxwt6mm8qv6u34qg4y4w3zuk93yafhqws0sz2z2z0yqq40qqqqqqqqlgqqqqqeqqjq9qyyssqd432fhw3shf0l3zy0l3ku3xv8re6lhaayeyr8u0ayfcy46348vrzjsa46j7prz70l34wklyennpk7dzsw8eqacde74z92jylvevvdhgpzcxhyn" + + let mamt = parseInvoiceAmount(inv) + + XCTAssert(mamt != nil) + let amt = mamt! + + switch amt { + case .amount(let _): + XCTAssert(false) + case .any: + XCTAssert(true) + } // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. }