commit 2505968ba2b49179932553a6c0072215e7b52a87
parent 56c8cc8be62030dffa8a7189a4d6a6c57a90a04b
Author: Marc Stibane <marc@taler.net>
Date: Mon, 7 Sep 2026 16:46:28 +0200
AI: reject digit edits that exceed the amount limit
Diffstat:
2 files changed, 71 insertions(+), 43 deletions(-)
diff --git a/taler-swift/Sources/taler-swift/Amount.swift b/taler-swift/Sources/taler-swift/Amount.swift
@@ -184,12 +184,14 @@ public final class Amount: Codable, Hashable, @unchecked Sendable, CustomStringC
/// The string representation of the value, formatted as "`integer``fraction`",
/// no group separator, no decimalSeparator, #inputDigits digits from fraction, padded with trailing zeroes
public func plainString(inputDigits: UInt) -> String {
- let base = fractionalBase()
- var frac = fraction
+ let normalized = (try? normalizedCopy()) ?? self
+ let integer = normalized.integer
+ let base = UInt64(fractionalBase())
+ var frac = UInt64(normalized.fraction)
var fracStr = ""
- var i = inputDigits
+ var i = min(inputDigits, Self.fractionalBaseDigits)
- var nextchar: UInt32 {
+ var nextchar: UInt64 {
let fracChar = frac / (base / 10)
frac = (frac * 10) % base
return fracChar
@@ -378,46 +380,24 @@ public final class Amount: Codable, Hashable, @unchecked Sendable, CustomStringC
self.fraction = remainder / 10
}
- /// Multiplies by ten, then adds digit
+ /// Multiplies by ten, then adds a digit at the input precision.
+ /// An unrepresentable edit leaves the entire amount unchanged.
public func shiftLeft(add digit: UInt8, _ inputDigits: UInt) {
- if inputDigits > 0 {
- // how many digits to shift right (e.g. inputD=2 ==> shift:=6)
- let digits = min(inputDigits, Self.fractionalBaseDigits)
- let shift = Self.fractionalBaseDigits - digits
- // mask to zero out fractions smaller than inputDigits
- let shiftMask = fractionalBase(shift)
-
- let carryMask = fractionalBase(Self.fractionalBaseDigits - 1)
- // get biggest fractional digit
- let carry = fraction / carryMask
- var remainder = fraction % carryMask
-// print("fraction: \(fraction) = \(carry) + \(remainder)")
-
- let shiftedInt = integer * 10 + UInt64(carry)
- if shiftedInt < Self.maxValue {
- self.integer = shiftedInt
-// print("remainder: \(remainder) / shiftMask \(shiftMask) = \(remainder / shiftMask)")
- remainder = (remainder / shiftMask) * 10
- } else { // will get too big
- // Just swap the last significant digit for the one the user typed last
- if shiftMask >= 10 {
- remainder = (remainder / (shiftMask / 10)) * 10
- } else {
- remainder = (remainder / 10) * 10
- }
- }
- let sum = remainder + UInt32(digit)
- self.fraction = sum * shiftMask
- // print("(remainder: \(remainder) + \(digit)) * base(shift) \(shiftMask) = fraction \(fraction)")
- } else {
- let shiftedInt = integer * 10 + UInt64(digit)
- if shiftedInt < Self.maxValue {
- self.integer = shiftedInt
- } else {
- self.integer = Self.maxValue
- }
- self.fraction = 0
- }
+ guard digit < 10, let amount = try? normalizedCopy() else { return }
+ // how many digits to shift (e.g. inputD=2 ==> shift:=6)
+ let digits = min(inputDigits, Self.fractionalBaseDigits)
+ let base = UInt64(fractionalBase())
+ let mask = UInt64(fractionalBase(Self.fractionalBaseDigits - digits))
+ let baseDiv10 = base / 10
+ let carry = digits == 0 ? UInt64(digit) : UInt64(amount.fraction) / baseDiv10
+ let (product, overflow) = amount.integer.multipliedReportingOverflow(by: 10)
+ let (nextInteger, additionOverflow) = product.addingReportingOverflow(carry)
+ guard !overflow, !additionOverflow, nextInteger <= Self.maxValue else { return }
+ let nextFraction = digits == 0 ? 0
+ : ((UInt64(amount.fraction) % baseDiv10) / mask * 10 + UInt64(digit)) * mask
+ guard nextFraction < base else { return }
+ integer = nextInteger
+ fraction = UInt32(nextFraction)
}
/// Sets all fractional digits after inputDigits to 0
diff --git a/taler-swift/Tests/taler-swiftTests/AmountTests.swift b/taler-swift/Tests/taler-swiftTests/AmountTests.swift
@@ -146,3 +146,51 @@ class AmountTests: XCTestCase {
XCTAssertThrowsError(try Amount(fromString: "EUR:0.5") < Amount(fromString: "USD:0.50000001"))
}
}
+
+// REL-001: the currency field formats after every digit, including rejected edits.
+extension AmountTests {
+ func testDigitEntryRemainsRepresentableAtEveryPrecision() throws {
+ for digits in UInt(0)...8 {
+ let amount = Amount.zero(currency: "EUR")
+ for _ in 0..<40 {
+ amount.shiftLeft(add: 9, digits)
+ XCTAssertLessThanOrEqual(amount.integer, 4_503_599_627_370_496)
+ XCTAssertLessThan(amount.fraction, 100_000_000)
+ _ = amount.plainString(inputDigits: digits)
+ XCTAssertEqual(try Amount(fromString: amount.description), amount)
+ }
+ let before = amount.copy()
+ amount.shiftLeft(add: 9, digits)
+ XCTAssertEqual(amount, before)
+ amount.shiftRight()
+ XCTAssertTrue(try amount < before)
+ amount.shiftLeft(add: 1, digits)
+ _ = amount.plainString(inputDigits: digits)
+ }
+ }
+
+ func testEighteenthDigitIsRejectedAtomically() throws {
+ let amount = Amount.zero(currency: "EUR")
+ for _ in 0..<17 { amount.shiftLeft(add: 9, 2) }
+ XCTAssertEqual(amount.description, "EUR:999999999999999.99")
+ amount.shiftLeft(add: 9, 2)
+ XCTAssertEqual(amount.plainString(inputDigits: 2), "99999999999999999")
+ }
+
+ func testMaximumAmountEditPreservesValue() throws {
+ for digits in UInt(0)...8 {
+ let amount = try Amount(fromString: "EUR:4503599627370496.99999999")
+ let before = amount.copy()
+ amount.shiftLeft(add: 0, digits)
+ XCTAssertEqual(amount, before)
+ }
+ let amount = try Amount(fromString: "EUR:450359962737049")
+ amount.shiftLeft(add: 6, 0)
+ XCTAssertEqual(amount.integer, 4_503_599_627_370_496)
+ }
+
+ func testPlainStringNormalizesWideFractionWithoutOverflow() {
+ let amount = Amount(currency: "EUR", integer: 0, fraction: 909_000_000)
+ XCTAssertEqual(amount.plainString(inputDigits: 2), "909")
+ }
+}