Skip to content

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

Description

@ogazboiz

Contributor coordination and questions: https://t.me/+DOylgFv1jyJlNzM0

Task Classification: Defect remediation and cross-layer invariant hardening
Affected Layers: contracts, backend, frontend, root orchestration
Affected Paths: contracts/Cargo.toml, contracts/money/ (new crate), contracts/loan_manager/src/lib.rs, contracts/lending_pool/src/lib.rs, backend/src/money/decimal.ts, backend/src/services/eventIndexer.ts, backend/src/services/defaultChecker.ts, backend/migrations/, frontend/lib/money/format.ts, money-policy.json (root), scripts/gen-money.ts (root), .github/workflows/ci.yml
Severity: High
Estimated Window: 48-72 hours

Technical Context & Monorepo Integration Failure

An amount exists in three encodings: on-chain i128 stroops at 7 decimal places, backend PostgreSQL NUMERIC, and frontend display strings. Each boundary currently applies its own rounding direction and precision, so conversions are not inverse operations. Accrual, allocation, and formatting each shed or add sub-units, producing owed-vs-paid mismatches that surface as false defaults and unreconcilable dust in the pool.

A single-layer fix cannot close the gap because the drift is a disagreement between three independent implementations. Correcting only the contract still lets the backend truncate a stroop; correcting only the backend still lets the browser round half-up against a half-even settlement value.

Concrete drift walkthrough:

  1. loan_manager::accrue_interest computes interest = principal * rate_bps * days / (10000 * 365) with i128 division truncating toward zero. The remainder r (up to denominator-1 stroops) is discarded and never credited, so pool accounting loses r per accrual.
  2. eventIndexer.ts reads the interest_accrued event, recomputes the figure in JavaScript, and writes it to loans.interest_accrued declared NUMERIC(20,6). Scale 6 drops the 7th decimal, so the stored value diverges from the on-chain stroop count by up to 1 stroop.
  3. defaultChecker.ts compares owed (DB NUMERIC) against paid (sum of payment event stroops). The scale-6 truncation and the discarded remainder make the two sides disagree by a few stroops, marking a current loan delinquent or masking a real shortfall.
  4. The frontend formats the due amount with Number(stroops) / 1e7 then toFixed(2), rounding half-up. The backend settles on a half-even value. The borrower pays the displayed number, which differs from the settlement number, and leaves residual dust that no layer reconciles.

Core Component Invariants & Code Paths

Smart Contract Infrastructure
Add a library crate contracts/money to contracts/Cargo.toml members. It exports const STROOP_SCALE: i128 = 10_000_000, enum RoundingMode { HalfEven, HalfUp, Floor, Ceil }, fn round_div(num: i128, den: i128, mode: RoundingMode) -> Result<i128, MathError>, and fn split_pro_rata(total: i128, weights: &[i128]) -> Result<Vec<i128>, MathError> using a largest-remainder allocation. Introduce #[contracterror] enum MathError { Overflow = 1, DivByZero = 2, DriftDetected = 3 }. loan_manager and lending_pool replace inline division in accrue_interest and distribute_yield with these helpers. Post-change invariants: every conversion routes through money; split_pro_rata guarantees parts.iter().sum() == total; the crate holds under the workspace overflow-checks = true profile; no bare / on a stroop quantity remains in either contract.

Backend/API Layer
Create backend/src/money/decimal.ts as the sole money path, using bigint only with no float arithmetic: STROOP_SCALE = 10_000_000n, roundDiv(num, den, mode), toStroops(input: string): bigint, fromStroops(value: bigint): string, and splitProRata(total, weights). The default mode is HALF_EVEN, matching the contract helper bit for bit. Add migration backend/migrations/<ts>_money_stroops_integer.sql retyping loans.principal, loans.interest_accrued, and payments.amount to NUMERIC(38,0) holding integer stroops, each with CHECK (value = trunc(value)). eventIndexer.ts and defaultChecker.ts import decimal.ts and compare owed against paid in integer stroops. SSE payloads carry the raw stroop string plus a display string produced by the shared formatter. Post-change invariants: no Number appears in the money path; the database stores exact stroops; owed equals paid at stroop granularity for a settled loan.

Frontend Client
frontend/lib/money/format.ts is generated from the root spec, not hand-authored. It exports formatStroops(value: bigint, opts): string and parseAmount(text: string): bigint, both BigInt-based with no Number division. TanStack Query select functions transform stroop strings through this module. NEXT_PUBLIC_MONEY_DISPLAY_DP (default 2) governs presentation only; settlement always uses the full 7 dp. Post-change invariants: parseAmount(formatStroops(x)) returns x at settlement precision; a truncated display value is never fed back into a transaction; the file is byte-identical to codegen output.

Root Configuration & Orchestration
Add money-policy.json at the repository root as the single source: { "scale": 7, "mode": "half_even", "display_dp": 2, "allocation": "largest_remainder" }. Add scripts/gen-money.ts emitting contracts/money/src/policy.rs, a backend constants module, and frontend/lib/money/format.ts from that spec. Extend .github/workflows/ci.yml with a money-policy job that runs the generator then git diff --exit-code, failing on any drift, and gate the backend, frontend, and contract jobs on it. Post-change invariants: all three layers derive precision, mode, and allocation from one file; CI fails if any generated artifact diverges from the committed copy.

Verification & Acceptance Criteria

  • Root-initiated compilation: scripts/gen-money.ts runs, then contracts cargo build, backend npm run build, and frontend npm run build all pass from a clean checkout.
  • Contract unit tests cover round_div per mode and split_pro_rata sum invariance across randomized weights.
  • Backend unit tests assert decimal.ts output matches contract fixtures byte for byte; integration test runs the migration-check job against postgres:16 and confirms NUMERIC(38,0) with the trunc check.
  • Cross-layer property test round-trips a randomized stroop value contract to DB to display to parse to stroops with zero drift over at least 10,000 cases.
  • Dust-reconciliation test: for a loan lifecycle, on-chain pool balance equals summed DB ledger equals summed displayed settlements, exact in stroops.
  • Frontend end-to-end Playwright test confirms displayed due amount equals the settled amount and leaves no residual.
  • PR attaches: generator diff proof (git diff --exit-code log), a before/after reconciliation state dump per loan, and the property-test seed and case count.

Suggested Execution Path

  • Phase 0, Spec and codegen scaffold (4-6h): author money-policy.json and scripts/gen-money.ts. Exit check: generator emits all three targets and re-run is idempotent.
  • Phase 1, Contract money crate and integration (10-14h): build contracts/money, wire into loan_manager and lending_pool, add MathError. Exit check: contract unit tests green under overflow-checks = true.
  • Phase 2, Backend module and migration (12-18h): implement decimal.ts, add the NUMERIC(38,0) migration, refit eventIndexer.ts and defaultChecker.ts. Exit check: migration-check job and backend integration tests pass.
  • Phase 3, Frontend formatter and consumers (10-16h): generate format.ts, route TanStack Query and SSE consumers through it. Exit check: Playwright display-equals-settlement test passes.
  • Phase 4, Root CI drift gate and cross-layer property tests (8-12h): add the money-policy job and the round-trip and dust-reconciliation suites. Exit check: CI fails on an injected spec edit and passes when regenerated.
  • Phase 5, Reconciliation dump and PR (4-6h): produce state dumps and assemble required attachments. Exit check: reviewer reproduces zero-drift round-trip from the attached seed.

Metadata

Metadata

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26architectureArchitectural changesbackendIssues related to backend developmentcontractsIssues related to smart contractsfrontendIssues related to frontend developmentrustPull requests that update rust code

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions