Skip to content

Latest commit

 

History

History
223 lines (144 loc) · 8.73 KB

File metadata and controls

223 lines (144 loc) · 8.73 KB

ERC-4626 Lending Vault Assignment

This repository implements a basic ERC-4626 lending vault where:

  • investors deposit one ERC-20 asset and receive vault shares,
  • one whitelisted borrower can request one amortizing loan,
  • a Vault Admin approves and disburses the loan,
  • repayments return principal plus fixed-rate interest to the vault,
  • share value increases only when interest or late fees are actually paid.

Project layout

  • src/LendingVault.sol: ERC-4626 vault + loan lifecycle + repayment accounting
  • src/mocks/MockERC20.sol: mock ERC-20 used by tests and demo
  • script/DemoLendingVault.s.sol: end-to-end scenario script
  • test/LendingVault.t.sol: focused Foundry tests

How to run

Install Foundry (forge, cast, anvil)

If you don’t have Foundry installed, use the official installer:

curl -L https://foundry.paradigm.xyz | bash
foundryup

For more options (precompiled binaries, Docker, building from source), see the Foundry installation guide.

Install project dependencies

From the repo root, install Forge dependencies (e.g. forge-std, OpenZeppelin):

forge install

Build and run

forge build
forge test -vv
forge script script/DemoLendingVault.s.sol:DemoLendingVault -vv

Validated results in this environment:

  • forge test -vv: 5/5 tests passed
  • forge script ...DemoLendingVault -vv: ran successfully and printed deposit, disbursement, repayment, and share-price changes

Design overview

flowchart LR
    Investors[Investors] -->|deposit asset| Vault[LendingVault]
    Vault -->|mint ERC4626 shares| Investors
    Borrower[WhitelistedBorrower] -->|request terms| Vault
    Admin[VaultAdmin] -->|approve and disburse| Vault
    Vault -->|loan principal| Borrower
    Borrower -->|EMI principal+interest+lateFee| Vault
    Vault -->|interest and fees raise PPS| ShareValue[ShareValue]
Loading

Key design decisions

1. Single vault, single borrower, single originated loan

The vault stores only one borrower address and one LoanDetails struct.

  • The borrower must be explicitly whitelisted by the admin.
  • A rejected request can be resubmitted.
  • Once a loan is actually originated and later closed, the vault does not allow another loan.

Reasoning:

  • This matches the assignment’s simplifying assumption.
  • It keeps state transitions explicit and avoids needing a loan registry or per-loan accounting.

2. No new deposits after loan is disbursed (or closed)

The assignment does not require allowing deposits after a loan is disbursed. New deposits are disabled once the loan is Active or Closed by returning 0 from maxDeposit and maxMint. Calls to deposit or mint then revert via the standard ERC-4626 max checks.

Reasoning:

  • Avoids flash-loan and timing attacks: e.g. an attacker could otherwise borrow, deposit right before a repayment (or right after, to capture the next share-price bump), then redeem and repay the loan for a risk-free profit.
  • Keeps the vault lifecycle simple: capital is raised before the loan, and no new capital can enter while the single loan is active or after it is closed.

3. Vault accounting uses cash + outstanding principal

totalAssets() is defined as:

idle liquidity in vault + outstanding principal deployed to borrower

Reasoning:

  • Loan disbursement should not immediately reduce vault share value, because the vault still owns a receivable.
  • Principal repayments move value from receivable back to idle cash, so they should not create yield.
  • Only interest and late fees should increase share value.

This means:

  • disbursement keeps totalAssets flat,
  • principal repayment keeps totalAssets flat,
  • interest and penalties increase totalAssets and therefore increase share price.

4. Fixed-rate EMI schedule

The borrower submits:

  • principal
  • annualInterestBps
  • termPeriods

The vault stores one fixed paymentInterval for the vault. In the demo/tests it is 30 days.

Periodic rate is computed as:

annual rate * payment interval / 365 days

EMI is then calculated on-chain using the standard amortization formula. The last installment is trued up to repay the exact remaining principal so rounding dust does not leave the loan partially open.

Reasoning:

  • Using a vault-wide fixed interval keeps the contract simpler than supporting arbitrary calendars.
  • Using an annual rate is easier to reason about than a raw per-period rate.
  • Final-payment true-up makes the loan finish cleanly despite integer math.

5. Admin-controlled approval and disbursement

The borrower can request a loan, but cannot self-fund it.

Required admin actions:

  1. approveLoanRequest()
  2. disburseLoan()

Reasoning:

  • Approval and cash release are intentionally separate.
  • The admin can approve first, then wait until the vault has enough idle liquidity to actually disburse.

6. Late fee policy

The implementation applies a simple late fee:

late fee = current installment due * lateFeeBps * missedPeriods / 10_000

Reasoning:

  • This is easy to audit and explain.
  • It avoids building a more complex delinquency/default engine for a basic assignment.

Tradeoff:

  • It is intentionally simple and not meant to model a production collections system.

7. Pro-rata redemption under partial deployment

Withdrawals are limited by idle liquidity, not by total assets including the loan receivable.

The practical redemption cap is:

maxWithdraw(owner) = idleLiquidity * ownerShares / totalSupply

Reasoning:

  • This directly matches the requirement that only currently available liquidity can be redeemed.
  • It prevents one investor from draining all idle cash while others are still exposed to the outstanding loan.

Tools and libraries used

  • Foundry: Solidity-native testing and scripting in one toolchain
  • OpenZeppelin Contracts 5.x:
    • ERC4626 for vault mechanics
    • ERC20 for shares and the mock asset
    • Ownable for Vault Admin access control
    • Math and SafeERC20 for safer accounting/transfers
  • forge-std: test assertions, cheatcodes, and script logging

I also enabled optimizer + via_ir in foundry.toml. The contract compiles cleanly this way with Solidity 0.8.24, and it avoids stack-depth issues from the richer ERC-4626 + reporting logic.

Assumptions

  • One vault supports one borrower and one originated loan.
  • Yield comes only from realized interest and late-fee payments.
  • The underlying asset uses standard ERC-20 transfer semantics.
  • The Vault Admin can be any address, including a multisig controlled off-chain.

Limitations

  • No default/workout flow beyond simple late fees
  • No partial prepayments or refinancing flow
  • No support for multiple concurrent borrowers or loans
  • Future unpaid interest is not marked-to-market in totalAssets

That final point is deliberate for accounting simplicity, but it means a new depositor could still share in interest that is economically close to being realized but not yet paid.

What the demo shows

The script runs an end-to-end scenario with three investors, one borrower, and a 6-period amortizing loan (600e18 principal, 12% APR, 30-day payment interval):

  1. Investor deposits and share issuance — Three investors deposit 500, 300, and 200 units of the mock asset; the vault mints shares and reports idle liquidity, total assets, and share price.

  2. Borrower whitelisting and loan request — The admin sets the borrower; the borrower requests a 6-installment loan. The script logs the fixed EMI amount.

  3. Admin approval and disbursement — The admin approves the request and then disburses; the vault state is logged (idle liquidity drops, outstanding principal appears).

  4. Full loan repayment in installments — The borrower repays all six installments. For each installment the script logs:

    • Installment breakdown — Principal component, interest component, late fee (if any), total due, and missed periods (from nextInstallmentBreakdown()).
    • Vault state — Idle liquidity, outstanding principal, total assets, and share price after the payment.
  5. On-time vs late behavior — Installment 1 is paid on the due date (no late fee). Installment 2 is paid 5 days late, so a late fee and one missed period apply. Installment 3 is still in the “delayed” window, so it also incurs a late fee. For installment 4, the script warps to the exact nextDueDate so the payment is on time; installments 4, 5, and 6 then have no late fee and use the normal EMI amount. After the sixth payment the loan closes (outstanding principal 0).

  6. Share price and total assets — The logs show how idle liquidity, total assets, and share price evolve as principal is repaid (no yield) and as interest and late fees are paid (yield increases share price).