Exercise 1: Cross-Chain Real Estate

The full source code for this boot camp is available at: https://github.com/andrejrakic/rwa-primer

Calling The Zillow API

Before creating our tokens, let's look at where we will source our data.

For this example, we'll be using Zillow's API

This is a test API. For more information about the API, please refer to the Zillow Documentation.

Let's start with our JavaScript function to get the information we need to create the NFT.

For our NFT's metadata, we need to obtain the following:

  • Property Address

  • Year Build

  • Lot Size

  • Living Area

  • Number of Bedrooms

We can use the API to obtain this information. We'll use the Functions Playground to test our JavaScript before adding it to our contract.

// Import the ethers library from npm
const { ethers } = await import("npm:ethers@6.10.0");
const Hash = await import("npm:ipfs-only-hash@4.0.0");

// Make an HTTP request to fetch real estate data
const apiResponse = await Functions.makeHttpRequest({
  url: `https://api.bridgedataoutput.com/api/v2/OData/test/Property('P_5dba1fb94aa4055b9f29696f')?access_token=6baca547742c6f96a6ff71b138424f21`,
});

// Extract relevant data from the API response
const realEstateAddress = apiResponse.data.UnparsedAddress;
const yearBuilt = Number(apiResponse.data.YearBuilt);
const lotSizeSquareFeet = Number(apiResponse.data.LotSizeSquareFeet);
const livingArea = Number(apiResponse.data.LivingArea);
const bedroomsTotal = Number(apiResponse.data.BedroomsTotal);

const metadata = {
  name: "Real Estate Token",
  attributes: [
    { trait_type: "realEstateAddress", value: realEstateAddress },
    { trait_type: "yearBuilt", value: yearBuilt },
    { trait_type: "lotSizeSquareFeet", value: lotSizeSquareFeet },
    { trait_type: "livingArea", value: livingArea },
    { trait_type: "bedroomsTotal", value: bedroomsTotal }
  ]
};

// Stringify the JSON object
const metadataString = JSON.stringify(metadata);

const ipfsCid = await Hash.of(metadataString);
console.log(ipfsCid);

return Functions.encodeString(`ipfs://${ipfsCid}`);

Pricing Information

We'll also gather the pricing information from the same API with a separate call:

// Import ethers library
const { ethers } = await import("npm:ethers@6.10.0");

// Create ABI coder for data encoding
const abiCoder = ethers.AbiCoder.defaultAbiCoder();

// Get token ID from input arguments
const tokenId = args[0];

// Fetch property data from API
const apiResponse = await Functions.makeHttpRequest({
  url: `https://api.bridgedataoutput.com/api/v2/OData/test/Property('P_5dba1fb94aa4055b9f29696f')?access_token=6baca547742c6f96a6ff71b138424f21`,
});

// Extract and convert pricing data to numbers
const listPrice = Number(apiResponse.data.ListPrice);
const originalListPrice = Number(apiResponse.data.OriginalListPrice);
const taxAssessedValue = Number(apiResponse.data.TaxAssessedValue);

// Encode data for NFT use
const encoded = abiCoder.encode(
  [`uint256`, `uint256`, `uint256`, `uint256`],
  [tokenId, listPrice, originalListPrice, taxAssessedValue]
);

// Log data for verification
console.log(
  `Token ID: ${tokenId} \nList Price: ${listPrice} \nOriginal List Price: ${originalListPrice} \nTax Assessed Value: ${taxAssessedValue}`
);

// Return encoded data as bytes
return ethers.getBytes(encoded);

Storing Our Function On-chain

Our JavaScrip is ready to go. We will now create our first smart contract to store the code on-chain.

Create a contract named FunctionsSource.sol

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

/**
 * 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.
 */
abstract contract FunctionsSource {
    string public getNftMetadata =
        "const { ethers } = await import('npm:ethers@6.10.0');"
        "const Hash = await import('npm:ipfs-only-hash@4.0.0');"
        "const apiResponse = await Functions.makeHttpRequest({"
        "    url: `https://api.bridgedataoutput.com/api/v2/OData/test/Property('P_5dba1fb94aa4055b9f29696f')?access_token=6baca547742c6f96a6ff71b138424f21`,"
        "});"
        "const realEstateAddress = apiResponse.data.UnparsedAddress;"
        "const yearBuilt = Number(apiResponse.data.YearBuilt);"
        "const lotSizeSquareFeet = Number(apiResponse.data.LotSizeSquareFeet);"
        "const livingArea = Number(apiResponse.data.LivingArea);"
        "const bedroomsTotal = Number(apiResponse.data.BedroomsTotal);"
        "const metadata = {"
        "name: `Real Estate Token`,"
        "attributes: ["
        "{ trait_type: `realEstateAddress`, value: realEstateAddress },"
        "{ trait_type: `yearBuilt`, value: yearBuilt },"
        "{ trait_type: `lotSizeSquareFeet`, value: lotSizeSquareFeet },"
        "{ trait_type: `livingArea`, value: livingArea },"
        "{ trait_type: `bedroomsTotal`, value: bedroomsTotal }"
        "]"
        "};"
        "const metadataString = JSON.stringify(metadata);"
        "const ipfsCid = await Hash.of(metadataString);"
        "return Functions.encodeString(`ipfs://${ipfsCid}`);";

    string public getPrices =
        "const { ethers } = await import('npm:ethers@6.10.0');"
        "const abiCoder = ethers.AbiCoder.defaultAbiCoder();"
        "const tokenId = args[0];"
        "const apiResponse = await Functions.makeHttpRequest({"
        "    url: `https://api.bridgedataoutput.com/api/v2/OData/test/Property('P_5dba1fb94aa4055b9f29696f')?access_token=6baca547742c6f96a6ff71b138424f21`,"
        "});"
        "const listPrice = Number(apiResponse.data.ListPrice);"
        "const originalListPrice = Number(apiResponse.data.OriginalListPrice);"
        "const taxAssessedValue = Number(apiResponse.data.TaxAssessedValue);"
        "const encoded = abiCoder.encode([`uint256`, `uint256`, `uint256`, `uint256`], [tokenId, listPrice, originalListPrice, taxAssessedValue]);"
        "return ethers.getBytes(encoded);";
}

Storing JavaScript in a smart contract can be tricky. Please note:

  • Protect each line with a pair of"s

  • 's and `s are used in the code to escape strings

  • It is possible to store all of the JavaScript in a single line, but you must ensure proper; placement

Using Factory Pattern to issue ERC-1155 tokens

Our tokenized asset project consists of multiple different smart contract that we need to develop during this boot camp. You can see the whole architecture in the picture below. The Chainlink services we will use for each of them are marked with blue letters.

Create ERC1155Core.sol

First of all, we would need to create the ERC1155Core.sol file. This smart contract holds all the logic related to ERC-1155 standard, plus the custom mint and mintBatch functions that we will use for the initial issue and cross-chain transfers via CCIP later.

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

import {ERC1155Supply, ERC1155} from "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol";
import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.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 ERC1155Core is ERC1155Supply, OwnerIsCreator {
    address internal s_issuer;

    // Optional mapping for token URIs
    mapping(uint256 tokenId => string) private _tokenURIs;

    event SetIssuer(address indexed issuer);

    error ERC1155Core_CallerIsNotIssuerOrItself(address msgSender);

    modifier onlyIssuerOrItself() {
        if (msg.sender != address(this) && msg.sender != s_issuer) {
            revert ERC1155Core_CallerIsNotIssuerOrItself(msg.sender);
        }
        _;
    }

    // Used as the URI for all token types by relying on ID substitution, e.g. https://token-cdn-domain/{id}.json
    constructor(string memory uri_) ERC1155(uri_) {}

    function setIssuer(address _issuer) external onlyOwner {
        s_issuer = _issuer;

        emit SetIssuer(_issuer);
    }

    function mint(address _to, uint256 _id, uint256 _amount, bytes memory _data, string memory _tokenUri)
        public
        onlyIssuerOrItself
    {
        _mint(_to, _id, _amount, _data);
        _tokenURIs[_id] = _tokenUri;
    }

    function mintBatch(
        address _to,
        uint256[] memory _ids,
        uint256[] memory _amounts,
        bytes memory _data,
        string[] memory _tokenUris
    ) public onlyIssuerOrItself {
        _mintBatch(_to, _ids, _amounts, _data);
        for (uint256 i = 0; i < _ids.length; ++i) {
            _tokenURIs[_ids[i]] = _tokenUris[i];
        }
    }

    function burn(address account, uint256 id, uint256 amount) public onlyIssuerOrItself {
        if (account != _msgSender() && !isApprovedForAll(account, _msgSender())) {
            revert ERC1155MissingApprovalForAll(_msgSender(), account);
        }

        _burn(account, id, amount);
    }

    function burnBatch(address account, uint256[] memory ids, uint256[] memory amounts) public onlyIssuerOrItself {
        if (account != _msgSender() && !isApprovedForAll(account, _msgSender())) {
            revert ERC1155MissingApprovalForAll(_msgSender(), account);
        }

        _burnBatch(account, ids, amounts);
    }

    function uri(uint256 tokenId) public view override returns (string memory) {
        string memory tokenURI = _tokenURIs[tokenId];

        return bytes(tokenURI).length > 0 ? tokenURI : super.uri(tokenId);
    }

    function _setURI(uint256 tokenId, string memory tokenURI) internal {
        _tokenURIs[tokenId] = tokenURI;
        emit URI(uri(tokenId), tokenId);
    }
}

Create CrossChainBurnAndMintERC1155.sol

This contract will inherit the ERC1155Core.sol and expand its basic ERC-1155 functionality to support cross-chain transfers via Chainlink CCIP.

Any non-fungible token is implemented by a smart contract that is intrinsically connected to a single blockchain. This means that any cross-chain NFT implementation requires at least two smart contracts on two blockchains and interconnection between them.

With this in mind, cross-chain NFTs can be implemented in three ways:

  • Burn-and-mint: An NFT owner puts their NFT into a smart contract on the source chain and burns it, in effect removing it from that blockchain. Once this is done, an equivalent NFT is created on the destination blockchain from its corresponding smart contract. This process can occur in both directions.

  • Lock-and-mint: An NFT owner locks their NFT into a smart contract on the source chain, and an equivalent NFT is created on the destination blockchain. When the owner wants to move their NFT back, they burn the NFT and it unlocks the NFT on the original blockchain.

  • Lock and unlock: The same NFT collection is minted on multiple blockchains. An NFT owner can lock their NFT on a source blockchain to unlock the equivalent NFT on a destination blockchain. This means only a single NFT can actively be used at any point in time, even if there are multiple instances of that NFT across blockchains.

We are going to implement the Burn-and-Mint mechanism.

In each scenario, a cross-chain messaging protocol in the middle is necessary to send data instructions from one blockchain to another.

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

import {ERC1155Core, ERC1155} from "./ERC1155Core.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";
import {IAny2EVMMessageReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IAny2EVMMessageReceiver.sol";
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/shared/interfaces/LinkTokenInterface.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Withdraw} from "./utils/Withdraw.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 CrossChainBurnAndMintERC1155 is ERC1155Core, IAny2EVMMessageReceiver, ReentrancyGuard, Withdraw {
    enum PayFeesIn {
        Native,
        LINK
    }

    error InvalidRouter(address router);
    error NotEnoughBalanceForFees(uint256 currentBalance, uint256 calculatedFees);
    error ChainNotEnabled(uint64 chainSelector);
    error SenderNotEnabled(address sender);
    error OperationNotAllowedOnCurrentChain(uint64 chainSelector);

    struct XNftDetails {
        address xNftAddress;
        bytes ccipExtraArgsBytes;
    }

    IRouterClient internal immutable i_ccipRouter;
    LinkTokenInterface internal immutable i_linkToken;
    uint64 private immutable i_currentChainSelector;

    mapping(uint64 destChainSelector => XNftDetails xNftDetailsPerChain) public s_chains;

    event ChainEnabled(uint64 chainSelector, address xNftAddress, bytes ccipExtraArgs);
    event ChainDisabled(uint64 chainSelector);
    event CrossChainSent(
        address from,
        address to,
        uint256 id,
        uint256 amount,
        bytes data,
        uint64 sourceChainSelector,
        uint64 destinationChainSelector
    );
    event CrossChainReceived(
        address from,
        address to,
        uint256 id,
        uint256 amount,
        bytes data,
        uint64 sourceChainSelector,
        uint64 destinationChainSelector
    );

    modifier onlyRouter() {
        if (msg.sender != address(i_ccipRouter)) {
            revert InvalidRouter(msg.sender);
        }
        _;
    }

    modifier onlyEnabledChain(uint64 _chainSelector) {
        if (s_chains[_chainSelector].xNftAddress == address(0)) {
            revert ChainNotEnabled(_chainSelector);
        }
        _;
    }

    modifier onlyEnabledSender(uint64 _chainSelector, address _sender) {
        if (s_chains[_chainSelector].xNftAddress != _sender) {
            revert SenderNotEnabled(_sender);
        }
        _;
    }

    modifier onlyOtherChains(uint64 _chainSelector) {
        if (_chainSelector == i_currentChainSelector) {
            revert OperationNotAllowedOnCurrentChain(_chainSelector);
        }
        _;
    }

    constructor(string memory uri_, address ccipRouterAddress, address linkTokenAddress, uint64 currentChainSelector)
        ERC1155Core(uri_)
    {
        i_ccipRouter = IRouterClient(ccipRouterAddress);
        i_linkToken = LinkTokenInterface(linkTokenAddress);
        i_currentChainSelector = currentChainSelector;
    }

    function enableChain(uint64 chainSelector, address xNftAddress, bytes memory ccipExtraArgs)
        external
        onlyOwner
        onlyOtherChains(chainSelector)
    {
        s_chains[chainSelector] = XNftDetails({xNftAddress: xNftAddress, ccipExtraArgsBytes: ccipExtraArgs});

        emit ChainEnabled(chainSelector, xNftAddress, ccipExtraArgs);
    }

    function disableChain(uint64 chainSelector) external onlyOwner onlyOtherChains(chainSelector) {
        delete s_chains[chainSelector];

        emit ChainDisabled(chainSelector);
    }

    function crossChainTransferFrom(
        address from,
        address to,
        uint256 id,
        uint256 amount,
        bytes memory data,
        uint64 destinationChainSelector,
        PayFeesIn payFeesIn
    ) external nonReentrant onlyEnabledChain(destinationChainSelector) returns (bytes32 messageId) {
        string memory tokenUri = uri(id);
        burn(from, id, amount);

        Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
            receiver: abi.encode(s_chains[destinationChainSelector].xNftAddress),
            data: abi.encode(from, to, id, amount, data, tokenUri),
            tokenAmounts: new Client.EVMTokenAmount[](0),
            extraArgs: s_chains[destinationChainSelector].ccipExtraArgsBytes,
            feeToken: payFeesIn == PayFeesIn.LINK ? address(i_linkToken) : address(0)
        });

        // Get the fee required to send the CCIP message
        uint256 fees = i_ccipRouter.getFee(destinationChainSelector, message);

        if (payFeesIn == PayFeesIn.LINK) {
            if (fees > i_linkToken.balanceOf(address(this))) {
                revert NotEnoughBalanceForFees(i_linkToken.balanceOf(address(this)), fees);
            }

            // Approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK
            i_linkToken.approve(address(i_ccipRouter), fees);

            // Send the message through the router and store the returned message ID
            messageId = i_ccipRouter.ccipSend(destinationChainSelector, message);
        } else {
            if (fees > address(this).balance) {
                revert NotEnoughBalanceForFees(address(this).balance, fees);
            }

            // Send the message through the router and store the returned message ID
            messageId = i_ccipRouter.ccipSend{value: fees}(destinationChainSelector, message);
        }

        emit CrossChainSent(from, to, id, amount, data, i_currentChainSelector, destinationChainSelector);
    }

    function supportsInterface(bytes4 interfaceId) public view override(ERC1155) returns (bool) {
        return interfaceId == type(IAny2EVMMessageReceiver).interfaceId || super.supportsInterface(interfaceId);
    }

    /// @inheritdoc IAny2EVMMessageReceiver
    function ccipReceive(Client.Any2EVMMessage calldata message)
        external
        virtual
        override
        onlyRouter
        nonReentrant
        onlyEnabledChain(message.sourceChainSelector)
        onlyEnabledSender(message.sourceChainSelector, abi.decode(message.sender, (address)))
    {
        uint64 sourceChainSelector = message.sourceChainSelector;
        (address from, address to, uint256 id, uint256 amount, bytes memory data, string memory tokenUri) =
            abi.decode(message.data, (address, address, uint256, uint256, bytes, string));

        mint(to, id, amount, data, tokenUri);

        emit CrossChainReceived(from, to, id, amount, data, sourceChainSelector, i_currentChainSelector);
    }
}

If you try to compile this contract, you will see a compilation error. This is because there is an import to the Withdraw.sol smart contract from the utils folder. Since we will fund the CrossChainBurnAndMintERC1155.sol smart contract with LINK tokens for CCIP fees, the Withdraw.sol smart contract will hold a logic for withdrawal any ERC-20 token and native coins from this smart contract.

Create Withdraw.sol smart contract in the ./utils folder.

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

import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.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 Withdraw is OwnerIsCreator {
    using SafeERC20 for IERC20;

    error NothingToWithdraw();
    error FailedToWithdrawEth(address owner, address target, uint256 value);

    function withdraw(address _beneficiary) public onlyOwner {
        uint256 amount = address(this).balance;

        if (amount == 0) revert NothingToWithdraw();

        (bool sent,) = _beneficiary.call{value: amount}("");

        if (!sent) revert FailedToWithdrawEth(msg.sender, _beneficiary, amount);
    }

    function withdrawToken(address _beneficiary, address _token) public onlyOwner {
        uint256 amount = IERC20(_token).balanceOf(address(this));

        if (amount == 0) revert NothingToWithdraw();

        IERC20(_token).safeTransfer(_beneficiary, amount);
    }
}

Create RealEstatePriceDetails.sol

This helper smart contract will be used to periodically get price details of our real-world assets using Chainlink Automation and Chainlink Functions combined. For it to function, we have already created the JavaScript script and put its content into the FunctionsSource.sol smart contract.

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

import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {FunctionsClient} from "@chainlink/contracts/src/v0.8/functions/v1_0_0/FunctionsClient.sol";
import {FunctionsRequest} from "@chainlink/contracts/src/v0.8/functions/v1_0_0/libraries/FunctionsRequest.sol";
import {FunctionsSource} from "./FunctionsSource.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 RealEstatePriceDetails is FunctionsClient, FunctionsSource, OwnerIsCreator {
    using FunctionsRequest for FunctionsRequest.Request;

    struct PriceDetails {
        uint80 listPrice;
        uint80 originalListPrice;
        uint80 taxAssessedValue;
    }

    address internal s_automationForwarderAddress;

    mapping(uint256 tokenId => PriceDetails) internal s_priceDetails;

    error OnlyAutomationForwarderOrOwnerCanCall();

    modifier onlyAutomationForwarderOrOwner() {
        if (msg.sender != s_automationForwarderAddress && msg.sender != owner()) {
            revert OnlyAutomationForwarderOrOwnerCanCall();
        }
        _;
    }

    constructor(address functionsRouterAddress) FunctionsClient(functionsRouterAddress) {}

    function setAutomationForwarder(address automationForwarderAddress) external onlyOwner {
        s_automationForwarderAddress = automationForwarderAddress;
    }

    function updatePriceDetails(string memory tokenId, uint64 subscriptionId, uint32 gasLimit, bytes32 donID)
        external
        onlyAutomationForwarderOrOwner
        returns (bytes32 requestId)
    {
        FunctionsRequest.Request memory req;
        req.initializeRequestForInlineJavaScript(this.getPrices());

        string[] memory args = new string[](1);
        args[0] = tokenId;

        req.setArgs(args);

        requestId = _sendRequest(req.encodeCBOR(), subscriptionId, gasLimit, donID);
    }

    function getPriceDetails(uint256 tokenId) external view returns (PriceDetails memory) {
        return s_priceDetails[tokenId];
    }

    function fulfillRequest(bytes32, /*requestId*/ bytes memory response, bytes memory err) internal override {
        if (err.length != 0) {
            revert(string(err));
        }

        (uint256 tokenId, uint256 listPrice, uint256 originalListPrice, uint256 taxAssessedValue) =
            abi.decode(response, (uint256, uint256, uint256, uint256));

        s_priceDetails[tokenId] = PriceDetails({
            listPrice: uint80(listPrice),
            originalListPrice: uint80(originalListPrice),
            taxAssessedValue: uint80(taxAssessedValue)
        });
    }
}

Create RealEstateToken.sol

And finally, let's create our main smart contract which just inherits all of the above contracts.

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

import {CrossChainBurnAndMintERC1155} from "./CrossChainBurnAndMintERC1155.sol";
import {RealEstatePriceDetails} from "./RealEstatePriceDetails.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 RealEstateToken is CrossChainBurnAndMintERC1155, RealEstatePriceDetails {
    /**
     *
     *  ██████╗ ███████╗ █████╗ ██╗         ███████╗███████╗████████╗ █████╗ ████████╗███████╗    ████████╗ ██████╗ ██╗  ██╗███████╗███╗   ██╗
     *  ██╔══██╗██╔════╝██╔══██╗██║         ██╔════╝██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝    ╚══██╔══╝██╔═══██╗██║ ██╔╝██╔════╝████╗  ██║
     *  ██████╔╝█████╗  ███████║██║         █████╗  ███████╗   ██║   ███████║   ██║   █████╗         ██║   ██║   ██║█████╔╝ █████╗  ██╔██╗ ██║
     *  ██╔══██╗██╔══╝  ██╔══██║██║         ██╔══╝  ╚════██║   ██║   ██╔══██║   ██║   ██╔══╝         ██║   ██║   ██║██╔═██╗ ██╔══╝  ██║╚██╗██║
     *  ██║  ██║███████╗██║  ██║███████╗    ███████╗███████║   ██║   ██║  ██║   ██║   ███████╗       ██║   ╚██████╔╝██║  ██╗███████╗██║ ╚████║
     *  ╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝╚══════╝    ╚══════╝╚══════╝   ╚═╝   ╚═╝  ╚═╝   ╚═╝   ╚══════╝       ╚═╝    ╚═════╝ ╚═╝  ╚═╝╚══════╝╚═╝  ╚═══╝
     *
     */
    constructor(
        string memory uri_,
        address ccipRouterAddress,
        address linkTokenAddress,
        uint64 currentChainSelector,
        address functionsRouterAddress
    )
        CrossChainBurnAndMintERC1155(uri_, ccipRouterAddress, linkTokenAddress, currentChainSelector)
        RealEstatePriceDetails(functionsRouterAddress)
    {}
}

Deploy RealEstateToken.sol to Avalanche Fuji

Make sure to turn the Solidity compiler optimizer on to 200 runs and set EVM version to "Paris".

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

  • uri_: "" (this is the base ERC-1155 token URI, we will leave it empty)

  • ccipRouterAddress: 0xF694E193200268f9a4868e4Aa017A0118C9a8177

  • linkTokenAddress: 0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846

  • currentChainSelector: 14767482510784806043

  • functionsRouterAddress: 0xA9d587a00A31A52Ed70D6026794a8FC5E2F5dCb0

Create Issuer.sol

As we already mentioned, the Issuance part is out of scope for this bootcamp. So, for simplicity we will just mint a mock version of real estate in a form of ERC-1155 tokens directly to Alice's address for the purpose of this exercise.

To accomplish that, we will use the Issuer.sol helper smart contract.

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

import {RealEstateToken} from "./RealEstateToken.sol";
import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {FunctionsClient} from "@chainlink/contracts/src/v0.8/functions/v1_0_0/FunctionsClient.sol";
import {FunctionsRequest} from "@chainlink/contracts/src/v0.8/functions/v1_0_0/libraries/FunctionsRequest.sol";
import {FunctionsSource} from "./FunctionsSource.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 Issuer is FunctionsClient, FunctionsSource, OwnerIsCreator {
    using FunctionsRequest for FunctionsRequest.Request;

    error LatestIssueInProgress();

    struct FractionalizedNft {
        address to;
        uint256 amount;
    }

    RealEstateToken internal immutable i_realEstateToken;

    bytes32 internal s_lastRequestId;
    uint256 private s_nextTokenId;

    mapping(bytes32 requestId => FractionalizedNft) internal s_issuesInProgress;

    constructor(address realEstateToken, address functionsRouterAddress) FunctionsClient(functionsRouterAddress) {
        i_realEstateToken = RealEstateToken(realEstateToken);
    }

    function issue(address to, uint256 amount, uint64 subscriptionId, uint32 gasLimit, bytes32 donID)
        external
        onlyOwner
        returns (bytes32 requestId)
    {
        if (s_lastRequestId != bytes32(0)) revert LatestIssueInProgress();

        FunctionsRequest.Request memory req;
        req.initializeRequestForInlineJavaScript(this.getNftMetadata());
        requestId = _sendRequest(req.encodeCBOR(), subscriptionId, gasLimit, donID);

        s_issuesInProgress[requestId] = FractionalizedNft(to, amount);

        s_lastRequestId = requestId;
    }

    function cancelPendingRequest() external onlyOwner {
        s_lastRequestId = bytes32(0);
    }

    function fulfillRequest(bytes32 requestId, bytes memory response, bytes memory err) internal override {
        if (err.length != 0) {
            revert(string(err));
        }

        if (s_lastRequestId == requestId) {
            string memory tokenURI = string(response);

            uint256 tokenId = s_nextTokenId++;
            FractionalizedNft memory fractionalizedNft = s_issuesInProgress[requestId];

            i_realEstateToken.mint(fractionalizedNft.to, tokenId, fractionalizedNft.amount, "", tokenURI);

            s_lastRequestId = bytes32(0);
        }
    }
}

Deploy Issuer.sol to Avalanche Fuji

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

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

  • functionsRouterAddress: 0xA9d587a00A31A52Ed70D6026794a8FC5E2F5dCb0

Call the setIssuer function of the RealEstateToken.sol smart contract

On Avalanche Fuji, call the setIssuer function of the RealEstateToken.sol smart contract and provide:

  • _issuer: The address of the Issuer.sol smart contract you previously deployed

You use a Chainlink Functions subscription to pay for, manage, and track Functions requests.

  1. Click Connect wallet:

  2. Read and accept the Chainlink Foundation Terms of Service. Then click MetaMask.

  3. Make sure your wallet is connected to the Avalanche Fuji testnet. If not, click the network name in the top right corner of the page and select Avalanche Fuji.

  4. Click Create Subscription:

  5. Provide an email address and a subscription name

  6. Approve the subscription creation

  7. After the subscription is created, the Functions UI prompts you to fund your subscription. Click Add funds and provide 10 LINK:

  8. After you fund your subscription, add Issuer.sol and RealEstateToken.sol smart contracts as consumers to it.

  9. Remember your Subscription ID

Call the issue function of the Issuer.sol smart contract

To issue an ERC-1155 token to Alice, call the issue function of the Issuer.sol smart contract from the address you used to deploy that contract, and provide:

  • to: Alice's wallet address (put any address you own)

  • amount: 20

  • subscriptionId: The Chainlink Functions Subscription ID you just created

  • gasLimit: 300000

  • donID: 0x66756e2d6176616c616e6368652d66756a692d31000000000000000000000000 (fun-avalanche-fuji-1)

  1. In the Chainlink Automation App, click the blue Register new Upkeep button

  2. Select Time-based trigger

  3. Provide the address of the RealEstateToken.sol smart contract as a "Target contract address"

  4. After you have successfully entered your contract address and ABI, specify your time schedule in the form of a CRON expression. We will call the updatePriceDetails every 24 hours. The CRON expression we need to provide is 0 0 * * * (this expression means that the function will be triggered every day at 00:00h)

  5. Provide "Upkeep name" of your choice. Provide 300000 as "Gas limit". Set 5 LINK tokens as "Starting balance (LINK)"

  6. Click Register upkeep and confirm the transaction in MetaMask

Call the setAutomationForwarder function of the RealEstateToken.sol smart contract

Each registered upkeep under the Chainlink Automation network has its own unique Forwarder contract. You can find its address from your Chainlink Automation Upkeep dashboard.

Call the setAutomationForwarder function of the RealEstateToken.sol smart contract and provide:

  • automationForwarderAddress: The address of the Forwarder contract

Enable Cross-chain transfers

To enable cross-chain transfers follow next steps:

  1. Deploy the RealEstateToken.sol smart contract on new blockchain

  2. Call the enableChain function and provide chainSelector, xNftAddress and ccipExtraArgs for each of other blockchains you have deployed the RealEstateToken.sol smart contract previously. At this point, that's only Avalanche Fuji.

  3. On all other blockchains you have deployed the RealEstateToken.sol smart contract previously (currently only Ethereum Sepolia) call the enableChain function and provide chainSelector, xNftAddress and ccipExtraArgs of the new blockchain you've just deployed new RealEstateToken.sol smart contract to.

Last updated