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:
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
+ }
+}