Exercise 2: Automated Real Estate Lending

Day 1 Recap

To recap, so far, we've created the ERC-1155 smart contract, connected it with the Zillow's API using Chainlink Functions to create a fractionalized real estate token. In the next two exercises, we will build use cases for that fractionalized real world asset:

  1. RWA Lending & Borrowing

  2. Selling fractionalized RWA on an English Auction

Use-case 1) RWA Lending & Borrowing

For this use-case we are going to create the RwaLending.sol smart contract in which Alice would lock certain amount of ERC-1155 tokens that represent her real estate in order to get a loan in USDC tokens in return. She can also loan a certain percentage of the real estate, because if you remember, if we issue her 20 RealEstateToken tokens and she loans only 5 of them, that means that she loaned the 25% of the real estate token representation.

To calculate the price of the real estate we will rely on the getPriceDetails function. If you remember, yesterday we've set up Chainlink Automation to periodically update real estate price details - in US Dollars ($). Then we will calculate the real estate token valuation directly on-chain using the following formula:

Valuation=W1×ListPrice+W2×OriginalListPrice+W3×TaxAssessedValueW1+W2+W3Valuation = \frac{W_1 \times ListPrice + W_2 \times OriginalListPrice + W_3 \times TaxAssessedValue}{W_1 + W_2 + W_3}

Where W stands for weight.

Once Alice repays her debt in USDC she gets her ERC-1155 tokens in return. If a value of an real estate drops below liquidation threshold, anyone can liquidate this position and Alice loses all of hers ERC-1155 tokens.

Create RWALending.sol

Inside the ./use-cases folder create the RWALending.sol file.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {RealEstateToken} from "../RealEstateToken.sol";
import {IERC1155Receiver, IERC165} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";

/**
 * THIS IS AN EXAMPLE CONTRACT THAT USES HARDCODED VALUES FOR CLARITY.
 * THIS IS AN EXAMPLE CONTRACT THAT USES UN-AUDITED CODE.
 * DO NOT USE THIS CODE IN PRODUCTION.
 */
contract RwaLending is IERC1155Receiver, OwnerIsCreator, ReentrancyGuard {
    using SafeERC20 for IERC20;

    struct LoanDetails {
        uint256 erc1155AmountSupplied;
        uint256 usdcAmountLoaned;
        uint256 usdcLiquidationThreshold;
    }

    RealEstateToken internal immutable i_realEstateToken;
    address internal immutable i_usdc;
    AggregatorV3Interface internal s_usdcUsdAggregator;
    uint32 internal s_usdcUsdFeedHeartbeat;

    uint256 internal immutable i_weightListPrice;
    uint256 internal immutable i_weightOriginalListPrice;
    uint256 internal immutable i_weightTaxAssessedValue;
    uint256 internal immutable i_ltvInitialThreshold;
    uint256 internal immutable i_ltvLiquidationThreshold;

    mapping(uint256 tokenId => mapping(address borrower => LoanDetails)) internal s_activeLoans;

    event Borrow(
        uint256 indexed tokenId, uint256 amount, uint256 indexed loanAmount, uint256 indexed liquidationThreshold
    );
    event BorrowRepayed(uint256 indexed tokenId, uint256 indexed amount);
    event Liquidated(uint256 indexed tokenId);

    error AlreadyBorrowed(address borrower, uint256 tokenId);
    error OnlyRealEstateTokenSupported();
    error InvalidValuation();
    error SlippageToleranceExceeded();
    error PriceFeedDdosed();
    error InvalidRoundId();
    error StalePriceFeed();
    error NothingToRepay();

    constructor(
        address realEstateTokenAddress,
        address usdc,
        address usdcUsdAggregatorAddress,
        uint32 usdcUsdFeedHeartbeat
    ) {
        i_realEstateToken = RealEstateToken(realEstateTokenAddress);
        i_usdc = usdc;
        s_usdcUsdAggregator = AggregatorV3Interface(usdcUsdAggregatorAddress);
        s_usdcUsdFeedHeartbeat = usdcUsdFeedHeartbeat;

        i_weightListPrice = 50;
        i_weightOriginalListPrice = 30;
        i_weightTaxAssessedValue = 20;

        i_ltvInitialThreshold = 60;
        i_ltvLiquidationThreshold = 75;
    }

    function borrow(
        uint256 tokenId,
        uint256 amount,
        bytes memory data,
        uint256 minLoanAmount,
        uint256 maxLiquidationThreshold
    ) external nonReentrant {
        if (s_activeLoans[tokenId][msg.sender].usdcAmountLoaned != 0) revert AlreadyBorrowed(msg.sender, tokenId);

        uint256 normalizedValuation = getValuationInUsdc(tokenId) * amount / i_realEstateToken.totalSupply(tokenId);

        if (normalizedValuation == 0) revert InvalidValuation();

        uint256 loanAmount = (normalizedValuation * i_ltvInitialThreshold) / 100;
        if (loanAmount < minLoanAmount) revert SlippageToleranceExceeded();

        uint256 liquidationThreshold = (normalizedValuation * i_ltvLiquidationThreshold) / 100;
        if (liquidationThreshold > maxLiquidationThreshold) {
            revert SlippageToleranceExceeded();
        }

        i_realEstateToken.safeTransferFrom(msg.sender, address(this), tokenId, amount, data);

        s_activeLoans[tokenId][msg.sender] = LoanDetails({
            erc1155AmountSupplied: amount,
            usdcAmountLoaned: loanAmount,
            usdcLiquidationThreshold: liquidationThreshold
        });

        IERC20(i_usdc).safeTransfer(msg.sender, loanAmount);

        emit Borrow(tokenId, amount, loanAmount, liquidationThreshold);
    }

    function repay(uint256 tokenId) external nonReentrant {
        LoanDetails memory loanDetails = s_activeLoans[tokenId][msg.sender];
        if (loanDetails.usdcAmountLoaned == 0) revert NothingToRepay();

        delete s_activeLoans[tokenId][msg.sender];

        IERC20(i_usdc).safeTransferFrom(msg.sender, address(this), loanDetails.usdcAmountLoaned);

        i_realEstateToken.safeTransferFrom(address(this), msg.sender, tokenId, loanDetails.erc1155AmountSupplied, "");

        emit BorrowRepayed(tokenId, loanDetails.erc1155AmountSupplied);
    }

    function liquidate(uint256 tokenId, address borrower) external {
        LoanDetails memory loanDetails = s_activeLoans[tokenId][borrower];

        uint256 normalizedValuation =
            getValuationInUsdc(tokenId) * loanDetails.erc1155AmountSupplied / i_realEstateToken.totalSupply(tokenId);
        if (normalizedValuation == 0) revert InvalidValuation();

        uint256 liquidationThreshold = (normalizedValuation * i_ltvLiquidationThreshold) / 100;
        if (liquidationThreshold < loanDetails.usdcLiquidationThreshold) {
            delete s_activeLoans[tokenId][borrower];
        }
    }

    function getUsdcPriceInUsd() public view returns (uint256) {
        uint80 _roundId;
        int256 _price;
        uint256 _updatedAt;
        try s_usdcUsdAggregator.latestRoundData() returns (
            uint80 roundId,
            int256 price,
            uint256,
            /* startedAt */
            uint256 updatedAt,
            uint80 /* answeredInRound */
        ) {
            _roundId = roundId;
            _price = price;
            _updatedAt = updatedAt;
        } catch {
            revert PriceFeedDdosed();
        }

        if (_roundId == 0) revert InvalidRoundId();

        if (_updatedAt < block.timestamp - s_usdcUsdFeedHeartbeat) {
            revert StalePriceFeed();
        }

        return uint256(_price);
    }

    function getValuationInUsdc(uint256 tokenId) public view returns (uint256) {
        RealEstateToken.PriceDetails memory priceDetails = i_realEstateToken.getPriceDetails(tokenId);

        uint256 valuation = (
            i_weightListPrice * priceDetails.listPrice + i_weightOriginalListPrice * priceDetails.originalListPrice
                + i_weightTaxAssessedValue * priceDetails.taxAssessedValue
        ) / (i_weightListPrice + i_weightOriginalListPrice + i_weightTaxAssessedValue);

        uint256 usdcPriceInUsd = getUsdcPriceInUsd();

        uint256 feedDecimals = s_usdcUsdAggregator.decimals();
        uint256 usdcDecimals = 6; // USDC uses 6 decimals

        uint256 normalizedValuation = Math.mulDiv((valuation * usdcPriceInUsd), 10 ** usdcDecimals, 10 ** feedDecimals); // Adjust the valuation from USD (Chainlink 1e8) to USDC (1e6)

        return normalizedValuation;
    }

    function setUsdcUsdPriceFeedDetails(address usdcUsdAggregatorAddress, uint32 usdcUsdFeedHeartbeat)
        external
        onlyOwner
    {
        s_usdcUsdAggregator = AggregatorV3Interface(usdcUsdAggregatorAddress);
        s_usdcUsdFeedHeartbeat = usdcUsdFeedHeartbeat;
    }

    function onERC1155Received(
        address, /*operator*/
        address, /*from*/
        uint256, /*id*/
        uint256, /*value*/
        bytes calldata /*data*/
    ) external view returns (bytes4) {
        if (msg.sender != address(i_realEstateToken)) {
            revert OnlyRealEstateTokenSupported();
        }

        return IERC1155Receiver.onERC1155Received.selector;
    }

    function onERC1155BatchReceived(
        address, /*operator*/
        address, /*from*/
        uint256[] calldata, /*ids*/
        uint256[] calldata, /*values*/
        bytes calldata /*data*/
    ) external view returns (bytes4) {
        if (msg.sender != address(i_realEstateToken)) {
            revert OnlyRealEstateTokenSupported();
        }

        return IERC1155Receiver.onERC1155BatchReceived.selector;
    }

    function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165) returns (bool) {
        return interfaceId == type(IERC1155Receiver).interfaceId || interfaceId == type(IERC165).interfaceId;
    }
}

Let's analyze important parts of the code

Let's analyze some important parts of this smart contract.

IERC1155Receiver

To be able to receive ERC-1155 tokens, this smart contract must implement the IERC1155Receiver interface. The most important check is inside the onERC1155Received and onERC1155ReceivedBatch functions from that interface. Specifically here we ensure that msg.sender is our RealEstateToken.sol smart contract.

Initial and Liquidation Thresholds

These to values are hard-coded in this example to 60% and 75% for code simplicity reasons. That means that if Alice's real estate worths 100$ she will get 60 USDC in return as a loan. If at some point the value of her real estate drops below 75$, anyone can liquidate its position and she will lose all of hers ERC-1155 tokens. She will keep USDC though.

Slippage protection

During the borrow function Alice also needs to provide minLoanAmount and maxLiquidationThreshold values for her own protection against slippage. Since loanAmount and liquidationThreshold are calculated on-chain dynamically, Alice needs to set a boundary values that she is comfortable with, otherwise the transaction will revert.

$1 is not equal to 1 USDC

In general case, $1 is not equal to 1 USDC. The value can vary between $0.99-something and $1.01-something, therefore it is extremely dangerous to hard-code US Dollar values to 1 USDC. We must use USDC/USD Chainlink Price Feed.

The USDC/USD feed has 8 decimals, while the USDC ERC-20 token itself has 6 decimals. For instance, the feed might return 99993242, which means 1 USDC = $0.99993242. Now, we need to convert those $100 to exact amount of USDC tokens we will actually sent to/from. For that we are using the getValuationInUsdc function.

It isn't practical to push price updates to the blockchain with every block. Therefore, there are two triggers (which can be seen in the "Trigger parameters" section on data.chain.link): Price Deviation Threshold and Heartbeat. These vary from feed to feed, and developers need to be mindful of them. Essentially, DONs constantly calculate new prices and compare them with the latest one. If the price falls below or rises above the Deviation Threshold, a new report is pushed on-chain. Additionally, if the Heartbeat is, for example, 1 hour long and no new reports have been generated, a new one will be pushed regardless. Why is this important for developers? Using stale prices for on-chain actions is risky. Therefore, developers must:

  • Use the latestRoundData() function instead of latestAnswer().

  • Compare the updatedAt variable against a usdcUsdFeedHeartbeat of their choice.

As a sanity check we would also want to validate that the returned roundId is greater than zero.

Deploy RWALending.sol to Avalanche Fuji

To deploy the RWALending.sol token to Avalanche Fuji we will need to provide the following information to constructor:

  • realEstateTokenAddress: The address of the RealEstateToken.sol smart contract we previously deployed

  • usdc: 0x5425890298aed601595a70AB815c96711a31Bc65

  • usdcUsdAggregatorAddress: 0x97FE42a7E96640D932bbc0e1580c73E705A8EB73

  • usdcUsdFeedHeartbeat: 86400

Last updated