diff --git a/Library/Coordinators/WalletCoordinator.swift b/Library/Coordinators/WalletCoordinator.swift index 8a2b86625..8a0e3ecf2 100644 --- a/Library/Coordinators/WalletCoordinator.swift +++ b/Library/Coordinators/WalletCoordinator.swift @@ -11,7 +11,8 @@ import SafariServices import SwiftBTC import UIKit -final class WalletCoordinator: NSObject, Coordinator { // swiftlint:disable:this type_body_length +// swiftlint:disable type_body_length file_length +final class WalletCoordinator: NSObject, Coordinator { let rootViewController: RootViewController private let lightningService: LightningService @@ -243,8 +244,11 @@ final class WalletCoordinator: NSObject, Coordinator { // swiftlint:disable:this } func presentSend(invoice: String?) { - let strategy = SendQRCodeScannerStrategy(lightningService: lightningService, authenticationViewModel: authenticationViewModel) - + let strategy = CombinedQRCodeScannetStrategy(strategies: [ + SendQRCodeScannerStrategy(lightningService: lightningService, authenticationViewModel: authenticationViewModel), + LNURLWithdrawQRCodeScannetStrategy(lightningService: lightningService) + ]) + if let invoice = invoice { DispatchQueue(label: "presentSend").async { let group = DispatchGroup() @@ -263,9 +267,16 @@ final class WalletCoordinator: NSObject, Coordinator { // swiftlint:disable:this } } + func presentLNURLWithdrawQRCodeScanner() { + let strategy = LNURLWithdrawQRCodeScannetStrategy(lightningService: lightningService) + let viewController = UINavigationController(rootViewController: QRCodeScannerViewController(strategy: strategy)) + viewController.modalPresentationStyle = .fullScreen + rootViewController.present(viewController, animated: true) + } + func presentRequest() { let viewModel = RequestViewModel(lightningService: lightningService) - let viewController = RequestViewController(viewModel: viewModel) + let viewController = RequestViewController(viewModel: viewModel, presentQrCode: presentLNURLWithdrawQRCodeScanner) rootViewController.present(viewController, animated: true) } diff --git a/Library/Generated/StoryboardScenes.swift b/Library/Generated/StoryboardScenes.swift index c21fdcfb3..b3e18d8b0 100644 --- a/Library/Generated/StoryboardScenes.swift +++ b/Library/Generated/StoryboardScenes.swift @@ -67,6 +67,11 @@ internal enum StoryboardScene { internal static let historyViewController = SceneType(storyboard: History.self, identifier: "HistoryViewController") } + internal enum LNURLWithdraw: StoryboardType { + internal static let storyboardName = "LNURLWithdraw" + + internal static let lnurlWithdrawViewController = SceneType(storyboard: LNURLWithdraw.self, identifier: "LNURLWithdrawViewController") + } internal enum LndLog: StoryboardType { internal static let storyboardName = "LndLog" diff --git a/Library/Generated/strings.swift b/Library/Generated/strings.swift index eeedd40cc..a60abd0ed 100644 --- a/Library/Generated/strings.swift +++ b/Library/Generated/strings.swift @@ -349,6 +349,24 @@ internal enum L10n { } } } + internal enum Lnurl { + internal enum Withdraw { + /// Withdraw + internal static let buttonTitle = L10n.tr("Localizable", "scene.lnurl.withdraw.button_title") + /// Description + internal static let description = L10n.tr("Localizable", "scene.lnurl.withdraw.description") + /// Withdraw + internal static let title = L10n.tr("Localizable", "scene.lnurl.withdraw.title") + } + } + internal enum LnurlQrcodeScanner { + internal enum Withdraw { + /// Paste LNURL + internal static let buttonTitle = L10n.tr("Localizable", "scene.lnurl_qrcode_scanner.withdraw.button_title") + /// Withdraw + internal static let title = L10n.tr("Localizable", "scene.lnurl_qrcode_scanner.withdraw.title") + } + } internal enum Main { /// %@ per BTC internal static func exchangeRateLabel(_ p1: String) -> String { diff --git a/Library/Scenes/Lnurl/LNURLWithdraw.storyboard b/Library/Scenes/Lnurl/LNURLWithdraw.storyboard new file mode 100644 index 000000000..f8ddec4fa --- /dev/null +++ b/Library/Scenes/Lnurl/LNURLWithdraw.storyboard @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Library/Scenes/Lnurl/LNURLWithdrawQRCodeScannetStrategy.swift b/Library/Scenes/Lnurl/LNURLWithdrawQRCodeScannetStrategy.swift new file mode 100644 index 000000000..ae5e82a2c --- /dev/null +++ b/Library/Scenes/Lnurl/LNURLWithdrawQRCodeScannetStrategy.swift @@ -0,0 +1,47 @@ +// +// Library +// +// Created by 0 on 08.11.19. +// Copyright © 2019 Zap. All rights reserved. +// + +import Foundation +import Lightning +import Logger + +final class LNURLWithdrawQRCodeScannetStrategy: QRCodeScannerStrategy { + let title = L10n.Scene.LnurlQrcodeScanner.Withdraw.title + let pasteButtonTitle = L10n.Scene.LnurlQrcodeScanner.Withdraw.buttonTitle + + let lightningService: LightningService + + init(lightningService: LightningService) { + self.lightningService = lightningService + } + + func viewControllerForAddress(address: String, completion: @escaping (Result) -> Void) { + LNURL.parse(string: address) { [lightningService] result in + DispatchQueue.main.async { + switch result { + case .success(let lnurl): + switch lnurl { + case .withdraw(let request): + let viewModel = LNURLWithdrawViewModel(request: request, lightningService: lightningService) + let viewController = LNURLWithdrawViewController.instantiate(viewModel: viewModel) + completion(.success(ZapNavigationController(rootViewController: viewController))) + } + case .failure(let error): + Logger.error(error) + switch error { + case .statusError(let message): + completion(.failure(QRCodeScannerStrategyError(message: message))) + case .urlError(let error): + completion(.failure(QRCodeScannerStrategyError(message: error.localizedDescription))) + case .invalidBech32, .jsonError, .unknownError, .unsupported: + completion(.failure(QRCodeScannerStrategyError(message: L10n.Scene.QrcodeScanner.Error.unknownFormat))) + } + } + } + } + } +} diff --git a/Library/Scenes/Lnurl/LNURLWithdrawViewController.swift b/Library/Scenes/Lnurl/LNURLWithdrawViewController.swift new file mode 100644 index 000000000..4a987bb69 --- /dev/null +++ b/Library/Scenes/Lnurl/LNURLWithdrawViewController.swift @@ -0,0 +1,97 @@ +// +// Library +// +// Created by 0 on 08.11.19. +// Copyright © 2019 Zap. All rights reserved. +// + +import Foundation +import SwiftBTC + +final class LNURLWithdrawViewController: UIViewController, ParentDismissable { + @IBOutlet private weak var amountLabel: UILabel! + @IBOutlet private weak var descriptionLabel: UILabel! + @IBOutlet private weak var withdrawButton: UIButton! + @IBOutlet private weak var slider: UISlider! + @IBOutlet private weak var sliderContainer: UIStackView! + @IBOutlet private weak var sliderMinusLabel: UILabel! + @IBOutlet private weak var sliderPlusLabel: UILabel! + @IBOutlet private weak var acticityIndicator: UIActivityIndicatorView! + @IBOutlet private weak var domainLabel: UILabel! + + // swiftlint:disable implicitly_unwrapped_optional + private var viewModel: LNURLWithdrawViewModel! + // swiftlint:enable implicitly_unwrapped_optional + + static func instantiate(viewModel: LNURLWithdrawViewModel) -> LNURLWithdrawViewController { + let viewController = StoryboardScene.LNURLWithdraw.lnurlWithdrawViewController.instantiate() + viewController.viewModel = viewModel + return viewController + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = L10n.Scene.Lnurl.Withdraw.title + + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel)) + navigationController?.navigationBar.prefersLargeTitles = false + + view.backgroundColor = UIColor.Zap.background + + withdrawButton.setTitle(L10n.Scene.Lnurl.Withdraw.buttonTitle, for: .normal) + Style.Button.background.apply(to: withdrawButton) + + viewModel.selectedAmount + .bind(to: amountLabel.reactive.text, currency: Settings.shared.primaryCurrency) + .dispose(in: reactive.bag) + + amountLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 80, weight: .regular) + amountLabel.textColor = .white + amountLabel.adjustsFontSizeToFitWidth = true + + acticityIndicator.hidesWhenStopped = true + + sliderPlusLabel.textColor = UIColor.Zap.gray + sliderMinusLabel.textColor = UIColor.Zap.gray + + descriptionLabel.text = viewModel.description + descriptionLabel.textColor = UIColor.Zap.gray + + domainLabel.text = viewModel.domain + domainLabel.textColor = UIColor.Zap.invisibleGray + + if viewModel.minWithdrawable == viewModel.maxWithdrawable { + sliderContainer.isHidden = true + } else { + slider.maximumValue = Float(truncating: viewModel.maxWithdrawable as NSNumber) + slider.minimumValue = Float(truncating: viewModel.minWithdrawable as NSNumber) + slider.value = slider.maximumValue + } + } + + @objc private func cancel() { + dismiss(animated: true) + } + + @IBAction private func amountChanged(_ sender: UISlider) { + viewModel.selectedAmount.value = Satoshi(floatLiteral: Double(sender.value)).floored + } + + @IBAction private func withdraw(_ sender: Any) { + withdrawButton.isEnabled = false + acticityIndicator.startAnimating() + + viewModel.withdraw { [weak self] error in + DispatchQueue.main.async { + if let error = error { + self?.withdrawButton.isEnabled = true + self?.acticityIndicator.stopAnimating() + Toast.presentError(error.localizedDescription) + } else { + self?.dismissParent() + } + } + } + } +} diff --git a/Library/Scenes/Lnurl/LNURLWithdrawViewModel.swift b/Library/Scenes/Lnurl/LNURLWithdrawViewModel.swift new file mode 100644 index 000000000..161cf5292 --- /dev/null +++ b/Library/Scenes/Lnurl/LNURLWithdrawViewModel.swift @@ -0,0 +1,49 @@ +// +// Library +// +// Created by 0 on 08.11.19. +// Copyright © 2019 Zap. All rights reserved. +// + +import Bond +import Foundation +import Lightning +import SwiftBTC + +final class LNURLWithdrawViewModel { + let request: LNURLWithdrawRequest + let lightningService: LightningService + + let selectedAmount: Observable + + let maxWithdrawable: Satoshi + let minWithdrawable: Satoshi + + var description: String { + return request.description + } + + var domain: String? { + return request.domain?.host + } + + init(request: LNURLWithdrawRequest, lightningService: LightningService) { + self.request = request + self.lightningService = lightningService + + minWithdrawable = request.minWithdrawable + maxWithdrawable = request.maxWithdrawable + selectedAmount = Observable(request.maxWithdrawable) + } + + func withdraw(completion: @escaping (LNURL.LNURLError?) -> Void) { + LNURL.withdraw(lightningService: lightningService, request: request, amount: selectedAmount.value) { result in + switch result { + case .success: + completion(nil) + case .failure(let error): + completion(error) + } + } + } +} diff --git a/Library/Scenes/ModalDetail/Request/RequestViewController.swift b/Library/Scenes/ModalDetail/Request/RequestViewController.swift index ba951028a..01cd5eb31 100644 --- a/Library/Scenes/ModalDetail/Request/RequestViewController.swift +++ b/Library/Scenes/ModalDetail/Request/RequestViewController.swift @@ -11,6 +11,8 @@ import Lightning final class RequestViewController: ModalDetailViewController { private weak var topSeparator: UIView? private weak var lightningButton: CallbackButton? + private weak var qrCodeButton: CallbackButton? + private weak var lightningButtonContainer: UIStackView? private weak var orSeparator: UIView? private weak var onChainButton: CallbackButton? private weak var titleLabel: UILabel? @@ -20,7 +22,8 @@ final class RequestViewController: ModalDetailViewController { private weak var memoTextField: UITextField? private var viewModel: RequestViewModel - + private var presentQrCode: () -> Void + private var currentState = State.methodSelection { didSet { guard oldValue != currentState else { return } @@ -42,7 +45,7 @@ final class RequestViewController: ModalDetailViewController { viewController.amountInputView?.becomeFirstResponder() viewController.amountInputView?.setKeypad(hidden: false, animated: true) viewController.topSeparator?.isHidden = false - viewController.lightningButton?.isHidden = true + viewController.lightningButtonContainer?.isHidden = true viewController.orSeparator?.isHidden = true viewController.onChainButton?.isHidden = true viewController.amountInputView?.isHidden = false @@ -60,8 +63,9 @@ final class RequestViewController: ModalDetailViewController { } } - init(viewModel: RequestViewModel) { + init(viewModel: RequestViewModel, presentQrCode: @escaping () -> Void) { self.viewModel = viewModel + self.presentQrCode = presentQrCode super.init(nibName: nil, bundle: nil) } @@ -109,16 +113,50 @@ final class RequestViewController: ModalDetailViewController { } private func setupRequestMethodSelection() { + let lightningStackView = UIStackView() + lightningStackView.spacing = 2 + lightningStackView.axis = .horizontal + lightningStackView.distribution = .fill + lightningButtonContainer = lightningStackView + let lightningImage = Asset.iconRequestLightningButton.image let lightningButtonStyle = Style.Button.background.with({ $0.setImage(lightningImage, for: .normal) $0.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 10) + $0.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMinXMinYCorner] + $0.contentEdgeInsets = UIEdgeInsets(top: 0, left: 56, bottom: 0, right: 0) }) - lightningButton = contentStackView.addArrangedElement(.customHeight(56, element: .button(title: L10n.Scene.Request.lightningButton, style: lightningButtonStyle, completion: { [weak self] _ in + lightningButton = lightningStackView.addArrangedElement(.customHeight(56, element: .button(title: L10n.Scene.Request.lightningButton, style: lightningButtonStyle, completion: { [weak self] _ in self?.presentAmountInput(requestMethod: .lightning) }))) as? CallbackButton lightningButton?.accessibilityIdentifier = "Lightning" + let qrCodeImage = Asset.iconQrCode.image + let qrCodeButtonStyle = Style.Button.background.with({ + $0.setImage(qrCodeImage, for: .normal) + $0.layer.maskedCorners = [.layerMaxXMaxYCorner, .layerMaxXMinYCorner] + }) + qrCodeButton = lightningStackView.addArrangedElement(.customHeight(56, element: .button(title: nil, style: qrCodeButtonStyle, completion: { [weak self] _ in + self?.presentQrCodeScanner() + }))) as? CallbackButton + if let qrCodeButton = qrCodeButton { + qrCodeButton.addConstraint(qrCodeButton.heightAnchor.constraint(equalTo: qrCodeButton.widthAnchor, multiplier: 1)) + } + contentStackView.addArrangedElement(.customView(lightningStackView)) + + addOrSeparator() + + let onChainImage = Asset.iconRequestOnChainButton.image + let onChainButtonStyle = Style.Button.background.with({ + $0.setImage(onChainImage, for: .normal) + $0.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 10) + }) + onChainButton = contentStackView.addArrangedElement(.customHeight(56, element: .button(title: L10n.Scene.Request.onChainButton, style: onChainButtonStyle, completion: { [weak self] _ in + self?.presentAmountInput(requestMethod: .onChain) + }))) as? CallbackButton + } + + private func addOrSeparator() { let horizontalStackView = UIStackView() horizontalStackView.spacing = 15 horizontalStackView.axis = .horizontal @@ -133,15 +171,6 @@ final class RequestViewController: ModalDetailViewController { contentStackView.addArrangedElement(.customView(horizontalStackView)) leftSeparator.widthAnchor.constraint(equalTo: rightSeparator.widthAnchor, multiplier: 1, constant: 0).isActive = true self.orSeparator = horizontalStackView - - let onChainImage = Asset.iconRequestOnChainButton.image - let onChainButtonStyle = Style.Button.background.with({ - $0.setImage(onChainImage, for: .normal) - $0.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 10) - }) - onChainButton = contentStackView.addArrangedElement(.customHeight(56, element: .button(title: L10n.Scene.Request.onChainButton, style: onChainButtonStyle, completion: { [weak self] _ in - self?.presentAmountInput(requestMethod: .onChain) - }))) as? CallbackButton } private func updateHeaderImage(for requestMethod: Layer) { @@ -173,6 +202,12 @@ final class RequestViewController: ModalDetailViewController { amountInputView?.subtitleTextColor = UIColor.Zap.gray } } + + private func presentQrCodeScanner() { + dismiss(animated: true) { [weak self] in + self?.presentQrCode() + } + } private func bottomButtonTapped() { switch currentState { diff --git a/Library/Scenes/QRCodeScanner/CombinedQRCodeScannetStrategy.swift b/Library/Scenes/QRCodeScanner/CombinedQRCodeScannetStrategy.swift new file mode 100644 index 000000000..9b65b8f8b --- /dev/null +++ b/Library/Scenes/QRCodeScanner/CombinedQRCodeScannetStrategy.swift @@ -0,0 +1,46 @@ +// +// Library +// +// Created by 0 on 08.11.19. +// Copyright © 2019 Zap. All rights reserved. +// + +import Foundation + +final class CombinedQRCodeScannetStrategy: QRCodeScannerStrategy { + var title: String { + return strategies[0].title + } + + var pasteButtonTitle: String { + return strategies[0].pasteButtonTitle + } + + let strategies: [QRCodeScannerStrategy] + + init(strategies: [QRCodeScannerStrategy]) { + self.strategies = strategies + } + + func viewControllerForAddress(address: String, completion: @escaping (Result) -> Void) { + firstViewController(strategies: strategies, address: address, completion: completion) + } + + func firstViewController(strategies: [QRCodeScannerStrategy], address: String, completion: @escaping (Result) -> Void) { + var strategies = Array(strategies.reversed()) + if let strategy = strategies.popLast() { + strategy.viewControllerForAddress(address: address) { [weak self] result in + switch result { + case .success: + completion(result) + case .failure: + if strategies.isEmpty { + completion(result) + } else { + self?.firstViewController(strategies: strategies, address: address, completion: completion) + } + } + } + } + } +} diff --git a/Library/Scenes/QRCodeScanner/QRCodeScannerViewController.swift b/Library/Scenes/QRCodeScanner/QRCodeScannerViewController.swift index 655052b79..bc40051fc 100644 --- a/Library/Scenes/QRCodeScanner/QRCodeScannerViewController.swift +++ b/Library/Scenes/QRCodeScanner/QRCodeScannerViewController.swift @@ -103,9 +103,11 @@ class QRCodeScannerViewController: UIViewController { } func presentViewController(_ viewController: UIViewController) { - guard let modalDetailViewController = viewController as? ModalDetailViewController else { fatalError("presented view is not of type ModalDetailViewController") } - modalDetailViewController.delegate = self - present(modalDetailViewController, animated: true, completion: nil) + if let modalDetailViewController = viewController as? ModalDetailViewController { + modalDetailViewController.delegate = self + } + + present(viewController, animated: true) } @objc private func pasteButtonTapped(_ sender: Any) { diff --git a/Library/Views/ModalDetailViewController.swift b/Library/Views/ModalDetailViewController.swift index 548e75dee..924fda512 100644 --- a/Library/Views/ModalDetailViewController.swift +++ b/Library/Views/ModalDetailViewController.swift @@ -12,11 +12,31 @@ protocol ModalDetailViewControllerDelegate: class { func childWillDisappear() } -class ModalDetailViewController: ModalViewController { +protocol ParentDismissable {} + +extension ParentDismissable where Self: UIViewController { + func dismissParent() { + // if presented from QR Code VC, also dismiss the QRCode VC + if let presentingViewController = presentingViewController, + presentingViewController.presentingViewController != nil { + // fixes the dismiss animation of two modals at once + if let snapshotView = view.superview?.snapshotView(afterScreenUpdates: false) { + snapshotView.frame.origin.y = presentingViewController.view.frame.height - snapshotView.frame.height + presentingViewController.view.addSubview(snapshotView) + } + + presentingViewController.presentingViewController?.dismiss(animated: true, completion: nil) + } else { + dismiss(animated: true, completion: nil) + } + } +} + +class ModalDetailViewController: ModalViewController, ParentDismissable { private let closeButton: UIButton = { let closeButton = UIButton(type: .custom) closeButton.setImage(Asset.iconClose.image, for: .normal) - closeButton.addTarget(self, action: #selector(dismissParent), for: .touchUpInside) + closeButton.addTarget(self, action: #selector(close), for: .touchUpInside) return closeButton }() @@ -91,26 +111,14 @@ class ModalDetailViewController: ModalViewController { delegate?.childWillDisappear() } - @objc func dismissParent() { - // if presented from QR Code VC, also dismiss the QRCode VC - if let presentingViewController = presentingViewController, - presentingViewController.presentingViewController != nil { - // fixes the dismiss animation of two modals at once - if let snapshotView = view.superview?.snapshotView(afterScreenUpdates: false) { - snapshotView.frame.origin.y = presentingViewController.view.frame.height - snapshotView.frame.height - presentingViewController.view.addSubview(snapshotView) - } - - presentingViewController.presentingViewController?.dismiss(animated: true, completion: nil) - } else { - dismiss(animated: true, completion: nil) - } - } - func addHeadline(_ headline: String) { contentStackView.addArrangedElement(.label(text: headline, style: Style.Label.headline.with({ $0.textAlignment = .center }))) contentStackView.addArrangedElement(.separator) } + + @objc func close() { + self.dismissParent() + } } extension ModalDetailViewController: ContentHeightProviding { diff --git a/Library/en.lproj/Localizable.strings b/Library/en.lproj/Localizable.strings index 904b38f0f..2d4d763c4 100644 --- a/Library/en.lproj/Localizable.strings +++ b/Library/en.lproj/Localizable.strings @@ -319,6 +319,13 @@ "scene.my_node.support.title" = "Support"; "scene.my_node.support.subtitle" = "Get support and send feedback"; +"scene.lnurl_qrcode_scanner.withdraw.title" = "Withdraw"; +"scene.lnurl_qrcode_scanner.withdraw.button_title" = "Paste LNURL"; + +"scene.lnurl.withdraw.title" = "Withdraw"; +"scene.lnurl.withdraw.description" = "Description"; +"scene.lnurl.withdraw.button_title" = "Withdraw"; + "notification.sync.title" = "Please sync"; "notification.sync.day_12.body" = "Please remember syncing your wallet to make sure you stay safe"; "notification.sync.day_13.body" = "You have one more day to sync your wallet before it's getting dangerous."; diff --git a/Lightning/LNURL/LNURL.swift b/Lightning/LNURL/LNURL.swift new file mode 100644 index 000000000..2de630ae1 --- /dev/null +++ b/Lightning/LNURL/LNURL.swift @@ -0,0 +1,145 @@ +// +// Lightning +// +// Created by 0 on 28.10.19. +// Copyright © 2019 Zap. All rights reserved. +// + +import Foundation +import SwiftBTC +import SwiftLnd + +private extension String { + var isValidURL: Bool { + if + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue), + let match = detector.firstMatch(in: self, options: [], range: NSRange(location: 0, length: self.utf16.count)) { + // it is a link, if the match covers the whole string + return match.range.length == self.utf16.count + } else { + return false + } + } +} + +public enum LNURL { + case withdraw(LNURLWithdrawRequest) + + public enum LNURLError: Error { + case invalidBech32 + case urlError(Error) + case jsonError + case statusError(String) + case unknownError + case unsupported + + init?(status: LNURLStatus) { + guard + status.status == .error, + let reason = status.reason + else { return nil } + self = .statusError(reason) + } + } + + public static func parse(string: String, completion: @escaping (Result) -> Void) { + switch decodeBech32(string: string) { + case .success(let data): + loadJSON(data: data, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + + private static func loadJSON(data: Data, completion: @escaping (Result) -> Void) { + if let dataString = String(data: data, encoding: .utf8), + dataString.isValidURL, + let url = URL(string: dataString) { + + fetch(url: url) { result in + switch result { + case .success(let data): + completion(parseJSON(data: data, domain: url)) + case .failure(let error): + completion(.failure(error)) + } + } + } else { + completion(parseJSON(data: data, domain: nil)) + } + } + + // internal to make tests work + internal static func decodeBech32(string: String) -> Result { + if let (hrp, data) = Bech32.decode(string, limit: false), + hrp == "lnurl", + let convertedData = data.convertBits(fromBits: 5, toBits: 8, pad: false) { + return .success(convertedData) + } else { + return .failure(.invalidBech32) + } + } + + private static func fetch(url: URL, completion: @escaping (Result) -> Void) { + let task = URLSession.shared.dataTask(with: url) { data, _, error in + if let error = error { + completion(.failure(.urlError(error))) + } else if let data = data { + completion(.success(data)) + } + } + task.resume() + } + + private static func parseJSON(data: Data, domain: URL?) -> Result { + if + let jsonData = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let tag = jsonData["tag"] as? String { + switch tag { + case "withdrawRequest": + guard let withdrawRequestJSON = try? JSONDecoder().decode(LNURLWithdrawRequestJSON.self, from: data) else { return .failure(.unknownError) } + return .success(.withdraw(LNURLWithdrawRequest(lnurlWithdrawRequestJSON: withdrawRequestJSON, domain: domain))) + default: + return .failure(.unsupported) + } + } else if + let status = try? JSONDecoder().decode(LNURLStatus.self, from: data), + let error = LNURLError(status: status) { + return .failure(error) + } + + return .failure(.unknownError) + } +} + +// MARK: - Withdraw +extension LNURL { + public static func withdraw(lightningService: LightningService, request: LNURLWithdrawRequest, amount: Satoshi, completion: @escaping (Result) -> Void) { + lightningService.transactionService.addInvoice(amount: amount, memo: request.description, expiry: .oneHour) { result in + switch result { + case .success(let invoice): + // Once accepted user software issues an HTTPS GET request using ?k1=&pr=. + let urlString = "\(request.callbackURL)?k1=\(request.ephemeralSecret)&pr=\(invoice)" + if let url = URL(string: urlString) { + LNURL.fetch(url: url) { + completion($0.flatMap { + if let status = try? JSONDecoder().decode(LNURLStatus.self, from: $0) { + if let error = LNURLError(status: status) { + return .failure(error) + } else { + return .success(Success()) + } + } else { + return .failure(.jsonError) + } + }) + } + } else { + completion(.failure(.unknownError)) + } + case .failure: + completion(.failure(.unknownError)) + } + } + } +} diff --git a/Lightning/LNURL/LNURLStatus.swift b/Lightning/LNURL/LNURLStatus.swift new file mode 100644 index 000000000..de1937177 --- /dev/null +++ b/Lightning/LNURL/LNURLStatus.swift @@ -0,0 +1,18 @@ +// +// Lightning +// +// Created by 0 on 11.11.19. +// Copyright © 2019 Zap. All rights reserved. +// + +import Foundation + +public struct LNURLStatus: Decodable { + public enum Status: String, Codable { + case ok = "OK" // swiftlint:disable:this identifier_name + case error = "ERROR" + } + + public let status: Status + public let reason: String? +} diff --git a/Lightning/LNURL/LNURLWithdrawRequest.swift b/Lightning/LNURL/LNURLWithdrawRequest.swift new file mode 100644 index 000000000..f8f696c55 --- /dev/null +++ b/Lightning/LNURL/LNURLWithdrawRequest.swift @@ -0,0 +1,59 @@ +// +// Lightning +// +// Created by 0 on 11.11.19. +// Copyright © 2019 Zap. All rights reserved. +// + +import Foundation +import SwiftBTC + +typealias MilliSatoshi = Decimal + +struct LNURLWithdrawRequestJSON: Decodable { + /// a second-level url which would accept a withdrawal Lightning invoice as query parameter + let callback: String // swiftlint:disable:this callback_naming + /// an ephemeral secret which would allow user to withdraw funds + let k1: String // swiftlint:disable:this identifier_name + /// max withdrawable amount for a given user on a given service + public let maxWithdrawable: MilliSatoshi + /// A default withdrawal invoice description + public let defaultDescription: String + /// An optional field, defaults to 1 MilliSatoshi if not present, can not be less than 1 or more than `maxWithdrawable` + public let minWithdrawable: MilliSatoshi? +} + +public extension Satoshi { + var floored: Satoshi { + var value = self + var result: Decimal = 0 + NSDecimalRound(&result, &value, 0, .down) + return result + } +} + +public struct LNURLWithdrawRequest { + let callbackURL: String + let ephemeralSecret: String + public let description: String + public let maxWithdrawable: Satoshi + public let minWithdrawable: Satoshi + + public let domain: URL? + + init(lnurlWithdrawRequestJSON request: LNURLWithdrawRequestJSON, domain: URL?) { + callbackURL = request.callback + ephemeralSecret = request.k1 + description = request.defaultDescription + + maxWithdrawable = (request.maxWithdrawable / 1000).floored + + if let minWithdrawable = request.minWithdrawable { + self.minWithdrawable = (minWithdrawable / 1000).floored + } else { + minWithdrawable = 1 + } + + self.domain = domain + } +} diff --git a/Lightning/Tests/LNURLTests.swift b/Lightning/Tests/LNURLTests.swift new file mode 100644 index 000000000..a22523c47 --- /dev/null +++ b/Lightning/Tests/LNURLTests.swift @@ -0,0 +1,19 @@ +// +// Lightning +// +// Created by 0 on 29.10.19. +// Copyright © 2019 Zap. All rights reserved. +// + +@testable import Lightning +import XCTest + +class LNURLTests: XCTestCase { + func testLNURLDecoding() { + let string = "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS" + + let data = try! LNURL.decodeBech32(string: string).get() // swiftlint:disable:this force_try + let decodedString = String(data: data, encoding: .utf8) + XCTAssertEqual(decodedString, "https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df") + } +} diff --git a/Zap.xcodeproj/project.pbxproj b/Zap.xcodeproj/project.pbxproj index cf935301b..1fcdbbc4a 100644 --- a/Zap.xcodeproj/project.pbxproj +++ b/Zap.xcodeproj/project.pbxproj @@ -311,6 +311,13 @@ ADB4CBA4225493A300D186BC /* ChannelEventUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB4CBA3225493A300D186BC /* ChannelEventUpdate.swift */; }; ADB4CBA62254AEEC00D186BC /* ListUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB4CBA52254AEEC00D186BC /* ListUpdater.swift */; }; ADB4CBA82254BE3400D186BC /* DateEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB4CBA72254BE3400D186BC /* DateEstimator.swift */; }; + ADBC29BE2375CE7C0097D0F6 /* CombinedQRCodeScannetStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADBC29BD2375CE7C0097D0F6 /* CombinedQRCodeScannetStrategy.swift */; }; + ADBC29C12375D4C70097D0F6 /* LNURLWithdrawViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADBC29C02375D4C70097D0F6 /* LNURLWithdrawViewController.swift */; }; + ADBC29C32375D4E30097D0F6 /* LNURLWithdraw.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = ADBC29C22375D4E30097D0F6 /* LNURLWithdraw.storyboard */; }; + ADBC29C52375D5170097D0F6 /* LNURLWithdrawQRCodeScannetStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADBC29C42375D5170097D0F6 /* LNURLWithdrawQRCodeScannetStrategy.swift */; }; + ADBC29C72375DAB70097D0F6 /* LNURLWithdrawViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADBC29C62375DAB70097D0F6 /* LNURLWithdrawViewModel.swift */; }; + ADBC29CC23795ADB0097D0F6 /* LNURLStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADBC29CB23795ADB0097D0F6 /* LNURLStatus.swift */; }; + ADBC29CE23795AF30097D0F6 /* LNURLWithdrawRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADBC29CD23795AF30097D0F6 /* LNURLWithdrawRequest.swift */; }; ADC08D8B21DBBB16000E730E /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = ADC08D8A21DBBB16000E730E /* README.md */; }; ADC0D2F02107953100B8C62F /* PinnedHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADC0D2EF2107953100B8C62F /* PinnedHost.swift */; }; ADC0D2F321079FF700B8C62F /* www.blockchain.com.cer in Resources */ = {isa = PBXBuildFile; fileRef = ADC0D2F221079FF600B8C62F /* www.blockchain.com.cer */; }; @@ -364,6 +371,8 @@ ADF4C3DE2367045F00C0E5B2 /* NotificationView.xib in Resources */ = {isa = PBXBuildFile; fileRef = ADF4C3DD2367045F00C0E5B2 /* NotificationView.xib */; }; ADF4C3E02367046B00C0E5B2 /* NotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF4C3DF2367046B00C0E5B2 /* NotificationView.swift */; }; ADF4C3E22367054C00C0E5B2 /* NotificationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF4C3E12367054C00C0E5B2 /* NotificationViewModel.swift */; }; + ADF4C3E62367591200C0E5B2 /* LNURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF4C3E52367591200C0E5B2 /* LNURL.swift */; }; + ADF4C3E923682E8700C0E5B2 /* LNURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF4C3E723682E8100C0E5B2 /* LNURLTests.swift */; }; ADFA46982226947A007A4C49 /* LndConnectURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFA46972226947A007A4C49 /* LndConnectURLTests.swift */; }; ADFAA01B228AED0D000AECA1 /* Sectigo RSA Domain Validation Secure Server CA 2.cer in Resources */ = {isa = PBXBuildFile; fileRef = ADFAA01A228AED0D000AECA1 /* Sectigo RSA Domain Validation Secure Server CA 2.cer */; }; ADFAA01D228C0F10000AECA1 /* FeeEstimate.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFAA01C228C0F10000AECA1 /* FeeEstimate.swift */; }; @@ -916,6 +925,13 @@ ADBB36282080B2AE005662B4 /* TestingTemplates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingTemplates.swift; sourceTree = ""; }; ADBB362E2084CA16005662B4 /* MnemonicPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MnemonicPageViewController.swift; sourceTree = ""; }; ADBB36302084CA3B005662B4 /* MnemonicWordListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MnemonicWordListViewController.swift; sourceTree = ""; }; + ADBC29BD2375CE7C0097D0F6 /* CombinedQRCodeScannetStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedQRCodeScannetStrategy.swift; sourceTree = ""; }; + ADBC29C02375D4C70097D0F6 /* LNURLWithdrawViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LNURLWithdrawViewController.swift; sourceTree = ""; }; + ADBC29C22375D4E30097D0F6 /* LNURLWithdraw.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LNURLWithdraw.storyboard; sourceTree = ""; }; + ADBC29C42375D5170097D0F6 /* LNURLWithdrawQRCodeScannetStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LNURLWithdrawQRCodeScannetStrategy.swift; sourceTree = ""; }; + ADBC29C62375DAB70097D0F6 /* LNURLWithdrawViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LNURLWithdrawViewModel.swift; sourceTree = ""; }; + ADBC29CB23795ADB0097D0F6 /* LNURLStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LNURLStatus.swift; sourceTree = ""; }; + ADBC29CD23795AF30097D0F6 /* LNURLWithdrawRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LNURLWithdrawRequest.swift; sourceTree = ""; }; ADBCC07420CC04A4002B6E59 /* TransactionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionService.swift; sourceTree = ""; }; ADC08D8A21DBBB16000E730E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; ADC0D2EF2107953100B8C62F /* PinnedHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedHost.swift; sourceTree = ""; }; @@ -991,6 +1007,8 @@ ADF4C3DD2367045F00C0E5B2 /* NotificationView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NotificationView.xib; sourceTree = ""; }; ADF4C3DF2367046B00C0E5B2 /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = ""; }; ADF4C3E12367054C00C0E5B2 /* NotificationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewModel.swift; sourceTree = ""; }; + ADF4C3E52367591200C0E5B2 /* LNURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LNURL.swift; sourceTree = ""; }; + ADF4C3E723682E8100C0E5B2 /* LNURLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LNURLTests.swift; sourceTree = ""; }; ADF79363208DCB630086B2CA /* PinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinView.swift; sourceTree = ""; }; ADF79365208E000C0086B2CA /* SetupPinViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupPinViewController.swift; sourceTree = ""; }; ADF79367208E09D00086B2CA /* SetupPinViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupPinViewModel.swift; sourceTree = ""; }; @@ -1188,6 +1206,7 @@ ADCE01B9207B57DA00CA72E8 /* CreateWallet */, A0DDC0732018C74F00AEFF94 /* History */, AD9E243C20A367E00014F9FA /* LndLog */, + ADBC29BF2375D4AE0097D0F6 /* Lnurl */, ADCE01CA207B966200CA72E8 /* Loading */, ADA58F9622049007009A5494 /* ManageWallets */, ADEEB5342134668B00D2F992 /* ModalPin */, @@ -1703,6 +1722,7 @@ ADEF96BB21078C31006AE1EC /* Certificates */, ADF093042147C6F000B48853 /* Events */, AD28844221281F9E003C3D94 /* Invoice */, + ADF4C3E4236758FD00C0E5B2 /* LNURL */, AD967FE12105FD400048085B /* QrCodes */, A07C1034203DE3D0006C7FE8 /* Services */, AD536BF420E62764002279BC /* Tests */, @@ -1721,6 +1741,7 @@ A0733CD920471F0A0005643A /* LightningInvoiceURITests.swift */, AD28844821282139003C3D94 /* LightningNodeURITests.swift */, ADFA46972226947A007A4C49 /* LndConnectURLTests.swift */, + ADF4C3E723682E8100C0E5B2 /* LNURLTests.swift */, ); path = Tests; sourceTree = ""; @@ -1863,6 +1884,7 @@ A06660F4202D97A300EE32FA /* QRCodeScannerView.swift */, A024B85220161560002B8897 /* QRCodeScannerViewController.swift */, ADA6BBBE211B3AEC00A1FF19 /* ScanRectView.swift */, + ADBC29BD2375CE7C0097D0F6 /* CombinedQRCodeScannetStrategy.swift */, ); path = QRCodeScanner; sourceTree = ""; @@ -2031,6 +2053,17 @@ path = KeyPad; sourceTree = ""; }; + ADBC29BF2375D4AE0097D0F6 /* Lnurl */ = { + isa = PBXGroup; + children = ( + ADBC29C22375D4E30097D0F6 /* LNURLWithdraw.storyboard */, + ADBC29C42375D5170097D0F6 /* LNURLWithdrawQRCodeScannetStrategy.swift */, + ADBC29C02375D4C70097D0F6 /* LNURLWithdrawViewController.swift */, + ADBC29C62375DAB70097D0F6 /* LNURLWithdrawViewModel.swift */, + ); + path = Lnurl; + sourceTree = ""; + }; ADC0D2F82107A19F00B8C62F /* blockcypher.com */ = { isa = PBXGroup; children = ( @@ -2233,6 +2266,16 @@ path = NotificationView; sourceTree = ""; }; + ADF4C3E4236758FD00C0E5B2 /* LNURL */ = { + isa = PBXGroup; + children = ( + ADF4C3E52367591200C0E5B2 /* LNURL.swift */, + ADBC29CB23795ADB0097D0F6 /* LNURLStatus.swift */, + ADBC29CD23795AF30097D0F6 /* LNURLWithdrawRequest.swift */, + ); + path = LNURL; + sourceTree = ""; + }; ADF79373208F87E60086B2CA /* Confirm Mnemonic */ = { isa = PBXGroup; children = ( @@ -2739,6 +2782,7 @@ AD7B1F00224253E8007D4171 /* WalletListActionCell.xib in Resources */, AD391D1120D16FF5007EE22A /* Toast.xib in Resources */, AD391D0420D16FF5007EE22A /* AmountInputView.xib in Resources */, + ADBC29C32375D4E30097D0F6 /* LNURLWithdraw.storyboard in Resources */, AD3DDF6922D38D4C0031BFC8 /* Onboarding.storyboard in Resources */, AD27022722E1C78800D4BF27 /* PushNotification.storyboard in Resources */, ADA58F9A22049047009A5494 /* ManageWallets.storyboard in Resources */, @@ -3116,6 +3160,7 @@ AD7EDAE52108C41D0058CCCA /* WalletViewController.swift in Sources */, AD391C5B20D16F7A007EE22A /* MnemonicViewController.swift in Sources */, AD8B656C22E5F73600E9DBD5 /* SkeletonView.swift in Sources */, + ADBC29C12375D4C70097D0F6 /* LNURLWithdrawViewController.swift in Sources */, AD391CA120D16FA8007EE22A /* RootViewController.swift in Sources */, ADA2DCFF225CC817007482D9 /* SuggestedPeers.swift in Sources */, AD391C2420D16EFA007EE22A /* Array.swift in Sources */, @@ -3149,6 +3194,7 @@ AD667E572136E622007B9160 /* TimeLockStore.swift in Sources */, AD4B4BE122EAF0CE0029773A /* EmptyStateView.swift in Sources */, AD1831C2229676CB00120260 /* OnChainFeeViewModel.swift in Sources */, + ADBC29C72375DAB70097D0F6 /* LNURLWithdrawViewModel.swift in Sources */, AD7ED61D22087812007C5F2C /* ManageWalletTableViewCell.swift in Sources */, AD4F7659232BCF0800F78184 /* SyncView.swift in Sources */, AD391C5320D16F7A007EE22A /* ConfirmMnemonicViewModel.swift in Sources */, @@ -3200,6 +3246,7 @@ ADFC816F20DA80EF00D26913 /* RecoverWalletViewModel.swift in Sources */, AD391C5220D16F7A007EE22A /* ConfirmMnemonicViewController.swift in Sources */, AD391C3D20D16F0B007EE22A /* BiometricAuthentication.swift in Sources */, + ADBC29BE2375CE7C0097D0F6 /* CombinedQRCodeScannetStrategy.swift in Sources */, ADCEAFD32255F2C2004F605B /* WarningSettingsItem.swift in Sources */, AD391C3020D16EFA007EE22A /* UICollectionView.swift in Sources */, AD293D4822DC84E70069A8AB /* MnemonicWordView.swift in Sources */, @@ -3272,6 +3319,7 @@ AD391C4720D16F68007EE22A /* ContainerViewController.swift in Sources */, AD391CB320D16FA8007EE22A /* GroupedTableViewController.swift in Sources */, ADC2FC9B22F1D97900902D43 /* ChannelListEmptyStateViewModel.swift in Sources */, + ADBC29C52375D5170097D0F6 /* LNURLWithdrawQRCodeScannetStrategy.swift in Sources */, ADFDE94622E7603F00B718A4 /* ManageWalletsViewModel.swift in Sources */, AD81C21721B80FCD00FA3FA9 /* SafariSettingsItem.swift in Sources */, AD667E522136B062007B9160 /* TimeLockedViewController.swift in Sources */, @@ -3315,6 +3363,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + ADBC29CC23795ADB0097D0F6 /* LNURLStatus.swift in Sources */, AD3781C421661A070083CE77 /* Pem.swift in Sources */, AD967FF8210731110048085B /* PinnedURLSessionDelegate.swift in Sources */, AD1D378B2253C9F900F32BE1 /* PaymentListUpdater.swift in Sources */, @@ -3325,6 +3374,7 @@ ADFEFA6E20E627F100FE1557 /* TransactionService.swift in Sources */, AD511B2C2253C14800EDFC7C /* InvoiceListUpdater.swift in Sources */, ADFEFA7920E62B1800FE1557 /* BlockChainHeightUpdater.swift in Sources */, + ADBC29CE23795AF30097D0F6 /* LNURLWithdrawRequest.swift in Sources */, AD967FE32105FD770048085B /* BTCPayQRCode.swift in Sources */, ADF0930F2148FA7300B48853 /* InvoiceEvent.swift in Sources */, ADF1BB3021997A820078BCC4 /* LndConnectURL.swift in Sources */, @@ -3334,6 +3384,7 @@ ADF093112148FDC600B48853 /* LightningPaymentEvent.swift in Sources */, ADF093132148FEB900B48853 /* ChannelEvent.swift in Sources */, ADB4CBA22254905100D186BC /* ChannelListUpdater.swift in Sources */, + ADF4C3E62367591200C0E5B2 /* LNURL.swift in Sources */, AD28844321281FAC003C3D94 /* LightningInvoiceURI.swift in Sources */, ADE64E0F22297311004C0BC8 /* ExchangeRates.swift in Sources */, ADFEFA6A20E627F100FE1557 /* BalanceService.swift in Sources */, @@ -3362,6 +3413,7 @@ AD511B2A2253748F00EDFC7C /* VersionTests.swift in Sources */, AD2884472128209B003C3D94 /* LightningInvoiceURITests.swift in Sources */, AD967FE7210603D50048085B /* BTCPayConfigurationTests.swift in Sources */, + ADF4C3E923682E8700C0E5B2 /* LNURLTests.swift in Sources */, ADFA46982226947A007A4C49 /* LndConnectURLTests.swift in Sources */, AD28844D21284DAC003C3D94 /* InvoiceTests.swift in Sources */, ADC48CA7214AD8B800949CD9 /* DateEstimatorTests.swift in Sources */,