diff --git a/Bitkit/Utilities/LightningAmountConversion.swift b/Bitkit/Utilities/LightningAmountConversion.swift new file mode 100644 index 00000000..76fcd122 --- /dev/null +++ b/Bitkit/Utilities/LightningAmountConversion.swift @@ -0,0 +1,18 @@ +import Foundation + +enum LightningAmountConversion { + /// Lightning amounts are commonly expressed in millisatoshis (msat). + /// + /// The UI and amount input operate in whole sats. When converting a minimum bound to sats we must round up: + /// `100500 msat` means the minimum payable amount is `101 sat` (not `100 sat`). + static func satsCeil(fromMsats msats: UInt64) -> UInt64 { + let quotient = msats / Env.msatsPerSat + let remainder = msats % Env.msatsPerSat + return remainder == 0 ? quotient : quotient + 1 + } + + /// Converts msats → sats by rounding down (safe for maximum bounds). + static func satsFloor(fromMsats msats: UInt64) -> UInt64 { + msats / Env.msatsPerSat + } +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 2d54ff20..29428c61 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -253,8 +253,8 @@ extension AppViewModel { } var normalizedData = data - normalizedData.minSendable = max(1, normalizedData.minSendable / Env.msatsPerSat) - normalizedData.maxSendable = max(normalizedData.minSendable, normalizedData.maxSendable / Env.msatsPerSat) + normalizedData.minSendable = max(1, LightningAmountConversion.satsCeil(fromMsats: normalizedData.minSendable)) + normalizedData.maxSendable = max(normalizedData.minSendable, LightningAmountConversion.satsFloor(fromMsats: normalizedData.maxSendable)) // Check if user has enough lightning balance to pay the minimum amount let lightningBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 @@ -292,8 +292,8 @@ extension AppViewModel { } var normalizedData = data - let minSats = max(1, minMsats / Env.msatsPerSat) - let maxSats = max(minSats, maxMsats / Env.msatsPerSat) + let minSats = max(1, LightningAmountConversion.satsCeil(fromMsats: minMsats)) + let maxSats = max(minSats, LightningAmountConversion.satsFloor(fromMsats: maxMsats)) normalizedData.minWithdrawable = minSats normalizedData.maxWithdrawable = maxSats diff --git a/Bitkit/Views/Wallets/Sheets/BoostSheet.swift b/Bitkit/Views/Wallets/Sheets/BoostSheet.swift index c7496755..9ee5af76 100644 --- a/Bitkit/Views/Wallets/Sheets/BoostSheet.swift +++ b/Bitkit/Views/Wallets/Sheets/BoostSheet.swift @@ -389,7 +389,8 @@ struct BoostSheet: View { app.toast( type: .success, title: t("wallet__boost_success_title"), - description: t("wallet__boost_success_msg") + description: t("wallet__boost_success_msg"), + accessibilityIdentifier: "BoostSuccessToast" ) Logger.info("Boost transaction completed successfully, hiding sheet", context: "BoostSheet.performBoost") @@ -408,8 +409,9 @@ struct BoostSheet: View { app.toast( type: .error, - title: t("wallet__boost_error"), - description: error.localizedDescription + title: t("wallet__boost_error_title"), + description: t("wallet__boost_error_msg"), + accessibilityIdentifier: "BoostFailureToast" ) throw error diff --git a/BitkitTests/LightningAmountConversionTests.swift b/BitkitTests/LightningAmountConversionTests.swift new file mode 100644 index 00000000..3793982b --- /dev/null +++ b/BitkitTests/LightningAmountConversionTests.swift @@ -0,0 +1,20 @@ +@testable import Bitkit +import XCTest + +final class LightningAmountConversionTests: XCTestCase { + func testSatsCeilRoundsUpWhenNotDivisibleBy1000() { + XCTAssertEqual(LightningAmountConversion.satsCeil(fromMsats: 100_500), 101) + XCTAssertEqual(LightningAmountConversion.satsCeil(fromMsats: 1500), 2) + } + + func testSatsCeilKeepsExactSatAmounts() { + XCTAssertEqual(LightningAmountConversion.satsCeil(fromMsats: 100_000), 100) + XCTAssertEqual(LightningAmountConversion.satsCeil(fromMsats: 0), 0) + } + + func testSatsFloorRoundsDown() { + XCTAssertEqual(LightningAmountConversion.satsFloor(fromMsats: 100_999), 100) + XCTAssertEqual(LightningAmountConversion.satsFloor(fromMsats: 100_000), 100) + XCTAssertEqual(LightningAmountConversion.satsFloor(fromMsats: 0), 0) + } +}