Skip to content

Commit 2f33cc3

Browse files
feat: enforce u64 max amount limit for payments (#408)
1 parent ba3bf4d commit 2f33cc3

File tree

8 files changed

+176
-9
lines changed

8 files changed

+176
-9
lines changed

dapps/poc-pos-app/app/amount.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { exceedsU64Max } from "@/utils/currency";
12
import { Button } from "@/components/button";
23
import { NumericKeyboard } from "@/components/numeric-keyboard";
34
import { ThemedText } from "@/components/themed-text";
@@ -109,6 +110,7 @@ export default function AmountScreen() {
109110
onChange?.(newDisplay);
110111
} else {
111112
const newDisplay = prev === "0" ? key : prev + key;
113+
if (exceedsU64Max(newDisplay)) return;
112114
onChange?.(newDisplay);
113115
}
114116
}}

dapps/poc-pos-app/utils/currency.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,50 @@
1-
import { dollarsToCents } from "./currency";
1+
import { dollarsToCents, exceedsU64Max } from "./currency";
2+
3+
describe("exceedsU64Max", () => {
4+
it("returns false for typical amounts", () => {
5+
expect(exceedsU64Max("0")).toBe(false);
6+
expect(exceedsU64Max("10.50")).toBe(false);
7+
expect(exceedsU64Max("1299.00")).toBe(false);
8+
expect(exceedsU64Max("49999.99")).toBe(false);
9+
});
10+
11+
it("returns false for empty and intermediate input states", () => {
12+
expect(exceedsU64Max("")).toBe(false);
13+
expect(exceedsU64Max(".")).toBe(false);
14+
expect(exceedsU64Max("0.")).toBe(false);
15+
});
16+
17+
it("returns false for the exact u64 max in cents", () => {
18+
expect(exceedsU64Max("184467440737095516.15")).toBe(false);
19+
});
20+
21+
it("returns true when cents value exceeds u64 max", () => {
22+
expect(exceedsU64Max("184467440737095516.16")).toBe(true);
23+
});
24+
25+
it("returns true for clearly excessive amounts", () => {
26+
expect(exceedsU64Max("999999999999999999.99")).toBe(true);
27+
});
28+
29+
it("returns false for large amounts still within u64 range", () => {
30+
expect(exceedsU64Max("184467440737095516.00")).toBe(false);
31+
expect(exceedsU64Max("184467440737095516")).toBe(false);
32+
});
33+
34+
it("handles whole numbers without decimal point", () => {
35+
expect(exceedsU64Max("184467440737095517")).toBe(true);
36+
expect(exceedsU64Max("184467440737095516")).toBe(false);
37+
});
38+
39+
it("handles single decimal digit input", () => {
40+
expect(exceedsU64Max("184467440737095516.1")).toBe(false);
41+
expect(exceedsU64Max("184467440737095516.2")).toBe(true);
42+
});
43+
44+
it("handles trailing decimal", () => {
45+
expect(exceedsU64Max("184467440737095516.")).toBe(false);
46+
});
47+
});
248

349
describe("dollarsToCents", () => {
450
it("converts whole dollars", () => {

dapps/poc-pos-app/utils/currency.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,21 @@
44
*/
55
export const dollarsToCents = (amount: string): number =>
66
Math.round(parseFloat(amount) * 100);
7+
8+
const U64_MAX = BigInt("18446744073709551615");
9+
10+
/**
11+
* Check if a dollar amount string, when converted to cents, exceeds the u64 max.
12+
* Uses BigInt and string manipulation (not floating-point) for precision.
13+
*/
14+
export function exceedsU64Max(dollarAmount: string): boolean {
15+
if (!dollarAmount || dollarAmount === "." || dollarAmount === "0.") {
16+
return false;
17+
}
18+
const parts = dollarAmount.includes(".")
19+
? dollarAmount.split(".")
20+
: [dollarAmount];
21+
const whole = (parts[0] || "0").replace(/^0+/, "") || "0";
22+
const fractional = (parts[1] || "").padEnd(2, "0").slice(0, 2);
23+
return BigInt(whole + fractional) > U64_MAX;
24+
}

dapps/pos-app/app/amount.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { ThemedText } from "@/components/themed-text";
55
import { BorderRadius, Spacing } from "@/constants/spacing";
66
import { useTheme } from "@/hooks/use-theme-color";
77
import { useSettingsStore } from "@/store/useSettingsStore";
8-
import { formatAmountWithSymbol, getCurrency } from "@/utils/currency";
8+
import {
9+
exceedsU64Max,
10+
formatAmountWithSymbol,
11+
getCurrency,
12+
} from "@/utils/currency";
913
import { router } from "expo-router";
1014
import { Controller, useForm } from "react-hook-form";
1115
import { Platform, StyleSheet, View } from "react-native";
@@ -111,6 +115,7 @@ export default function AmountScreen() {
111115
if (decimalPart.length >= 2) return;
112116
}
113117
const newDisplay = prev === "0" ? key : prev + key;
118+
if (exceedsU64Max(newDisplay)) return;
114119
onChange?.(newDisplay);
115120
}
116121
}}

dapps/pos-app/components/big-amount-input/hooks/useAnimatedNumberLayout.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ function getScaleForLength(length: number): number {
4343
if (length <= 6) return 1;
4444
if (length <= 9) return 0.85;
4545
if (length <= 12) return 0.7;
46-
return 0.6;
46+
if (length <= 15) return 0.55;
47+
if (length <= 18) return 0.45;
48+
return 0.38;
4749
}
4850

4951
export type CharacterLayoutInfo = {

dapps/pos-app/components/big-amount-input/utils/formatAmount.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,31 @@ export function parseRawValue(rawValue: string): number {
3131
return isNaN(parsed) ? 0 : parsed;
3232
}
3333

34+
function getGroupSeparator(locale: SupportedLocale): string {
35+
const parts = new Intl.NumberFormat(locale).formatToParts(10000);
36+
return parts.find((p) => p.type === "group")?.value ?? ",";
37+
}
38+
39+
function addThousandsSeparators(
40+
integerStr: string,
41+
separator: string,
42+
): string {
43+
let result = "";
44+
for (let i = integerStr.length - 1, count = 0; i >= 0; i--, count++) {
45+
if (count > 0 && count % 3 === 0) {
46+
result = separator + result;
47+
}
48+
result = integerStr[i] + result;
49+
}
50+
return result;
51+
}
52+
3453
/**
3554
* Formats raw value (e.g., "123.45") to display string with currency.
3655
* When decimal separator is entered, always shows 2 decimal places
3756
* so placeholder zeros can be styled differently.
57+
* Uses string manipulation instead of parseFloat to avoid precision loss
58+
* with large numbers (> Number.MAX_SAFE_INTEGER).
3859
*/
3960
export function formatRawValueToDisplay(
4061
rawValue: string,
@@ -49,14 +70,22 @@ export function formatRawValueToDisplay(
4970
if (!rawValue || rawValue === "") return "";
5071

5172
const hasDecimalSeparator = rawValue.includes(".") || rawValue.includes(",");
52-
const numericValue = parseRawValue(rawValue);
73+
const normalized = rawValue.replace(",", ".");
74+
const [intPart, decPart] = normalized.split(".");
75+
const cleanInteger = (intPart || "0").replace(/^0+/, "") || "0";
76+
77+
const groupSep = getGroupSeparator(locale);
78+
const decSep = getDecimalSeparator(locale);
79+
const formattedInteger = addThousandsSeparators(cleanInteger, groupSep);
5380

54-
const formatter = new Intl.NumberFormat(locale, {
55-
minimumFractionDigits: hasDecimalSeparator ? 2 : 0,
56-
maximumFractionDigits: 2,
57-
});
81+
let formatted: string;
82+
if (hasDecimalSeparator) {
83+
const paddedDecimal = (decPart || "").padEnd(2, "0").slice(0, 2);
84+
formatted = `${formattedInteger}${decSep}${paddedDecimal}`;
85+
} else {
86+
formatted = formattedInteger;
87+
}
5888

59-
const formatted = formatter.format(numericValue);
6089
return symbolPosition === "right"
6190
? `${formatted}${currency}`
6291
: `${currency}${formatted}`;

dapps/pos-app/utils/currency.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
amountToCents,
33
CURRENCIES,
4+
exceedsU64Max,
45
formatAmountWithSymbol,
56
formatFiatAmount,
67
getCurrency,
@@ -47,6 +48,52 @@ describe("amountToCents", () => {
4748
});
4849
});
4950

51+
describe("exceedsU64Max", () => {
52+
it("returns false for typical amounts", () => {
53+
expect(exceedsU64Max("0")).toBe(false);
54+
expect(exceedsU64Max("10.50")).toBe(false);
55+
expect(exceedsU64Max("1299.00")).toBe(false);
56+
expect(exceedsU64Max("49999.99")).toBe(false);
57+
});
58+
59+
it("returns false for empty and intermediate input states", () => {
60+
expect(exceedsU64Max("")).toBe(false);
61+
expect(exceedsU64Max(".")).toBe(false);
62+
expect(exceedsU64Max("0.")).toBe(false);
63+
});
64+
65+
it("returns false for the exact u64 max in cents", () => {
66+
expect(exceedsU64Max("184467440737095516.15")).toBe(false);
67+
});
68+
69+
it("returns true when cents value exceeds u64 max", () => {
70+
expect(exceedsU64Max("184467440737095516.16")).toBe(true);
71+
});
72+
73+
it("returns true for clearly excessive amounts", () => {
74+
expect(exceedsU64Max("999999999999999999.99")).toBe(true);
75+
});
76+
77+
it("returns false for large amounts still within u64 range", () => {
78+
expect(exceedsU64Max("184467440737095516.00")).toBe(false);
79+
expect(exceedsU64Max("184467440737095516")).toBe(false);
80+
});
81+
82+
it("handles whole numbers without decimal point", () => {
83+
expect(exceedsU64Max("184467440737095517")).toBe(true);
84+
expect(exceedsU64Max("184467440737095516")).toBe(false);
85+
});
86+
87+
it("handles single decimal digit input", () => {
88+
expect(exceedsU64Max("184467440737095516.1")).toBe(false);
89+
expect(exceedsU64Max("184467440737095516.2")).toBe(true);
90+
});
91+
92+
it("handles trailing decimal", () => {
93+
expect(exceedsU64Max("184467440737095516.")).toBe(false);
94+
});
95+
});
96+
5097
describe("CURRENCIES", () => {
5198
it("contains USD and EUR", () => {
5299
expect(CURRENCIES).toHaveLength(2);

dapps/pos-app/utils/currency.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,24 @@ export function formatAmountWithSymbol(
5656
export const amountToCents = (amount: string): number =>
5757
Math.round(parseFloat(amount) * 100);
5858

59+
const U64_MAX = BigInt("18446744073709551615");
60+
61+
/**
62+
* Check if a dollar amount string, when converted to cents, exceeds the u64 max.
63+
* Uses BigInt and string manipulation (not floating-point) for precision.
64+
*/
65+
export function exceedsU64Max(dollarAmount: string): boolean {
66+
if (!dollarAmount || dollarAmount === "." || dollarAmount === "0.") {
67+
return false;
68+
}
69+
const parts = dollarAmount.includes(".")
70+
? dollarAmount.split(".")
71+
: [dollarAmount];
72+
const whole = (parts[0] || "0").replace(/^0+/, "") || "0";
73+
const fractional = (parts[1] || "").padEnd(2, "0").slice(0, 2);
74+
return BigInt(whole + fractional) > U64_MAX;
75+
}
76+
5977
/**
6078
* Extract currency code from CAIP format (e.g., "iso4217/USD" -> "USD")
6179
*/

0 commit comments

Comments
 (0)