Why 96 of 100 paise endings repeat in binary, how that can flip a signed credit tier, and how I parse bank amounts from text into integer paise.
I'm building TEE Income Oracle, a service that reads a borrower's bank statement inside a secure enclave and signs a credit tier: A, B, C or reject. A lending pool reads that tier on chain. The score has two implementations: the enclave scorer in Rust, and a second scorer, written separately in TypeScript, that produces the expected tier for a set of test statements. The Rust tests fail unless the two agree.
So I need the same statement to produce the same tier, every time, in two languages. This post follows one amount through that pipeline: from the bank's text, to exact paise, to arithmetic that both scorers do the same way.
A tier that depends on the order of addition
One rule for tier A is about FOIR: monthly loan repayments (EMIs) as a share of monthly income. My policy measures it in whole basis points, rounded down, and tier A allows at most 4,000 (40.00%).
Take a ₹50,000 income and three EMIs that add up to ₹20,005.00. That's 40.01%, or 4,001 basis points, so this borrower is not tier A. Here's the check with floats:
const income = 50000;
const emis = [5000.0, 12345.06, 2659.94]; // ₹20,005.00: 40.01%, not tier A
const bps = total => Math.floor((total * 10000) / income);
const forward = emis[0] + emis[1] + emis[2]; // 20004.999999999996
const backward = emis[2] + emis[1] + emis[0]; // 20005
bps(forward); // 4000 → tier A
bps(backward); // 4001 → not tier A
Same amounts, same rule, two tiers. Nothing here is a bug in the usual sense: both orders are reasonable, and two programs written separately can easily pick different ones. With floats, they don't have to be wrong to disagree. And in my system the tier gets signed, so a wrong one is a signed claim about a real person.
With integer paise, the order can't matter:
const emisPaise = [500000n, 1234506n, 265994n];
const total = emisPaise[0] + emisPaise[1] + emisPaise[2]; // 2000500n
(total * 10000n) / 5000000n; // 4001n in any order → not tier A
Why floats can't hold most rupee amounts
A float stores numbers in binary, and binary can only write some fractions exactly. Decimal has the same problem with 1/3: you can't write it, only 0.3333… forever. Binary can write halves, quarters and eighths, but not tenths or hundredths. So 0.05 gets stored as the nearest value binary can hold:
(0.05).toFixed(30); // "0.050000000000000002775557561563"
I checked all 100 possible paise endings with a script: 96 of them can't be stored exactly. Only .00, .25, .50 and .75 can, and only if the whole amount fits the format's precision.
That's a statement about endings, not about real transactions, which don't spread evenly across them. For amounts with any of those 96 endings, a binary float holds an approximation. Each error is tiny. Across hundreds of transactions, rounding errors can accumulate, and as the example above shows, a tiny error is enough when it lands on a boundary.
Where the amounts come from
The statement arrives in the format India's Account Aggregator network uses. ReBIT defines it as an XML schema, and the DEPOSIT schema types Transaction.amount as xs:float. Balances and limits are xs:string, with no pattern.
ReBIT doesn't say why, and it isn't specific to this field: the whole schema family types numbers this way.1 I read it as "this is a number", not as a choice of binary format. Strictly, xs:float means a 32-bit float, and taken literally a 32-bit float can't hold ₹1,23,456.78 (it becomes 123456.78125).
The schema describes XML, but the data my service accepts is JSON. Finvu's sample fetch response, from the Account Aggregator I target, shows the mapping: attributes become keys, amount arrives as a JSON number (6036), and balances arrive as JSON strings ("322644.09"). So my parser sees two forms:
-
JSON numbers, such as
85000,1234.050or1.2E7. -
Numeric strings, such as
"112340.50". Here the schema allows any text, so XML-style spellings like"+5"and".5"can appear too. They aren't valid JSON numbers, only valid inside strings.
1.2E7 is a real possibility, not a curiosity. Java's Double.toString writes values of 10⁷ and above in scientific notation, so a producer that uses it sends ₹1.2 crore as 1.2E7. My first parser accepted only plain decimals with at most two places, and it would have rejected that spelling.
Integer paise, parsed from the text
The money type in the oracle is a count of paise:
/// Money in integer paise (₹1 = 100 paise). The only money type in
/// `tio-core`.
pub struct Paise(i64);
₹1,234.05 is 123405. Integer sums are exact as long as every intermediate result stays in range, so the Rust scorer adds with checked arithmetic and does ratio maths in i128. The TypeScript scorer uses bigint with the same bounds.
The amount has to become an integer without ever becoming a float. If a JSON library turns 1234.05 into a float first, the error is in before my code sees the value. So the parser reads the raw text of each JSON value and never converts through a float. That avoids adding error at this step. It can't recover precision a producer already lost upstream.
The rule: within the supported grammar and range, accept any spelling that is exactly a whole number of paise, and reject everything else. Never round. Rounding would hide bad bank data inside a signed result.
The idea is simple: read the amount as text, move the decimal point two places to the right, and insist that what's left is a whole number.
| Input | Result |
|---|---|
1234.05 |
1,23,405 paise |
1234.050 |
1,23,405 paise: a trailing zero changes nothing |
1.2E7 |
1,20,00,00,000 paise (₹1.2 crore) |
1234.567 |
rejected: the last digit would be a fraction of a paisa |
The code works with digit counts rather than moving a character around, so scientific notation goes through the same path as a plain decimal. It also turns away anything too big to be a real amount, such as a hostile 1e999999999, before doing any arithmetic. The full parser is about 220 lines with comments: tio-core/src/money.rs.
Rounding that both scorers agree on
Integers remove accidental rounding, not all rounding. A scorer still divides: FOIR is a ratio, and two other features need a median and a square root. So each of those steps rounds down, the spec says so, and both scorers do exactly that. Every amount involved is zero or positive, so "round down" means the same thing in Rust and in JavaScript's bigint.
Rounding down is a policy choice, and it has a visible effect: ₹20,000.01 against ₹50,000 is 40.00002%, which rounds down to 4,000 basis points and still counts as tier A. I'm fine with that band in an eligibility rule, because it's written down and both scorers apply it. If you need "at most 40%" exactly, skip the division and compare products: obligations × 5 ≤ income × 2.
Other ways to do it
Integer paise isn't the only sound design. Exact decimal types, such as Java's BigDecimal, also avoid binary rounding.
I chose integers for my own constraints: two languages that must agree to the paisa, arithmetic that is mostly sums and comparisons plus a few specified ratios, and an output that gets signed. Payment APIs work the same way: Stripe takes 1099 to charge 10.99 USD, and Razorpay takes amounts as integers in the smallest currency sub-unit.
If you handle money in JavaScript or TypeScript
-
Keep amounts as integers in the smallest unit, and check intermediate results too, not just amounts.
Number.MAX_SAFE_INTEGERis about ₹90 lakh crore in paise, but a paise amount times 10,000 leaves the safe range above about ₹900 crore. Usebigintwhen products can get that big. -
Don't multiply a float by 100 and hope.
19.99 * 100is1998.9999999999998. Parse the string. -
Get the raw text if an API sends JSON numbers. Newer runtimes pass the original text to the
JSON.parsereviver (I checked Node 24):
JSON.parse('{"amount": 1234.05}', (key, value, context) =>
key === "amount" && typeof value === "number" ? context.source : value
); // { amount: "1234.05" }
The typeof check matters if the field can also arrive as a string. For a JSON string, context.source is the original text including its quotes ('"1234.05"'), while value is already the string you want.
-
Don't round money with
toFixed.(1.005).toFixed(2)returns"1.00", because the stored value is slightly below 1.005. - Write the rounding rules down, and use the same ones in every implementation.
- Read the spec, not just the sample. The sample I started from had plain decimals. The schema allows much more.
Integer paise keeps the money exact. Specified rounding rules make the remaining calculations reproducible across both implementations. Together, the same statement gives the same signed tier in Rust and in TypeScript.
If you have a money parser, try it with 1234.05, 1234.050, 1.2E7 and 1234.567. The first two should give the same value, the third should keep its full value (₹1.2 crore), and the last should fail rather than round.
Originally published on prashantchaudhari.dev.
-
Across the 23 FI type schemas in the Account Aggregator standards repo, the numeric declarations use
xs:floatand integer types, includingxs:integerandxs:positiveInteger. None usesxs:decimal. The deposit schema has typed amounts this way since the earliest version in that repo (April 2021), and version 2.0.0 keeps it. ↩
Top comments (0)