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.
src/LendingVault.sol: ERC-4626 vault + loan lifecycle + repayment accountingsrc/mocks/MockERC20.sol: mock ERC-20 used by tests and demoscript/DemoLendingVault.s.sol: end-to-end scenario scripttest/LendingVault.t.sol: focused Foundry tests
If you don’t have Foundry installed, use the official installer:
curl -L https://foundry.paradigm.xyz | bash
foundryupFor more options (precompiled binaries, Docker, building from source), see the Foundry installation guide.
From the repo root, install Forge dependencies (e.g. forge-std, OpenZeppelin):
forge installforge build
forge test -vv
forge script script/DemoLendingVault.s.sol:DemoLendingVault -vvValidated results in this environment:
forge test -vv: 5/5 tests passedforge script ...DemoLendingVault -vv: ran successfully and printed deposit, disbursement, repayment, and share-price changes
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]
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.
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.
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
totalAssetsflat, - principal repayment keeps
totalAssetsflat, - interest and penalties increase
totalAssetsand therefore increase share price.
The borrower submits:
principalannualInterestBpstermPeriods
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.
The borrower can request a loan, but cannot self-fund it.
Required admin actions:
approveLoanRequest()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.
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.
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.
- Foundry: Solidity-native testing and scripting in one toolchain
- OpenZeppelin Contracts 5.x:
ERC4626for vault mechanicsERC20for shares and the mock assetOwnablefor Vault Admin access controlMathandSafeERC20for 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.
- 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.
- 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.
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):
-
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.
-
Borrower whitelisting and loan request — The admin sets the borrower; the borrower requests a 6-installment loan. The script logs the fixed EMI amount.
-
Admin approval and disbursement — The admin approves the request and then disburses; the vault state is logged (idle liquidity drops, outstanding principal appears).
-
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.
- Installment breakdown — Principal component, interest component, late fee (if any), total due, and missed periods (from
-
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
nextDueDateso 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). -
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).