You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Ledgers +2 through +6,311,520: each step recomputes 0.79 stroops and truncates to 0. owed remains 10_0000000 for the full year.
Closed-form simple owed after one year = 10_5000000. On-chain owed = 10_0000000. Drift = 5_000000 stroops, the full year of interest.
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.
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_manageraccrues interest per ledger. The current path recomputes each step asdelta = owed * rate_num / rate_denusing i128 integer division, then addsdeltato the storedowed. i128 division discards the sub-stroop remainder before the next ledger readsowed, 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, soowednever grows.Owed is computed independently in three places: on-chain in
loan_manager, in the backend mirror that replays events ineventIndexer.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 therepayinvocation reverts withLoanError::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):
owed = 10_0000000.delta = 100000000 * rate_num / rate_den. True value 0.7922 stroops; i128 division yieldsfloor(0.7922) = 0. Add 0.owedunchanged.owedremains 10_0000000 for the full year.Core Component Invariants & Code Paths
Smart Contract Infrastructure. Add a cumulative interest index in
contracts/loan_manager/src/accrual.rs. Defineconst INDEX_SCALE: i128 = 1_000_000_000_000_000_000(1e18). Store a globalBorrowIndexin persistent storage underDataKey::BorrowIndexand bump its TTL on each write.accrue(env)advances it once per ledger delta asindex = mul_div(index, INDEX_SCALE + rate_per_ledger_scaled, INDEX_SCALE), wheremul_divperforms a 256-bit-widened multiply then a single divide with half-up rounding to keep i128overflow-checks=truefrom aborting on intermediate products.Loangainsindex_at_origination: i128. Owed is derived once:owed_amount(env, loan) = mul_div(loan.principal, current_index, loan.index_at_origination). AddLoanError::IndexOverflow, returned bymul_divwhen a widened product exceeds i128 range, andLoanError::StaleIndex, returned byowed_amountwhenDataKey::BorrowIndexhas not advanced to the currentledger_seq. Emitenv.events().publish((symbol_short!("accrue"),), (ledger_seq, index)).lending_poolreads the same index for utilization interest via a sharedaccrualmodule rather than a second formula. Invariants:owedis monotonic non-decreasing;|owed_amount - closed_form|bounded byEPSILON = 1stroop for any horizon up toMAX_LEDGERS(10 years).Backend/API Layer. Add
backend/src/lib/fixedPoint.tsexporting a BigIntmulDivthat reproduces the Rust half-up rounding bit-for-bit.eventIndexer.tsconsumesaccrueevents through Soroban RPCgetEventsand persists index snapshots. Add migrationbackend/migrations/0009_interest_index.sqlcreating tableinterest_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.tsreads 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 sameledger_seqacross the shared parity vectors.Frontend Client. Add
frontend/src/lib/accrual.tswith the same BigIntmulDiv. A TanStack Query hookuseOwedProjectiontakes the last backend-indexed snapshot and projects owed forward to the current ledger usingNEXT_PUBLIC_LEDGER_INTERVAL_SECONDSand the loan rate, marking the projected component as unsettled. The SSE consumer replaces the projection with the authoritative backend value on arrival. ReadNEXT_PUBLIC_INDEX_SCALEandNEXT_PUBLIC_LEDGER_INTERVAL_SECONDS. Invariant:projected_owed - backend_owednever 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 theloan_managercrate, backend jest, frontend vitest. In.github/workflows/ci.ymladd anaccrual-parityjob that runs all three vector suites and fails on any mismatch; keep the existingbackend,migration-check(postgres:16), andfrontendjobs. Extenddocker-compose.ymlusage in the e2e stage to assert on-chain owed equals API owed at repayment.Verification & Acceptance Criteria
cargo build --workspaceundercontracts/,npm run buildandnpm run typecheckunderbackend/andfrontend/, all pass.LoanError::IndexOverflow, and a stale index returningLoanError::StaleIndex.(principal, rate, ledgers)asserts|owed_amount - closed_form| <= 1stroop across up toMAX_LEDGERS, and owed monotonicity.accrueevents intointerest_indexand asserts owed equals contract owed for every vector; migration0009applies and rolls back viamigrate:up/migrate:down.useOwedProjectionstays within one ledger of the backend value and yields to the settled value.LoanError::InsufficientRepaymentrevert.accrual-parityCI job green across contract, backend, frontend vector suites.interest_indexstate dump at three ledger heights,cargo fmt --checkoutput.Suggested Execution Path
Phase 1 - Contract index and invariant (16-22h). Implement
accrual.rs,mul_div,BorrowIndexstorage with TTL bumps,index_at_origination,owed_amount,LoanError::IndexOverflowandLoanError::StaleIndex, and theaccrueevent. Authoraccrual_vectors.jsonfrom a reference closed-form calculator. Exit check: proptest passes theEPSILON = 1bound and monotonicity.Phase 2 - Backend mirror and migration (12-18h). Add
fixedPoint.ts, migration0009_interest_index.sql,eventIndexer.tsconsumption,defaultChecker.tsowed 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-parityCI job, extend docker-compose e2e to assert owed equality at repayment, produce PR drift table and state dumps. Exit check: full CI green includingaccrual-parityand e2e repayment with noLoanError::InsufficientRepaymentrevert.