From 15daf50a33c0c9e2050030f204f95d30b0920734 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 17 Dec 2025 08:02:01 +0100 Subject: [PATCH 1/5] fix: correct lnurl msat to sat min rounding --- Bitkit/Utilities/LnurlAmountConversion.swift | 18 ++++++++++++++++++ Bitkit/ViewModels/AppViewModel.swift | 8 ++++---- 2 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 Bitkit/Utilities/LnurlAmountConversion.swift diff --git a/Bitkit/Utilities/LnurlAmountConversion.swift b/Bitkit/Utilities/LnurlAmountConversion.swift new file mode 100644 index 00000000..ddb5ee55 --- /dev/null +++ b/Bitkit/Utilities/LnurlAmountConversion.swift @@ -0,0 +1,18 @@ +import Foundation + +enum LnurlAmountConversion { + /// LNURL `minSendable` / `maxSendable` values are expressed in millisatoshis (msat). + /// + /// The UI and amount input operate in whole sats. When converting a minimum bound to sats we must round up: + /// `minSendable = 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..76031c84 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, LnurlAmountConversion.satsCeil(fromMsats: normalizedData.minSendable)) + normalizedData.maxSendable = max(normalizedData.minSendable, LnurlAmountConversion.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, LnurlAmountConversion.satsCeil(fromMsats: minMsats)) + let maxSats = max(minSats, LnurlAmountConversion.satsFloor(fromMsats: maxMsats)) normalizedData.minWithdrawable = minSats normalizedData.maxWithdrawable = maxSats From 906791f0064fde8d2acd803fd178b40eff338e56 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 17 Dec 2025 08:02:20 +0100 Subject: [PATCH 2/5] test: add lnurl amount conversion unit tests --- BitkitTests/LnurlAmountConversionTests.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 BitkitTests/LnurlAmountConversionTests.swift diff --git a/BitkitTests/LnurlAmountConversionTests.swift b/BitkitTests/LnurlAmountConversionTests.swift new file mode 100644 index 00000000..e2b0948c --- /dev/null +++ b/BitkitTests/LnurlAmountConversionTests.swift @@ -0,0 +1,19 @@ +@testable import Bitkit +import XCTest + +final class LnurlAmountConversionTests: XCTestCase { + func testSatsCeilRoundsUpWhenNotDivisibleBy1000() { + XCTAssertEqual(LnurlAmountConversion.satsCeil(fromMsats: 100_500), 101) + XCTAssertEqual(LnurlAmountConversion.satsCeil(fromMsats: 1500), 2) + } + + func testSatsCeilKeepsExactSatAmounts() { + XCTAssertEqual(LnurlAmountConversion.satsCeil(fromMsats: 100_000), 100) + XCTAssertEqual(LnurlAmountConversion.satsCeil(fromMsats: 0), 0) + } + + func testSatsFloorRoundsDown() { + XCTAssertEqual(LnurlAmountConversion.satsFloor(fromMsats: 100_999), 100) + XCTAssertEqual(LnurlAmountConversion.satsFloor(fromMsats: 100_000), 100) + } +} From 3a0792c3c910c6d32c8b7ddd8ef2560e7aaf0130 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 16 Dec 2025 15:08:02 +0100 Subject: [PATCH 3/5] boost toast ids --- Bitkit/Views/Wallets/Sheets/BoostSheet.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 From 777eb4caf0d5716110cde74c8a7ca1791dd7ff56 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 17 Dec 2025 09:15:32 +0100 Subject: [PATCH 4/5] test: add zero case floor --- BitkitTests/LnurlAmountConversionTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/BitkitTests/LnurlAmountConversionTests.swift b/BitkitTests/LnurlAmountConversionTests.swift index e2b0948c..788743f8 100644 --- a/BitkitTests/LnurlAmountConversionTests.swift +++ b/BitkitTests/LnurlAmountConversionTests.swift @@ -15,5 +15,6 @@ final class LnurlAmountConversionTests: XCTestCase { func testSatsFloorRoundsDown() { XCTAssertEqual(LnurlAmountConversion.satsFloor(fromMsats: 100_999), 100) XCTAssertEqual(LnurlAmountConversion.satsFloor(fromMsats: 100_000), 100) + XCTAssertEqual(LnurlAmountConversion.satsFloor(fromMsats: 0), 0) } } From b2510e31ef6b2c930ea4632a892cad7e69d2636c Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 17 Dec 2025 14:04:53 +0100 Subject: [PATCH 5/5] refactor: rename LnurlAmountConversion to LightningAmountConversion --- ....swift => LightningAmountConversion.swift} | 6 +++--- Bitkit/ViewModels/AppViewModel.swift | 8 ++++---- .../LightningAmountConversionTests.swift | 20 +++++++++++++++++++ BitkitTests/LnurlAmountConversionTests.swift | 20 ------------------- 4 files changed, 27 insertions(+), 27 deletions(-) rename Bitkit/Utilities/{LnurlAmountConversion.swift => LightningAmountConversion.swift} (71%) create mode 100644 BitkitTests/LightningAmountConversionTests.swift delete mode 100644 BitkitTests/LnurlAmountConversionTests.swift diff --git a/Bitkit/Utilities/LnurlAmountConversion.swift b/Bitkit/Utilities/LightningAmountConversion.swift similarity index 71% rename from Bitkit/Utilities/LnurlAmountConversion.swift rename to Bitkit/Utilities/LightningAmountConversion.swift index ddb5ee55..76fcd122 100644 --- a/Bitkit/Utilities/LnurlAmountConversion.swift +++ b/Bitkit/Utilities/LightningAmountConversion.swift @@ -1,10 +1,10 @@ import Foundation -enum LnurlAmountConversion { - /// LNURL `minSendable` / `maxSendable` values are expressed in millisatoshis (msat). +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: - /// `minSendable = 100500 msat` means the minimum payable amount is `101 sat` (not `100 sat`). + /// `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 diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 76031c84..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, LnurlAmountConversion.satsCeil(fromMsats: normalizedData.minSendable)) - normalizedData.maxSendable = max(normalizedData.minSendable, LnurlAmountConversion.satsFloor(fromMsats: normalizedData.maxSendable)) + 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, LnurlAmountConversion.satsCeil(fromMsats: minMsats)) - let maxSats = max(minSats, LnurlAmountConversion.satsFloor(fromMsats: maxMsats)) + 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/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) + } +} diff --git a/BitkitTests/LnurlAmountConversionTests.swift b/BitkitTests/LnurlAmountConversionTests.swift deleted file mode 100644 index 788743f8..00000000 --- a/BitkitTests/LnurlAmountConversionTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -@testable import Bitkit -import XCTest - -final class LnurlAmountConversionTests: XCTestCase { - func testSatsCeilRoundsUpWhenNotDivisibleBy1000() { - XCTAssertEqual(LnurlAmountConversion.satsCeil(fromMsats: 100_500), 101) - XCTAssertEqual(LnurlAmountConversion.satsCeil(fromMsats: 1500), 2) - } - - func testSatsCeilKeepsExactSatAmounts() { - XCTAssertEqual(LnurlAmountConversion.satsCeil(fromMsats: 100_000), 100) - XCTAssertEqual(LnurlAmountConversion.satsCeil(fromMsats: 0), 0) - } - - func testSatsFloorRoundsDown() { - XCTAssertEqual(LnurlAmountConversion.satsFloor(fromMsats: 100_999), 100) - XCTAssertEqual(LnurlAmountConversion.satsFloor(fromMsats: 100_000), 100) - XCTAssertEqual(LnurlAmountConversion.satsFloor(fromMsats: 0), 0) - } -}