diff --git a/CLAUDE.md b/CLAUDE.md index ecf3077b..e348ee4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -386,6 +386,25 @@ extension RatesController { Place test support extensions in `FlipcashTests/TestSupport/` using the naming pattern `{Type}+TestSupport.swift`. +### Regression Tests + +**Every crash fixed from Bugsnag (or similar) gets a dedicated regression test** in `FlipcashTests/Regressions/`. + +- **One file per incident:** `Regression_{bugsnag_id}.swift` +- **Suite name includes the short ID:** `@Suite("Regression: {short_id} – {brief description}")` +- **Reproduce the crash path**, not just the low-level fix. If the crash came through `EnterAmountCalculator`, test through `EnterAmountCalculator`. + +```swift +// FlipcashTests/Regressions/Regression_698ef3b65e6cc4bb5554e13d.swift + +@Suite("Regression: 698ef3b – Quarks comparison overflow for high-rate currencies") +struct Regression_698ef3b { + + @Test("CLP quarks comparison across 6 and 10 decimal precisions does not overflow") + func quarksComparison_CLP_doesNotOverflow() { ... } +} +``` + --- ## Git & Workflow diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift index ba8dd062..b5d4c97c 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift @@ -20,7 +20,7 @@ class CurrencySellConfirmationViewModel: ObservableObject { var canDismissSheet: Bool = false - private var fee: ExchangedFiat { + var fee: ExchangedFiat { let bps: UInt64 = 100 let underlying = Quarks( quarks: amount.underlying.quarks * bps / 10_000, diff --git a/FlipcashCore/Sources/FlipcashCore/Extensions/UInt64+Operations.swift b/FlipcashCore/Sources/FlipcashCore/Extensions/UInt64+Operations.swift index 051b2540..bdcff3de 100644 --- a/FlipcashCore/Sources/FlipcashCore/Extensions/UInt64+Operations.swift +++ b/FlipcashCore/Sources/FlipcashCore/Extensions/UInt64+Operations.swift @@ -24,6 +24,8 @@ extension UInt64 { func scaleUp(_ d: Int) -> UInt64 { let factor = pow10(d) - return self * factor + let (result, overflow) = self.multipliedReportingOverflow(by: factor) + precondition(!overflow, "UInt64.scaleUp overflow: \(self) * 10^\(d)") + return result } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Quarks.swift b/FlipcashCore/Sources/FlipcashCore/Models/Quarks.swift index 2e4b06c1..351f9698 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Quarks.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Quarks.swift @@ -228,13 +228,14 @@ extension Quarks: ExpressibleByFloatLiteral { extension Quarks: Comparable { public static func < (lhs: Self, rhs: Self) -> Bool { - do { - let (l, r, _) = try lhs.align(with: rhs) - return l.quarks < r.quarks - } catch { + guard lhs.currencyCode == rhs.currencyCode else { assertionFailure("Attempting to compare different currency Fiat values.") - print(error) return false } + + // Compare using Decimal to avoid UInt64 overflow when scaling + // quarks up to a common precision (e.g. high-rate currencies + // like CLP, VND, IRR can overflow UInt64 during scaleUp). + return lhs.decimalValue < rhs.decimalValue } } diff --git a/FlipcashTests/EnterAmountCalculatorTests.swift b/FlipcashTests/EnterAmountCalculatorTests.swift index c62fc75c..0ab85a61 100644 --- a/FlipcashTests/EnterAmountCalculatorTests.swift +++ b/FlipcashTests/EnterAmountCalculatorTests.swift @@ -167,7 +167,7 @@ import FlipcashCore @Test func maxEnterAmount_whenLimitLessThanBalance_returnsConvertedLimit() { let balance = Self.createExchangedFiat(underlyingQuarks: 2_000_000, convertedQuarks: 2_000_000) let limit = Quarks(quarks: 1_000_000 as UInt64, currencyCode: .usd, decimals: 6) - + let calculator = EnterAmountCalculator( mode: .currency, entryCurrency: .usd, @@ -175,10 +175,11 @@ import FlipcashCore transactionLimitProvider: { _ in return limit }, rateProvider: { _ in Rate.oneToOne } ) - + let result = calculator.maxEnterAmount(maxBalance: balance) let expectedLimit = limit.converting(to: Rate.oneToOne, decimals: PublicKey.usdf.mintDecimals) - + #expect(result == expectedLimit) } + } diff --git a/FlipcashTests/FiatTests.swift b/FlipcashTests/FiatTests.swift index 2a1f655c..68635f70 100644 --- a/FlipcashTests/FiatTests.swift +++ b/FlipcashTests/FiatTests.swift @@ -140,6 +140,7 @@ struct FiatTests { // Should show ¥10 (1000 quarks / 100 decimals = 10), no decimal places #expect(!formatted.contains(".")) } + } diff --git a/FlipcashTests/Regressions/Regression_698ef3b65e6cc4bb5554e13d.swift b/FlipcashTests/Regressions/Regression_698ef3b65e6cc4bb5554e13d.swift new file mode 100644 index 00000000..3eb8485b --- /dev/null +++ b/FlipcashTests/Regressions/Regression_698ef3b65e6cc4bb5554e13d.swift @@ -0,0 +1,70 @@ +// +// Regression_698ef3b65e6cc4bb5554e13d.swift +// Flipcash +// +// Crash: UInt64.scaleUp arithmetic overflow when comparing Quarks +// with different decimal precisions for high-rate currencies (CLP). +// +// Fix: Quarks.< now compares via decimalValue (Decimal) instead of +// aligning UInt64 quarks to a common precision via scaleUp. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@Suite("Regression: 698ef3b – Quarks comparison overflow for high-rate currencies") +struct Regression_698ef3b { + + @Test("CLP quarks comparison across 6 and 10 decimal precisions does not overflow") + func quarksComparison_CLP_doesNotOverflow() { + // clpAt6: 475_000_000_000_000 / 10^6 = 475,000,000 CLP + // clpAt10: 1_000_000_000_000_000_000 / 10^10 = 100,000,000 CLP + let clpAt6 = Quarks(quarks: 475_000_000_000_000 as UInt64, currencyCode: .clp, decimals: 6) + let clpAt10 = Quarks(quarks: 1_000_000_000_000_000_000 as UInt64, currencyCode: .clp, decimals: 10) + + // Must not crash, and 100M < 475M + #expect(clpAt10 < clpAt6) + } + + @Test("EnterAmountCalculator.maxEnterAmount with CLP does not overflow") + func maxEnterAmount_CLP_doesNotOverflow() { + let clpRate = Rate(fx: 950, currency: .clp) + + let underlying = Quarks(quarks: 500_000_000 as UInt64, currencyCode: .usd, decimals: 6) + let converted = Quarks(quarks: 475_000_000_000 as UInt64, currencyCode: .clp, decimals: 6) + let balance = ExchangedFiat( + underlying: underlying, + converted: converted, + rate: clpRate, + mint: .usdf + ) + + let limit = Quarks(quarks: 950_000_000_000 as UInt64, currencyCode: .clp, decimals: 6) + + let calculator = EnterAmountCalculator( + mode: .currency, + entryCurrency: .clp, + onrampCurrency: .usd, + transactionLimitProvider: { _ in return limit }, + rateProvider: { _ in clpRate } + ) + + // Must not crash with arithmetic overflow + let result = calculator.maxEnterAmount(maxBalance: balance) + #expect(result == balance.converted) + } + + @Test( + "No currency overflows when comparing quarks across 6 and 10 decimal precisions", + arguments: CurrencyCode.allCases + ) + func quarksComparison_allCurrencies_noOverflow(currency: CurrencyCode) { + let largeAt6 = Quarks(quarks: 1_000_000_000_000_000 as UInt64, currencyCode: currency, decimals: 6) + let largeAt10 = Quarks(quarks: 1_000_000_000_000_000 as UInt64, currencyCode: currency, decimals: 10) + + // 10^15 / 10^6 = 10^9 vs 10^15 / 10^10 = 10^5 + #expect(largeAt10 < largeAt6) + } +} diff --git a/FlipcashTests/UInt64OperationsTests.swift b/FlipcashTests/UInt64OperationsTests.swift new file mode 100644 index 00000000..2ee98973 --- /dev/null +++ b/FlipcashTests/UInt64OperationsTests.swift @@ -0,0 +1,92 @@ +// +// UInt64OperationsTests.swift +// Flipcash +// +// Created by Claude on 2026-02-13. +// + +import Foundation +import Testing +@testable import FlipcashCore + +// MARK: - scaleDown + +@Suite("UInt64.scaleDown") +struct UInt64ScaleDownTests { + + @Test("Converts quarks to Decimal by dividing by 10^d") + func convertsQuarksToDecimal() { + #expect((123_456_789 as UInt64).scaleDown(6) == Decimal(string: "123.456789")) + } + + @Test("Value smaller than factor returns a fractional Decimal") + func smallValue() { + #expect((1 as UInt64).scaleDown(6) == Decimal(string: "0.000001")) + } + + @Test("Preserves precision for high-rate currency quarks") + func highRateCurrencyQuarks() { + // CLP-scale quarks used in Quarks.converting(to:decimals:) + #expect((475_000_000_000_000 as UInt64).scaleDown(6) == Decimal(475_000_000)) + } + + @Test("Works with bonded token precision (10 decimals)") + func bondedTokenDecimals() { + #expect((1_000_000_000_000 as UInt64).scaleDown(10) == Decimal(100)) + } +} + +// MARK: - scaleDownInt + +@Suite("UInt64.scaleDownInt") +struct UInt64ScaleDownIntTests { + + @Test("Performs integer division by 10^d") + func integerDivision() { + #expect((123_456_789 as UInt64).scaleDownInt(4) == 12_345) + } + + @Test("Truncates rather than rounds") + func truncates() { + #expect((999_999 as UInt64).scaleDownInt(6) == 0) + } + + @Test("Exact match returns whole units") + func exactMatch() { + #expect((5_000_000 as UInt64).scaleDownInt(6) == 5) + } +} + +// MARK: - scaleUp + +@Suite("UInt64.scaleUp") +struct UInt64ScaleUpTests { + + @Test("Multiplies by 10^d") + func multipliesByFactor() { + #expect((123 as UInt64).scaleUp(4) == 1_230_000) + } + + @Test("Zero value stays zero regardless of exponent") + func zeroValue() { + #expect((0 as UInt64).scaleUp(10) == 0) + } + + @Test("Succeeds near the UInt64 boundary without overflow") + func maxSafeValue() { + // UInt64.max / 10_000 ≈ 1_844_674_407_370_955 + let quarks: UInt64 = 1_844_674_407_370_955 + #expect(quarks.scaleUp(4) == 18_446_744_073_709_550_000) + } + + // MARK: - Roundtrip + + @Test("scaleUp and scaleDownInt are inverse operations") + func roundtrip() { + let original: UInt64 = 475_000 + let scaled = original.scaleUp(6) + + #expect(scaled == 475_000_000_000) + #expect(scaled.scaleDownInt(6) == original) + } +}