Skip to content

Unify decimal precision and rounding into one cross-layer money policy - #1574

Merged
ogazboiz merged 8 commits into
LabsCrypt:mainfrom
nanlebenthel-web:feat/issue-1378-money-policy
Jul 30, 2026
Merged

Unify decimal precision and rounding into one cross-layer money policy#1574
ogazboiz merged 8 commits into
LabsCrypt:mainfrom
nanlebenthel-web:feat/issue-1378-money-policy

Conversation

@nanlebenthel-web

Copy link
Copy Markdown
Contributor

Problem

An amount exists in three encodings: on-chain i128 stroops (7dp), Postgres NUMERIC, and frontend display strings. Each boundary applied its own rounding, so conversions weren't inverse operations. Concretely: loan_manager's late fee / collateral-ratio / extension-fee / repayment-split math used bare i128 division with no shared rounding policy; simulationController.ts's repayment-history endpoint summed parseFloat(amount) / 1e7 per event (compounding float error across the reduce); and the frontend's LoanRepaymentForm filled "Pay Full Amount" via .toFixed(7) regardless of the form's actual asset precision (rounding half-up against a half-even settlement).

Fix: one policy file, generated into all three layers

  • money-policy.json (root): { scale: 7, mode: half_even, display_dp: 2, allocation: largest_remainder }.
  • scripts/gen-money.ts derives contracts/money/src/policy.rs, backend/src/money/policy.generated.ts, frontend/lib/money/policy.generated.ts from it (--check mode for CI drift detection).
  • A new money-policy CI job runs the generator and gates backend/frontend/contracts.

Contracts

New workspace member contracts/money (#![no_std]) exporting STROOP_SCALE, RoundingMode (HalfEven/HalfUp/Floor/Ceil), round_div, split_pro_rata (largest-remainder allocation over soroban_sdk::Vec), and #[contracterror] MathError.

loan_manager and lending_pool now route every stroop division through money::round_div instead of bare /: interest accrual, late fee, collateral ratio, liquidation bonus, extension fee, the repayment pro-rata split (kept as a hand-rolled largest-remainder allocator since it must also cap each bucket at its own due amount — noted in a code comment — but the division inside it now goes through round_div), LP share mint/redeem, share price, and pool utilisation.

Verified: cargo test -p money — 8/8 pass, including a 1,000-case randomized split_pro_rata sum-invariance property test. cargo test --workspace — 192/192 pass (75 lending_pool + 117 loan_manager, run separately above). cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings, and cargo build --target wasm32-unknown-unknown --release all pass; wasm sizes (36–85 KiB per contract) are well under the CI's 256 KiB budget.

Important, unrelated pre-existing finding: main currently does not compile at all. loan_manager, lending_pool and multisig_governance each had a dead free function appended after their #[contractimpl] block closed (refinance_loan, deposit, propose_admin_transfer), referencing an undefined Error type and calling Self:: outside an impl, plus matching debris tests calling nonexistent helpers (setup_lending_pool, mint_tokens, setup_test_loan) with mismatched signatures. None of this is related to money policy — it looks like leftover merge debris, and the real, correctly-implemented versions of these three functions already exist inside the actual #[contractimpl] blocks with their own passing tests. I removed the dead code (see the fix(contracts): remove orphaned dead code... commit) purely so cargo build/cargo test could run at all to verify this PR; happy to split that into a separate PR if maintainers prefer.

Backend

backend/src/money/decimal.ts is the sole sanctioned money-math module: bigint-only, zero float arithmetic, roundDiv/toStroops/fromStroops/splitProRata, mirroring the contract crate instruction-for-instruction and sourcing constants from policy.generated.ts.

  • eventIndexer.ts: decodeAmount now rejects a non-integer XDR value instead of silently stringifying a lossy float; SSE LoanEventPayload now carries both the raw stroop amount and an amountDisplay derived via fromStroops, so consumers never have to re-derive a display value with ad-hoc math.
  • defaultChecker.ts: adds reconcileLoanStroops(loanId), an owed-vs-paid reconciliation computed entirely in bigint from contract_events (approved principal vs. summed LoanRepaid amounts), with a driftStroops field for the dust-reconciliation invariant.
  • simulationController.ts: the getRemittanceHistory per-month accrual now sums in stroops (bigint) and formats to a display number only once at the end, instead of parseFloat(amount)/1e7 accumulated per event.
  • New migration backend/migrations/1802000000000_money_stroops_integer.js retypes contract_events.amount and loan_history's principal_amount/principal_paid/interest_paid/accrued_interest to NUMERIC(38,0) with CHECK (value = trunc(value)).

Verified: npm run typecheck/npm run build — the 31 pre-existing errors are unrelated to this change (confirmed identical on a clean main checkout before any of my edits — unused-variable warnings, an AppError.serviceUnavailable that doesn't exist, transactionController.ts redeclaring MAX_LIMIT, etc.); zero new errors introduced. npm test for the money-specific suites (decimal.test.ts, reconcileLoanStroops.test.ts, simulationController.test.ts, eventIndexer.test.ts) — 57/57 pass. Full npm test — 359/398 pass; the 30 failing suites are pre-existing (same count minus my +6 new tests versus a clean-main baseline run of the identical suite), all cascading from the same MAX_LIMIT redeclaration in transactionController.ts breaking app.ts — unrelated to money policy, not touched by this PR.

Not verified here (documented honestly, not skipped): the migration's live-Postgres behavior. Docker is not available in this sandbox (docker ps fails to reach the daemon), so I could not run migrate:up against a real postgres:16 to confirm the CHECK constraint and retype apply cleanly against existing data. The migration is written to the same node-pg-migrate conventions as the surrounding files (pgm.alterColumn, pgm.addConstraint, reversible down), with a pre-check UPDATE ... SET col = trunc(col) so historical fractional rows (if any exist) don't block the up.

Frontend

frontend/lib/money/format.ts: formatStroops/parseAmount, both bigint-based, zero Number division, mirroring the backend/contract rounding semantics. utils/amount.ts's toStroops/fromStroops route the canonical 7-decimal case through this module. LoanRepaymentForm's "Pay Full Amount" no longer hardcodes 7dp regardless of asset.

Verified: npm run typecheck — clean. npm run build — clean (next build succeeds, all routes compile). npx jest — 162/162 pass across all 15 suites, including 14 in lib/money/format.test.ts (contract-fixture-matched roundDiv cases, half-even vs half-up disagreement demonstration, round-trip invariants).

Not verified here: a new Playwright e2e spec (e2e/money-display-settlement.spec.ts) asserting "Pay Full Amount" fills the exact displayed total owed. I installed Chromium and actually ran it — it times out waiting for the mocked "Repay" button to render, the same pre-existing drift documented in the header comment of e2e/borrower-repay-flow.spec.ts (already .skip'd in main for that reason: "wallet-connect state, /api/* mock paths, Zustand hydration ... has drifted from the current app"). I marked the new spec .skip for the identical reason rather than leave it silently red; the assertions are real and will run once someone re-aligns the mocking harness (tracked by the same follow-up as the existing skipped spec).

Cross-layer property test

scripts/money-property-test.ts round-trips randomized stroop values through the actual backend (decimal.ts) and frontend (format.ts) modules in one Node process (DB serialization is simulated via BigInt(x.toString()), which is lossless for NUMERIC(38,0); on-chain agreement is separately covered by cargo test -p money using byte-for-byte matching fixtures).

$ npm run money:property-test -- 10000 13781378
> tsx money-property-test.ts 10000 13781378
money-property-test: OK — 10000 cases, zero drift, seed=0x13781378

Actually run: 10,000 cases, seed 0x13781378, zero drift.

Generator-diff proof (no drift)

$ npx ts-node gen-money.ts --check
ok    contracts/money/src/policy.rs
ok    backend/src/money/policy.generated.ts
ok    frontend/lib/money/policy.generated.ts

Before/after reconciliation

Before: loan_manager's repayment split divided with bare /; simulationController.ts accumulated parseFloat(amount)/1e7 per repayment event, so a loan with many small repayments could drift by multiple sub-cent units by the time it reached the history endpoint; the frontend's full-repayment fill used .toFixed(7) irrespective of asset, disagreeing with a half-even settlement at the last displayed digit. After: every layer's division routes through the same round_div/roundDiv semantics (Floor for allocation splits that must not overshoot a cap, HalfEven as the shared default elsewhere), simulationController.ts accrues in stroops and formats once, and reconcileLoanStroops gives an explicit, testable owed-vs-paid stroop comparison (reconcileLoanStroops.test.ts demonstrates driftStroops === 0n for a loan whose repayments exactly sum to principal, and a nonzero exact drift for a partial repayment).

Closes #1378

Adds contracts/money as a new workspace member exporting STROOP_SCALE,
RoundingMode, round_div, split_pro_rata (largest-remainder allocation)
and MathError. loan_manager and lending_pool now route every stroop
division (interest accrual, late fees, collateral ratio, liquidation
bonus, extension fee, LP share mint/redeem, share price, utilisation)
through these helpers instead of bare i128 division.
loan_manager, lending_pool and multisig_governance each had a stray
free function (refinance_loan, deposit, propose_admin_transfer)
appended after their #[contractimpl] block closed, referencing an
undefined Error type and calling Self:: at module scope. Along with
matching debris test cases referencing nonexistent helpers
(setup_lending_pool, mint_tokens, setup_test_loan) and mismatched
client call signatures, this left main in a state where neither
'cargo build --workspace' nor 'cargo test --workspace' could run at
all, independent of this change. Removed so the workspace actually
builds and the pre-existing (correctly implemented) refinance_loan /
deposit / propose_admin_transfer methods inside the real
#[contractimpl] blocks are exercised by their existing, still-passing
test suites.
money-policy.json is the single source of truth (scale 7, half_even
rounding, 2dp display, largest-remainder allocation). scripts/gen-money.ts
derives contracts/money/src/policy.rs, backend/src/money/policy.generated.ts
and frontend/lib/money/policy.generated.ts from it, and supports --check
for CI drift detection. scripts/money-property-test.ts round-trips
randomized stroop values through the backend and frontend money modules
in a single process (contract-side agreement is covered by cargo test -p
money using the same fixtures).
…y path

backend/src/money/decimal.ts is the sole sanctioned place to divide,
round, parse or format a stroop amount (roundDiv, toStroops, fromStroops,
splitProRata), mirroring contracts/money instruction-for-instruction and
sourcing its constants from policy.generated.ts.

- eventIndexer.ts: decodeAmount now rejects non-integer XDR values instead
  of silently stringifying a lossy float; SSE payloads carry both the raw
  stroop amount and an amountDisplay derived via fromStroops.
- defaultChecker.ts: adds reconcileLoanStroops, an owed-vs-paid
  reconciliation computed entirely in bigint stroops from contract_events.
- simulationController.ts: getRemittanceHistory summed
  parseFloat(amount)/1e7 per repayment event, compounding float rounding
  error across the reduce; now accrues in stroops and only formats once,
  at the end.
- New migration retypes contract_events.amount and the loan_history money
  columns to NUMERIC(38,0) with an explicit CHECK (value = trunc(value)).
… utils

frontend/lib/money/format.ts is generated-style (policy constants from
policy.generated.ts) and exports formatStroops/parseAmount, both
bigint-based with no Number division anywhere in the settlement path.
utils/amount.ts routes its 7-decimal (stroop) case through this module
so parsing/formatting agrees bit-for-bit with the contract and backend.
LoanRepaymentForm's 'Pay Full Amount' no longer hardcodes
totalOwed.toFixed(7) regardless of asset (previously mis-displaying a
2-decimal USDC amount and rounding half-up against a half-even
settlement); it now matches the form's actual asset precision.

Adds a Playwright e2e spec asserting 'Pay Full Amount' fills the exact
displayed total owed. It is skipped for the same pre-existing reason
e2e/borrower-repay-flow.spec.ts is skipped (wallet/loan mocks have
drifted from the current app), not for any money-policy reason.
…heck

Adds a money-policy job that installs scripts/ deps and runs
'gen-money.ts --check', failing the build if any of the three generated
policy files would differ from what's committed. backend, frontend and
contracts now all depend on this job.
Comment thread scripts/gen-money.ts Fixed
writeIfChanged used fs.existsSync followed by a separate fs.readFileSync,
a check-then-use race CodeQL flags as a file system race condition.
Read directly and handle ENOENT instead.

Refs LabsCrypt#1378
Reconcile the money-policy (round_div/split_pro_rata) work with several
contributions that landed on upstream/main after this PR's base:

- backend/src/services/defaultChecker.ts: kept both the LabsCrypt#1378 bigint
  stroop reconciliation (reconcileLoanStroops/fromStroops) and the LabsCrypt#1376
  cursor-invariant ledger-gap guard (hasUnresolvedLedgerGaps) — the two
  additions didn't overlap, just needed re-threading into one file.

- contracts/lending_pool/src/lib.rs: kept the LabsCrypt#1380 virtual
  shares/assets offset (VIRTUAL_SHARES/VIRTUAL_ASSETS) in
  calc_shares_to_mint, calc_assets_to_redeem, and get_share_price, but
  replaced the bare checked_mul/checked_div chains those functions used
  with money::round_div(..., RoundingMode::Floor) so the slippage fix
  still rounds through the shared money crate instead of raw i128
  division.

- contracts/lending_pool/src/test.rs: purely additive — kept the full
  LabsCrypt#1380 slippage/virtual-share test suite (donation-resistance,
  first-depositor inflation, round-trip non-profitability, min_shares/
  min_assets_out slippage bounds).

Verified: cargo build/test --workspace green (66/66 lending_pool tests,
including the LabsCrypt#1380 suite), backend npm test green (83 passed, 5
skipped, 0 failed), scripts/gen-money.ts --check reports ok for all 3
generated policy files.
@nanlebenthel-web

Copy link
Copy Markdown
Contributor Author

Merged `upstream/main` in to pick up #1572, #1449, #1573, #1451, #1452 and resolved the 3 files with conflicting markers:

Verified after resolving:

  • `cargo build --workspace` / `cargo test --workspace`: green, 66/66 `lending_pool` tests passing (including the new slippage suite).
  • backend `npm run test`: 83 suites passed, 5 skipped, 0 failed (no regressions).
  • `scripts` `gen:money:check`: `ok` for all 3 generated policy files.

Pushed as a normal merge commit (no force-push). `gh pr view` now reports `mergeable: MERGEABLE`, `mergeStateStatus: CLEAN`.

@ogazboiz

Copy link
Copy Markdown
Contributor

thanks for your contributon

@ogazboiz
ogazboiz merged commit 57857e9 into LabsCrypt:main Jul 30, 2026
10 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Cross-Layer] Unify decimal precision and rounding into one cross-layer money policy (stroops, PostgreSQL NUMERIC, display)

3 participants