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