Solidity Tutorial (ERC20 & AAVE)

Solidity Oct 9, 2021

The code for this tutorial was written by JulienKode and I.
Disclaimer: The code obviously hasn't been audited so it may contain vulnerabilities.

If you want to jump straight into it, the repository is accessible here (spoiler alert!).

What we will be building

For this tutorial, we are going to build a decentralized application that is a bit more suited for a blockchain than, say, a to-do app. Using smart contracts, the goal will be to replace, or rather complement, a rental agreement. In brief, it should be able to manage rent payments and security deposits for landlords and tenants without any third-party or guarantors AND generate interests on the deposit.

For the full context and motivation of what we will develop in this tutorial, please refer to this post. However, beyond the "why", here's the "what", i.e the requirements.


First, let's define a security deposit as money protecting against damage to the property and rent guarantee as protecting against unpaid rent. There's usually no distinction between the two. Here we're making one because the latter is verifiable on-chain, the former is not, so the way to reach a settlement differs.

1. Moving in

After agreeing upon an amount for rent and security deposit, landlord and tenants should be able to enter into an agreement.

As a tenant:

  • I should be able to lock up a certain amount as security deposit and rent guarantee, and pay the first month rent to start the lease.
  • I should earn interest on the money locked up.

2. Renting

As a tenant:

  • I should be able to pay my rent on time

As a landlord:

  • I should be able to withdraw from the protocol the rent from the moment it's due
  • If rent payment is late, I should be able to withdraw from the rent guarantee an amount equivalent to the unpaid rent

3. Moving out

As a tenant:

  • If I always paid my rent in time, I should be able to get the rent guarantee back in full
  • I should get all earned interests on the security deposit and rent guarantee

As a landlord:

  • I should have control over how much of the security deposit is given back to the tenant (in case there were damages)

Let's dive in!


  • Some familiarity with Solidity, in particular types, visibility specifiers, functions definitions... My previous post has got you covered.
  • Node and Yarn installed

And that's it! This is a tutorial for beginners. :)


Instead of spending too much time, setting things up we are going to re-use an excellent template created by Paul Razvan Berg: Solidity-template.

It comes with many plugins and super useful tools already installed for us:

  • Hardhat: My favorite framework to develop, compile and run smart contracts locally
  • Ethers: An essential library to interact with smart contracts from client-side apps and tests
  • TypeChain: Generates TypeScript types for smart contracts
  • Waffle: Tooling for writing comprehensive smart contract tests
  • Solhint: Linter
  • Solcover: Generates code coverage for smart contracts
  • Prettier Plugin Solidity: Code formatter for Solidity

If you want to follow along, click on "Use this template" on Github, follow these steps and then clone your repo locally. Inside of it, we install the dependencies by running:

yarn install

Lending Service


We are going to create a Lending Service which will be responsible for interacting with a DeFi protocol as we want the deposit and rent guarantee to earn interests.

Because we could potentially have a lending service for each yield-earning DeFi protocol (Aave, Compound, Yearn etc...), we first define an interface that any Lending Service will have to implement.

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

interface ILendingService {
    /// @notice Deposits funds from current smart contract to a lending
    /// protocol
    /// @dev Will revert if the amount exceeds the contract balance or
    /// caller is not the owner.
    /// @param amount The amount to deposit
    function deposit(uint256 amount) external;

    /// @notice Withdraws `amount` from a lending protocol
    /// @dev Will revert if the amount exceeds the balance of capital
    /// deposited or caller is not the owner.
    /// @param amount The amount to withdraw
    function withdraw(uint256 amount) external;

    /// @notice Withdraws all capital and interests earned from a 
    /// lending protocol
    /// @dev Will revert if the caller is not the owner.
    function withdrawCapitalAndInterests() external;

    /// @notice Returns the amount deposited on a lending protocol
    function depositedBalance() external view returns (uint256);

The first line specifies the license for this file.

The second line indicates to the Solidity compiler the Solidity version this file was written for, in this case ^0.8.0. If the compiler's version does not match, an error will be thrown.  The syntax to specify a valid version is the same as npm.

Then, in the interface itself, we have function declarations for depositing, withdrawing and checking the balance of deposited tokens on a lending protocol. They are declared without their implementation, because they're in an interface.

Aave Lending Service

Now that we have a generic interface we can define a smart contract that inherits it. It will be responsible for interacting with AAVE v2, a DeFi protocol that we can use to lend tokens.

import "./ILendingService.sol";

contract AAVELendingService is ILendingService { }

ERC20 🤑

We need to declare the token that will be used as currency. This token must follow the widely-used ERC20 standard and adhere to its interface (IERC20) which we import from OpenZeppelin.

With Aave, whenever you deposit tokens, you get aTokens in return. Deposit 100 DAI, you get 100 aDAI; 300 USDC, you get 300 aUSDC etc... We declare both token types as ERC20 tokens.

 import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

 contract AAVELendingService is ILendingService {
   IERC20 public aToken;
   IERC20 public tokenUsedForPayments;

We're also indicating that our contract uses the SafeERC20 library from OpenZeppelin by 1. importing it and 2. including using SafeERC20 for IERC20;.

The SafeERC20 library makes interacting with an ERC20 token a bit safer by reverting if an operation fails and sends back a return value equals to false.

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract AAVELendingService is ILendingService {
    using SafeERC20 for IERC20;

    // this shouldn't be harcoded normally
    address public aDaiTokenAddress = 0xdCf0aF9e59C002FA3AA091a46196b37530FD48a8;

    IERC20 public aToken;
    IERC20 public tokenUsedForPayments;

    constructor(address _tokenUsedToPay) {
        tokenUsedForPayments = IERC20(_tokenUsedToPay);
        aToken = IERC20(aDaiTokenAddress);

The address of the token used as currency is passed in the constructor which offers some flexibility, but normally the aToken's address should be retrieved based on that token address (only kovan DAI will work here). I leave that to you to implement ;) (Hint: this method).


Granted their visibility is public or external, functions defined in a smart contract can be called by anyone. We don't want anyone to withdraw the funds here so we'll need some access control.

address payable public owner;

modifier onlyOwner() {
  require(msg.sender == owner, "Restricted to the owner only");

constructor(address _tokenUsedToPay) {
   owner = payable(msg.sender);

We first declare a variable for the owner of the smart contract.
We then add a modifier, onlyOwner. It contains a require statement which reverts the transaction if the caller is not the owner that we defined. When applied to a function, that check will be performed before executing the function itself (_ represents the function body).

In the constructor, the address that deployed the current smart contract gets assigned to that variable.

It's great to have an owner defined, but we should also enable transferring ownership. Here's how to do it and a first example of applying our onlyOwner modifier:

function transferOwnership(address newOwner) public onlyOwner {
  require(newOwner != address(0), "new owner is the zero address");
  owner = payable(newOwner);

Deposit & Withdraw

Let's move on to the core features now.

First, we define a variable which will be helpful to keep track of the balance of token that were deposited in AAVE.

uint256 public depositedAmountBalance;

Next, we define a function used to deposit some amount of tokens in AAVE.

function deposit(uint256 amount) external override(ILendingService) onlyOwner {
  require(amount <= tokenUsedForPayments.balanceOf(address(this)), "amount exceeds contract balance");
  // Approve the LendingPool contract to pull the amount to deposit
  tokenUsedForPayments.approve(address(aaveLendingPool), amount);
  // Deposit the amount in the LendingPool
  aaveLendingPool.deposit(address(tokenUsedForPayments), amount, address(this), 0);

  depositedAmountBalance += amount;

It first does a preventive check, verifying that the smart contract holds the amount to deposit.

The AAVE lending pool will pull these tokens from our contract. So if you're not familiar with how ERC20 tokens work, we first need to allow Aave's smart contract to transfer the tokens the Lending Service holds on its behalf (tokenUsedForPayments.approve).

We then call aaveLendingPool.deposit  passing in 1. the underlying asset, 2. amount to be deposited, 3. the address that will receive the aTokens in exchange which is the current contract's address and 0 as we're not using any referral code.

depositedAmountBalance  gets logically incremented by amount.

To withdraw, it's almost the same process but in reverse. After checking that the contract holds a balance superior to the amount to withdraw (of aTokens this time!), we need to approve the lending pool to transfer aTokens from our smart contract in order to get our underlying tokens back in exchange.

function withdraw(uint256 amount) external override(ILendingService) onlyOwner {
    uint256 aTokenBalance = aToken.balanceOf(address(this));
    // Check that the amount to withdraw is less than what the contract holds
    require(aTokenBalance >= amount, "Amount exceeds balance");

    // Approve the aToken contract to pull the amount to withdraw
    aToken.approve(address(aaveLendingPool), amount);
    // Withdraw from the LendingPool
    aaveLendingPool.withdraw(address(tokenUsedForPayments), amount, address(this));

At that point if all goes well, our lending service should hold the amount in underlying tokens so we can transfer it and then decrease the depositedAmountBalance.

function withdraw(uint256 amount) external override(ILendingService) onlyOwner {
    uint256 aTokenBalance = aToken.balanceOf(address(this));
    // Check that the amount to withdraw is less than what the contract holds
    require(aTokenBalance >= amount, "Amount exceeds balance");

    // Approve the aToken contract to pull the amount to withdraw
    aToken.approve(address(aaveLendingPool), amount);
    // Withdraw from the LendingPool
    aaveLendingPool.withdraw(address(tokenUsedForPayments), amount, address(this));
    // Transfer withdrawn amount
    tokenUsedForPayments.safeTransfer(msg.sender, amount);

    depositedAmountBalance -= amount;

View Function

Finally, let's define a function that will return the depositedAmountBalance. It will be needed later.

 function depositedBalance() external override(ILendingService) view returns (uint256) {
  return depositedAmountBalance;

It is a simple view  function as it simply reads data from the blockchain.

Developing the rental agreement

With our lending service created, we can now develop a smart contract called RentalAgreement. It will make use of the Lending Service and contain the "rules" of the agreement between a landlord and a tenant, embedded programmatically (🔥🔥🔥).

Inside our contract,  let's declare some state variables. They get assigned their values in the constructor which will be provided when deploying a RentalAgreement.

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

import "./ILendingService.sol";

contract RentalAgreement {
    address public landlord;
    address public tenant;
    uint256 public rent;
    uint256 public deposit;
    uint256 public rentGuarantee;

    ILendingService public lendingService;

        address _landlord,
        address _tenantAddress,
        uint256 _rent,
        uint256 _deposit,
        uint256 _rentGuarantee,
        address _tokenUsedToPay,
        address _lendingService
    ) {
        require(_landlord != address(0), "Landlord address cannot be the zero address");
        require(_tenantAddress != address(0), "Tenant address cannot be the zero address");
        require(_tokenUsedToPay != address(0), "Token address cannot be the zero address");
        require(_lendingService != address(0), "Lending Service address cannot be the zero address");
        require(_rent > 0, "rent cannot be 0");

        landlord = _landlord;
        tenant = _tenantAddress;
        rent = _rent;
        deposit = _deposit;
        rentGuarantee = _rentGuarantee;
        lendingService = ILendingService(_lendingService);

The different variables declared are:

  • addresses for landlord and tenant
  • unsigned integer for the rent
  • unsigned integer for deposit and rent guarantee (these two are different! See specs above)

They all have a public visibility.

The require statements are used for input validation. If the condition provided, such as _rent > 0, is true, execution will proceed; otherwise, the transaction will revert and since it's in the constructor, contract deployment will fail. Boohoo.

This smart contract also expects the address of a lendingService to be passed to its constructor. We keep it simple for now and assume the landlord deploys a lending service, and then has to transfer ownership to the rental agreement once deployed.


Let's add two modifiers to restrict permissions:

modifier onlyTenant() {
        require(msg.sender == tenant, "Restricted to the tenant only");

modifier onlyLandlord() {
        require(msg.sender == landlord, "Restricted to the landlord only");

Once added to a function, the onlyTenant modifier will revert the transaction if the caller is not the tenant (that we defined previously as state variable).

Same thing for the second modifier, onlyLandlord but only allowing the landlord.

ERC20 (again!)

The RentalAgreement will be transferring the tokens used as currency so we need to add the following lines, just like we did in the Lending Service.

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract RentalAgreement {
    using SafeERC20 for IERC20;

    IERC20 public tokenUsedForPayments;

        address _tokenUsedToPay,
    ) {
        tokenUsedForPayments = IERC20(_tokenUsedToPay);


Entering the agreement 🤝

Assuming the landlord has to deploy the RentalAgreement, we are now going to add a function which will make the tenant acknowledge the terms by providing them as arguments and start the lease. (A cool improvement would be to have the tenant sign these terms with Metamask for example, and verify the signature in our RentalAgreement smart contract).

Here's what we add, explained with comments:

// Timestamp (e.g. 1622131469) used to keep track of when the rent will be due next
uint256 public nextRentDueTimestamp;

// Event emitted at the start of the lease
event TenantEnteredAgreement(uint256 depositLocked, uint256 rentGuaranteeLocked, uint256 firstMonthRentPaid);

// ...

function enterAgreementAsTenant(
    address _landlordAddress,
    uint256 _deposit,
    uint256 _rentGuarantee,
    uint256 _rent
) public onlyTenant {
    // Makes sure the tenant "agrees" with the terms first registered by the landlord
    require(_landlordAddress == landlord, "Incorrect landlord address");
    require(_deposit == deposit, "Incorrect deposit amount");
    require(_rentGuarantee == rentGuarantee, "Incorrect rent guarantee amount");
    require(_rent == rent, "Incorrect rent amount");

    uint256 deposits = deposit + rentGuarantee;
    // Transfers the deposits to this smart contract
    tokenUsedForPayments.safeTransferFrom(tenant, address(this), deposits);
    // Approve the transfer of the deposits to the lending service
    tokenUsedForPayments.approve(address(lendingService), deposits);
    // Deposit the `deposits` amount in the lending service

    // Transfer the first month of rent to the landlord
    tokenUsedForPayments.safeTransferFrom(tenant, landlord, rent);
    // First rent payment was made, we set the next time it's due as 4 weeks from now
    nextRentDueTimestamp = block.timestamp + 4 weeks;

    emit TenantEnteredAgreement(deposit, rentGuarantee, rent);

Note that we're using lendingService  which was declared and coded previously.


This tutorial wouldn't be complete without explaining how to test the Solidity code we write.

You can dive in the repository for the full test suite but let's focus on a test for the previous function. And before we can test it, we actually need to initialize several things:


import hre from "hardhat";
import { Artifact } from "hardhat/types";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address";

import { RentalAgreement } from "../typechain/RentalAgreement";
import { MockLendingService } from "../typechain/MockLendingService";
import { expect } from "chai";
import { deployMockLendingService } from "./utils/mockLendingService";
import { parseUnits18 } from "../utils/parseUnits";

const { deployContract } = hre.waffle;
const { ethers } = hre;

describe("Rental Agreement", function () {
  let landlord: SignerWithAddress;
  let tenant1: SignerWithAddress;
  let rental: RentalAgreement;
  let lendingService: MockLendingService;

  let rent: BigNumber;
  let deposit: BigNumber;
  let rentGuarantee: BigNumber;
  let dai: Contract;

  before(async function () {
    [landlord, tenant1] = await ethers.getSigners();

    // deploy an ERC20 token to mock dai
    const daiFactory = await ethers.getContractFactory("ERC20PresetMinterPauser");
    dai = await daiFactory.deploy("dai", "DAI");
    console.log("dai deployed at:", dai.address);

    // Give 1 000 000 mock dai to tenant1
    const initialTenant1Balance = parseUnits18("1000000");
    const mintingTx = await, initialTenant1Balance);
    await mintingTx.wait();

    expect(await dai.balanceOf(tenant1.address)).to.eq(initialTenant1Balance);

Here we assign 2 signers: the first one playing the role of landlord and the other tenant (tenant1). Then a simple ERC20 contract is deployed to serve as a mock token used for payments. We use it to grant 1M of them to tenant1. That should be enough to pay rent for a little while. 😄

beforeEach(async function () {
  rent = parseUnits18("500");
  deposit = parseUnits18("500");
  rentGuarantee = parseUnits18("1500");

  lendingService = await deployMockLendingService(landlord, dai);

  const rentalArtifact: Artifact = await hre.artifacts.readArtifact("RentalAgreement");
  rental = <RentalAgreement>(
    await deployContract(landlord, rentalArtifact, [

  const transferOwnershipTx = await lendingService.connect(landlord).transferOwnership(rental.address);
  await transferOwnershipTx.wait();

Next, beforeEach is used to re-deploy our smart contracts before each test in order to isolate them. Deployment is done in 2 steps:

  1. Getting the contract's artifact with await hre.artifacts.readArtifact(<CONTRACT NAME>);
  2. Calling deployContract  passing in a signer, the contract's artifact and then an array of parameters expected by the constructor.

As mentioned before, after deploying, a landlord needs to give up ownership of the lending service.

Testing enterAgreementAsTenant

First we compute the sum of everything a tenant needs to transfer upfront when entering the agreeement: deposit, rentGuarantee and first month's rent. That sum is used to call approve first so that our smart contract is allowed to pull that amount from the tenant.

  it("should let the tenant enter the agreement", async () => {
      const deposits = deposit.add(rentGuarantee);
      const totalUpfront = deposits.add(rent);

      const approveTx = await dai.connect(tenant1).approve(rental.address, totalUpfront);
      await approveTx.wait();

We can then call the function to enter the agreement, wait for the transaction to be mined and get the block that included it.

 const tx = await rental.connect(tenant1).enterAgreementAsTenant(landlord.address, deposit, rentGuarantee, rent);
 const txReceipt = await tx.wait();
 const blockHash = txReceipt.blockHash;
 const block = await ethers.provider.getBlock(blockHash);

Calling nextRentDueTimestamp,  we can check that it's set 4 weeks from the block's timestamp.

const nextRentDueTimestamp = await rental.nextRentDueTimestamp();
expect(nextRentDueTimestamp).to.eq(block.timestamp + FOUR_WEEKS_IN_SECS);

Finally we check that the deposits were indeed deposited in the lending service, the rentalAgreement's balance is 0 and the landlord received the first month's rent!

expect(await dai.balanceOf(lendingService.address)).to.eq(deposits);
expect(await dai.balanceOf(rental.address)).to.eq(parseUnits18("0"));
expect(await dai.balanceOf(landlord.address)).to.eq(rent);

Everything should pass ✅

Paying Rent

Back to Solidity, we define a function to allow the tenant to pay rent, fittingly called payRent.

 event RentPaid(address tenant, uint256 amount, uint256 timestamp);
 // ...
function payRent() public onlyTenant {
    require(tokenUsedForPayments.allowance(tenant, address(this)) >= rent, "Not enough allowance");

    tokenUsedForPayments.safeTransferFrom(tenant, landlord, rent);

    nextRentDueTimestamp += 4 weeks;

    emit RentPaid(tenant, rent, block.timestamp);

Let's unpack the 4 lines in the function body.

The first one checks on the token's smart contract that the allowance given to our current smart contract by the tenant is at least the amount of rent. If it wasn't the case, the transfer would fail but we catch that possibility earlier here.

The second line executes the transfer from tenant to landlord of the rent  amount.

Once done, we consider that the tenant just "bought 4 more weeks" in the property so we add 4 weeks to the current value of nextRentDueTimestamp.

The last line emits a RentPaid event, notifying that rent was paid.

What if the tenant stops paying rent?! 😨

We are going to add a function that handles this situation and lets the landlord withdraw from the money locked up by the tenant.

function withdrawUnpaidRent() public onlyLandlord {
    // Checks that rent payment is late
    require(block.timestamp > nextRentDueTimestamp, "There are no unpaid rent");
    // Pushes back next time rent is due by 4 weeks
    nextRentDueTimestamp += 4 weeks;
    // Registers that the amount for rent was taken out of `rentGuarantee`
    rentGuarantee -= rent;
    // Withdraws from the Lending Service
    // Transfers "rent" to the landlord
    tokenUsedForPayments.safeTransfer(landlord, rent);

As you can see the landlord is still getting rent but from the rent guarantee, not the tenant directly.

Ending the tenancy

Let's recap what needs to happen at the end of a tenancy:

The tenant should get:

  • her deposit back minus any amount retained by the landlord for damages
  • her rent guarantee back (or whatever is left of it)
  • any interest earned on both of these deposits

Let's code this up!

We add a public function but with the onlyLandlord  modifier so only the landlord can call it passing as parameter how much should be given back to the tenant from the deposit amount (_amountOfDepositBack).

We're also checking that this amount is inferior or equal to the deposit.

function endRental(uint256 _amountOfDepositBack) public onlyLandlord {
  require(_amountOfDepositBack <= deposit, "Invalid deposit amount");

Next, we need to send to the tenant the right amount which includes the amount of deposit back, the rent guarantee (or whatever is left of it) and the interests earned.

function endRental(uint256 _amountOfDepositBack) public onlyLandlord {
    require(_amountOfDepositBack <= deposit, "Invalid deposit amount");

    uint256 depositedOnLendingService = lendingService.depositedBalance();
    uint256 beforeWithdrawBalance = tokenUsedForPayments.balanceOf(address(this));
    uint256 afterWithdrawBalance = tokenUsedForPayments.balanceOf(address(this));
    uint256 interestEarned = (afterWithdrawBalance - depositedOnLendingService) - beforeWithdrawBalance;

    // compute and transfer funds to tenant
    uint256 fundsToReturnToTenant = _amountOfDepositBack + rentGuarantee + interestEarned;
    tokenUsedForPayments.safeTransfer(tenant, fundsToReturnToTenant);

There's a bit going on here because we are computing how much interest was earned. With this amount known, that means we could easily split it between tenant and landlord.

This computation is done by comparing the capital deposited in the lending service with how much was transferred to the smart contract after withdrawing everything from it.

If the landlord is keeping some of the deposit we need to figure this out and transfer it to  them!

 uint256 landlordWithdraw = deposit - _amountOfDepositBack;
// landlord is keeping some of the deposit
if (landlordWithdraw > 0) {
    tokenUsedForPayments.safeTransfer(landlord, landlordWithdraw);

Finally, we set both deposit and rentGuarantee  to 0 and emit an event to signal the end of the tenancy:

deposit = 0;
rentGuarantee = 0;
emit EndRental(fundsToReturnToTenant, landlordWithdraw);

And that's it for the Rental Agreement!


Let's now create a smart contract that will make it easy for landlords to deploy rental agreements.

import "./RentalAgreement.sol";

contract RentalFactory {

    function createNewRental(
        address _tenantAddress,
        uint256 _rent,
        uint256 _deposit,
        uint256 _rentGuarantee,
        address _tokenUsedToPay,
        address _lendingService
    ) public {
        RentalAgreement newRental =
            new RentalAgreement(msg.sender, _tenantAddress, _rent, _deposit, _rentGuarantee, _tokenUsedToPay, _lendingService);

We import the contract we created and add a public function that forwards all the parameters needed to create a new RentalAgreement.

Next, let's add an event for whenever a new agreement is deployed.

  event NewRentalDeployed(address contractAddress, address landlord, address tenant);

    function createNewRental(
        address _tenantAddress,
        uint256 _rent,
        uint256 _deposit,
        uint256 _rentGuarantee,
        address _tokenUsedToPay,
        address _lendingService
    ) public {
        RentalAgreement newRental =
            new RentalAgreement(msg.sender, _tenantAddress, _rent, _deposit, _rentGuarantee, _tokenUsedToPay, _lendingService);

        emit NewRentalDeployed(address(newRental), msg.sender, _tenantAddress);

Note that we are getting the address of the freshly deployed contract as the first event argument.

Finally, we use a mapping that keeps track of the RentalAgreements  deployed by address. Any new rental deployed gets pushed to it.

mapping(address => RentalAgreement[]) public rentalsByOwner;

function createNewRental( ...
  RentalAgreement newRental = new RentalAgreement(msg.sender, _tenantAddress, _rent, _deposit, _rentGuarantee, _tokenUsedToPay, _lendingService);

  emit NewRentalDeployed(address(newRental), msg.sender, _tenantAddress);

🏁🏁 And we're done! 🏁🏁
If you have any comment or feedback about this tutorial, we'd love to hear it, so please don't hesitate to reach out!