Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class CurrencySellConfirmationViewModel: ObservableObject {

var canDismissSheet: Bool = false

private var fee: ExchangedFiat {
var fee: ExchangedFiat {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was preventing the tests from running (I really need to start running the tests in Xcode Cloud)

let bps: UInt64 = 100
let underlying = Quarks(
quarks: amount.underlying.quarks * bps / 10_000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
11 changes: 6 additions & 5 deletions FlipcashCore/Sources/FlipcashCore/Models/Quarks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
7 changes: 4 additions & 3 deletions FlipcashTests/EnterAmountCalculatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,18 +167,19 @@ 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,
onrampCurrency: .usd,
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)
}

}
1 change: 1 addition & 0 deletions FlipcashTests/FiatTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ struct FiatTests {
// Should show ¥10 (1000 quarks / 100 decimals = 10), no decimal places
#expect(!formatted.contains("."))
}

}


Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
92 changes: 92 additions & 0 deletions FlipcashTests/UInt64OperationsTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}