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:
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.
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.
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.
- 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
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.
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.ymlSeverity: High
Estimated Window: 48-72 hours
Technical Context & Monorepo Integration Failure
An amount exists in three encodings: on-chain
i128stroops at 7 decimal places, backend PostgreSQLNUMERIC, 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:
loan_manager::accrue_interestcomputesinterest = principal * rate_bps * days / (10000 * 365)withi128division truncating toward zero. The remainderr(up to denominator-1 stroops) is discarded and never credited, so pool accounting losesrper accrual.eventIndexer.tsreads theinterest_accruedevent, recomputes the figure in JavaScript, and writes it toloans.interest_accrueddeclaredNUMERIC(20,6). Scale 6 drops the 7th decimal, so the stored value diverges from the on-chain stroop count by up to 1 stroop.defaultChecker.tscompares owed (DBNUMERIC) against paid (sum ofpaymentevent 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.Number(stroops) / 1e7thentoFixed(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/moneytocontracts/Cargo.tomlmembers. It exportsconst STROOP_SCALE: i128 = 10_000_000,enum RoundingMode { HalfEven, HalfUp, Floor, Ceil },fn round_div(num: i128, den: i128, mode: RoundingMode) -> Result<i128, MathError>, andfn 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_managerandlending_poolreplace inline division inaccrue_interestanddistribute_yieldwith these helpers. Post-change invariants: every conversion routes throughmoney;split_pro_rataguaranteesparts.iter().sum() == total; the crate holds under the workspaceoverflow-checks = trueprofile; no bare/on a stroop quantity remains in either contract.Backend/API Layer
Create
backend/src/money/decimal.tsas the sole money path, usingbigintonly with no float arithmetic:STROOP_SCALE = 10_000_000n,roundDiv(num, den, mode),toStroops(input: string): bigint,fromStroops(value: bigint): string, andsplitProRata(total, weights). The default mode isHALF_EVEN, matching the contract helper bit for bit. Add migrationbackend/migrations/<ts>_money_stroops_integer.sqlretypingloans.principal,loans.interest_accrued, andpayments.amounttoNUMERIC(38,0)holding integer stroops, each withCHECK (value = trunc(value)).eventIndexer.tsanddefaultChecker.tsimportdecimal.tsand 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: noNumberappears 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.tsis generated from the root spec, not hand-authored. It exportsformatStroops(value: bigint, opts): stringandparseAmount(text: string): bigint, bothBigInt-based with noNumberdivision. TanStack Queryselectfunctions transform stroop strings through this module.NEXT_PUBLIC_MONEY_DISPLAY_DP(default2) governs presentation only; settlement always uses the full 7 dp. Post-change invariants:parseAmount(formatStroops(x))returnsxat 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.jsonat the repository root as the single source:{ "scale": 7, "mode": "half_even", "display_dp": 2, "allocation": "largest_remainder" }. Addscripts/gen-money.tsemittingcontracts/money/src/policy.rs, a backend constants module, andfrontend/lib/money/format.tsfrom that spec. Extend.github/workflows/ci.ymlwith amoney-policyjob that runs the generator thengit diff --exit-code, failing on any drift, and gate thebackend,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
scripts/gen-money.tsruns, thencontractscargo build,backendnpm run build, andfrontendnpm run buildall pass from a clean checkout.round_divper mode andsplit_pro_ratasum invariance across randomized weights.decimal.tsoutput matches contract fixtures byte for byte; integration test runs themigration-checkjob againstpostgres:16and confirmsNUMERIC(38,0)with thetrunccheck.git diff --exit-codelog), a before/after reconciliation state dump per loan, and the property-test seed and case count.Suggested Execution Path
money-policy.jsonandscripts/gen-money.ts. Exit check: generator emits all three targets and re-run is idempotent.contracts/money, wire intoloan_managerandlending_pool, addMathError. Exit check: contract unit tests green underoverflow-checks = true.decimal.ts, add theNUMERIC(38,0)migration, refiteventIndexer.tsanddefaultChecker.ts. Exit check:migration-checkjob and backend integration tests pass.format.ts, route TanStack Query and SSE consumers through it. Exit check: Playwright display-equals-settlement test passes.money-policyjob and the round-trip and dust-reconciliation suites. Exit check: CI fails on an injected spec edit and passes when regenerated.