Skip to content

[Cross-Layer] Fixed-point cumulative interest index to eliminate per-ledger truncation drift in loan accrual #1382

Description

@ogazboiz

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

Task Classification: Correctness defect, numerical precision, cross-layer
Affected Layers: contracts (loan_manager, lending_pool), backend, frontend
Affected Paths: contracts/loan_manager/src, contracts/lending_pool/src, backend/src/lib, backend/src/services, backend/migrations, frontend/src/lib, contracts/testdata, .github/workflows/ci.yml
Severity: High
Estimated Window: 48-72 hours

Technical Context & Monorepo Integration Failure

loan_manager accrues interest per ledger. The current path recomputes each step as delta = owed * rate_num / rate_den using i128 integer division, then adds delta to the stored owed. i128 division discards the sub-stroop remainder before the next ledger reads owed, so the dropped fraction is never carried forward. Over N ledgers the loss is the sum of N truncated remainders, a monotonic downward divergence from the closed-form owed value. For small principals the per-ledger interest falls below one stroop and truncates to zero, so owed never grows.

Owed is computed independently in three places: on-chain in loan_manager, in the backend mirror that replays events in eventIndexer.ts, and in the frontend projection that extrapolates the last indexed value. A contract-only fix leaves the backend and frontend reproducing the old per-step truncation, so display owed and on-chain owed disagree at repayment: a repay amount computed off-chain undershoots on-chain owed and the repay invocation reverts with LoanError::InsufficientRepayment. A single-layer fix is insufficient; all three owed computations must share one fixed-point index and identical rounding.

Failure walkthrough (ledger interval approx 5s, approx 6,311,520 ledgers/year, APR 5%, per-ledger rate r = 7.922e-9):

  1. Origination: principal = 10_0000000 stroops (10.0000000, 7-decimal asset). Store owed = 10_0000000.
  2. Ledger +1: delta = 100000000 * rate_num / rate_den. True value 0.7922 stroops; i128 division yields floor(0.7922) = 0. Add 0. owed unchanged.
  3. Ledgers +2 through +6,311,520: each step recomputes 0.79 stroops and truncates to 0. owed remains 10_0000000 for the full year.
  4. Closed-form simple owed after one year = 10_5000000. On-chain owed = 10_0000000. Drift = 5_000000 stroops, the full year of interest.
  5. Larger principal 1000_0000000: per-ledger interest 79.22 stroops truncates to 79, losing 0.22 per ledger, approx 1_390000 stroops per year, growing across a multi-year loan.

Core Component Invariants & Code Paths

Smart Contract Infrastructure. Add a cumulative interest index in contracts/loan_manager/src/accrual.rs. Define const INDEX_SCALE: i128 = 1_000_000_000_000_000_000 (1e18). Store a global BorrowIndex in persistent storage under DataKey::BorrowIndex and bump its TTL on each write. accrue(env) advances it once per ledger delta as index = mul_div(index, INDEX_SCALE + rate_per_ledger_scaled, INDEX_SCALE), where mul_div performs a 256-bit-widened multiply then a single divide with half-up rounding to keep i128 overflow-checks=true from aborting on intermediate products. Loan gains index_at_origination: i128. Owed is derived once: owed_amount(env, loan) = mul_div(loan.principal, current_index, loan.index_at_origination). Add LoanError::IndexOverflow, returned by mul_div when a widened product exceeds i128 range, and LoanError::StaleIndex, returned by owed_amount when DataKey::BorrowIndex has not advanced to the current ledger_seq. Emit env.events().publish((symbol_short!("accrue"),), (ledger_seq, index)). lending_pool reads the same index for utilization interest via a shared accrual module rather than a second formula. Invariants: owed is monotonic non-decreasing; |owed_amount - closed_form| bounded by EPSILON = 1 stroop for any horizon up to MAX_LEDGERS (10 years).

Backend/API Layer. Add backend/src/lib/fixedPoint.ts exporting a BigInt mulDiv that reproduces the Rust half-up rounding bit-for-bit. eventIndexer.ts consumes accrue events through Soroban RPC getEvents and persists index snapshots. Add migration backend/migrations/0009_interest_index.sql creating table interest_index (loan_id BIGINT, ledger_seq BIGINT, index_value NUMERIC(78,0), origin_index NUMERIC(78,0), PRIMARY KEY (loan_id, ledger_seq)); NUMERIC(78,0) holds the full i128 range exactly. Owed = mulDiv(principal, latest_index, origin_index). defaultChecker.ts reads this owed for its threshold test. The SSE channel pushes {loan_id, ledger_seq, index_value} on each new snapshot. Invariant: backend owed equals contract owed for the same ledger_seq across the shared parity vectors.

Frontend Client. Add frontend/src/lib/accrual.ts with the same BigInt mulDiv. A TanStack Query hook useOwedProjection takes the last backend-indexed snapshot and projects owed forward to the current ledger using NEXT_PUBLIC_LEDGER_INTERVAL_SECONDS and the loan rate, marking the projected component as unsettled. The SSE consumer replaces the projection with the authoritative backend value on arrival. Read NEXT_PUBLIC_INDEX_SCALE and NEXT_PUBLIC_LEDGER_INTERVAL_SECONDS. Invariant: projected_owed - backend_owed never exceeds one ledger of interest, and repayment always uses the backend-settled owed, never the projection.

Root Configuration & Orchestration. Commit shared golden vectors at contracts/testdata/accrual_vectors.json (fields: principal, rate_per_ledger_scaled, ledgers, expected_index, expected_owed). No root workspace exists, so each layer loads the same file: Rust proptest in the loan_manager crate, backend jest, frontend vitest. In .github/workflows/ci.yml add an accrual-parity job that runs all three vector suites and fails on any mismatch; keep the existing backend, migration-check (postgres:16), and frontend jobs. Extend docker-compose.yml usage in the e2e stage to assert on-chain owed equals API owed at repayment.

Verification & Acceptance Criteria

  • Root-initiated compilation: cargo build --workspace under contracts/, npm run build and npm run typecheck under backend/ and frontend/, all pass.
  • Contract unit tests cover single-step accrual, sub-stroop principal (no drift to zero), index overflow returning LoanError::IndexOverflow, and a stale index returning LoanError::StaleIndex.
  • Contract proptest over random (principal, rate, ledgers) asserts |owed_amount - closed_form| <= 1 stroop across up to MAX_LEDGERS, and owed monotonicity.
  • Backend integration test replays accrue events into interest_index and asserts owed equals contract owed for every vector; migration 0009 applies and rolls back via migrate:up / migrate:down.
  • Frontend test asserts useOwedProjection stays within one ledger of the backend value and yields to the settled value.
  • End-to-end container test (docker-compose): accrue over a scripted ledger span, repay using API owed, confirm no LoanError::InsufficientRepayment revert.
  • accrual-parity CI job green across contract, backend, frontend vector suites.
  • PR attachments: pre-fix vs post-fix owed drift table for the walkthrough loan, interest_index state dump at three ledger heights, cargo fmt --check output.

Suggested Execution Path

Phase 1 - Contract index and invariant (16-22h). Implement accrual.rs, mul_div, BorrowIndex storage with TTL bumps, index_at_origination, owed_amount, LoanError::IndexOverflow and LoanError::StaleIndex, and the accrue event. Author accrual_vectors.json from a reference closed-form calculator. Exit check: proptest passes the EPSILON = 1 bound and monotonicity.

Phase 2 - Backend mirror and migration (12-18h). Add fixedPoint.ts, migration 0009_interest_index.sql, eventIndexer.ts consumption, defaultChecker.ts owed source, and SSE push. Exit check: backend owed matches contract owed across all vectors; migration up/down verified.

Phase 3 - Frontend projection (10-16h). Add accrual.ts, useOwedProjection, SSE reconciliation, and settled-owed repayment path. Exit check: projection within one ledger of backend value; repayment path reads settled owed only.

Phase 4 - Root parity and e2e (10-16h). Wire the accrual-parity CI job, extend docker-compose e2e to assert owed equality at repayment, produce PR drift table and state dumps. Exit check: full CI green including accrual-parity and e2e repayment with no LoanError::InsufficientRepayment revert.

Metadata

Metadata

Assignees

No one assigned

    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