lnlink

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

commit 1d30d0d5662583178de7823d1fe3351a6a101aa4
parent 6d3cf24117a5b718596267b6a371c1622a549117
Author: William Casarin <jb55@jb55.com>
Date:   Sat,  5 Feb 2022 19:49:24 -0800

payment works

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

Diffstat:
M.gitignore | 1+
Mlightninglink.xcodeproj/project.pbxproj | 12++++++++++++
Mlightninglink.xcodeproj/xcuserdata/jb55.xcuserdatad/xcschemes/xcschememanagement.plist | 18++++++++++++++++++
Alightninglink/Bech32.swift | 347+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlightninglink/ContentView.swift | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Alightninglink/Invoice.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlightninglink/LNSocket.swift | 19++++---------------
Alightninglink/PayView.swift | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlightninglink/RPC.swift | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mlightninglink/lightninglinkApp.swift | 39++++++++++++++++++++++++++++++++++++---
10 files changed, 765 insertions(+), 36 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1 +1,2 @@ *xcuserdata* +*xcshareddata* diff --git a/lightninglink.xcodeproj/project.pbxproj b/lightninglink.xcodeproj/project.pbxproj @@ -8,6 +8,9 @@ /* Begin PBXBuildFile section */ 4C0359FB27AEE86600FF92CE /* QRScan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0359FA27AEE86600FF92CE /* QRScan.swift */; }; + 4C035A0027AEF90000FF92CE /* PayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0359FF27AEF90000FF92CE /* PayView.swift */; }; + 4C035A0227AEFB2400FF92CE /* Bech32.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C035A0127AEFB2400FF92CE /* Bech32.swift */; }; + 4C035A0427AEFD2F00FF92CE /* Invoice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C035A0327AEFD2F00FF92CE /* Invoice.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 */; }; @@ -44,6 +47,9 @@ /* Begin PBXFileReference section */ 4C0359FA27AEE86600FF92CE /* QRScan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScan.swift; sourceTree = "<group>"; }; 4C0359FE27AEEE8500FF92CE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; + 4C0359FF27AEF90000FF92CE /* PayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayView.swift; sourceTree = "<group>"; }; + 4C035A0127AEFB2400FF92CE /* Bech32.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bech32.swift; sourceTree = "<group>"; }; + 4C035A0327AEFD2F00FF92CE /* Invoice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Invoice.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>"; }; @@ -128,6 +134,9 @@ 4C641D1E2788FF30002A36C9 /* Preview Content */, 4C873FD627A6F1F5008C972C /* RPC.swift */, 4C0359FA27AEE86600FF92CE /* QRScan.swift */, + 4C0359FF27AEF90000FF92CE /* PayView.swift */, + 4C035A0127AEFB2400FF92CE /* Bech32.swift */, + 4C035A0327AEFD2F00FF92CE /* Invoice.swift */, ); path = lightninglink; sourceTree = "<group>"; @@ -310,11 +319,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4C035A0027AEF90000FF92CE /* PayView.swift in Sources */, 4C873FD527A6EF3F008C972C /* LNSocket.swift in Sources */, 4C641D1B2788FF2F002A36C9 /* ContentView.swift in Sources */, 4C641D492789083E002A36C9 /* lightninglink.c in Sources */, + 4C035A0427AEFD2F00FF92CE /* Invoice.swift in Sources */, 4C641D192788FF2F002A36C9 /* lightninglinkApp.swift in Sources */, 4C873FD727A6F1F5008C972C /* RPC.swift in Sources */, + 4C035A0227AEFB2400FF92CE /* Bech32.swift in Sources */, 4C0359FB27AEE86600FF92CE /* QRScan.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/lightninglink.xcodeproj/xcuserdata/jb55.xcuserdatad/xcschemes/xcschememanagement.plist b/lightninglink.xcodeproj/xcuserdata/jb55.xcuserdatad/xcschemes/xcschememanagement.plist @@ -10,5 +10,23 @@ <integer>0</integer> </dict> </dict> + <key>SuppressBuildableAutocreation</key> + <dict> + <key>4C641D142788FF2F002A36C9</key> + <dict> + <key>primary</key> + <true/> + </dict> + <key>4C641D242788FF30002A36C9</key> + <dict> + <key>primary</key> + <true/> + </dict> + <key>4C641D2E2788FF31002A36C9</key> + <dict> + <key>primary</key> + <true/> + </dict> + </dict> </dict> </plist> diff --git a/lightninglink/Bech32.swift b/lightninglink/Bech32.swift @@ -0,0 +1,347 @@ +// Copyright (c) 2017 Alex Bosworth +// Copyright (c) 2017 Pieter Wuille +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation + +extension String { + func lastIndex(of string: String) -> Int? { + guard let range = self.range(of: string, options: .backwards) else { return nil } + + return self.distance(from: startIndex, to: range.lowerBound) + } +} + +let CHARSET = byteConvert(string: "qpzry9x8gf2tvdw0s3jn54khce6mua7l") +let GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + +func polymod(_ values: [Int]) -> Int { + return values.reduce(1) { chk, value in + let top = chk >> 25 + + return (Int()..<5).reduce((chk & 0x1ffffff) << 5 ^ value) { chk, i in + guard (top >> i) & 1 > Int() else { return chk } + + return chk ^ GENERATOR[i] + } + } +} + +func hrpExpand(_ hrp: [UInt8]) -> [UInt8] { + return (Int()..<hrp.count).map { hrp[$0] >> 5 } + [UInt8()] + (Int()..<hrp.count).map { hrp[$0] & 31 } +} + +func verifyChecksum(hrp: [UInt8], data: [UInt8]) -> Bool { + return polymod((hrpExpand(hrp) + data).map { Int($0) }) == 1 +} + +func createChecksum(hrp: [UInt8], data: [UInt8]) -> [UInt8] { + let values = (hrpExpand(hrp) + data + Array(repeating: UInt8(), count: 6)).map { Int($0) } + let mod: Int = polymod(values) ^ 1 + + return (Int()..<6).map { (mod >> (5 * (5 - $0))) & 31 }.map { UInt8($0) } +} + +func byteConvert(string: String) -> [UInt8] { + return string.map { String($0).unicodeScalars.first?.value }.flatMap { $0 }.map { UInt8($0) } +} + +func stringConvert(bytes: [UInt8]) -> String { + return bytes.reduce(String(), { $0 + String(format: "%c", $1)}) +} + +func encode(hrp: [UInt8], data: [UInt8]) -> String { + let checksum = createChecksum(hrp: hrp, data: data) + + return stringConvert(bytes: hrp) + "1" + stringConvert(bytes: (data + checksum).map { CHARSET[Int($0)] }) +} + +enum DecodeBech32Error: Error { + case caseMixing + case inconsistentHrp + case invalidAddress + case invalidBits + case invalidCharacter(String) + case invalidChecksum + case invalidPayToHashLength + case invalidVersion + case missingSeparator + case missingVersion + + var localizedDescription: String { + switch self { + case .caseMixing: + return "Mixed case characters are not allowed" + + case .inconsistentHrp: + return "Internally inconsistent HRP" + + case .invalidAddress: + return "Address is not a valid type" + + case .invalidBits: + return "Bits are not valid" + + case .invalidCharacter(let char): + return "Character \"\(char)\" is not valid" + + case .invalidChecksum: + return "Checksum failed to verify data" + + case .invalidPayToHashLength: + return "Unknown hash length for encoded output payload hash" + + case .invalidVersion: + return "Invalid version number" + + case .missingSeparator: + return "Missing address data separator" + + case .missingVersion: + return "Missing address version" + } + } +} + +public func decodeBech32(bechString: String) throws -> (hrp: [UInt8], data: [UInt8]) { + let bechBytes = byteConvert(string: bechString) + + guard !(bechBytes.contains() { $0 < 33 && $0 > 126 }) else { throw DecodeBech32Error.invalidCharacter(bechString) } + + let hasLower = bechBytes.contains() { $0 >= 97 && $0 <= 122 } + let hasUpper = bechBytes.contains() { $0 >= 65 && $0 <= 90 } + + if hasLower && hasUpper { throw DecodeBech32Error.caseMixing } + + let bechString = bechString.lowercased() + + guard let pos = bechString.lastIndex(of: "1") else { throw DecodeBech32Error.missingSeparator } + + if pos < 1 || pos + 7 > bechString.count { + throw DecodeBech32Error.missingSeparator + } + + let bechStringBytes = byteConvert(string: bechString) + let hrp = byteConvert(string: bechString.substring(to: bechString.index(bechString.startIndex, offsetBy: pos))) + + let data: [UInt8] = try ((pos + 1)..<bechStringBytes.count).map { i in + guard let d = CHARSET.firstIndex(of: bechStringBytes[i]) else { + throw DecodeBech32Error.invalidCharacter(stringConvert(bytes: [bechStringBytes[i]])) + } + + return UInt8(d) + } + + guard verifyChecksum(hrp: hrp, data: data) else { throw DecodeBech32Error.invalidChecksum } + + return (hrp: hrp, data: Array(data[Int()..<data.count - 6])) +} + +func convertbits(data: [UInt8], fromBits: Int, toBits: Int, pad: Bool) throws -> [UInt8] { + var acc = Int() + var bits = UInt8() + let maxv = (1 << toBits) - 1 + + let converted: [[Int]] = try data.map { value in + if (value < 0 || (UInt8(Int(value) >> fromBits)) != 0) { + throw DecodeBech32Error.invalidCharacter(stringConvert(bytes: [value])) + } + + acc = (acc << fromBits) | Int(value) + bits += UInt8(fromBits) + + var values = [Int]() + + while bits >= UInt8(toBits) { + bits -= UInt8(toBits) + values += [(acc >> Int(bits)) & maxv] + } + + return values + } + + let padding = pad && bits > UInt8() ? [acc << (toBits - Int(bits)) & maxv] : [] + + if !pad && (bits >= UInt8(fromBits) || acc << (toBits - Int(bits)) & maxv > Int()) { + throw DecodeBech32Error.invalidBits + } + + return ((converted.flatMap { $0 }) + padding).map { UInt8($0) } +} + +func encode(hrp: [UInt8], version: UInt8, program: [UInt8]) throws -> String { + let address = try encode(hrp: hrp, data: [version] + convertbits(data: program, fromBits: 8, toBits: 5, pad: true)) + + // Confirm encoded address parses without error + let _ = try decodeAddress(hrp: hrp, address: address) + + return address +} + +func decodeAddress(hrp: [UInt8], address: String) throws -> (version: UInt8, program: [UInt8]) { + let decoded = try decodeBech32(bechString: address) + + // Confirm decoded address matches expected type + guard stringConvert(bytes: decoded.hrp) == stringConvert(bytes: hrp) else { throw DecodeBech32Error.inconsistentHrp } + + // Confirm version byte is present + guard let versionByte = decoded.data.first else { throw DecodeBech32Error.missingVersion } + + // Confirm version byte is within the acceptable range + guard !decoded.data.isEmpty && versionByte <= 16 else { throw DecodeBech32Error.invalidVersion } + + let program = try convertbits(data: Array(decoded.data[1..<decoded.data.count]), fromBits: 5, toBits: 8, pad: false) + + // Confirm program is a valid length + guard program.count > 1 && program.count < 41 else { throw DecodeBech32Error.invalidAddress } + + if versionByte == UInt8() { + // Confirm program is a known byte length (20 for pkhash, 32 for scripthash) + guard program.count == 20 || program.count == 32 else { throw DecodeBech32Error.invalidPayToHashLength } + } + + return (version: versionByte, program: program) +} + +func segwitScriptPubKey(version: UInt8, program: [UInt8]) -> [UInt8] { + return [version > UInt8() ? version + 0x50 : UInt8(), UInt8(program.count)] + program +} + + +/* +class TestBech32: XCTestCase { + func testInvalidAddresses() { + let INVALID_ADDRESS = [ + "tc1qw508d6qejxtdg4y5r3zarvary0c5xw7kg3g4ty", + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5", + "BC13W508D6QEJXTDG4Y5R3ZARVARY0C5XW7KN40WF2", + "bc1rw5uspcuh", + "bc10w508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kw5rljs90", + "BC1QR508D6QEJXTDG4Y5R3ZARVARYV98GJ9P", + "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sL5k7", + "tb1pw508d6qejxtdg4y5r3zarqfsj6c3", + "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3pjxtptv", + ] + + INVALID_ADDRESS.forEach { test in + ["bc", "tb"].forEach { type in + do { + let _ = try decodeAddress(hrp: byteConvert(string: type), address: test) + + XCTFail("Expected invalid address: \(test)") + } catch { + return + } + } + } + } + + func testChecksums() { + let VALID_CHECKSUM: [String] = [ + "A12UEL5L", + "an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs", + "abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw", + "11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", + "split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w" + ] + + do { + try VALID_CHECKSUM.forEach { test in + let _ = try decodeBech32(bechString: test) + } + } catch { + XCTFail(error.localizedDescription) + } + } + + func testValidAddresses() { + let VALID_BC_ADDRESSES: [String: (decoded: [UInt8], type: String)] = [ + "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4": ( + decoded: [ + 0x00, 0x14, 0x75, 0x1e, 0x76, 0xe8, 0x19, 0x91, 0x96, 0xd4, 0x54, + 0x94, 0x1c, 0x45, 0xd1, 0xb3, 0xa3, 0x23, 0xf1, 0x43, 0x3b, 0xd6 + ], + type: "bc" + ), + + "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7": ( + decoded: [ + 0x00, 0x20, 0x18, 0x63, 0x14, 0x3c, 0x14, 0xc5, 0x16, 0x68, 0x04, + 0xbd, 0x19, 0x20, 0x33, 0x56, 0xda, 0x13, 0x6c, 0x98, 0x56, 0x78, + 0xcd, 0x4d, 0x27, 0xa1, 0xb8, 0xc6, 0x32, 0x96, 0x04, 0x90, 0x32, + 0x62 + ], + type: "tb" + ), + + "bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7k7grplx": ( + decoded: [ + 0x51, 0x28, 0x75, 0x1e, 0x76, 0xe8, 0x19, 0x91, 0x96, 0xd4, 0x54, + 0x94, 0x1c, 0x45, 0xd1, 0xb3, 0xa3, 0x23, 0xf1, 0x43, 0x3b, 0xd6, + 0x75, 0x1e, 0x76, 0xe8, 0x19, 0x91, 0x96, 0xd4, 0x54, 0x94, 0x1c, + 0x45, 0xd1, 0xb3, 0xa3, 0x23, 0xf1, 0x43, 0x3b, 0xd6 + ], + type: "bc" + ), + + "BC1SW50QA3JX3S": (decoded: [0x60, 0x02, 0x75, 0x1e], type: "bc"), + + "bc1zw508d6qejxtdg4y5r3zarvaryvg6kdaj": ( + decoded: [ + 0x52, 0x10, 0x75, 0x1e, 0x76, 0xe8, 0x19, 0x91, 0x96, 0xd4, 0x54, + 0x94, 0x1c, 0x45, 0xd1, 0xb3, 0xa3, 0x23 + ], + type: "bc" + ), + + "tb1qqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesrxh6hy": ( + decoded: [ + 0x00, 0x20, 0x00, 0x00, 0x00, 0xc4, 0xa5, 0xca, 0xd4, 0x62, 0x21, + 0xb2, 0xa1, 0x87, 0x90, 0x5e, 0x52, 0x66, 0x36, 0x2b, 0x99, 0xd5, + 0xe9, 0x1c, 0x6c, 0xe2, 0x4d, 0x16, 0x5d, 0xab, 0x93, 0xe8, 0x64, + 0x33 + ], + type: "tb" + ) + ] + + do { + try VALID_BC_ADDRESSES.forEach { address, result in + let scriptPubKey = result.decoded + let hrp = byteConvert(string: result.type) + + let ret = try decodeAddress(hrp: hrp, address: address) + + let output = segwitScriptPubKey(version: ret.version, program: ret.program) + + XCTAssertEqual(output, scriptPubKey) + + let recreated = try encode(hrp: hrp, version: ret.version, program: ret.program).lowercased() + + XCTAssertEqual(recreated, address.lowercased()) + } + } catch { + XCTFail(error.localizedDescription) + } + } +} + + */ diff --git a/lightninglink/ContentView.swift b/lightninglink/ContentView.swift @@ -7,36 +7,141 @@ import SwiftUI +extension Notification.Name { + static var sentPayment: Notification.Name { + return Notification.Name("did send payment") + } +} + +enum ActiveSheet: Identifiable { + var id: String { + switch self { + case .qr: + return "qrcode" + case .pay: + return "paysheet" + } + } + + case qr + case pay(Invoice, String) +} + +struct Funds { + public var onchain_sats: Int64 + public var channel_sats: Int64 + + public static var empty = Funds(onchain_sats: 0, channel_sats: 0) + + public static func from_listfunds(fs: ListFunds) -> Funds { + var onchain_sats: Int64 = 0 + var channel_sats: Int64 = 0 + + for channel in fs.channels { + channel_sats += channel.channel_sat + } + + for output in fs.outputs { + onchain_sats += output.value + } + + return Funds(onchain_sats: onchain_sats, channel_sats: channel_sats) + } +} + struct ContentView: View { @State private var info: GetInfo - @State private var showingQRScanner = false + @State private var activeSheet: ActiveSheet? + @State private var last_pay: Pay? + @State private var funds: Funds + + private var ln: LNSocket + private var token: String - init(info: GetInfo) { + init(info: GetInfo, ln: LNSocket, token: String, funds: ListFunds) { self.info = info + self.ln = ln + self.token = token + self.funds = Funds.from_listfunds(fs: funds) + } + + func refresh_funds() { + let funds = fetch_funds(ln: self.ln, token: self.token) + self.funds = Funds.from_listfunds(fs: funds) + } + + func format_last_pay() -> String { + guard let pay = last_pay else { + return "" + } + + if (pay.msatoshi >= 1000) { + let sats = pay.msatoshi / 1000 + let fee = (pay.msatoshi_sent - pay.msatoshi) / 1000 + return "-\(sats) sats (\(fee) sats fee)" + } + + return "-\(pay.msatoshi) msats (\(pay.msatoshi_sent) msats sent)" } var body: some View { - let _self = self VStack { - Button("Pay") { - showingQRScanner = true + Group { + Text(self.info.alias) + .font(.largeTitle) + .padding() + Text("\(self.info.num_active_channels) active channels") + Text("\(self.info.msatoshi_fees_collected / 1000) sats collected in fees") + } + Spacer() + Text("\(format_last_pay())") + .foregroundColor(Color.red) + + Text("\(self.funds.channel_sats) sats") + .font(.title) + .padding() + Text("\(self.funds.onchain_sats) onchain") + Spacer() + HStack { + Spacer() + Button("Pay", + action: { self.activeSheet = .qr }) + .font(.title) + .padding() } - Text(self.info.alias) - Text("\(self.info.num_active_channels) active channels") - Text("\(self.info.msatoshi_fees_collected / 1000) sats collected in fees") } - .sheet(isPresented: $showingQRScanner) { - QRScanner() { code in - print(code) + .sheet(item: $activeSheet) { sheet in + switch sheet { + case .qr: + QRScanner() { code in + var invstr: String = code + if code.starts(with: "lightning:") { + let index = code.index(code.startIndex, offsetBy: 10) + invstr = String(code[index...]) + } + let m_parsed = parseInvoice(invstr) + guard let parsed = m_parsed else { + return + } + self.activeSheet = .pay(parsed, invstr) + } + + case .pay(let inv, let raw): + PayView(invoice_str: raw, invoice: inv, ln: self.ln, token: self.token) } } + .onReceive(NotificationCenter.default.publisher(for: .sentPayment)) { payment in + last_pay = payment.object as! Pay + refresh_funds() + } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { + let ln = LNSocket() Group { - ContentView(info: .empty) + ContentView(info: .empty, ln: ln, token: "", funds: .empty) } } } diff --git a/lightninglink/Invoice.swift b/lightninglink/Invoice.swift @@ -0,0 +1,63 @@ +// +// Invoice.swift +// lightninglink +// +// Created by William Casarin on 2022-02-05. +// + +import Foundation + + +public struct Bolt11Invoice { + var msats: Int64 +} + +public struct Bolt12Invoice { + var msats: Int64 +} + +public enum Invoice { + case bolt11(Bolt11Invoice) + case bolt12(Bolt12Invoice) + + func amount() -> Int64 { + return invoiceAmount(self) + } + + static var empty: Invoice { + let b11 = Bolt11Invoice(msats: 0) + let inv: Invoice = .bolt11(b11) + return inv + } +} + +public func parseInvoice(_ str: String) -> Invoice? +{ + // decode bech32 + + do { + let (hrp, _) = try decodeBech32(bechString: str) + + let hrp_data = Data(hrp) + let hrp_str = String(data: hrp_data, encoding: .utf8)! + print(hrp_str) + + } catch { + print("parseInvoice: unexpected error \(error)") + return nil + } + + return .bolt11(Bolt11Invoice(msats: 100000)) +} + + +public func invoiceAmount(_ inv: Invoice) -> Int64 +{ + switch (inv) { + case .bolt11(let b11): + return b11.msats + case .bolt12(let b12): + return b12.msats + } + +} diff --git a/lightninglink/LNSocket.swift b/lightninglink/LNSocket.swift @@ -12,30 +12,19 @@ public class LNSocket { lnsocket_genkey(self.ln) } - func testrun() -> GetInfo? { - let node_id = "03f3c108ccd536b8526841f0a5c58212bb9e6584a1eb493080e7c1cc34f82dad71" - let host = "24.84.152.187" + func connect_and_init(node_id: String, host: String) -> Bool { self.genkey() guard self.connect(node_id: node_id, host: host) else { - return nil + return false } guard self.perform_init() else { - return nil + return false } - let res = rpc_getinfo(ln: self, token: "") - - switch res { - case .success(let getinfo): - return getinfo - - case .failure(let err): - print("\(err)") - return nil - } + return true } func connect(node_id: String, host: String) -> Bool { diff --git a/lightninglink/PayView.swift b/lightninglink/PayView.swift @@ -0,0 +1,98 @@ +// +// PayView.swift +// lightninglink +// +// Created by William Casarin on 2022-02-05. +// + +import SwiftUI + +struct PayView: View { + var invoice_str: String + var invoice: Invoice + var ln: LNSocket + var token: String + @State var pay_result: Pay? + @State var error: String? + + @Environment(\.presentationMode) var presentationMode + + init(invoice_str: String, invoice: Invoice, ln: LNSocket, token: String) { + self.invoice_str = invoice_str + self.invoice = invoice + self.ln = ln + self.token = token + } + + var successView: some View { + VStack() { + Text("Payment Success!").font(.largeTitle) + } + } + + var failView: some View { + VStack() { + Text("Payment Failed").font(.largeTitle) + Text(self.error!) + } + } + + private func dismiss() { + self.presentationMode.wrappedValue.dismiss() + } + + var body: some View { + return VStack() { + Text("Confirm payment") + .font(.largeTitle) + Spacer() + Text("Pay") + Text("\(self.invoice.amount()) msats?") + .font(.title) + Text("\(self.error ?? "")") + Spacer() + HStack { + Button("Cancel") { + self.dismiss() + } + .font(.title) + + Spacer() + + Button("Confirm") { + let res = rpc_pay( + ln: self.ln, + token: self.token, + bolt11: self.invoice_str, + amount_msat: nil) + + switch res { + case .failure(let req_err): + // handle error + self.error = req_err.description + + case .success(let pay): + self.error = nil + print(pay) + self.dismiss() + NotificationCenter.default.post(name: .sentPayment, object: pay) + } + } + .font(.title) + } + } + .padding() + } +} + +/* +struct PayView_Previews: PreviewProvider { + @Binding var invoice: Invoice? + + static var previews: some View { + PayView(invoice: self.$invoice) + } +} + + +*/ diff --git a/lightninglink/RPC.swift b/lightninglink/RPC.swift @@ -14,6 +14,49 @@ public struct ResultWrapper<T: Decodable>: Decodable { public var result: T } +public struct Output: Decodable { + public var txid: String + public var output: Int + public var value: Int64 + public var amount_msat: String + public var scriptpubkey: String + public var address: String + public var status: String + public var blockheight: Int + public var reserved: Bool +} + +public struct Channel: Decodable { + public var peer_id: String + public var connected: Bool + public var state: String + public var short_channel_id: String + public var channel_sat: Int64 + public var channel_total_sat: Int64 + public var funding_txid: String + public var funding_output: Int +} + +public struct ListFunds: Decodable { + public var outputs: [Output] + public var channels: [Channel] + + public static var empty = ListFunds(outputs: [], channels: []) +} + +public struct Pay: Decodable { + public var destination: String + public var payment_hash: String + public var created_at: Float + public var parts: Int + public var msatoshi: Int64 + public var amount_msat: String + public var msatoshi_sent: Int64 + public var amount_sent_msat: String + public var payment_preimage: String + public var status: String +} + public struct GetInfo: Decodable { public var alias: String public var id: String @@ -22,8 +65,9 @@ public struct GetInfo: Decodable { public var num_peers: Int public var msatoshi_fees_collected: Int public var num_active_channels: Int + public var blockheight: Int - public static var empty = GetInfo(alias: "", id: "", color: "", network: "", num_peers: 0, msatoshi_fees_collected: 0, num_active_channels: 0) + public static var empty = GetInfo(alias: "", id: "", color: "", network: "", num_peers: 0, msatoshi_fees_collected: 0, num_active_channels: 0, blockheight: 0) } public enum RequestErrorType: Error { @@ -78,6 +122,7 @@ func parse_connection_string(_ cs: String) -> (String, String)? { public func performRpcOnce<IN: Encodable, OUT: Decodable>( connectionString: String, operation: String, authToken: String, + timeout_ms: Int32, params: IN ) -> RequestRes<OUT> { guard let parts = parse_connection_string(connectionString) else { @@ -98,11 +143,11 @@ public func performRpcOnce<IN: Encodable, OUT: Decodable>( return .failure(RequestError(errorType: .initFailed)) } - return performRpc(ln: ln, operation: operation, authToken: authToken, params: params) + return performRpc(ln: ln, operation: operation, authToken: authToken, timeout_ms: timeout_ms, params: params) } public func performRpc<IN: Encodable, OUT: Decodable>( - ln: LNSocket, operation: String, authToken: String, params: IN) -> RequestRes<OUT> + ln: LNSocket, operation: String, authToken: String, timeout_ms: Int32, params: IN) -> RequestRes<OUT> { guard let msg = make_commando_msg(authToken: authToken, operation: operation, params: params) else { @@ -113,7 +158,7 @@ public func performRpc<IN: Encodable, OUT: Decodable>( return .failure(RequestError(errorType: .writeFailed)) } - switch commando_read_all(ln: ln) { + switch commando_read_all(ln: ln, timeout_ms: timeout_ms) { case .failure(let req_err): return .failure(req_err) @@ -194,16 +239,34 @@ func commando_read_all(ln: LNSocket, timeout_ms: Int32 = 2000) -> RequestRes<Dat } else if msgtype == COMMANDO_REPLY_CONTINUES { continue } else { - return .failure(RequestError(errorType: .badCommandoMsgType(Int(msgtype)))) + //return .failure(RequestError(errorType: .badCommandoMsgType(Int(msgtype)))) + // we could get random messages like channel update! just ignore them + continue } } return .success(all_data) } +public let default_timeout: Int32 = 3000 public func rpc_getinfo(ln: LNSocket, token: String) -> RequestRes<GetInfo> { let params: Array<String> = [] - return performRpc(ln: ln, operation: "getinfo", authToken: token, params: params) + return performRpc(ln: ln, operation: "getinfo", authToken: token, timeout_ms: default_timeout, params: params) +} + +public func rpc_pay(ln: LNSocket, token: String, bolt11: String, amount_msat: Int64?) -> RequestRes<Pay> +{ + var params: Array<String> = [ bolt11 ] + if amount_msat != nil { + params.append("\(amount_msat!)") + } + return performRpc(ln: ln, operation: "pay", authToken: token, timeout_ms: 30000, params: params) +} + +public func rpc_listfunds(ln: LNSocket, token: String) -> RequestRes<ListFunds> +{ + let params: Array<String> = [] + return performRpc(ln: ln, operation: "listfunds", authToken: token, timeout_ms: default_timeout, params: params) } diff --git a/lightninglink/lightninglinkApp.swift b/lightninglink/lightninglinkApp.swift @@ -10,15 +10,48 @@ import SwiftUI @main struct lightninglinkApp: App { var info: GetInfo = .empty + var funds: ListFunds = .empty + var ln: LNSocket + var token: String init() { - let ln = LNSocket() - self.info = ln.testrun() ?? .empty + self.ln = LNSocket() + self.token = "" + let node_id = "03f3c108ccd536b8526841f0a5c58212bb9e6584a1eb493080e7c1cc34f82dad71" + let host = "24.84.152.187" + + guard ln.connect_and_init(node_id: node_id, host: host) else { + return + } + + self.info = fetch_info(ln: ln, token: token) + self.funds = fetch_funds(ln: ln, token: token) } var body: some Scene { WindowGroup { - ContentView(info: self.info) + ContentView(info: self.info, ln: self.ln, token: self.token, funds: self.funds) } } } + +func fetch_info(ln: LNSocket, token: String) -> GetInfo { + switch rpc_getinfo(ln: ln, token: token) { + case .failure(let err): + print("fetch_info err: \(err)") + return .empty + + case .success(let getinfo): + return getinfo + } +} + +func fetch_funds(ln: LNSocket, token: String) -> ListFunds { + switch rpc_listfunds(ln: ln, token: token) { + case .failure(let err): + print("fetch_funds error: \(err)") + return .empty + case .success(let funds): + return funds + } +}