diff --git a/Library/Extensions/Localizable/ExpiryTime+Localizable.swift b/Library/Extensions/Localizable/ExpiryTime+Localizable.swift index 43f610070..013d2ad19 100644 --- a/Library/Extensions/Localizable/ExpiryTime+Localizable.swift +++ b/Library/Extensions/Localizable/ExpiryTime+Localizable.swift @@ -12,23 +12,23 @@ extension ExpiryTime: Localizable { public var localized: String { switch self { case .oneMinute: - return L10n.ExpiryTime.oneMinute + return L10n.ExpiryTime.oneMinute case .tenMinutes: - return L10n.ExpiryTime.tenMinutes + return L10n.ExpiryTime.tenMinutes case .thirtyMinutes: - return L10n.ExpiryTime.thirtyMinutes + return L10n.ExpiryTime.thirtyMinutes case .oneHour: - return L10n.ExpiryTime.oneHour + return L10n.ExpiryTime.oneHour case .sixHours: - return L10n.ExpiryTime.sixHours + return L10n.ExpiryTime.sixHours case .oneDay: - return L10n.ExpiryTime.oneDay + return L10n.ExpiryTime.oneDay case .oneWeek: - return L10n.ExpiryTime.oneWeek + return L10n.ExpiryTime.oneWeek case .thirtyDays: - return L10n.ExpiryTime.thirtyDays + return L10n.ExpiryTime.thirtyDays case .oneYear: - return L10n.ExpiryTime.oneYear - } + return L10n.ExpiryTime.oneYear + } } } diff --git a/Library/Extensions/Localizable/PaymentFeeLimitPercentage+Localizable.swift b/Library/Extensions/Localizable/PaymentFeeLimitPercentage+Localizable.swift new file mode 100644 index 000000000..57c2b9553 --- /dev/null +++ b/Library/Extensions/Localizable/PaymentFeeLimitPercentage+Localizable.swift @@ -0,0 +1,19 @@ +// +// Library +// +// Created by Christopher Pinski on 10/26/19. +// Copyright © 2019 Zap. All rights reserved. +// + +import Foundation + +extension PaymentFeeLimitPercentage: Localizable { + public var localized: String { + switch self { + case .none: + return L10n.PaymentFeeLimitPercentage.none + default: + return self.rawValue.formattedAsPercentage + } + } +} diff --git a/Library/Extensions/UIAlertController.swift b/Library/Extensions/UIAlertController.swift index af37f02c8..8a561a1a1 100644 --- a/Library/Extensions/UIAlertController.swift +++ b/Library/Extensions/UIAlertController.swift @@ -45,4 +45,20 @@ extension UIAlertController { return alertController } + + static func feeLimitAlertController(message: String, sendAction: @escaping () -> Void) -> UIAlertController { + let title: String = L10n.Scene.Send.FeeAlert.title + let confirmButtonTitle: String = L10n.Scene.Send.FeeAlert.ConfirmButton.title + + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + let cancelAlertAction = UIAlertAction(title: L10n.Generic.cancel, style: .cancel, handler: nil) + + let confirmAlertAction = UIAlertAction(title: confirmButtonTitle, style: .default) { _ in + sendAction() + } + alertController.addAction(cancelAlertAction) + alertController.addAction(confirmAlertAction) + + return alertController + } } diff --git a/Library/Generated/strings.swift b/Library/Generated/strings.swift index 1e65f3ccf..731b8d5c5 100644 --- a/Library/Generated/strings.swift +++ b/Library/Generated/strings.swift @@ -155,6 +155,11 @@ internal enum L10n { } } } + + internal enum PaymentFeeLimitPercentage { + /// None + internal static let none = L10n.tr("Localizable", "payment_fee_limit_percentage.none") + } internal enum RpcConnectQrcodeError { /// Could not read BTCPay configurations file. @@ -633,9 +638,27 @@ internal enum L10n { internal static let successLabel = L10n.tr("Localizable", "scene.send.success_label") /// Send internal static let title = L10n.tr("Localizable", "scene.send.title") + /// The fee for this payment (%@) exceeds the limit specified in the settings (%@). + internal static func feeExceedsUserLimit(_ p1: String, _ p2: String) -> String { + return L10n.tr("Localizable", "scene.send.fee_exceeds_user_limit", p1, p2) + } + /// The fee for this payment (%@ sats) will be higher than the payment amount (%@ sats). + internal static func feeExceedsPayment(_ p1: String, _ p2: String) -> String { + return L10n.tr("Localizable", "scene.send.fee_exceeds_payment_amount", p1, p2) + } + internal enum FeeAlert { + /// Fee Limit Alert + internal static let title = L10n.tr("Localizable", "scene.send.fee_alert.title") + internal enum ConfirmButton { + /// Yes + internal static let title = L10n.tr("Localizable", "scene.send.fee_alert.confirm_button.title") + } + } internal enum Lightning { /// Send Lightning Payment internal static let title = L10n.tr("Localizable", "scene.send.lightning.title") + /// Do you really want to pay this invoice? + internal static let paymentConfirmation = L10n.tr("Localizable", "scene.send.lightning.payment_confirmation") } internal enum OnChain { /// Fee: @@ -694,6 +717,8 @@ internal enum L10n { internal static let currency = L10n.tr("Localizable", "scene.settings.item.currency") /// Need Help? internal static let help = L10n.tr("Localizable", "scene.settings.item.help") + /// Lightning Payment Fee Limit + internal static let lightningPaymentFeeLimit = L10n.tr("Localizable", "scene.settings.item.lightning_payment_fee_limit") /// Lightning Request Expiry internal static let lightningRequestExpiry = L10n.tr("Localizable", "scene.settings.item.lightning_request_expiry") /// Show lnd Log diff --git a/Library/Scenes/ModalDetail/Send/SendViewController.swift b/Library/Scenes/ModalDetail/Send/SendViewController.swift index 77f08a15b..218050d13 100644 --- a/Library/Scenes/ModalDetail/Send/SendViewController.swift +++ b/Library/Scenes/ModalDetail/Send/SendViewController.swift @@ -11,6 +11,29 @@ import SwiftBTC import SwiftLnd import UIKit +extension Formatter { + static let asPercentage: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + formatter.minimumSignificantDigits = 1 + formatter.maximumSignificantDigits = 3 + formatter.multiplier = 1 + return formatter + }() +} + +extension Int { + var formattedAsPercentage: String { + return Formatter.asPercentage.string(for: self) ?? "" + } +} + +extension Decimal { + var formattedAsPercentage: String { + return Formatter.asPercentage.string(for: self) ?? "" + } +} + final class SendViewController: ModalDetailViewController { private let viewModel: SendViewModel private let authenticationViewModel: AuthenticationViewModel @@ -87,6 +110,14 @@ final class SendViewController: ModalDetailViewController { self?.amountInputView?.subtitleTextColor = $0 } .dispose(in: reactive.bag) + + viewModel.sendStatus.observeNext { [weak self] feeLimitPercent in + self?.triggerSend(feeLimitPercent: feeLimitPercent) + }.dispose(in: reactive.bag) + + viewModel.sendStatus.observeFailed { [weak self] error in + self?.showFeeLimitAlert(sendError: error) + }.dispose(in: reactive.bag) } private func addAmountInputView() { @@ -160,13 +191,40 @@ final class SendViewController: ModalDetailViewController { } private func sendButtonTapped() { - authenticate { [weak self] result in - switch result { - case .success: - self?.send() - case .failure: - Toast.presentError(L10n.Scene.Send.authenticationFailed) + viewModel.determineSendStatus() + } + + private func showFeeLimitAlert(sendError: SendViewModel.SendError) { + let message: String + switch viewModel.method { + case .lightning: + let sendFeeLimitPercentage: Int? + + switch sendError { + case .feeGreaterThanPayment(let feeInfo): + message = """ + \(L10n.Scene.Send.feeExceedsPayment(feeInfo.feePercentage.formattedAsPercentage, feeInfo.userFeeLimitPercentage.formattedAsPercentage)) + + \(L10n.Scene.Send.Lightning.paymentConfirmation) + """ + + sendFeeLimitPercentage = feeInfo.sendFeeLimitPercentage + case .feePercentageGreaterThanUserLimit(let feeInfo): + message = """ + \(L10n.Scene.Send.feeExceedsUserLimit(feeInfo.feePercentage.formattedAsPercentage, feeInfo.userFeeLimitPercentage.formattedAsPercentage)) + + \(L10n.Scene.Send.Lightning.paymentConfirmation) + """ + + sendFeeLimitPercentage = feeInfo.sendFeeLimitPercentage } + + let controller = UIAlertController.feeLimitAlertController(message: message) { [weak self] in + self?.triggerSend(feeLimitPercent: sendFeeLimitPercentage) + } + self.present(controller, animated: true) + default: + return } } @@ -252,29 +310,33 @@ final class SendViewController: ModalDetailViewController { self?.dismissParent() } } - - private func send() { - let sendStartTime = Date() - - presentLoading() - - viewModel.send { [weak self] result in - let minimumLoadingTime: TimeInterval = 1 - let sendingTime = Date().timeIntervalSince(sendStartTime) - let delay = max(0, minimumLoadingTime - sendingTime) - - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + delay) { - switch result { - case .success: - - self?.presentSuccess() - case .failure(let error): - UINotificationFeedbackGenerator().notificationOccurred(.error) - - Toast.presentError(error.localizedDescription) - self?.amountInputView?.isEnabled = true - self?.recoverFromLoadingState() + + private func triggerSend(feeLimitPercent: Int?) { + authenticate { [weak self] result in + switch result { + case .success: + self?.presentLoading() + let sendStartTime = Date() + + self?.viewModel.send(feeLimitPercent: feeLimitPercent) { result in + let minimumLoadingTime: TimeInterval = 1 + let sendingTime = Date().timeIntervalSince(sendStartTime) + let delay = max(0, minimumLoadingTime - sendingTime) + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + delay) { + switch result { + case .success: + self?.presentSuccess() + case .failure(let error): + UINotificationFeedbackGenerator().notificationOccurred(.error) + Toast.presentError(error.localizedDescription) + self?.amountInputView?.isEnabled = true + self?.recoverFromLoadingState() + } + } } + case .failure: + Toast.presentError(L10n.Scene.Send.authenticationFailed) } } } diff --git a/Library/Scenes/ModalDetail/Send/SendViewModel.swift b/Library/Scenes/ModalDetail/Send/SendViewModel.swift index a8e7a7760..b5b51c6c4 100644 --- a/Library/Scenes/ModalDetail/Send/SendViewModel.swift +++ b/Library/Scenes/ModalDetail/Send/SendViewModel.swift @@ -8,6 +8,7 @@ import Bond import Foundation import Lightning +import ReactiveKit import SwiftBTC import SwiftLnd @@ -47,13 +48,26 @@ final class SendViewModel: NSObject { } } } + + struct FeeInfo { + var feePercentage: Decimal + var userFeeLimitPercentage: Int + var sendFeeLimitPercentage: Int? + } + + enum SendError: Error { + case feeGreaterThanPayment(FeeInfo) + case feePercentageGreaterThanUserLimit(FeeInfo) + } + let paymentFeeThreshold: Satoshi = 100 let fee = Observable>>(.loading) - let method: SendMethod - let subtitleText = Observable(nil) let isSubtitleTextWarning = Observable(false) + let sendStatus = Subject() + + var feePercent: Decimal? var amount: Satoshi? { didSet { @@ -129,6 +143,71 @@ final class SendViewModel: NSObject { setupPrimaryCurrencyListener() } + func send(feeLimitPercent: Int?, completion: @escaping ApiCompletion) { + guard let amount = amount else { return } + + isSending = true + + let internalComplection: ApiCompletion = { [weak self] in + if case .failure = $0 { + self?.isSending = false + } + completion($0) + } + + switch method { + case .lightning(let paymentRequest): + lightningService.transactionService.sendPayment(paymentRequest, amount: amount, feeLimitPercent: feeLimitPercent, completion: internalComplection) + case .onChain(let bitcoinURI): + lightningService.transactionService.sendCoins(bitcoinURI: bitcoinURI, amount: amount, confirmationTarget: confirmationTarget, completion: internalComplection) + } + } + + func determineSendStatus() { + switch method { + case .lightning: + self.determineLightningSendStatus() + default: + self.sendStatus.receive(nil) + } + } + + private func determineLightningSendStatus() { + guard let amount = amount, let feePercent = feePercent else { return } + + let actualFee: Satoshi + + switch fee.value { + case .element(let fee): + switch fee { + case .success(let fee): + actualFee = fee + default: + return + } + default: + return + } + + if amount <= paymentFeeThreshold { + if actualFee >= amount { + self.sendStatus.receive(event: .failed(.feeGreaterThanPayment(FeeInfo(feePercentage: feePercent, userFeeLimitPercentage: Settings.shared.lightningPaymentFeeLimit.value.rawValue, sendFeeLimitPercentage: nil)))) + } else { + self.sendStatus.receive(100) + } + } else if actualFee >= amount { + self.sendStatus.receive(event: .failed(.feeGreaterThanPayment(FeeInfo(feePercentage: feePercent, userFeeLimitPercentage: Settings.shared.lightningPaymentFeeLimit.value.rawValue, sendFeeLimitPercentage: nil)))) + } else if Settings.shared.lightningPaymentFeeLimit.value == .none { + self.sendStatus.receive(nil) + } else { + if feePercent > Decimal(Settings.shared.lightningPaymentFeeLimit.value.rawValue) { + self.sendStatus.receive(event: .failed(.feePercentageGreaterThanUserLimit(FeeInfo(feePercentage: feePercent, userFeeLimitPercentage: Settings.shared.lightningPaymentFeeLimit.value.rawValue, sendFeeLimitPercentage: 100)))) + } else { + self.sendStatus.receive(Settings.shared.lightningPaymentFeeLimit.value.rawValue) + } + } + } + private func setupPrimaryCurrencyListener() { Settings.shared.primaryCurrency .compactMap { [method, maxPaymentAmount] in @@ -177,9 +256,11 @@ final class SendViewModel: NSObject { private func updateFee() { if isAmountValid { fee.value = .loading + feePercent = nil debounceFetchFee() } else { fee.value = .element(.failure(.invalidAmount)) + feePercent = nil } } @@ -199,12 +280,15 @@ final class SendViewModel: NSObject { self.isTransactionDust = false if let fee = result.fee { self.fee.value = .element(.success(fee)) + self.calculateFeePercent(fee: fee, amount: result.amount) } else { self.fee.value = .element(.failure(.invalidAmount)) + self.feePercent = nil } case .failure(let lndApiError): self.isTransactionDust = lndApiError == .transactionOutputIsDust self.fee.value = amount > 0 ? .element(Result.failure(.lndApiError(lndApiError))) : .element(.failure(.invalidAmount)) + self.feePercent = nil self.updateIsUIEnabled() } } @@ -216,24 +300,8 @@ final class SendViewModel: NSObject { lightningService.transactionService.onChainFees(address: bitcoinURI.bitcoinAddress, amount: amount, confirmationTarget: confirmationTarget, completion: feeCompletion) } } - - func send(completion: @escaping ApiCompletion) { - guard let amount = amount else { return } - - isSending = true - - let internalComplection: ApiCompletion = { [weak self] in - if case .failure = $0 { - self?.isSending = false - } - completion($0) - } - - switch method { - case .lightning(let paymentRequest): - lightningService.transactionService.sendPayment(paymentRequest, amount: amount, completion: internalComplection) - case .onChain(let bitcoinURI): - lightningService.transactionService.sendCoins(bitcoinURI: bitcoinURI, amount: amount, confirmationTarget: confirmationTarget, completion: internalComplection) - } + + private func calculateFeePercent(fee: Satoshi, amount: Satoshi) { + feePercent = ( fee / amount ) * 100 } } diff --git a/Library/Scenes/Settings/Items/LightningPaymentFeeLimitSettingsItem.swift b/Library/Scenes/Settings/Items/LightningPaymentFeeLimitSettingsItem.swift new file mode 100644 index 000000000..c886212e8 --- /dev/null +++ b/Library/Scenes/Settings/Items/LightningPaymentFeeLimitSettingsItem.swift @@ -0,0 +1,49 @@ +// +// Library +// +// Created by Christopher Pinski on 10/12/19. +// Copyright © 2019 Zap. All rights reserved. +// + +import Bond +import Foundation + +final class LightningPaymentFeeLimitSelectionSettingsItem: DetailDisclosureSettingsItem, SubtitleSettingsItem { + let subtitle = Settings.shared.lightningPaymentFeeLimit.map { Optional($0.localized) } + + let title = L10n.Scene.Settings.Item.lightningPaymentFeeLimit + + func didSelectItem(from fromViewController: UIViewController) { + let items: [SettingsItem] = PaymentFeeLimitPercentage.allCases.map { LightningPaymentFeeLimitSettingsItem(percentage: $0) } + let section = Section(title: nil, rows: items) + + let viewController = GroupedTableViewController(sections: [section]) + viewController.title = title + viewController.navigationItem.largeTitleDisplayMode = .never + + fromViewController.navigationController?.show(viewController, sender: nil) + } +} + +final class LightningPaymentFeeLimitSettingsItem: NSObject, SelectableSettingsItem { + var isSelectedOption = Observable(false) + + let title: String + private let percentage: PaymentFeeLimitPercentage + + init(percentage: PaymentFeeLimitPercentage) { + self.percentage = percentage + title = percentage.localized + super.init() + + Settings.shared.lightningPaymentFeeLimit + .observeNext { [isSelectedOption] currentPercentage in + isSelectedOption.value = currentPercentage == percentage + } + .dispose(in: reactive.bag) + } + + func didSelectItem(from fromViewController: UIViewController) { + Settings.shared.lightningPaymentFeeLimit.value = percentage + } +} diff --git a/Library/Scenes/Settings/Items/LightningRequestExpirySettingsItem.swift b/Library/Scenes/Settings/Items/LightningRequestExpirySettingsItem.swift index d5b29bf3e..8dec71fd6 100644 --- a/Library/Scenes/Settings/Items/LightningRequestExpirySettingsItem.swift +++ b/Library/Scenes/Settings/Items/LightningRequestExpirySettingsItem.swift @@ -34,12 +34,12 @@ final class LightningRequestExpirySettingsItem: NSObject, SelectableSettingsItem init(expiryTime: ExpiryTime) { self.expiryTime = expiryTime - title = "\(expiryTime.localized)" + title = expiryTime.localized super.init() Settings.shared.lightningRequestExpiry - .observeNext { [isSelectedOption] currenteExpiryTime in - isSelectedOption.value = currenteExpiryTime == expiryTime + .observeNext { [isSelectedOption] currentExpiryTime in + isSelectedOption.value = currentExpiryTime == expiryTime } .dispose(in: reactive.bag) } diff --git a/Library/Scenes/Settings/Model/PaymentFeeLimitPercentage.swift b/Library/Scenes/Settings/Model/PaymentFeeLimitPercentage.swift new file mode 100644 index 000000000..9ebba3cb5 --- /dev/null +++ b/Library/Scenes/Settings/Model/PaymentFeeLimitPercentage.swift @@ -0,0 +1,16 @@ +// +// Library +// +// Created by Christopher Pinski on 10/26/19. +// Copyright © 2019 Zap. All rights reserved. +// + +import Foundation + +public enum PaymentFeeLimitPercentage: Int, Codable, CaseIterable { + case none = 0 + case one = 1 + case three = 3 + case five = 5 + case ten = 10 +} diff --git a/Library/Scenes/Settings/Settings.swift b/Library/Scenes/Settings/Settings.swift index 03cf2a87e..5e4ba2230 100644 --- a/Library/Scenes/Settings/Settings.swift +++ b/Library/Scenes/Settings/Settings.swift @@ -14,6 +14,7 @@ import SwiftLnd public final class Settings: NSObject, Persistable { // Persistable public typealias Value = SettingsData + public var data: SettingsData = SettingsData() { didSet { savePersistable() @@ -28,6 +29,7 @@ public final class Settings: NSObject, Persistable { var blockExplorer: BlockExplorer? var onChainRequestAddressType: OnChainRequestAddressType? var lightningRequestExpiry: ExpiryTime? + var lightningPaymentFeeLimit: PaymentFeeLimitPercentage? } public let primaryCurrency: Observable @@ -38,6 +40,7 @@ public final class Settings: NSObject, Persistable { let blockExplorer: Observable let onChainRequestAddressType: Observable let lightningRequestExpiry: Observable + let lightningPaymentFeeLimit: Observable public static let shared = Settings() @@ -55,6 +58,7 @@ public final class Settings: NSObject, Persistable { blockExplorer = Observable(data?.blockExplorer ?? .blockstream) onChainRequestAddressType = Observable(data?.onChainRequestAddressType ?? .witnessPubkeyHash) lightningRequestExpiry = Observable(data?.lightningRequestExpiry ?? .oneHour) + lightningPaymentFeeLimit = Observable(data?.lightningPaymentFeeLimit ?? .one) super.init() @@ -95,6 +99,11 @@ public final class Settings: NSObject, Persistable { .skip(first: 1) .observeNext { [weak self] in self?.data.lightningRequestExpiry = $0 + }, + lightningPaymentFeeLimit + .skip(first: 1) + .observeNext { [weak self] in + self?.data.lightningPaymentFeeLimit = $0 } ].dispose(in: reactive.bag) } diff --git a/Library/Scenes/Settings/SettingsViewController.swift b/Library/Scenes/Settings/SettingsViewController.swift index 85d6e230d..a79e3b7c2 100644 --- a/Library/Scenes/Settings/SettingsViewController.swift +++ b/Library/Scenes/Settings/SettingsViewController.swift @@ -40,7 +40,8 @@ final class SettingsViewController: GroupedTableViewController { BitcoinUnitSelectionSettingsItem(), OnChainRequestAddressTypeSelectionSettingsItem(), BlockExplorerSelectionSettingsItem(), - LightningRequestExpirySelectionSettingsItem() + LightningRequestExpirySelectionSettingsItem(), + LightningPaymentFeeLimitSelectionSettingsItem() ]) ] sections.append(contentsOf: [ diff --git a/Library/Tests/SendViewModelTests.swift b/Library/Tests/SendViewModelTests.swift new file mode 100644 index 000000000..a392ae2bd --- /dev/null +++ b/Library/Tests/SendViewModelTests.swift @@ -0,0 +1,346 @@ +// +// LibraryTests +// +// Created by Christopher Pinski on 11/11/19. +// Copyright © 2019 Zap. All rights reserved. +// + +@testable import Library +@testable import Lightning +import SwiftBTC +@testable import SwiftLnd +import XCTest + +final class MockBackupService: StaticChannelBackupServiceType { + func save(data: Result, nodePubKey: String, fileName: String) {} +} + +extension RPCCredentials { + // swiftlint:disable:next force_unwrapping + static var mock: RPCCredentials = RPCCredentials(certificate: nil, macaroon: Macaroon(hexadecimalString: "deadbeef")!, host: URL(string: "127.0.0.1")!) +} + +// swiftlint:disable force_unwrapping +// swiftlint:disable implicitly_unwrapped_optional +final class SendViewModelTests: XCTestCase { + + private var mockService: LightningService! + + override func setUp() { + super.setUp() + + let api = LightningApi(connection: MockLightningConnection()) + let testConnection = LightningConnection.remote(RPCCredentials.mock) + + mockService = LightningService(api: api, connection: testConnection, backupService: MockBackupService()) + } + + func testBitcoinTransactionSendStatus() { + let expectation = self.expectation(description: "Send Status") + + let bitcoinURI = BitcoinURI(address: BitcoinAddress(string: "mv4rnyY3Su5gjcDNzbMLKBQkBicCtHUtFB")!, amount: 1234.0, memo: nil, lightningFallback: nil) + let bitcoinInvoice = BitcoinInvoice(lightningPaymentRequest: nil, bitcoinURI: bitcoinURI) + + let sendViewModel = SendViewModel(invoice: bitcoinInvoice, lightningService: mockService) + + sendViewModel.sendStatus.observeNext { feeLimitPercent in + XCTAssertEqual(nil, feeLimitPercent) + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.sendStatus.observeFailed { _ in + XCTFail("Shouldn't observe a fee limit error") + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.determineSendStatus() + + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testLightningPaymentUnderThresholdSendStatus() { + let expectation = self.expectation(description: "Send Status") + + let paymentAmount: Satoshi = 90.0 + let feeAmount: Satoshi = 1.0 + let feePercentage: Decimal = 1.111 + + let currentDate = Date() + let paymentRequest = PaymentRequest(paymentHash: "c1dbb4c26256205edb85105422c30d6570c1f7e136574d2bc4c9a1d526b26a3a", destination: "test", amount: paymentAmount, memo: nil, date: currentDate, expiryDate: Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)!, raw: "test", fallbackAddress: nil, network: .testnet) + let bitcoinInvoice = BitcoinInvoice(lightningPaymentRequest: paymentRequest, bitcoinURI: nil) + + let sendViewModel = SendViewModel(invoice: bitcoinInvoice, lightningService: mockService) + + sendViewModel.fee.value = .element(.success(feeAmount)) + sendViewModel.feePercent = feePercentage + + sendViewModel.sendStatus.observeNext { feeLimitPercent in + XCTAssertEqual(100, feeLimitPercent) + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.sendStatus.observeFailed { _ in + XCTFail("Shouldn't observe a fee limit error") + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.determineSendStatus() + + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testLightningPaymentUnderThresholdWithFeeGreaterThanPaymentSendStatus() { + let expectation = self.expectation(description: "Send Status") + + let paymentAmount: Satoshi = 90.0 + let feeAmount: Satoshi = 95.0 + let feePercentage: Decimal = 105.556 + let userLightningPaymentFeeLimit = PaymentFeeLimitPercentage.one + + let currentDate = Date() + let paymentRequest = PaymentRequest(paymentHash: "c1dbb4c26256205edb85105422c30d6570c1f7e136574d2bc4c9a1d526b26a3a", destination: "test", amount: paymentAmount, memo: nil, date: currentDate, expiryDate: Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)!, raw: "test", fallbackAddress: nil, network: .testnet) + let bitcoinInvoice = BitcoinInvoice(lightningPaymentRequest: paymentRequest, bitcoinURI: nil) + + let sendViewModel = SendViewModel(invoice: bitcoinInvoice, lightningService: mockService) + + Settings.shared.lightningPaymentFeeLimit.value = userLightningPaymentFeeLimit + sendViewModel.fee.value = .element(.success(feeAmount)) + sendViewModel.feePercent = feePercentage + + sendViewModel.sendStatus.observeNext { _ in + XCTFail("Shouldn't observe a fee limit success") + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.sendStatus.observeFailed { sendError in + switch sendError { + case .feeGreaterThanPayment(let feeInfo): + XCTAssertEqual(feePercentage, feeInfo.feePercentage) + XCTAssertEqual(nil, feeInfo.sendFeeLimitPercentage) + XCTAssertEqual(userLightningPaymentFeeLimit.rawValue, feeInfo.userFeeLimitPercentage) + case .feePercentageGreaterThanUserLimit: + XCTFail("Shouldn't observe a fee percentage greater than user limit error") + } + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.determineSendStatus() + + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testLightningPaymentAtThresholdSendStatus() { + let expectation = self.expectation(description: "Send Status") + + let paymentAmount: Satoshi = 100.0 + let feeAmount: Satoshi = 1.0 + let feePercentage: Decimal = 100.0 + + let currentDate = Date() + let paymentRequest = PaymentRequest(paymentHash: "c1dbb4c26256205edb85105422c30d6570c1f7e136574d2bc4c9a1d526b26a3a", destination: "test", amount: paymentAmount, memo: nil, date: currentDate, expiryDate: Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)!, raw: "test", fallbackAddress: nil, network: .testnet) + let bitcoinInvoice = BitcoinInvoice(lightningPaymentRequest: paymentRequest, bitcoinURI: nil) + + let sendViewModel = SendViewModel(invoice: bitcoinInvoice, lightningService: mockService) + + sendViewModel.fee.value = .element(.success(feeAmount)) + sendViewModel.feePercent = feePercentage + + sendViewModel.sendStatus.observeNext { feeLimitPercent in + XCTAssertEqual(100, feeLimitPercent) + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.sendStatus.observeFailed { _ in + XCTFail("Shouldn't observe a fee limit error") + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.determineSendStatus() + + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testLightningPaymentWithFeeUnderUserLimit() { + let expectation = self.expectation(description: "Send Status") + + let paymentAmount: Satoshi = 150.0 + let feeAmount: Satoshi = 1.0 + let feePercentage: Decimal = 0.667 + + let currentDate = Date() + let paymentRequest = PaymentRequest(paymentHash: "c1dbb4c26256205edb85105422c30d6570c1f7e136574d2bc4c9a1d526b26a3a", destination: "test", amount: paymentAmount, memo: nil, date: currentDate, expiryDate: Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)!, raw: "test", fallbackAddress: nil, network: .testnet) + let bitcoinInvoice = BitcoinInvoice(lightningPaymentRequest: paymentRequest, bitcoinURI: nil) + + let sendViewModel = SendViewModel(invoice: bitcoinInvoice, lightningService: mockService) + + Settings.shared.lightningPaymentFeeLimit.value = .one + sendViewModel.fee.value = .element(.success(feeAmount)) + sendViewModel.feePercent = feePercentage + + sendViewModel.sendStatus.observeNext { feeLimitPercent in + XCTAssertEqual(1, feeLimitPercent) + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.sendStatus.observeFailed { _ in + XCTFail("Shouldn't observe a fee limit error") + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.determineSendStatus() + + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testLightningPaymentWithFeeGreaterThanThresholdAndPaymentAmount() { + let expectation = self.expectation(description: "Send Status") + + let paymentAmount: Satoshi = 150.0 + let feeAmount: Satoshi = 160.0 + let feePercentage: Decimal = 106.667 + let userLightningPaymentFeeLimit = PaymentFeeLimitPercentage.one + + let currentDate = Date() + let paymentRequest = PaymentRequest(paymentHash: "c1dbb4c26256205edb85105422c30d6570c1f7e136574d2bc4c9a1d526b26a3a", destination: "test", amount: paymentAmount, memo: nil, date: currentDate, expiryDate: Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)!, raw: "test", fallbackAddress: nil, network: .testnet) + let bitcoinInvoice = BitcoinInvoice(lightningPaymentRequest: paymentRequest, bitcoinURI: nil) + + let sendViewModel = SendViewModel(invoice: bitcoinInvoice, lightningService: mockService) + + Settings.shared.lightningPaymentFeeLimit.value = userLightningPaymentFeeLimit + sendViewModel.fee.value = .element(.success(feeAmount)) + sendViewModel.feePercent = feePercentage + + sendViewModel.sendStatus.observeNext { _ in + XCTFail("Shouldn't observe a fee limit success") + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.sendStatus.observeFailed { sendError in + switch sendError { + case .feeGreaterThanPayment(let feeInfo): + XCTAssertEqual(feePercentage, feeInfo.feePercentage) + XCTAssertEqual(nil, feeInfo.sendFeeLimitPercentage) + XCTAssertEqual(userLightningPaymentFeeLimit.rawValue, feeInfo.userFeeLimitPercentage) + case .feePercentageGreaterThanUserLimit: + XCTFail("Shouldn't observe a fee percentage greater than user limit error") + } + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.determineSendStatus() + + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testLightningPaymentWithFeeGreaterThanThresholdAndEqualToPaymentAmount() { + let expectation = self.expectation(description: "Send Status") + + let paymentAmount: Satoshi = 150.0 + let feeAmount: Satoshi = 150.0 + let feePercentage: Decimal = 100.0 + let userLightningPaymentFeeLimit = PaymentFeeLimitPercentage.one + + let currentDate = Date() + let paymentRequest = PaymentRequest(paymentHash: "c1dbb4c26256205edb85105422c30d6570c1f7e136574d2bc4c9a1d526b26a3a", destination: "test", amount: paymentAmount, memo: nil, date: currentDate, expiryDate: Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)!, raw: "test", fallbackAddress: nil, network: .testnet) + let bitcoinInvoice = BitcoinInvoice(lightningPaymentRequest: paymentRequest, bitcoinURI: nil) + + let sendViewModel = SendViewModel(invoice: bitcoinInvoice, lightningService: mockService) + + Settings.shared.lightningPaymentFeeLimit.value = .one + sendViewModel.fee.value = .element(.success(feeAmount)) + sendViewModel.feePercent = feePercentage + + sendViewModel.sendStatus.observeNext { _ in + XCTFail("Shouldn't observe a fee limit success") + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.sendStatus.observeFailed { sendError in + switch sendError { + case .feeGreaterThanPayment(let feeInfo): + XCTAssertEqual(feePercentage, feeInfo.feePercentage) + XCTAssertEqual(nil, feeInfo.sendFeeLimitPercentage) + XCTAssertEqual(userLightningPaymentFeeLimit.rawValue, feeInfo.userFeeLimitPercentage) + case .feePercentageGreaterThanUserLimit: + XCTFail("Shouldn't observe a fee percentage greater than user limit error") + } + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.determineSendStatus() + + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testLightningPaymentWithFeePercentageGreaterThanUserLimit() { + let expectation = self.expectation(description: "Send Status") + + let paymentAmount: Satoshi = 300 + let feeAmount: Satoshi = 100.0 + let feePercentage: Decimal = 33.333 + let userLightningPaymentFeeLimit = PaymentFeeLimitPercentage.three + + let currentDate = Date() + let paymentRequest = PaymentRequest(paymentHash: "c1dbb4c26256205edb85105422c30d6570c1f7e136574d2bc4c9a1d526b26a3a", destination: "test", amount: paymentAmount, memo: nil, date: currentDate, expiryDate: Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)!, raw: "test", fallbackAddress: nil, network: .testnet) + let bitcoinInvoice = BitcoinInvoice(lightningPaymentRequest: paymentRequest, bitcoinURI: nil) + + let sendViewModel = SendViewModel(invoice: bitcoinInvoice, lightningService: mockService) + + Settings.shared.lightningPaymentFeeLimit.value = userLightningPaymentFeeLimit + sendViewModel.fee.value = .element(.success(feeAmount)) + sendViewModel.feePercent = feePercentage + + sendViewModel.sendStatus.observeNext { _ in + XCTFail("Shouldn't observe a fee limit success") + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.sendStatus.observeFailed { sendError in + switch sendError { + case .feeGreaterThanPayment: + XCTFail("Shouldn't observe a fee greater than payment error") + case .feePercentageGreaterThanUserLimit(let feeInfo): + XCTAssertEqual(feePercentage, feeInfo.feePercentage) + XCTAssertEqual(100, feeInfo.sendFeeLimitPercentage) + XCTAssertEqual(userLightningPaymentFeeLimit.rawValue, feeInfo.userFeeLimitPercentage) + } + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.determineSendStatus() + + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testLightningPaymentWithUserLimitNone() { + let expectation = self.expectation(description: "Send Status") + + let paymentAmount: Satoshi = 200.0 + let feeAmount: Satoshi = 100.0 + let feePercentage: Decimal = 50.0 + + let currentDate = Date() + let paymentRequest = PaymentRequest(paymentHash: "c1dbb4c26256205edb85105422c30d6570c1f7e136574d2bc4c9a1d526b26a3a", destination: "test", amount: paymentAmount, memo: nil, date: currentDate, expiryDate: Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)!, raw: "test", fallbackAddress: nil, network: .testnet) + let bitcoinInvoice = BitcoinInvoice(lightningPaymentRequest: paymentRequest, bitcoinURI: nil) + + let sendViewModel = SendViewModel(invoice: bitcoinInvoice, lightningService: mockService) + + Settings.shared.lightningPaymentFeeLimit.value = .none + sendViewModel.fee.value = .element(.success(feeAmount)) + sendViewModel.feePercent = feePercentage + + sendViewModel.sendStatus.observeNext { feeLimitPercent in + XCTAssertEqual(nil, feeLimitPercent) + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.sendStatus.observeFailed { _ in + XCTFail("Shouldn't observe a fee limit error") + expectation.fulfill() + }.dispose(in: reactive.bag) + + sendViewModel.determineSendStatus() + + waitForExpectations(timeout: 0.1, handler: nil) + } +} diff --git a/Library/en.lproj/Localizable.strings b/Library/en.lproj/Localizable.strings index bb353538a..5bcc50c1f 100644 --- a/Library/en.lproj/Localizable.strings +++ b/Library/en.lproj/Localizable.strings @@ -66,6 +66,8 @@ "expiry_time.thirty_days" = "30 Days"; "expiry_time.one_year" = "1 Year"; +"payment_fee_limit_percentage.none" = "None"; + "scene.history.title" = "Activity"; "scene.history.empty_state_label" = "0 transactions 🙁"; @@ -75,6 +77,7 @@ "scene.settings.section.wallet" = "Wallet"; "scene.settings.item.version_warning" = "Your lnd is outdated (%@). Zap iOS works best with lnd version %@ or above."; "scene.settings.item.bitcoin_unit" = "Bitcoin Unit"; +"scene.settings.item.lightning_payment_fee_limit" = "Lightning Payment Fee Limit"; "scene.settings.item.lightning_request_expiry" = "Lightning Request Expiry"; "scene.settings.item.currency" = "Currency"; "scene.settings.item.currency.popular" = "Popular Currencies"; @@ -174,6 +177,12 @@ "scene.send.subtitle.lightning_can_send_balance" = "Can send: %@"; "scene.send.success_label" = "Payment Successful"; "scene.send.paste_button.title" = "Paste Address"; +"scene.send.fee_exceeds_user_limit" = "The fee for this payment (%@) exceeds the limit specified in the settings (%@)."; +"scene.send.fee_exceeds_payment_amount" = "The fee for this payment (%@ sats) will be higher than the payment amount (%@ sats)."; +"scene.send.lightning.payment_confirmation" = "Do you really want to pay this invoice?"; + +"scene.send.fee_alert.title" = "Fee Limit Alert"; +"scene.send.fee_alert.confirm_button.title" = "Send"; "scene.connect_remote_node.title" = "Connect Remote Node"; "scene.connect_remote_node.empty_state" = "Scan the QR Code generated by lndconnect, BTCPay Server, or paste the link you get from running 'lndconnect -j' to connect to your node."; diff --git a/Lightning/Services/TransactionService.swift b/Lightning/Services/TransactionService.swift index 4926c1002..afa58ef56 100644 --- a/Lightning/Services/TransactionService.swift +++ b/Lightning/Services/TransactionService.swift @@ -58,8 +58,8 @@ public final class TransactionService { } } - public func sendPayment(_ paymentRequest: PaymentRequest, amount: Satoshi, completion: @escaping ApiCompletion) { - api.sendPayment(paymentRequest, amount: amount) { [balanceService, paymentListUpdater] in + public func sendPayment(_ paymentRequest: PaymentRequest, amount: Satoshi, feeLimitPercent: Int?, completion: @escaping ApiCompletion) { + api.sendPayment(paymentRequest, amount: amount, feeLimitPercent: feeLimitPercent) { [balanceService, paymentListUpdater] in if case .success(let payment) = $0 { balanceService.update() paymentListUpdater.add(payment: payment) diff --git a/SwiftLnd/Api/LightningApi.swift b/SwiftLnd/Api/LightningApi.swift index 5b40cd185..e6eec4331 100644 --- a/SwiftLnd/Api/LightningApi.swift +++ b/SwiftLnd/Api/LightningApi.swift @@ -119,8 +119,8 @@ public final class LightningApi { connection.listPayments(Lnrpc_ListPaymentsRequest(), completion: map(completion) { $0.payments.map(Payment.init) }) } - public func sendPayment(_ paymentRequest: PaymentRequest, amount: Satoshi?, completion: @escaping ApiCompletion) { - let request = Lnrpc_SendRequest(paymentRequest: paymentRequest.raw, amount: amount) + public func sendPayment(_ paymentRequest: PaymentRequest, amount: Satoshi?, feeLimitPercent: Int?, completion: @escaping ApiCompletion) { + let request = Lnrpc_SendRequest(paymentRequest: paymentRequest.raw, amount: amount, feeLimitPercent: feeLimitPercent) connection.sendPaymentSync(request) { result in switch result { case .success(let value): diff --git a/SwiftLnd/Extensions/Protobuf+Extensions.swift b/SwiftLnd/Extensions/Protobuf+Extensions.swift index a3d0362ec..852fac9c4 100644 --- a/SwiftLnd/Extensions/Protobuf+Extensions.swift +++ b/SwiftLnd/Extensions/Protobuf+Extensions.swift @@ -90,13 +90,24 @@ extension Lnrpc_SendCoinsRequest { } extension Lnrpc_SendRequest { - init(paymentRequest: String, amount: Satoshi?) { + init(paymentRequest: String, amount: Satoshi?, feeLimitPercent: Int?) { self.init() self.paymentRequest = paymentRequest if let amount = amount { self.amt = amount.int64 } + if let feeLimitPercent = feeLimitPercent { + self.feeLimit = Lnrpc_FeeLimit(percent: feeLimitPercent) + } + } +} + +extension Lnrpc_FeeLimit { + init(percent: Int) { + self.init() + + self.percent = Int64(percent) } } diff --git a/Zap.xcodeproj/project.pbxproj b/Zap.xcodeproj/project.pbxproj index 86e01a804..1a85598ff 100644 --- a/Zap.xcodeproj/project.pbxproj +++ b/Zap.xcodeproj/project.pbxproj @@ -12,9 +12,13 @@ 37DC0DB072FDF3A74598EA06 /* Pods_RPC_Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 71E4E79032A371505A0E58C1 /* Pods_RPC_Library.framework */; }; 3FCED3EA112663089C273764 /* *.bitcoinaverage.com.cer in Resources */ = {isa = PBXBuildFile; fileRef = 3FCED45A10ACE912173010E1 /* *.bitcoinaverage.com.cer */; }; 4295D217D276D875A94F4622 /* Pods_RPC_SwiftLnd.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0B55A0B343498CAAB75872BD /* Pods_RPC_SwiftLnd.framework */; }; + 5C10EAD92352DE5900FB80DA /* LightningPaymentFeeLimitSettingsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C10EAD82352DE5900FB80DA /* LightningPaymentFeeLimitSettingsItem.swift */; }; 5C18BB6F234C015100BCF9D9 /* ExpiryTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C18BB6E234C015100BCF9D9 /* ExpiryTime.swift */; }; 5C18BB71234C09EB00BCF9D9 /* LightningRequestExpirySettingsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C18BB70234C09EB00BCF9D9 /* LightningRequestExpirySettingsItem.swift */; }; 5C18BB77234C0BF400BCF9D9 /* ExpiryTime+Localizable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C18BB76234C0BF400BCF9D9 /* ExpiryTime+Localizable.swift */; }; + 5CA13678236555A3003D4B98 /* PaymentFeeLimitPercentage+Localizable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA13677236555A3003D4B98 /* PaymentFeeLimitPercentage+Localizable.swift */; }; + 5CC505812379FF5E00FB1C19 /* PaymentFeeLimitPercentage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CD423AC2364FB160031194F /* PaymentFeeLimitPercentage.swift */; }; + 5CF94808237A27D40086DD5B /* SendViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF94807237A27D40086DD5B /* SendViewModelTests.swift */; }; 5E7CF0840E8C74EB58F80DB5 /* Pods_SnapshotUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C6AEA9B7EDFEBE4BBEB56DAD /* Pods_SnapshotUITests.framework */; }; 6A9518DA222DCFCA0008FE4F /* Array2DElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A9518D9222DCFCA0008FE4F /* Array2DElement.swift */; }; 7BE67678020C235E19A3D64E /* Pods_SwiftLndTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 71876EABB08A5425C9DA33C7 /* Pods_SwiftLndTests.framework */; }; @@ -570,9 +574,13 @@ 4D052C7425BFF1AED850782A /* Pods-LibraryTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LibraryTests.debug.xcconfig"; path = "Target Support Files/Pods-LibraryTests/Pods-LibraryTests.debug.xcconfig"; sourceTree = ""; }; 4E372F564A61EB8E70FA424B /* Pods-SnapshotUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SnapshotUITests.release.xcconfig"; path = "Target Support Files/Pods-SnapshotUITests/Pods-SnapshotUITests.release.xcconfig"; sourceTree = ""; }; 5284CC239994D6B844AD6ACE /* Pods-Zap.debugremote.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Zap.debugremote.xcconfig"; path = "Target Support Files/Pods-Zap/Pods-Zap.debugremote.xcconfig"; sourceTree = ""; }; + 5C10EAD82352DE5900FB80DA /* LightningPaymentFeeLimitSettingsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightningPaymentFeeLimitSettingsItem.swift; sourceTree = ""; }; 5C18BB6E234C015100BCF9D9 /* ExpiryTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiryTime.swift; sourceTree = ""; }; 5C18BB70234C09EB00BCF9D9 /* LightningRequestExpirySettingsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightningRequestExpirySettingsItem.swift; sourceTree = ""; }; 5C18BB76234C0BF400BCF9D9 /* ExpiryTime+Localizable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ExpiryTime+Localizable.swift"; sourceTree = ""; }; + 5CA13677236555A3003D4B98 /* PaymentFeeLimitPercentage+Localizable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PaymentFeeLimitPercentage+Localizable.swift"; sourceTree = ""; }; + 5CD423AC2364FB160031194F /* PaymentFeeLimitPercentage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentFeeLimitPercentage.swift; sourceTree = ""; }; + 5CF94807237A27D40086DD5B /* SendViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendViewModelTests.swift; sourceTree = ""; }; 5E8DFC750E5DE2109A4B90CB /* Pods-RPC-Lightning.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RPC-Lightning.debug.xcconfig"; path = "Target Support Files/Pods-RPC-Lightning/Pods-RPC-Lightning.debug.xcconfig"; sourceTree = ""; }; 5F7C5272C0C2909B7C7987FF /* Pods-SnapshotUITests.debugremote.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SnapshotUITests.debugremote.xcconfig"; path = "Target Support Files/Pods-SnapshotUITests/Pods-SnapshotUITests.debugremote.xcconfig"; sourceTree = ""; }; 6A9518D9222DCFCA0008FE4F /* Array2DElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array2DElement.swift; sourceTree = ""; }; @@ -1214,6 +1222,14 @@ path = Pods; sourceTree = ""; }; + 5CC505802379FF2800FB1C19 /* Model */ = { + isa = PBXGroup; + children = ( + 5CD423AC2364FB160031194F /* PaymentFeeLimitPercentage.swift */, + ); + path = Model; + sourceTree = ""; + }; A01009322014BD960001EF94 /* Scenes */ = { isa = PBXGroup; children = ( @@ -1333,6 +1349,7 @@ AD28CB7A211DE93300A31004 /* BlockExplorerSettingsItem.swift */, ADCD5B0E20D7E40C0037F156 /* ChangePinSettingsItem.swift */, A091914A203B297D00FA525A /* CurrencySettingsItem.swift */, + 5C10EAD82352DE5900FB80DA /* LightningPaymentFeeLimitSettingsItem.swift */, 5C18BB70234C09EB00BCF9D9 /* LightningRequestExpirySettingsItem.swift */, AD6FFCA520AB0A3700B57330 /* OnChainRequestAddressTypeSettingsItem.swift */, ADFE2E4B216CF4A800988243 /* PushViewControllerSettingsItem.swift */, @@ -1501,6 +1518,7 @@ isa = PBXGroup; children = ( A06660F8202E070D00EE32FA /* Items */, + 5CC505802379FF2800FB1C19 /* Model */, A0919144203B1CF400FA525A /* GroupedTableViewController.swift */, A0DDC06D2018C08800AEFF94 /* Section.swift */, A0A05A5620149A0D0007D1C9 /* Settings.swift */, @@ -1839,6 +1857,7 @@ 5C18BB76234C0BF400BCF9D9 /* ExpiryTime+Localizable.swift */, A0C2EABD206F8B5B003AE56E /* Localizable.swift */, AD77464D2076162300EC596B /* Network+Localizable.swift */, + 5CA13677236555A3003D4B98 /* PaymentFeeLimitPercentage+Localizable.swift */, AD967FEF21062AB20048085B /* RPCConnectQRCodeError+Localizable.swift */, AD3A7BCF237C1EC00025632D /* LndApiError+Localizable.swift */, ); @@ -2355,6 +2374,7 @@ ADFE537F20D693F500B3BA2A /* Info.plist */, ADFCEAE021776E1C00370242 /* EventDetailViewModelTests.swift */, A0F9AEE2206E3E0300E93DCA /* InputNumberFormatterTests.swift */, + 5CF94807237A27D40086DD5B /* SendViewModelTests.swift */, AD81C21A21B86A3100FA3FA9 /* SyncPercentageEstimatorTests.swift */, ); path = Tests; @@ -3222,6 +3242,7 @@ AD391C3920D16F0B007EE22A /* BockExplorer.swift in Sources */, ADA81E9E210DE14100B7C6F2 /* UIView.swift in Sources */, ADDD13B62119CE740035D2F9 /* StackViewElement.swift in Sources */, + 5CC505812379FF5E00FB1C19 /* PaymentFeeLimitPercentage.swift in Sources */, ADA58F9C22049470009A5494 /* WalletConfigurationStore.swift in Sources */, AD27021F22E06E5000D4BF27 /* Keychain.swift in Sources */, AD391C9A20D16FA8007EE22A /* QRCodeScannerView.swift in Sources */, @@ -3236,6 +3257,7 @@ AD931BD122DE23CC0048431C /* Password.swift in Sources */, ADA2DD03225CDAED007482D9 /* ChannelQRCodeScannerViewController.swift in Sources */, ADEEB5362134669B00D2F992 /* ModalPinViewController.swift in Sources */, + 5C10EAD92352DE5900FB80DA /* LightningPaymentFeeLimitSettingsItem.swift in Sources */, AD391CDB20D16FE1007EE22A /* HistoryViewController.swift in Sources */, AD667E572136E622007B9160 /* TimeLockStore.swift in Sources */, AD4B4BE122EAF0CE0029773A /* EmptyStateView.swift in Sources */, @@ -3389,6 +3411,7 @@ ADCEAFD522579C2A004F605B /* LoadingAnimationView.swift in Sources */, AD391C5F20D16F7A007EE22A /* RecoverWalletViewController.swift in Sources */, AD391C6B20D16F89007EE22A /* InputNumberFormatter.swift in Sources */, + 5CA13678236555A3003D4B98 /* PaymentFeeLimitPercentage+Localizable.swift in Sources */, AD391C4920D16F68007EE22A /* ModalPresentationController.swift in Sources */, AD391CA820D16FA8007EE22A /* SendQRCodeScannerStrategy.swift in Sources */, AD391C5520D16F7A007EE22A /* ConnectRemoteNodeViewController.swift in Sources */, @@ -3569,6 +3592,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5CF94808237A27D40086DD5B /* SendViewModelTests.swift in Sources */, ADFE538520D693FF00B3BA2A /* InputNumberFormatterTests.swift in Sources */, ADFCEAE221776E2100370242 /* EventDetailViewModelTests.swift in Sources */, AD81C21B21B86A3100FA3FA9 /* SyncPercentageEstimatorTests.swift in Sources */,