Unify decimal precision and rounding into one cross-layer money policy - #1574
Conversation
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.
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.
|
Merged `upstream/main` in to pick up #1572, #1449, #1573, #1451, #1452 and resolved the 3 files with conflicting markers:
Verified after resolving:
Pushed as a normal merge commit (no force-push). `gh pr view` now reports `mergeable: MERGEABLE`, `mergeStateStatus: CLEAN`. |
|
thanks for your contributon |
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 barei128division with no shared rounding policy;simulationController.ts's repayment-history endpoint summedparseFloat(amount) / 1e7per event (compounding float error across the reduce); and the frontend'sLoanRepaymentFormfilled "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.tsderivescontracts/money/src/policy.rs,backend/src/money/policy.generated.ts,frontend/lib/money/policy.generated.tsfrom it (--checkmode for CI drift detection).money-policyCI job runs the generator and gatesbackend/frontend/contracts.Contracts
New workspace member
contracts/money(#![no_std]) exportingSTROOP_SCALE,RoundingMode(HalfEven/HalfUp/Floor/Ceil),round_div,split_pro_rata(largest-remainder allocation oversoroban_sdk::Vec), and#[contracterror] MathError.loan_managerandlending_poolnow route every stroop division throughmoney::round_divinstead 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 owndueamount — noted in a code comment — but the division inside it now goes throughround_div), LP share mint/redeem, share price, and pool utilisation.Verified:
cargo test -p money— 8/8 pass, including a 1,000-case randomizedsplit_pro_ratasum-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, andcargo build --target wasm32-unknown-unknown --releaseall pass; wasm sizes (36–85 KiB per contract) are well under the CI's 256 KiB budget.Important, unrelated pre-existing finding:
maincurrently does not compile at all.loan_manager,lending_poolandmultisig_governanceeach had a dead free function appended after their#[contractimpl]block closed (refinance_loan,deposit,propose_admin_transfer), referencing an undefinedErrortype and callingSelf::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 thefix(contracts): remove orphaned dead code...commit) purely socargo build/cargo testcould run at all to verify this PR; happy to split that into a separate PR if maintainers prefer.Backend
backend/src/money/decimal.tsis 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 frompolicy.generated.ts.eventIndexer.ts:decodeAmountnow rejects a non-integer XDR value instead of silently stringifying a lossy float; SSELoanEventPayloadnow carries both the raw stroopamountand anamountDisplayderived viafromStroops, so consumers never have to re-derive a display value with ad-hoc math.defaultChecker.ts: addsreconcileLoanStroops(loanId), an owed-vs-paid reconciliation computed entirely inbigintfromcontract_events(approved principal vs. summedLoanRepaidamounts), with adriftStroopsfield for the dust-reconciliation invariant.simulationController.ts: thegetRemittanceHistoryper-month accrual now sums in stroops (bigint) and formats to a display number only once at the end, instead ofparseFloat(amount)/1e7accumulated per event.backend/migrations/1802000000000_money_stroops_integer.jsretypescontract_events.amountandloan_history'sprincipal_amount/principal_paid/interest_paid/accrued_interesttoNUMERIC(38,0)withCHECK (value = trunc(value)).Verified:
npm run typecheck/npm run build— the 31 pre-existing errors are unrelated to this change (confirmed identical on a cleanmaincheckout before any of my edits — unused-variable warnings, anAppError.serviceUnavailablethat doesn't exist,transactionController.tsredeclaringMAX_LIMIT, etc.); zero new errors introduced.npm testfor the money-specific suites (decimal.test.ts,reconcileLoanStroops.test.ts,simulationController.test.ts,eventIndexer.test.ts) — 57/57 pass. Fullnpm test— 359/398 pass; the 30 failing suites are pre-existing (same count minus my +6 new tests versus a clean-mainbaseline run of the identical suite), all cascading from the sameMAX_LIMITredeclaration intransactionController.tsbreakingapp.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 psfails to reach the daemon), so I could not runmigrate:upagainst a realpostgres:16to confirm theCHECKconstraint and retype apply cleanly against existing data. The migration is written to the samenode-pg-migrateconventions as the surrounding files (pgm.alterColumn,pgm.addConstraint, reversibledown), with a pre-checkUPDATE ... SET col = trunc(col)so historical fractional rows (if any exist) don't block theup.Frontend
frontend/lib/money/format.ts:formatStroops/parseAmount, bothbigint-based, zeroNumberdivision, mirroring the backend/contract rounding semantics.utils/amount.ts'stoStroops/fromStroopsroute 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 buildsucceeds, all routes compile).npx jest— 162/162 pass across all 15 suites, including 14 inlib/money/format.test.ts(contract-fixture-matchedroundDivcases, 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 ofe2e/borrower-repay-flow.spec.ts(already.skip'd inmainfor that reason: "wallet-connect state, /api/* mock paths, Zustand hydration ... has drifted from the current app"). I marked the new spec.skipfor 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.tsround-trips randomized stroop values through the actual backend (decimal.ts) and frontend (format.ts) modules in one Node process (DB serialization is simulated viaBigInt(x.toString()), which is lossless forNUMERIC(38,0); on-chain agreement is separately covered bycargo test -p moneyusing byte-for-byte matching fixtures).Actually run: 10,000 cases, seed 0x13781378, zero drift.
Generator-diff proof (no drift)
Before/after reconciliation
Before:
loan_manager's repayment split divided with bare/;simulationController.tsaccumulatedparseFloat(amount)/1e7per 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 sameround_div/roundDivsemantics (Floor for allocation splits that must not overshoot a cap, HalfEven as the shared default elsewhere),simulationController.tsaccrues in stroops and formats once, andreconcileLoanStroopsgives an explicit, testable owed-vs-paid stroop comparison (reconcileLoanStroops.test.tsdemonstratesdriftStroops === 0nfor a loan whose repayments exactly sum to principal, and a nonzero exact drift for a partial repayment).Closes #1378