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:
RWA Lending & Borrowing
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:
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.
Working with Chainlink Data Feeds - Decimals
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.
Working with Chainlink Data Feeds - Price update frequency
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 oflatestAnswer().
Compare the
updatedAt
variable against ausdcUsdFeedHeartbeat
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 deployedusdc:
0x5425890298aed601595a70AB815c96711a31Bc65
usdcUsdAggregatorAddress:
0x97FE42a7E96640D932bbc0e1580c73E705A8EB73
usdcUsdFeedHeartbeat:
86400
Last updated