🎓 A1Learner
🔍
0 of 45 lessons completed

Solidity Lending Protocol Design

A lending protocol is a contract that lets users deposit collateral, borrow within a ratio, and repay with interest over time. Interest accrues in this toy contract at a fixed rate per second, so the owed amount grows as time passes.

Deposit and borrow within the ratio succeed

Depositing 2000 collateral allows borrowing up to 1000. Borrowing 1000 succeeds and the balances show it.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract ToyToken {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    constructor(uint256 initialSupply) {
        balanceOf[msg.sender] = initialSupply;
    }

    function transfer(address to, uint256 amount) public returns (bool) {
        require(balanceOf[msg.sender] >= amount, "not enough");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        return true;
    }

    function approve(address spender, uint256 amount) public returns (bool) {
        allowance[msg.sender][spender] = amount;
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) public returns (bool) {
        require(balanceOf[from] >= amount, "not enough");
        require(allowance[from][msg.sender] >= amount, "not allowed");
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }
}

contract LendingPool {
    ToyToken public collateralToken;
    ToyToken public debtToken;
    mapping(address => uint256) public collateral;
    mapping(address => uint256) public principal;
    mapping(address => uint256) public lastUpdate;

    constructor(ToyToken collateralToken_, ToyToken debtToken_) {
        collateralToken = collateralToken_;
        debtToken = debtToken_;
    }

    function depositCollateral(uint256 amount) public {
        require(amount > 0, "amount must be positive");
        collateralToken.transferFrom(msg.sender, address(this), amount);
        collateral[msg.sender] += amount;
    }

    function accruedDebt(address user) public view returns (uint256) {
        if (principal[user] == 0) {
            return 0;
        }
        uint256 elapsed = block.timestamp - lastUpdate[user];
        return principal[user] + (principal[user] * elapsed) / 1000;
    }

    function borrow(uint256 amount) public {
        require(amount > 0, "amount must be positive");
        uint256 owed = accruedDebt(msg.sender);
        require(owed + amount <= collateral[msg.sender] / 2, "borrow too much");
        debtToken.transfer(msg.sender, amount);
        principal[msg.sender] = owed + amount;
        lastUpdate[msg.sender] = block.timestamp;
    }

    function repay(uint256 amount) public {
        uint256 owed = accruedDebt(msg.sender);
        require(amount > 0 && amount <= owed, "bad repay amount");
        debtToken.transferFrom(msg.sender, address(this), amount);
        principal[msg.sender] = owed - amount;
        lastUpdate[msg.sender] = block.timestamp;
    }

    function withdraw(uint256 amount) public {
        require(principal[msg.sender] == 0, "debt outstanding");
        require(amount <= collateral[msg.sender], "not enough collateral");
        collateral[msg.sender] -= amount;
        collateralToken.transfer(msg.sender, amount);
    }
}

Borrowing beyond the ratio reverts

Depositing 2000 collateral caps borrowing at 1000. Borrowing 1001 reverts and leaves the debt at zero.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract ToyToken {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    constructor(uint256 initialSupply) {
        balanceOf[msg.sender] = initialSupply;
    }

    function transfer(address to, uint256 amount) public returns (bool) {
        require(balanceOf[msg.sender] >= amount, "not enough");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        return true;
    }

    function approve(address spender, uint256 amount) public returns (bool) {
        allowance[msg.sender][spender] = amount;
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) public returns (bool) {
        require(balanceOf[from] >= amount, "not enough");
        require(allowance[from][msg.sender] >= amount, "not allowed");
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }
}

contract LendingPool {
    ToyToken public collateralToken;
    ToyToken public debtToken;
    mapping(address => uint256) public collateral;
    mapping(address => uint256) public principal;
    mapping(address => uint256) public lastUpdate;

    constructor(ToyToken collateralToken_, ToyToken debtToken_) {
        collateralToken = collateralToken_;
        debtToken = debtToken_;
    }

    function depositCollateral(uint256 amount) public {
        require(amount > 0, "amount must be positive");
        collateralToken.transferFrom(msg.sender, address(this), amount);
        collateral[msg.sender] += amount;
    }

    function accruedDebt(address user) public view returns (uint256) {
        if (principal[user] == 0) {
            return 0;
        }
        uint256 elapsed = block.timestamp - lastUpdate[user];
        return principal[user] + (principal[user] * elapsed) / 1000;
    }

    function borrow(uint256 amount) public {
        require(amount > 0, "amount must be positive");
        uint256 owed = accruedDebt(msg.sender);
        require(owed + amount <= collateral[msg.sender] / 2, "borrow too much");
        debtToken.transfer(msg.sender, amount);
        principal[msg.sender] = owed + amount;
        lastUpdate[msg.sender] = block.timestamp;
    }

    function repay(uint256 amount) public {
        uint256 owed = accruedDebt(msg.sender);
        require(amount > 0 && amount <= owed, "bad repay amount");
        debtToken.transferFrom(msg.sender, address(this), amount);
        principal[msg.sender] = owed - amount;
        lastUpdate[msg.sender] = block.timestamp;
    }

    function withdraw(uint256 amount) public {
        require(principal[msg.sender] == 0, "debt outstanding");
        require(amount <= collateral[msg.sender], "not enough collateral");
        collateral[msg.sender] -= amount;
        collateralToken.transfer(msg.sender, amount);
    }
}

Interest accrues over warped time

Borrowing 1000 and warping forward 100 seconds adds 100 of interest. The owed debt grows from 1000 to 1100.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract ToyToken {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    constructor(uint256 initialSupply) {
        balanceOf[msg.sender] = initialSupply;
    }

    function transfer(address to, uint256 amount) public returns (bool) {
        require(balanceOf[msg.sender] >= amount, "not enough");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        return true;
    }

    function approve(address spender, uint256 amount) public returns (bool) {
        allowance[msg.sender][spender] = amount;
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) public returns (bool) {
        require(balanceOf[from] >= amount, "not enough");
        require(allowance[from][msg.sender] >= amount, "not allowed");
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }
}

contract LendingPool {
    ToyToken public collateralToken;
    ToyToken public debtToken;
    mapping(address => uint256) public collateral;
    mapping(address => uint256) public principal;
    mapping(address => uint256) public lastUpdate;

    constructor(ToyToken collateralToken_, ToyToken debtToken_) {
        collateralToken = collateralToken_;
        debtToken = debtToken_;
    }

    function depositCollateral(uint256 amount) public {
        require(amount > 0, "amount must be positive");
        collateralToken.transferFrom(msg.sender, address(this), amount);
        collateral[msg.sender] += amount;
    }

    function accruedDebt(address user) public view returns (uint256) {
        if (principal[user] == 0) {
            return 0;
        }
        uint256 elapsed = block.timestamp - lastUpdate[user];
        return principal[user] + (principal[user] * elapsed) / 1000;
    }

    function borrow(uint256 amount) public {
        require(amount > 0, "amount must be positive");
        uint256 owed = accruedDebt(msg.sender);
        require(owed + amount <= collateral[msg.sender] / 2, "borrow too much");
        debtToken.transfer(msg.sender, amount);
        principal[msg.sender] = owed + amount;
        lastUpdate[msg.sender] = block.timestamp;
    }

    function repay(uint256 amount) public {
        uint256 owed = accruedDebt(msg.sender);
        require(amount > 0 && amount <= owed, "bad repay amount");
        debtToken.transferFrom(msg.sender, address(this), amount);
        principal[msg.sender] = owed - amount;
        lastUpdate[msg.sender] = block.timestamp;
    }

    function withdraw(uint256 amount) public {
        require(principal[msg.sender] == 0, "debt outstanding");
        require(amount <= collateral[msg.sender], "not enough collateral");
        collateral[msg.sender] -= amount;
        collateralToken.transfer(msg.sender, amount);
    }
}

Example

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract ToyToken {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    constructor(uint256 initialSupply) {
        balanceOf[msg.sender] = initialSupply;
    }

    function transfer(address to, uint256 amount) public returns (bool) {
        require(balanceOf[msg.sender] >= amount, "not enough");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        return true;
    }

    function approve(address spender, uint256 amount) public returns (bool) {
        allowance[msg.sender][spender] = amount;
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) public returns (bool) {
        require(balanceOf[from] >= amount, "not enough");
        require(allowance[from][msg.sender] >= amount, "not allowed");
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }
}

contract LendingPool {
    ToyToken public collateralToken;
    ToyToken public debtToken;
    mapping(address => uint256) public collateral;
    mapping(address => uint256) public principal;
    mapping(address => uint256) public lastUpdate;

    constructor(ToyToken collateralToken_, ToyToken debtToken_) {
        collateralToken = collateralToken_;
        debtToken = debtToken_;
    }

    function depositCollateral(uint256 amount) public {
        require(amount > 0, "amount must be positive");
        collateralToken.transferFrom(msg.sender, address(this), amount);
        collateral[msg.sender] += amount;
    }

    function accruedDebt(address user) public view returns (uint256) {
        if (principal[user] == 0) {
            return 0;
        }
        uint256 elapsed = block.timestamp - lastUpdate[user];
        return principal[user] + (principal[user] * elapsed) / 1000;
    }

    function borrow(uint256 amount) public {
        require(amount > 0, "amount must be positive");
        uint256 owed = accruedDebt(msg.sender);
        require(owed + amount <= collateral[msg.sender] / 2, "borrow too much");
        debtToken.transfer(msg.sender, amount);
        principal[msg.sender] = owed + amount;
        lastUpdate[msg.sender] = block.timestamp;
    }

    function repay(uint256 amount) public {
        uint256 owed = accruedDebt(msg.sender);
        require(amount > 0 && amount <= owed, "bad repay amount");
        debtToken.transferFrom(msg.sender, address(this), amount);
        principal[msg.sender] = owed - amount;
        lastUpdate[msg.sender] = block.timestamp;
    }

    function withdraw(uint256 amount) public {
        require(principal[msg.sender] == 0, "debt outstanding");
        require(amount <= collateral[msg.sender], "not enough collateral");
        collateral[msg.sender] -= amount;
        collateralToken.transfer(msg.sender, amount);
    }
}

Key points

  • Collateral: Collateral is the asset a borrower deposits to back a loan.
  • Borrow ratio: The borrow ratio caps borrowing, here at half the deposited collateral.
  • Interest: Interest is the extra amount owed for borrowing, growing with time in this toy contract.
  • Accrual: Accrual is the growth of owed debt as seconds pass since the last update.
  • Repay: Repayment reduces the owed debt, and a full repayment clears it to zero.
  • Restatement: Deposit collateral, borrow within the ratio, watch interest accrue, repay, and then withdraw.

In this example, the borrower deposits 2000 collateral and borrows 1000. After warping forward 100 seconds, interest of 100 brings the owed debt to 1100. Repaying 1100 clears the principal, so withdrawing 2000 returns the full collateral.

Note

The interest rate here is a fixed toy rate per second, not a market rate from a real protocol.

Try it Yourself

Edit the code, then press Run.

Running is simulated · Remix IDE

Exercise

A borrower deposits 2000 collateral in a pool capped at half the collateral. What is the largest borrow?