Architectural Decisions
This page documents the key design decisions behind RevvFi’s current architecture, and — where relevant — what was tried first and why it changed.
Decision 1: Per-Position Interest Accrual, Not a Shared Index
Decision: Each lender position tracks its own principal, its own APR, and its own last-accrual timestamp. There is no market-wide blended interest index.
Why it changed: An earlier design accrued interest via a single shared borrowIndex that grew at the market’s weighted-average APR across all active positions. In practice this meant two lenders who quoted different rates (say 12.5% and 15%) on the same market both earned the same blended ~13.75% — the individual rate a lender quoted didn’t actually determine what they earned. The fix replaces the shared index with per-position state (positionPrincipal, positionApr, positionLastAccrualTime), so each lender now earns exactly the rate they quoted, verified on-chain against real repay/claim cycles.
Trade-off: Slightly more storage per position (three slots instead of a shared index lookup), in exchange for correctness that materially affects what lenders are owed.
Decision 2: EIP-1167 Minimal Proxy Clones Per Market
Decision: RevvFiFactory.deployMarket() deploys a Market, CollateralEscrow, OfferBook, and LiquidityQueue as EIP-1167 minimal proxy clones (~100 bytes each) pointing at one shared implementation per contract type, rather than deploying full bytecode per market.
Rationale:
- Isolates every borrower’s risk and collateral in its own contract instance
- Deployment gas is dramatically lower than deploying full contract bytecode per market
- A single implementation upgrade path (redeploy implementation, factory points new clones at it) without touching already-deployed markets
Decision 3: Configurable Oracle Staleness, Not a Global Constant
Decision: RevvFiCollateralEscrow.stalePriceThreshold is a per-market state variable (default 24 hours), settable via setStalePriceThreshold() (factory-only), rather than one hardcoded constant shared by every market.
Why it changed: The original constant was 2 hours, which is workable for high-liquidity mainnet feeds but unrealistic for many real Chainlink feeds — testnet feeds in particular can go far longer than 2 hours between updates purely because there’s little market activity to trigger a new round. A single global constant would either be too tight (spurious OraclePriceStale() reverts on legitimately-fresh but slower-updating feeds) or too loose (accepting genuinely stale data on fast-moving feeds). Making it per-market and admin-tunable lets each market match its own feed’s actual heartbeat.
Decision 4: Pull-Based Claims, Not Push Distribution
Decision: repay()/repayFull() only credit each affected lender’s positionClaimableAmount. Lenders must separately call claimFunds(positionId) to actually receive tokens.
Rationale:
- A repayment’s gas cost stays bounded regardless of how many lenders are in the market — pushing tokens to every lender on every repayment would make gas cost scale with lender count
- Isolates a misbehaving/blacklisted lender token-transfer failure to that lender’s own claim transaction, not the borrower’s repayment transaction
Trade-off: Lenders need to take an explicit action to receive funds — the frontend surfaces a “Claim” affordance whenever a position’s claimable balance is non-zero to make this visible rather than something a lender has to remember to check for.
Decision 5: Order-Book Matching Instead of a Pooled Rate
Decision: Lenders submit individual offers (amount, APR, seniority, duration) into a per-market order book. Borrowing fills the request starting from the lowest-APR active offers — there’s no shared pool rate to discover.
Rationale:
- Rate discovery happens per-offer, driven by what lenders are actually willing to accept, not a formula reacting to aggregate utilization
- Seniority (senior/junior) lets lenders choose their own risk/reward tier per offer, and borrowers can optionally restrict a draw to senior-only liquidity via
useSeniorOnly
Decision 6: Reputation as a Recalculated Success Rate, Not an Accumulating Counter
Decision: ReputationRegistry recalculates a borrower’s score from scratch on every update: (successfulLoans * 1000 / totalLoans) - (defaultedLoans * 50), clamped to [0, 1000].
Rationale: A simple “+1 per success, -50 per default” running counter has no natural ceiling and rewards volume over reliability — a borrower who’s taken 500 loans with a handful of defaults would still look excellent under a pure counter, even if their most recent behavior was poor. Recomputing from the success rate keeps the score meaningful regardless of how many loans a borrower has taken.
Decision 7: Dutch/English Hybrid Liquidation
Decision: Liquidation auctions start at 100% of debt and decay in discrete steps (5% every hour, by default) toward an 80%-of-debt reserve floor — but unlike a pure Dutch auction, subsequent bidders can still outbid the current highest bid before the auction ends, with a 15-minute anti-sniping extension on late bids.
Rationale: A pure Dutch auction settles instantly to whoever bids first at the current price, which can under-recover if the “right” price is reached during low attention (e.g. overnight). Allowing competing bids up to endTime, combined with the declining starting reference price, gives the auction more chances to find genuine demand while still guaranteeing a floor via the reserve price.
Next Steps
- Explore Smart Contracts for the functions implementing these decisions
- Review Data Structures for exact storage layouts
- Check Mechanics for step-by-step operational walkthroughs