Build a Simple Lending and Borrowing Smart Contract on Linea

Learn how to build and deploy a simple lending and borrowing smart contract on Linea.

13 min read
Build a Simple Lending and Borrowing Smart Contract on Linea
Decentralized finance (DeFi) has reshaped how people lend and borrow money by removing middlemen and using smart contracts to handle transactions automatically. What used to require banks and paperwork can now happen in a few clicks, all on-chain.
Linea helps developers build DeFi applications with improved scalability and lower gas costs compared to Ethereum mainnet by using zero-knowledge rollups (zk-rollups) to batch and compress transactions.
In this guide, we’ll walk through building and deploying a simple lending and borrowing smart contract on Linea. You’ll learn how to manage collateral, issue loans, and set up repayment rules—all essential features of a DeFi lending system.
By the end, you’ll have a working smart contract and a solid understanding of how DeFi lending works under the hood. Whether you're new to blockchain development or looking to sharpen your skills, this step-by-step guide will give you the tools to start building on Linea.

Prerequisite

  • You should have a basic understanding of Solidity.
  • You should have Nodejs and Foundry installed on your PC.

Overview of the contract

This contract enables:
  1. Collateral Deposit: Users can deposit collateral into the contract, with details securely stored in the smart contract.
  2. Borrowing: Users can take a loan against their deposited collateral.
  3. Repayment: Users can repay their loans to unlock their collateral.
  4. Collateral Withdrawal: Users can withdraw collateral that is not locked due to an active loan.

Project setup

We will be using the Foundry framework for this project. With Foundry installed, run the command below to initiate a new project:
forge init lending
Open the market folder on Vscode or your favorite code editor, and delete the scripts/counter.s.sol, src/counter.sol, and test/counter.t.sol.

Install all dependencies

forge install foundry-rs/forge-std --no-commit && forge install OpenZeppelin/openzeppelin-contracts --no-commit

Full code for the lending and borrowing smart contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import "lib/openzeppelin-contracts/contracts/access/Ownable.sol";
contract LendingBorrowing is Ownable {
    struct Loan {
        uint256 amount; // Borrowed amount
        uint256 collateral; // Collateral amount
        bool isActive; // Loan status
    }
    IERC20 public immutable collateralToken;
    IERC20 public immutable lendingToken;
    uint256 public collateralFactor; // Percentage of collateral allowed to be borrowed (e.g., 50%)
    mapping(address => uint256) public collateralBalances; // User collateral balances
    mapping(address => Loan) public loans; // User loans
    event CollateralDeposited(address indexed user, uint256 amount);
    event CollateralWithdrawn(address indexed user, uint256 amount);
    event LoanTaken(address indexed user, uint256 amount);
    event LoanRepaid(address indexed user, uint256 amount);
    constructor(IERC20 _collateralToken, IERC20 _lendingToken, uint256 _collateralFactor) Ownable(msg.sender) {
        require(_collateralFactor <= 100, "Collateral factor must be <= 100");
        collateralToken = _collateralToken;
        lendingToken = _lendingToken;
        collateralFactor = _collateralFactor;
    }
    function setCollateralFactor(uint256 _newFactor) external onlyOwner {
        require(_newFactor <= 100, "Collateral factor must be <= 100");
        collateralFactor = _newFactor;
    }
    function depositCollateral(uint256 _amount) external {
        require(_amount > 0, "Amount must be greater than zero");
        collateralBalances[msg.sender] += _amount;
        collateralToken.transferFrom(msg.sender, address(this), _amount);
        emit CollateralDeposited(msg.sender, _amount);
    }
    function withdrawCollateral(uint256 _amount) external {
        require(_amount > 0, "Amount must be greater than zero");
        require(collateralBalances[msg.sender] >= _amount, "Insufficient collateral");
        uint256 maxWithdrawable = collateralBalances[msg.sender] - _loanRequiredCollateral(msg.sender);
        require(_amount <= maxWithdrawable, "Cannot withdraw collateral locked for a loan");
        collateralBalances[msg.sender] -= _amount;
        collateralToken.transfer(msg.sender, _amount);
        emit CollateralWithdrawn(msg.sender, _amount);
    }
    function takeLoan(uint256 _amount) external {
        require(_amount > 0, "Amount must be greater than zero");
        require(loans[msg.sender].isActive == false, "Existing loan must be repaid first");
        uint256 maxLoan = (collateralBalances[msg.sender] * collateralFactor) / 100;
        require(_amount <= maxLoan, "Loan exceeds collateral limit");
        loans[msg.sender] = Loan({
            amount: _amount,
            collateral: collateralBalances[msg.sender],
            isActive: true
        });
        lendingToken.transfer(msg.sender, _amount);
        emit LoanTaken(msg.sender, _amount);
    }
    function repayLoan(uint256 _amount) external {
        require(_amount > 0, "Amount must be greater than zero");
        Loan storage userLoan = loans[msg.sender];
        require(userLoan.isActive, "No active loan");
        require(_amount <= userLoan.amount, "Repay amount exceeds loan");
        lendingToken.transferFrom(msg.sender, address(this), _amount);
        userLoan.amount -= _amount;
        if (userLoan.amount == 0) {
            userLoan.isActive = false;
        }
        emit LoanRepaid(msg.sender, _amount);
    }
    function _loanRequiredCollateral(address _user) internal view returns (uint256) {
        Loan memory userLoan = loans[_user];
        if (!userLoan.isActive) return 0;
        return (userLoan.amount * 100) / collateralFactor;
    }
    function getLoanDetails(address _user) external view returns (uint256 amount, uint256 collateral, bool isActive) {
        Loan memory userLoan = loans[_user];
        return (userLoan.amount, userLoan.collateral, userLoan.isActive);
    }
}
Copy

Code overview

Loan struct

struct Loan {
        uint256 amount; // Borrowed amount
        uint256 collateral; // Collateral amount
        bool isActive; // Loan status
    }
Copy
The Loan struct serves as a blueprint for storing details of individual loans taken by users. Each field within the struct represents a key piece of information about the loan’s status and conditions. Below is a breakdown of its fields:
  • Amount: Stores the amount of tokens the user has borrowed. This value helps track how much the user owes, determines repayment obligations, and validates loan conditions.
  • Collateral: This stores the value of the collateral deposited by the user to secure the loan. It safeguards the lender’s funds and helps determine the maximum borrowable amount based on the collateral factor.
  • IsActive: Indicates whether the loan is currently active. This field helps track the loan's status and prevents users from taking a new loan until the existing one is fully repaid.

State variables

IERC20 public immutable collateralToken;
    IERC20 public immutable lendingToken;
    uint256 public collateralFactor; // Percentage of collateral allowed to be borrowed (e.g., 50%)
Copy
The above snippet defines three critical state variables, each playing a specific role in enabling and securing the functionality of the lending platform. Below is a breakdown of these variables:
  • CollateralToken: Represents the token that users deposit as collateral for their loans. For example, if USDC is used as collateral, this variable stores the blockchain address of the USDC contract.
  • LendingToken: This variable represents the token that the platform lends to borrowers. If USDT is the lending token, it stores the blockchain address of the USDT contract.
  • CollateralFactor: Defines the percentage of collateral value that a user can borrow. It is expressed as a whole number, where 100 represents 100% of the collateral value. For example, if the collateralFactor is 50, a user can borrow up to 50% of their collateral’s value. This ensures loans are over-collateralized to maintain the security of the lending system. If a user deposits $1,000 worth of collateral and the collateralFactor is 50%, the maximum loan they can take is $500.

Mappings

mapping(address => uint256) public collateralBalances; // User collateral balances
 mapping(address => Loan) public loans; // User loans
Copy
  • CollateralBalances mapping: Tracks the amount of collateral deposited by each user. Every user has a unique collateral balance associated with their address. This mapping helps validate whether a user has enough collateral to take a loan or withdraw funds.
  • Loans mapping: Tracks active loan details for each user by linking their address to their loan information. It ensures that a user cannot take a new loan while they still have an active one.

Events

 event CollateralDeposited(address indexed user, uint256 amount); 
 event CollateralWithdrawn(address indexed user, uint256 amount);
 event LoanTaken(address indexed user, uint256 amount);
 event LoanRepaid(address indexed user, uint256 amount);
Copy
  • CollateralDeposited event – Emitted when a user deposits collateral into the contract. It records the user’s address and the deposited amount.
  • CollateralWithdrawn event – Emitted when a user withdraws collateral from the contract. It records the user’s address and the withdrawn amount.
  • LoanTaken event – Emitted when a user takes out a loan. It logs the user’s address and the borrowed amount.
  • LoanRepaid event – Emitted when a user repays a loan. It records the user’s address and the repayment amount.

Constructor

 constructor(IERC20 _collateralToken, IERC20 _lendingToken, uint256 _collateralFactor) Ownable(msg.sender) {
        require(_collateralFactor <= 100, "Collateral factor must be <= 100");
        collateralToken = _collateralToken;
        lendingToken = _lendingToken;
        collateralFactor = _collateralFactor;
    }
Copy
The constructor is a special function in Solidity that runs only once during contract deployment. Its purpose is to:
  • Set up the initial configuration of the contract.
  • Assign values to key state variables.
  • Establish ownership and access control through Ownable.
The constructor takes three arguments: _collateralToken, _lendingToken, and _collateralFactor.
  • _collateralToken – The token used as collateral for loans.
  • _lendingToken – The token that users borrow from the system.
  • _collateralFactor – The percentage of collateral that determines the maximum borrowing capacity, ensuring it does not exceed 100%.
Upon initialization, the constructor:
  • Assigns _collateralToken as the collateral asset.
  • Sets _lendingToken as the borrowing token.
  • Configures _collateralFactor to establish the collateral-to-loan ratio.
Additionally, Ownable(msg.sender) ensures that the contract deployer becomes the owner, granting them administrative privileges, such as modifying the collateralFactor when necessary.

Setting collateral factor

function setCollateralFactor(uint256 _newFactor) external onlyOwner {
        require(_newFactor <= 100, "Collateral factor must be <= 100");
        collateralFactor = _newFactor;
    }
Copy
This function is a vital administrative feature that allows the owner of the contract to modify the collateral factor, which controls the ratio between collateral and borrowable funds. The collateral factor is used to determine the maximum amount a user can borrow against their collateral. By allowing updates to this factor, the contract can adapt to changing market conditions or risk profiles.
The function is marked as external, meaning it can only be called from outside the contract. The onlyOwner modifier ensures that only the contract owner (typically the deployer) can call this function, providing strict access control.
The function ensures that the new collateral factor is a valid percentage between 0 and 100 to prevent setting unrealistic or unsafe values. For example, a factor above 100 could allow users to borrow more than their collateral covers.
The function updates the collateralFactor state variable with the new value passed as _newFactor.

Deposit collateral

 function depositCollateral(uint256 _amount) external {
        require(_amount > 0, "Amount must be greater than zero");
        collateralBalances[msg.sender] += _amount;
        collateralToken.transferFrom(msg.sender, address(this), _amount);
        emit CollateralDeposited(msg.sender, _amount);
    }
Copy
This function is a critical feature that allows users to deposit collateral into the contract. It is a required step before a user can take out a loan and ensures that collateral is properly accounted for and stored within the contract.
The function validates that the user provides a valid amount greater than zero and increases the user’s collateral balance by the deposited amount. It uses transferFrom to transfer tokens from the user to the contract, which requires the user to approve the contract to spend their tokens before calling this function.
This function enables the following:
  • Collateral deposit: Users deposit a specified amount of a predefined token (collateralToken) into the contract. This collateral acts as security for any loans they take out.
  • Tracking collateral balances: The deposited amount is added to the user’s collateral balance, which is tracked in the collateralBalances mapping.
  • Enabling borrowing: The collateral serves as the basis for calculating how much the user can borrow.

Withdraw collateral

function withdrawCollateral(uint256 _amount) external {
        require(_amount > 0, "Amount must be greater than zero");
        require(collateralBalances[msg.sender] >= _amount, "Insufficient collateral");
        uint256 maxWithdrawable = collateralBalances[msg.sender] - _loanRequiredCollateral(msg.sender);
        require(_amount <= maxWithdrawable, "Cannot withdraw collateral locked for a loan");
        collateralBalances[msg.sender] -= _amount;
        collateralToken.transfer(msg.sender, _amount);
        emit CollateralWithdrawn(msg.sender, _amount);
    }
Copy
This function allows users to withdraw a portion or all of their deposited collateral while ensuring that collateral locked as security for an active loan cannot be withdrawn.
The function validates that the withdrawal amount is greater than zero and verifies that the user has enough available collateral to cover the requested withdrawal. It calculates the portion of collateral locked for any active loan, decreases the user’s collateral balance by the withdrawal amount, and transfers the requested tokens from the contract back to the user.
In summary, this function enables the following:
  • Collateral withdrawal: Allows users to withdraw their available collateral from the contract.
  • Loan protection: Ensures users cannot withdraw collateral needed to secure an active loan.
  • State management: Updates the user’s collateral balance after a successful withdrawal.

Take loan

function takeLoan(uint256 _amount) external {
        require(_amount > 0, "Amount must be greater than zero");
        require(loans[msg.sender].isActive == false, "Existing loan must be repaid first");
        uint256 maxLoan = (collateralBalances[msg.sender] * collateralFactor) / 100;
        require(_amount <= maxLoan, "Loan exceeds collateral limit");
        loans[msg.sender] = Loan({
            amount: _amount,
            collateral: collateralBalances[msg.sender],
            isActive: true
        });
        lendingToken.transfer(msg.sender, _amount);
        emit LoanTaken(msg.sender, _amount);
    }
Copy
This function allows users to borrow tokens from the contract based on their deposited collateral. The loan amount is limited by the user's deposited collateral and the contract’s collateral factor, ensuring borrowing remains within safe limits. This function plays a key role in facilitating loans while maintaining system security.
The function ensures the following:
  • The loan amount is greater than zero.
  • The user does not have an active loan, preventing multiple simultaneous loans.
  • The requested loan does not exceed the maximum borrowing limit based on the collateral.
  • The loans mapping is updated with the user’s loan details.
  • The borrowed tokens are transferred from the contract to the user.
    In summary, this function enables the following:
  • Loan issuance: Allows users to borrow tokens backed by their deposited collateral.
  • Collateral management: Ensures the loan amount stays within the user’s borrowing capacity.
  • Loan tracking: Maintains user loan records to prevent over-borrowing and ensure system integrity.

Repay loan

function repayLoan(uint256 _amount) external {
        require(_amount > 0, "Amount must be greater than zero");
        Loan storage userLoan = loans[msg.sender];
        require(userLoan.isActive, "No active loan");
        require(_amount <= userLoan.amount, "Repay amount exceeds loan");
        lendingToken.transferFrom(msg.sender, address(this), _amount);
        userLoan.amount -= _amount;
        if (userLoan.amount == 0) {
            userLoan.isActive = false;
        }
        emit LoanRepaid(msg.sender, _amount);
    }
Copy
This function allows users to repay their active loans, either partially or in full. It updates the loan status and ensures that repayments are processed correctly, helping to maintain the integrity of the system.
The function performs the following checks and actions:
  • Ensures the repayment amount is greater than zero to prevent invalid operations.
  • Retrieves the user's loan details and confirms they have an active loan before proceeding.
  • Ensures the repayment amount does not exceed the remaining loan balance.
  • Transfers the repayment amount from the user to the contract, requiring prior approval for the specified amount.
  • Reduces the loan balance by the repayment amount.
  • If the loan is fully repaid, the function marks it as inactive.
    In summary, this function enables the following:
  • Loan Repayment: Allows borrowers to return the borrowed amount to the contract.
  • Loan Tracking: Updates the loan balance and status after repayment.
  • Transparency: Emits an event to record repayment transactions on-chain.

Tests

Create a Token.sol file in the src folder and add the code below to the file.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {ERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract Token is ERC20 {
    constructor(string memory name, string memory symbol) ERC20(name, symbol){}
    function mint(address user, uint256 amount) public {
        _mint(user, amount);
    }
}
Copy
Create a lending.t.sol file in the test folder and add the code below to the file.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import { LendingBorrowing} from "../src/Lending.sol";
import { Token } from "../src/Token.sol";
contract LendingTest is Test {
     LendingBorrowing public lending;
     Token public collateral;
     Token public lendingToken;
     address public user1 = makeAddr("user1");
     address public user2 = makeAddr("user2");
     address public user3 = makeAddr("user3");

    function setUp() public {
        collateral = new Token("collateralToken", "ct");
        lendingToken = new Token("lendingToken", "lt");
        lending = new LendingBorrowing(collateral, lendingToken, 80);
        collateral.mint(user1, 100 ether);
        lendingToken.mint(address(lending), 1000 ether);
    }
    function test_DepositCollateral() public {
        vm.startPrank(user1);
        collateral.approve(address(lending), 100 ether);
        lending.depositCollateral(50 ether);

        assert(collateral.balanceOf(user1) == 50 ether);
        assert(collateral.balanceOf(address(lending)) == 50 ether);  
    }

    function test_TakeLoan() public {
        vm.startPrank(user1);
        collateral.approve(address(lending), 100 ether);
        lending.depositCollateral(50 ether);
        vm.expectRevert();
        lending.takeLoan(100 ether);
        vm.expectRevert();
        lending.takeLoan(49 ether);
        lending.takeLoan(40 ether);
        assert(lendingToken.balanceOf(user1) == 40 ether);
    }

    function test_RepayLoan() public {
        vm.startPrank(user1);
        collateral.approve(address(lending), 100 ether);
        lending.depositCollateral(50 ether);
        lending.takeLoan(40 ether);
        lendingToken.approve(address(lending), 40 ether);
        lending.repayLoan(40 ether);
        assert(lendingToken.balanceOf(user1) == 0 ether);
    }

    function test_WithdrawCollateral() public {
        vm.startPrank(user1);
        collateral.approve(address(lending), 100 ether);
        lending.depositCollateral(50 ether);
        lending.takeLoan(40 ether);
         lendingToken.approve(address(lending), 40 ether);
        lending.repayLoan(40 ether);
        lending.withdrawCollateral(50 ether);
        assert(lendingToken.balanceOf(user1) == 0 ether);
        assert(collateral.balanceOf(user1) == 100 ether);
    }

}
Copy

Test setup

This function is called before each test function and is used to initialize the state.
  • It deploys the token contracts: Both the collateral and lendingToken contracts are deployed.
  • It deploys the LendingBorrowing contract: The LendingBorrowing contract is initialized with the collateral token, lendingToken, and a collateral-to-loan ratio of 80% (meaning a user can only borrow up to 80% of their collateral value).
  • Mints tokens:
    • 100 collateral tokens are minted for User 1.
    • 1000 lending tokens are minted to the lending contract.

Test deposit collateral

This function tests that a user can deposit collateral into the lending contract. User 1 approves the lending contract to spend up to 100 of their collateral tokens and deposits 50 collateral tokens into the contract. The function verifies that:
  • User 1's balance is reduced by 50 tokens.
  • The lending contract's balance of collateral increases by 50 tokens.

Test take loan

This function tests that a user can take a loan based on the collateral they have deposited. User 1 deposits 50 collateral tokens and approves the lending contract to spend them.
  • The test ensures that taking a loan higher than the allowed amount or below the required minimum (based on collateral) reverts.
  • User 1 successfully takes out a loan of 40 lendingToken tokens.
  • The test verifies that User 1 receives 40 lendingToken tokens after taking the loan.

Test repay loan

This function tests that a user can repay their loan correctly.
  • User 1 deposits 50 collateral tokens and takes out a loan of 40 lendingToken tokens.
  • User 1 approves the lending contract to spend 40 lendingToken tokens and repays the loan.
  • The test verifies that User 1's lendingToken balance is zero after loan repayment.

Test withdraw collateral

This function tests that a user can withdraw their collateral after repaying the loan.
  • User 1 deposits 50 collateral tokens, takes a loan of 40 lendingToken tokens, and repays the loan.
  • After the loan is repaid, User 1 withdraws their collateral.
  • The test verifies that:
    • User 1 receives their 50 collateral tokens back.
    • User 1's balance is 100 (since they initially minted 100 tokens).
    • User 1's lendingToken balance is zero after the loan repayment.

Run test

forge test

Deployment and verification on Linea

Add the necessary variables to .env, the .env file should be at the root level.
LINEA_RPC_URL=https://linea-mainnet.infura.io/v3/ // Infura RPC endpoint for connecting to Linea 
PRIVATE_KEY=************************************************* // Wallet private key for signing transactions LINEA_API_KEY=************************* // API key for accessing Linea services via Infura
Copy
Add the details below to foundry.toml
[etherscan]
linea = { key = "${LINEA_API_KEY}", url = "https://api-sepolia.lineascan.build/api" }
Copy
Create a Makefile and add the details below
deploy:
    forge create src/Lending.sol: LendingBorrowing  --rpc-url $(LINEA_RPC_URL) --private-key $(PRIVATE_KEY)

verify:; forge verify-contract --rpc-url $(LINEA_RPC_URL) --chain linea <contract address> src/Lending.sol:LendingBorrowing --constructor-args arg1 arg2
Copy

Deployment

make deploy

Verification

make verify

Conclusion

Building a simple lending and borrowing smart contract provides a foundation for understanding DeFi and blockchain development. This guide covered:
  • Depositing collateral – Users lock assets as security for loans.
  • Taking loans – Borrowers access funds based on their collateral.
  • Repaying loans – Users return borrowed funds to clear their debt.
  • Withdrawing collateral – Users reclaim collateral after full repayment.
By integrating these elements, we’ve created a functional, automated lending system. As DeFi grows, these concepts serve as building blocks for more advanced financial applications.
For more hands-on tutorials and deep dives into blockchain development, check out the MetaMask Developer YouTube channel, and stay updated with our latest insights by following us on Twitter.

This article is written by:

  • Kingsley Okonkwo
    Kingsley Okonkwo

    Kingsley, a certified Ethereum Blockchain developer, serves as a technical writer at Consensys, with a special focus on developer tools. Before joining Consensys, Kingsley worked as a freelance backend developer on sevral platforms including Gigster and Braintrust. Boasting 5 years of software development experience, he's proficient with tools such as JavaScript (Node.js & React), GoLang, SQL, and Docker. Kingsley excels in producing step-by-step tutorials and how-to guides, utilizing the latest in web3 technology to help fellow developers build better dapps. Currently based in Dubai, Kingsley enjoys playing basketball and soccer in his free time

    Read all articles