lnlink

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

commit 735990f1e3ab566962853fff0591f08eb65b7f14
parent 2507585e1092e15eaa6740607054d4a02979ffd8
Author: William Casarin <jb55@jb55.com>
Date:   Wed,  2 Mar 2022 22:35:37 -0800

initial bolt12 support

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

Diffstat:
Mlightninglink/Invoice.swift | 21+++++++++++++++------
Mlightninglink/RPC.swift | 43+++++++++++++++++++++++++++++++++++++++++++
Mlightninglink/Views/ContentView.swift | 14+++++++-------
Mlightninglink/Views/PayView.swift | 297+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mlightninglink/Views/SetupView.swift | 1+
MlightninglinkTests/lightninglinkTests.swift | 2+-
6 files changed, 335 insertions(+), 43 deletions(-)

diff --git a/lightninglink/Invoice.swift b/lightninglink/Invoice.swift @@ -8,16 +8,25 @@ import Foundation +public enum DecodeType { + case offer + case invoice(InvoiceAmount) +} + 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? +public func parseInvoiceString(_ invoice: String) -> DecodeType? { let inv = invoice.lowercased() + if inv.starts(with: "lno1") { + return .offer + } + if !inv.starts(with: "lnbc") { return nil } @@ -43,7 +52,7 @@ public func parseInvoiceAmount(_ invoice: String) -> InvoiceAmount? num = String(inv[start_ind..<end_ind]) if sep != "1" { - return .any + return .invoice(.any) } break @@ -59,10 +68,10 @@ public func parseInvoiceAmount(_ invoice: String) -> InvoiceAmount? } switch scale { - 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)); + 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))); default: return nil } } diff --git a/lightninglink/RPC.swift b/lightninglink/RPC.swift @@ -46,6 +46,25 @@ public struct Channel: Decodable { public var channel_total_sat: Int64 } +public struct Decode: Decodable { + public var type: String + public var currency: String? + public var valid: Bool + public var created_at: Int64? + public var expiry: Int64? + public var payee: String? + public var msatoshi: Int64? + public var quantity_min: Int? + public var description: String? + public var node_id: String? + public var amount_msat: String? + public var vendor: String? +} + +public struct FetchInvoice: Decodable { + public var invoice: String +} + public struct ListFunds: Decodable { public var outputs: [Output]? public var channels: [Channel]? @@ -287,6 +306,30 @@ public func rpc_listfunds(ln: LNSocket, token: String) -> RequestRes<ListFunds> return performRpc(ln: ln, operation: "listfunds", authToken: token, timeout_ms: default_timeout, params: params) } +public func rpc_decode(ln: LNSocket, token: String, inv: String) -> RequestRes<Decode> +{ + let params = [ inv ]; + return performRpc(ln: ln, operation: "decode", authToken: token, timeout_ms: 1000, params: params) +} + +public func rpc_fetchinvoice(ln: LNSocket, token: String, req: FetchInvoiceReq) -> RequestRes<FetchInvoice> +{ + var params: [String: String] = [ "offer": req.offer ] + + switch req.amount { + case .amount(let amt): + params["msatoshi"] = "\(amt)msat" + case .any: + break + } + + if req.quantity != nil { + params["quantity"] = "\(req.quantity!)" + } + + return performRpc(ln: ln, operation: "fetchinvoice", authToken: token, timeout_ms: 10000, params: params) +} + public func maybe_decode_error_json<T: Decodable>(_ dat: Data) -> Either<String, T>? { do { return .right(try JSONDecoder().decode(ErrorWrapper<T>.self, from: dat).error) diff --git a/lightninglink/Views/ContentView.swift b/lightninglink/Views/ContentView.swift @@ -22,7 +22,7 @@ enum ActiveAlert: Identifiable { } } - case pay(InvoiceAmount, String) + case pay(DecodeType, String) } public enum ActiveSheet: Identifiable { @@ -36,7 +36,7 @@ public enum ActiveSheet: Identifiable { } case qr - case pay(InvoiceAmount, String) + case pay(DecodeType, String) } struct Funds { @@ -194,7 +194,7 @@ struct ContentView: View { let index = code.index(code.startIndex, offsetBy: 10) invstr = String(code[index...]) } - let m_parsed = parseInvoiceAmount(invstr) + let m_parsed = parseInvoiceString(invstr) guard let parsed = m_parsed else { return } @@ -207,8 +207,8 @@ struct ContentView: View { } - case .pay(let amt, let raw): - PayView(invoice_str: raw, amount: amt, lnlink: self.lnlink) + case .pay(let decode_type, let raw): + PayView(invoice_str: raw, decode_type: decode_type, lnlink: self.lnlink) } } .onReceive(NotificationCenter.default.publisher(for: .sentPayment)) { payment in @@ -239,12 +239,12 @@ struct ContentView_Previews: PreviewProvider { */ -func get_clipboard_invoice() -> (InvoiceAmount, String)? { +func get_clipboard_invoice() -> (DecodeType, String)? { guard let inv = UIPasteboard.general.string else { return nil } - guard let amt = parseInvoiceAmount(inv) else { + guard let amt = parseInvoiceString(inv) else { return nil } diff --git a/lightninglink/Views/PayView.swift b/lightninglink/Views/PayView.swift @@ -6,21 +6,40 @@ // import SwiftUI +import Combine +public struct FetchInvoiceReq { + let offer: String + let amount: InvoiceAmount + let quantity: Int? +} + +public enum PayState { + case initial + case decoding(LNSocket?, String) + case decoded(DecodeType) + case fetch_invoice(LNSocket, FetchInvoiceReq) + case ready(InvoiceAmount, String) +} struct PayView: View { - var invoice_str: String - var amount: InvoiceAmount - var lnlink: LNLink + 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() @State var pay_result: Pay? + @State var state: PayState = .initial + @State var invoice: Decode? @State var error: String? + @State var expiry_percent: Double? @Environment(\.presentationMode) var presentationMode - init(invoice_str: String, amount: InvoiceAmount, lnlink: LNLink) { - self.invoice_str = invoice_str - self.amount = amount + init(invoice_str: String, decode_type: DecodeType, lnlink: LNLink) { + self.init_invoice_str = invoice_str + self.init_decode_type = decode_type self.lnlink = lnlink } @@ -42,13 +61,60 @@ struct PayView: View { } var body: some View { + main_view() + } + + func progress_color() -> Color { + guard let perc = expiry_percent else { + return Color.green + } + + if perc < 0.25 { + return Color.red + } else if perc < 0.5 { + return Color.yellow + } + + return Color.green + } + + func main_view() -> some View { return VStack() { - Text("Confirm payment") - .font(.largeTitle) + if self.invoice != nil { + Text("Confirm Payment") + .font(.largeTitle) + .padding() + } else { + Text("Fetching invoice") + .font(.largeTitle) + .padding() + } + + if self.expiry_percent != nil { + ProgressView(value: self.expiry_percent! * 100, total: 100) + .accentColor(progress_color()) + } + + if self.invoice != nil { + let invoice = self.invoice! + if invoice.description != nil { + Text(invoice.description!) + .padding() + } + + if invoice.vendor != nil { + Text(invoice.vendor!) + .font(.callout) + .foregroundColor(.gray) + } + } Spacer() - Text("Pay") - Text("\(render_amount(self.amount))") - .font(.title) + + let inv_tup = is_ready(state) + if inv_tup != nil { + amount_view_inv(inv_tup!.0) + } + Text("\(self.error ?? "")") .foregroundColor(Color.red) Spacer() @@ -56,41 +122,175 @@ struct PayView: View { Button("Cancel") { self.dismiss() } + .foregroundColor(Color.red) .font(.title) Spacer() - Button("Confirm") { - let res = confirm_payment(bolt11: self.invoice_str, lnlink: self.lnlink) + if inv_tup != nil { + Button("Confirm") { + let res = confirm_payment(bolt11: inv_tup!.1, lnlink: self.lnlink) - switch res { - case .left(let err): - self.error = "Error: \(err)" + switch res { + case .left(let err): + self.error = "Error: \(err)" - case .right(let pay): - print(pay) - self.dismiss() - NotificationCenter.default.post(name: .sentPayment, object: pay) + case .right(let pay): + print(pay) + self.dismiss() + NotificationCenter.default.post(name: .sentPayment, object: pay) + } + } + .foregroundColor(Color.green) + .font(.title) } } - .font(.title) - } + } + .onAppear() { + handle_state_change() } .padding() + .onReceive(self.expiry_timer) { _ in + update_expiry_percent() + } } -} -/* -struct PayView_Previews: PreviewProvider { - @Binding var invoice: Invoice? + func handle_state_change() { + switch self.state { + case .ready: + break + case .initial: + self.state = .decoding(nil, self.init_invoice_str) + handle_state_change() + case .decoding(let ln, let inv): + DispatchQueue.global(qos: .background).async { + self.handle_decode(ln, inv: inv) + } + case .fetch_invoice(let ln, let req): + DispatchQueue.global(qos: .background).async { + self.handle_fetch_invoice(ln: ln, req: req) + } + case .decoded: + break + } + + } + + func handle_fetch_invoice(ln: LNSocket, req: FetchInvoiceReq) { + switch rpc_fetchinvoice(ln: ln, token: self.lnlink.token, req: req) { + case .failure(let err): + self.error = err.description + case .success(let fetch_invoice): + self.state = .decoding(ln, fetch_invoice.invoice) + handle_state_change() + } + } + + func handle_decode(_ oldln: LNSocket?, inv: String) { + let ln = oldln ?? LNSocket() + if oldln == nil { + guard ln.connect_and_init(node_id: self.lnlink.node_id, host: self.lnlink.host) else { + return + } + } + + switch rpc_decode(ln: ln, token: self.lnlink.token, inv: inv) { + case .failure(let fail): + self.error = fail.description + case .success(let decoded): + if decoded.type == "bolt12 offer" { + // TODO: handle custom amounts for offers + let amt: Int64? = 10000 + let req = fetchinvoice_req_from_offer(offer: decoded, offer_str: inv, amount: amt) + switch req { + case .left(let err): + self.error = err + case .right(let req): + self.state = .fetch_invoice(ln, req) + handle_state_change() + } + } else if decoded.type == "bolt11 invoice" || decoded.type == "bolt12 invoice" { + var amount: InvoiceAmount = .any + if decoded.amount_msat != nil { + guard let amt = parse_msat(decoded.amount_msat!) else { + self.error = "invalid msat amount: \(decoded.amount_msat!)" + return + } + + amount = .amount(amt) + } + + self.state = .ready(amount, inv) + self.invoice = decoded + update_expiry_percent() + } else { + self.error = "unknown decoded type: \(decoded.type)" + } + } + + } + + func update_expiry_percent() { + guard let invoice = self.invoice else { + return + } + + guard let expiry = invoice.expiry else { + self.expiry_percent = nil + return + } + + guard let created_at = invoice.created_at else { + self.expiry_percent = nil + return + } + + let now = Int64(Date().timeIntervalSince1970) + let expires_at = created_at + expiry + + guard expiry > 0 else { + self.expiry_percent = nil + return + } + + guard now < expires_at else { + self.expiry_percent = nil + return + } + + guard now >= created_at else { + self.expiry_percent = 1 + return + } + + let prog = now - created_at + self.expiry_percent = 1.0 - (Double(prog) / Double(expiry)) - static var previews: some View { - PayView(invoice: self.$invoice) } } +func fetchinvoice_req_from_offer(offer: Decode, offer_str: String, amount: Int64?) -> Either<String, FetchInvoiceReq> { -*/ + var qty: Int? = nil + if offer.quantity_min != nil { + qty = offer.quantity_min! + } + + if offer.amount_msat != nil { + return .right(FetchInvoiceReq(offer: offer_str, amount: .any, quantity: qty)) + } else { + guard let amt = amount else { + return .left("Amount required for offer") + } + + return .right(FetchInvoiceReq(offer: offer_str, amount: .amount(amt), quantity: qty)) + } +} + +func parse_msat(_ s: String) -> Int64? { + let str = s.replacingOccurrences(of: "msat", with: "") + return Int64(str) +} public enum Either<L, R> { case left(L) @@ -122,6 +322,35 @@ func confirm_payment(bolt11: String, lnlink: LNLink) -> Either<String, Pay> { } } +func amount_view(_ state: PayState) -> some View { + Group { + } +} + +func is_ready(_ state: PayState) -> (InvoiceAmount, String)? { + switch state { + case .ready(let inv_amount, let inv): + return (inv_amount, inv) + case .fetch_invoice: fallthrough + case .initial: fallthrough + case .decoding: fallthrough + case .decoded: + return nil + } +} + +func amount_view_inv(_ amt: InvoiceAmount) -> some View { + Group { + switch amt { + case .any: + Text("Custom amounts not supported yet :(") + case .amount(let amt): + Text("Pay") + Text("\(render_amount_msats(amt))") + .font(.title) + } + } +} func render_amount(_ amt: InvoiceAmount) -> String { switch amt { @@ -139,3 +368,13 @@ func render_amount_msats(_ amount: Int64) -> String { return "\(amount / 1000) sats" } + +/* +struct PayView_Previews: PreviewProvider { + @Binding var invoice: Invoice? + + static var previews: some View { + PayView(invoice: self.$invoice) + } +} +*/ diff --git a/lightninglink/Views/SetupView.swift b/lightninglink/Views/SetupView.swift @@ -217,6 +217,7 @@ func validate_connection(lnlink: LNLink, completion: @escaping (SetupResult) -> switch funds_res { case .failure(let err): + print(err) completion(.success(getinfo, .empty)) case .success(let listfunds): completion(.success(getinfo, listfunds)) diff --git a/lightninglinkTests/lightninglinkTests.swift b/lightninglinkTests/lightninglinkTests.swift @@ -22,7 +22,7 @@ class lightninglinkTests: XCTestCase { func testAnyAmountParsesOk() throws { let inv = "lnbc1p3psxjypp5335lq3qyr4vaexez53yxac5jfatdavwyq5eskkkvnrx6yw9j75vsdqvw3jhxarpdeusxqyjw5qcqpjsp5z65t0t70q4e6yp0t2rcajwslkz6uqmaw2eu5s3fkdfgaf5sdm7vsrzjqv7cv43pj3u8qy38rxwt6mm8qv6u34qg4y4w3zuk93yafhqws0sz2z2z0yqq40qqqqqqqqlgqqqqqeqqjq9qyyssqd432fhw3shf0l3zy0l3ku3xv8re6lhaayeyr8u0ayfcy46348vrzjsa46j7prz70l34wklyennpk7dzsw8eqacde74z92jylvevvdhgpzcxhyn" - let mamt = parseInvoiceAmount(inv) + let mamt = parseInvoiceString(inv) XCTAssert(mamt != nil) let amt = mamt!