Skip to content

[Cross-Layer] Deterministic Rent Obligation Lifecycle: Ledger-Anchored Due Dates, Integer Late-Fee Accrual, and Ordered Partial-Payment Allocation #239

Description

@david87131

Task Classification: Cross-layer accounting-invariant hardening (contract/backend/frontend consistency)
Affected Layers: contract, backend, frontend
Affected Paths: contract/contracts/rent_obligation/, contract/contracts/payment/, backend/src/modules/rent/, backend/src/modules/payments/, backend/src/modules/transactions/, backend/src/modules/queues/, backend/src/migrations/, frontend/lib/format.ts, frontend/lib/stellar.ts, frontend/app/payments/, frontend/app/dashboard/
Severity: High
Estimated Window: 48-72 hours

Technical Context & Monorepo Integration Failure

Three components independently compute what a tenant owes: the rent_obligation crate (authoritative ledger state), the backend rent module (indexed projection driving invoices, reminders, and reconciliation), and the frontend (owed-amount display before a tenant signs a payment transaction). Each currently derives due dates and late fees from its own time source and its own per-period arithmetic. The contract reads env.ledger().timestamp(); the backend reads PostgreSQL now() and a Bull accrual job cadence; the frontend reads Date.now(). These clocks diverge, and the per-period math rounds differently (floating-point in TypeScript versus integer i128 on-chain). The result: three answers to one question.

A single-layer fix cannot close this. Correcting only the contract leaves the backend invoice and the frontend quote wrong, so tenants sign a submit_payment for an amount the contract rejects or under-allocates. Correcting only the backend produces reminders and dunning that contradict on-chain owed. The shared invariant, owed amount is a pure function of (anchor timestamp, period length, principal, late-fee rate, grace, and current ledger time), must be defined once and mirrored byte-for-byte across all three.

Concrete drift walkthrough (missing control: no single ledger-anchored accrual formula):

  1. Obligation created for 1,000 USDC monthly. Contract stores an ad hoc next_due field and increments it by a hardcoded 30-day constant per period.
  2. Backend seeds rent_obligations.next_due_at from the creation HTTP timestamp, not the ledger close time, introducing a multi-second-to-minute skew at genesis.
  3. Frontend computes days-late with Math.floor((Date.now() - dueMs) / 86400000) using the browser clock, which is ahead of the ledger.
  4. A tenant one day into grace sees zero late fee on-chain, a nonzero fee in the backend invoice (its accrual job already ran), and a third value on the frontend.
  5. The tenant submits a partial payment sized to the frontend quote. The contract allocates it to principal first; the backend reconciler expects fee-first allocation. Principal and fee balances now disagree, and the late_fee_accrued event the indexer consumes no longer reconciles against on-chain state.

The missing control is a deterministic, ledger-time-anchored owed model with a fixed allocation order. The consequence of its absence is permanent balance divergence that compounds every period and blocks correct dispute and escrow settlement.

Core Component Invariants & Code Paths

Smart Contract Infrastructure (rent_obligation, payment crates). Anchor the schedule at creation: store anchor_ts = round_down(env.ledger().timestamp(), period_seconds) in instance storage, plus period_seconds, principal_per_period, late_fee_bps, grace_seconds, late_fee_cap_bps. Post-change invariants: due date of period n equals anchor_ts + n * period_seconds (no per-period drift accumulation); days_late = (ledger_ts - (due_ts + grace_seconds)) / 86_400 using integer division, zero when negative; accrued fee per period equals min(outstanding_principal * late_fee_bps / 10_000 * days_late, principal * late_fee_cap_bps / 10_000), all i128, floor semantics, no intermediate overflow (widen before multiply). submit_payment applies the allocation waterfall (below) atomically and emits payment_allocated and late_fee_accrued events carrying period index, fee portion, and principal portion. Add typed variants to the obligation contracterror enum: PaymentBelowZero, PeriodNotDue, AllocationOverflow, ObligationClosed. Extend TTL on the obligation instance and per-period persistent entries via extend_ttl on every state mutation.

Allocation waterfall invariant (contract-authoritative, mirrored in backend): an incoming amount fills, oldest unpaid period first, in order (1) that period's accrued late fee, then (2) that period's principal, before advancing to the next period. A partial payment never touches principal of period n while fee of period n remains, and never touches period n+1 while any obligation on period n remains. Overpayment past the current period credits forward to the next unpaid period; no silent burn.

Backend/API Layer (rent, payments, transactions, queues modules). Replace now()-based accrual with a pure RentAccrualService.computeOwed(obligation, ledgerTs) reusing the same integer formulas (use bigint, never number). Source ledgerTs from the stellar module RPC latest-ledger close time, not wall clock. The Bull accrual job in queues becomes a projector that recomputes and persists, never a source of truth. Tables: rent_obligations (add anchor_ts, period_seconds, late_fee_bps, grace_seconds, late_fee_cap_bps), rent_payment_allocations (period_index, fee_paid, principal_paid, ledger_seq, tx_hash). TypeORM migration AddDeterministicAccrualFields under backend/src/migrations/. The webhooks/indexer path consuming payment_allocated and late_fee_accrued writes allocations transactionally and asserts reconciliation against computeOwed; mismatch raises a monitoring alert rather than overwriting. Invariant: backend owed for any (obligation, ledgerTs) equals contract owed at the same ledger time.

Frontend Client (lib/format.ts, lib/stellar.ts, app/payments, app/dashboard). Add projectOwed(obligation, ledgerTs) in format.ts implementing the identical bigint formulas. lib/stellar.ts fetches the latest ledger close time and feeds it in; remove all Date.now() from owed math. TanStack Query keys include the ledger sequence so quotes invalidate on new ledgers. The payment form sizes submit_payment from projectOwed, displays the fee-vs-principal split from the same waterfall, and shows a stale-quote banner when the cached ledger lags. Invariant: the amount the tenant signs equals the amount the contract allocates, with matching per-period fee/principal breakdown.

Root Configuration & Orchestration (CI, shared constants). Define late_fee_bps, grace_seconds, period_seconds defaults in one documented place and thread them through migration seeds and NEXT_PUBLIC_ config so no layer hardcodes a divergent constant. Extend .github/workflows/contract-ci-cd.yml, backend-ci-cd.yml, and frontend-ci-cd.yml to run a shared cross-layer parity test fixture (identical input vectors, identical expected owed) so drift fails CI.

Verification & Acceptance Criteria

  • Contract compiles; contract/check-all.sh passes (build, clippy, test).
  • Backend pnpm build and pnpm typecheck pass; migration applies and reverts without error (migration:run, migration:revert).
  • Frontend pnpm build and lint pass.
  • Contract unit tests: due-date derivation, floor-division days-late, fee cap, allocation waterfall ordering, overflow guards, error enum coverage.
  • Contract long-horizon property tests: 120 consecutive periods with randomized partial payments; owed never negative, fee never exceeds cap, allocation order invariant holds, no accumulated drift versus the closed-form anchor_ts + n * period_seconds.
  • Backend integration tests: computeOwed equals contract output across a shared vector fixture; indexer reconciliation on payment_allocated/late_fee_accrued.
  • End-to-end test: create obligation, advance ledger, submit two partial payments, assert contract, backend, and projectOwed agree on owed and on fee/principal split.
  • Cross-layer parity fixture runs in all three CI workflows.
  • PR attachments: property-test seed and 120-period run log; a before/after state dump of rent_obligations and rent_payment_allocations for a divergent case now reconciled.

Suggested Execution Path

Phase 1 - Contract accrual and allocation model (0-20h). Add anchor and rate fields to storage; implement computeOwed, days_late, capped fee, and the allocation waterfall in submit_payment; emit payment_allocated/late_fee_accrued; extend the contracterror enum. Deliverables: crate plus unit tests. Exit check: check-all.sh green, waterfall unit tests pass.

Phase 2 - Contract property tests (20-32h). Long-horizon randomized partial-payment tests against the closed-form schedule. Deliverable: seeded 120-period suite. Exit check: no drift, no cap breach, ordering holds.

Phase 3 - Backend projection and migration (32-52h). AddDeterministicAccrualFields migration; RentAccrualService.computeOwed in bigint; ledger-time sourcing via stellar module; indexer reconciliation and allocation persistence; Bull job downgraded to projector. Deliverable: integration tests against the shared fixture. Exit check: backend owed equals contract owed on every vector; reconciliation mismatch alerts, does not overwrite.

Phase 4 - Frontend projection (52-64h). projectOwed in format.ts; ledger time in stellar.ts; ledger-seq-keyed queries; fee/principal split and stale-quote banner in app/payments. Deliverable: e2e agreement test. Exit check: signed amount equals allocated amount with matching split.

Phase 5 - Root config and CI parity (64-72h). Centralize rate constants; wire the shared parity fixture into all three workflows. Deliverable: CI runs that fail on cross-layer drift. Exit check: all three pipelines green; induced drift fails CI.

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignbugSomething isn't workinghelp wantedExtra attention is needed

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions