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
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):
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.
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.
Frontend computes days-late with Math.floor((Date.now() - dueMs) / 86400000) using the browser clock, which is ahead of the ledger.
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.
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.
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.
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_obligationcrate (authoritative ledger state), the backendrentmodule (indexed projection driving invoices, reminders, and reconciliation), and the frontend (owed-amount display before a tenant signs apaymenttransaction). Each currently derives due dates and late fees from its own time source and its own per-period arithmetic. The contract readsenv.ledger().timestamp(); the backend reads PostgreSQLnow()and a Bull accrual job cadence; the frontend readsDate.now(). These clocks diverge, and the per-period math rounds differently (floating-point in TypeScript versus integeri128on-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_paymentfor 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):
next_duefield and increments it by a hardcoded 30-day constant per period.rent_obligations.next_due_atfrom the creation HTTP timestamp, not the ledger close time, introducing a multi-second-to-minute skew at genesis.Math.floor((Date.now() - dueMs) / 86400000)using the browser clock, which is ahead of the ledger.late_fee_accruedevent 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,paymentcrates). Anchor the schedule at creation: storeanchor_ts = round_down(env.ledger().timestamp(), period_seconds)in instance storage, plusperiod_seconds,principal_per_period,late_fee_bps,grace_seconds,late_fee_cap_bps. Post-change invariants: due date of periodnequalsanchor_ts + n * period_seconds(no per-period drift accumulation);days_late = (ledger_ts - (due_ts + grace_seconds)) / 86_400using integer division, zero when negative; accrued fee per period equalsmin(outstanding_principal * late_fee_bps / 10_000 * days_late, principal * late_fee_cap_bps / 10_000), alli128, floor semantics, no intermediate overflow (widen before multiply).submit_paymentapplies the allocation waterfall (below) atomically and emitspayment_allocatedandlate_fee_accruedevents carrying period index, fee portion, and principal portion. Add typed variants to the obligationcontracterrorenum:PaymentBelowZero,PeriodNotDue,AllocationOverflow,ObligationClosed. Extend TTL on the obligation instance and per-period persistent entries viaextend_ttlon 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
nwhile fee of periodnremains, and never touches periodn+1while any obligation on periodnremains. Overpayment past the current period credits forward to the next unpaid period; no silent burn.Backend/API Layer (
rent,payments,transactions,queuesmodules). Replacenow()-based accrual with a pureRentAccrualService.computeOwed(obligation, ledgerTs)reusing the same integer formulas (usebigint, nevernumber). SourceledgerTsfrom thestellarmodule RPC latest-ledger close time, not wall clock. The Bull accrual job inqueuesbecomes a projector that recomputes and persists, never a source of truth. Tables:rent_obligations(addanchor_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 migrationAddDeterministicAccrualFieldsunderbackend/src/migrations/. Thewebhooks/indexer path consumingpayment_allocatedandlate_fee_accruedwrites allocations transactionally and asserts reconciliation againstcomputeOwed; 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). AddprojectOwed(obligation, ledgerTs)informat.tsimplementing the identicalbigintformulas.lib/stellar.tsfetches the latest ledger close time and feeds it in; remove allDate.now()from owed math. TanStack Query keys include the ledger sequence so quotes invalidate on new ledgers. The payment form sizessubmit_paymentfromprojectOwed, 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_secondsdefaults in one documented place and thread them through migration seeds andNEXT_PUBLIC_config so no layer hardcodes a divergent constant. Extend.github/workflows/contract-ci-cd.yml,backend-ci-cd.yml, andfrontend-ci-cd.ymlto run a shared cross-layer parity test fixture (identical input vectors, identical expected owed) so drift fails CI.Verification & Acceptance Criteria
contract/check-all.shpasses (build, clippy, test).pnpm buildandpnpm typecheckpass; migration applies and reverts without error (migration:run,migration:revert).pnpm buildand lint pass.anchor_ts + n * period_seconds.computeOwedequals contract output across a shared vector fixture; indexer reconciliation onpayment_allocated/late_fee_accrued.projectOwedagree on owed and on fee/principal split.rent_obligationsandrent_payment_allocationsfor 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 insubmit_payment; emitpayment_allocated/late_fee_accrued; extend thecontracterrorenum. Deliverables: crate plus unit tests. Exit check:check-all.shgreen, 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).
AddDeterministicAccrualFieldsmigration;RentAccrualService.computeOwedinbigint; ledger-time sourcing viastellarmodule; 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).
projectOwedinformat.ts; ledger time instellar.ts; ledger-seq-keyed queries; fee/principal split and stale-quote banner inapp/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.