Projects
Integration Examples
RWA Tokenization

Use-Case Example: RWA Tokenization

Tokenize a Real Estate Project by Leveraging the ERC-7208

Introduction to tokenization

What is tokenization?

Tokenization is the creation of a digital version of an asset in the blockchain space, represented by an on-chain token. This process involves abstracting various essential features of an asset in the blockchain space, often encompassing:

  • Ownership: The token owner corresponds to the current owner of the asset.
  • Rights: Intellectual property (IP) rights and the right to fair asset use.
  • Rules and Regulations: Legal compliance requirements dictating the responsibilities associated with the asset.
  • Physical Characteristics: Capturing the tangible attributes of the asset (if any).
  • Value: Capturing the inherent value of the asset.
  • Business Logic: Underlying rules and processes associated with the asset's operations (if any).

The ERC-7208 (opens in a new tab) effectively facilitates the tokenization of both real-world and financial assets, as it capitalizes on on-chain data management, interoperability, and abstraction. It introduces On-chain Data Cointainers (ODCs), which serve as on-chain mutable storage entities that can have both mutable data and metadata and Property Managers (PMs), which are specialized smart contracts that manage data within ODCs.

img

Getting Started

We will create a semi-realistic sample project to see the ERC-7208 features in action. In this tutorial, a construction company wants to tokenize an appartment building complex. The scope of this example will be limited to the following:

  • bundling all individual apartments into a single complex
  • storing some of the construction project's data on-chain
  • integrating the asset's business logic with its digital counterpart

It's important to note that in a realistic scenario, the tokenization of Real World Assets (RWAs) usually involves at least three main phases:

  1. Pre-Issuance Phase: The comprehensive information about the asset is gathered, considering the required regulatory framework. Here is where the necessary paperwork is prepared to ensure compliance, and where all legal preparations take place.
  2. Issuance Phase: During this phase, the asset is brought on-chain. This process can involve emitting a digital twin or deploying and configuring smart contracts that will create the token representing the asset digitally.
  3. Post-Issuance Phase: Following the issuance, the digital asset undergoes various transitions in its life cycle. In this phase it is critical to take into consideration all regulations, rights and obligations. For example, actions such as transfer, staking, or fractionalization will have different impacts on the life cycle of the asset, depending on the context.

In the case of the following example, we are omitting the first and last phases and concentrating solely on demonstrating the capabilities of the ERC-7208 for the digital representation of an asset. If you wish to explore the complete end-to-end process, we invite you to visit our playground at playground.nexeraprotocol.com (opens in a new tab).

About the use of ODCs

Employing On-Chain Data Containers and Property Managers

ODCs can store arbitrary amounts of information in the form of Properties managed by PMs. We will represent the asset on-chain by a digital twin encoded as an ODC, incorporating and encapsulating some of the real-world asset's data and features. There are two main components:

  • The ODC, acting as a digital twin and storing information about the asset (the WHAT)
  • The PM, acting as the logic and implementation of the use-case (the HOW)

In the upcoming sections, we will show you a simplified walkthrough of a customized PM tailored to the abovementioned tasks. If you want to follow along, the source code can be found here (opens in a new tab), including smart contracts and tests for the following implementations.


🛈

In a real-world scenario, Property Managers must be registered within a Category for security reasons. This is handled within the MetaPropertyRegistry, which facilitates the recording and organization of Properties and oversees the assignment of their designated managers.

Defining and Setting a Property

Properties are indexed by bytes32 keys attached to ODC unique IDs, representing specific data stored, depending on the use case. In our theoretical example, the associated Property is defined as the keccak256 hash of the "Apartments-Complex" string literal:

bytes32 internal _prop = keccak256("Apartments-Complex");

Property keys are unique within a Category. Therefore, this key (i.e., _prop) serves as the unique ID of the Property to be associated with the project's use-case data. Accessing the Property key is facilitated through a straightforward getter function:

function prop() public view returns (bytes32) {
    return _prop;
}

Note: Although indexed by a simple key, a single Property can store a lot of information. In this example, the PM inherits from the BaseSinglePropertyManager (opens in a new tab) smart contract, opting for the utilization of a single Property. Complex scenarios may require the use of multiple Properties and, therefore, the use of BaseMultiPropertyManager (opens in a new tab).

Storing Property data

Properties offer a flexible structure for storing data in various formats, including bytes32 key-value pairs (mappings), sets of bytes32 values, and individual bytes32 and bytes data types. Properties' stored data points are identified and organized by bytes32 primary keys, effectively acting as tags and facilitating the organization and identification of information within the storage system. A detailed overview of this storage mechanism can be found here.

In our example, the information to be stored within the ODC is the following:

  • Number of apartments within the complex
  • Physical address of the building
  • Construction status
  • Price per apartment

To this end, we have defined and assigned the following keys:

/// @dev Number of Apartments :: formatBytes32String("NumberOfApartments")
bytes32 internal constant _key1 = 0x4e756d6265724f6641706172746d656e74730000000000000000000000000000;
/// @dev Physical Address :: formatBytes32String("Address") 
bytes32 internal constant _key2 = 0x4164647265737300000000000000000000000000000000000000000000000000;
/// @dev Construction Status :: formatBytes32String("ConstructionStatus")
bytes32 internal constant _key3 = 0x436f6e737472756374696f6e5374617475730000000000000000000000000000;
/// @dev Apartment Price :: formatBytes32String("ApartmentPrice")
bytes32 internal constant _key4 = 0x41706172746d656e745072696365000000000000000000000000000000000000;

The values assigned to these keys are computed using ethers.utils.formatBytes32String(). We adopted this method in favor of hashing the respective string literals, as it enables the conversion of the key back to its string representation. This flexibility will be beneficial in the metadata generation process. More information about the formatBytes32String() utility method can be found on the EthersJS documentation (opens in a new tab).

We will be using the bytes format for storing the use-case data, as it is a suitable representation for storing individual pieces of data. The MetaProperties (opens in a new tab) facet of the ODC Diamond contract exposes the setDataBytes() function, storing data as bytes under a designated primary key within a specific Property of a target ODC token.

The following function in the PM invokes the setDataBytes() on the ODC contract for each primary key and its associated specified data to facilitate storing the use-case digital representation within the Property.

/// @dev ouid The ID of the ODC
function _setData(
    uint256 ouid,
    uint256 numberOfApartments,
    uint256 apartmentPrice,
    string calldata addressOfComplex,
    string calldata status
) internal {
    _odc.setDataBytes(ouid, _prop, _key1, bytes(StringNumberUtils.fromUint256(numberOfApartments, 0, 0, false)));
    _odc.setDataBytes(ouid, _prop, _key2, bytes(addressOfComplex));
    _odc.setDataBytes(ouid, _prop, _key3, bytes(status));
    _odc.setDataBytes(ouid, _prop, _key4, bytes(StringNumberUtils.fromUint256(apartmentPrice, 0, 0, false)));
}

And here is the external function that calls the internal setData():

/// @dev ouid The ID of the ODC
function setData(
    uint256 ouid,
    uint256 numberOfApartments,
    uint256 apartmentPrice,
    string calldata addressOfComplex,
    string calldata status
) external onlyAdmin {
    _setData(ouid, numberOfApartments, apartmentPrice, addressOfComplex, status);
}

The StringNumberUtils (opens in a new tab) is a utility library developed by Nexera, featuring functions for converting numbers into strings.

To query the stored data, we utilize the getDataBytes() function exposed from the MetaProperties (opens in a new tab) facet of the ODC Diamond contract. The following function in the PM retrieves the data stored as bytes under the defined primary keys within the Property of a target ODC.

/// @dev ouid The ID of the ODC
function getData(uint256 ouid) external view returns (bytes[] memory) {
    bytes[] memory data = new bytes[](4);
    data[0] = _odc.getDataBytes(ouid, _prop, _key1);
    data[1] = _odc.getDataBytes(ouid, _prop, _key2);
    data[2] = _odc.getDataBytes(ouid, _prop, _key3);
    data[3] = _odc.getDataBytes(ouid, _prop, _key4);
    return data;
}

Minting an ODC token to represent the asset

Since we have implemented the base logic for storing the use-case data within ODCs, the next step is to mint an ODC representing the RWA on-chain. We can use the setData() from the previous section for storing information.

The following function resides in the PM, and will be used for:

  • Minting an ODC token to the PM by invoking the mint() function exposed from the MetaToken (opens in a new tab) facet of the ODC Diamond contract. This function returns the unique ID of the newly minted ODC.
  • Adding the managed Property to the newly minted ODC by invoking the addProperty() function exposed from the MetaProperties (opens in a new tab) facet.
  • The isRestricted input parameter indicates whether to enforce a Transfer Restriction to the ODC token. An overview of Restrictions is available here.
function addNewApartmentComplex(bool isRestricted) external onlyAdmin {
    uint256 ouid = _odc.mint(address(this));
    if (isRestricted) {
        IMetaRestrictions.Restriction[] memory restrictions = new IMetaRestrictions.Restriction[](1);
        restrictions[0] = IMetaRestrictions.Restriction({rtype: RestrictionTypes.TRANSFER_RESTRICTION, data: ""});
        _odc.addProperty(ouid, _prop, restrictions);
    } else {
        _odc.addProperty(ouid, _prop, new IMetaRestrictions.Restriction[](0));
    }
    emit NewComplexAdded(ouid);
}

🛈

To mint an ODC, the caller (e.g., the PM) must be registered as a minter in the MetaPropertyRegistry. In this particular scenario, the owner of the ODC will be the PM contract itself, as there are no ownership requirements whatsoever.

Generating metadata

If we wanted to have static metadata (i.e., like the ones on any traditional NFT), we could store an http link to the uri within a Property. But metadata can be generated based on the stored data within the Property(ies) of an ODC. To implement the metadata generation use-case logic, we override the generateMetadata() function of the parent BaseSinglePropertyManager (opens in a new tab).

Our PM will override the generateMetadata() function to perform the following tasks:

  • Retrieve all the primary keys for the data stored within the managed Property of the specified ODC. This is achieved by invoking the getAllKeys() function exposed from the MetaProperties (opens in a new tab) facet. We only cache the associated primary keys for the data stored as bytes since the PM exclusively stores data in this format.
  • Instantiate an array of Metadata.StringProperty structures to store the metadata information. The length of this array is determined by the number of primary keys retrieved in the previous step. The Metadata (opens in a new tab) library is developed by Nexera and provides essential functionalities for generating metadata.
  • Iterate through each primary key, fetching the associated data stored as bytes by invoking the getDataBytes() function exposed from the MetaProperties (opens in a new tab) facet. For each key-value pair, a Metadata.StringProperty struct member is populated accordingly: the string representation of the key populates the "name" field, and the string representation of the value populates the "value" field.
  • Return the populated Metadata.ExtraProperties struct containing the array of Metadata.StringProperty structs that encapsulate the generated metadata information for the specified ODC.
/// @notice Generates Metadata for a specified ODC based on the data stored within the managed Property
/// @dev `prop_` parameter is commented out since this PM only manages a single property (i.e., `_prop`)
/// @param ouid The ID of the ODC
function generateMetadata(
    bytes32 /*prop_*/,
    uint256 ouid
) public view virtual override returns (Metadata.ExtraProperties memory ep) {
    // Cache the primary keys for the data stored as bytes
    (, bytes32[] memory keys, , ) = _odc.getAllKeys(ouid, _prop);
    ep.stringProperties = new Metadata.StringProperty[](keys.length);
    for (uint256 i; i < keys.length; ) {
        bytes32 key = keys[i];
        bytes memory value = _odc.getDataBytes(ouid, _prop, key);
        ep.stringProperties[i] = Metadata.StringProperty({
            // `_bytesToString()` internal function  converts a bytes value
            // to its string representation by removing unused bytes.
            name: _bytesToString(abi.encodePacked(key)),
            value: _bytesToString(value)
        });
        unchecked {
            i++;
        }
    }
    return ep;
}

In a realistic scenario, the metadata would be used for storing a lot of the information generated in the pre-issuance phase. Keeping live updates of the asset's metadata is crucial for this scenario, as it enables to link the legally binding contract between the RWA and its digital representation.

Adding more features

Now that we've explored the gist of it, the potential for customization is vast, allowing for various enhancements. More intricate details can bring our use case closer to the real-world asset representation and so we will explore the integration of its business logic. Following this path, we can introduce the following enums, structs, and state variables:

enum ComplexStatus {
    NON_EXISTENT, // Non-Existent Complex (i.e., not tokenized yet)
    UNDER_CONSTRUCTION, // Complex Under Construction (i.e., tokenized under transfer restriction)
    CONSTRUCTED //  Complex Constructed (i.e., fractionalized into its apartments)
}

Now we have scenarios where the company has not started construction, where the building is under contruction, and when construction has finalized and apartments are ready for sale.

struct Complex {
    // The current status of Complex
    ComplexStatus status;
    // The number of Apartments the Complex has
    uint256 numberOfApartments;
    // The ID of the ODC representing the Complex
    uint256 ouid;
    // The associated ERC-721 Fraction Token contract (ERC721Minter) 
    IFractionsContract fractionsContract;
    // The index of the restriction that prohibits the transfer of the ODC
    // while Apartment Complex is at `UNDER_CONSTRUCTION` status
    uint256 rIdx;
    // The price of each apartment
    uint256 pricePerApartment;
}

Now we include a couple of more data points that will be reflecting information related to the business logic. So far, we are only making the PM more complex, by developing the logic that will handle the stored data (ODC).

/// @dev counter for the amount of Apartment Complexes created (i.e., tokenized)
uint256 public complexCount;
/// @dev The ADMIN
address private _admin;
/// @dev The Fractionalizer contract (ERC721FractionType)
IFractionalizerERC721 private _fractionalizer;
/// @dev The designated ERC-20 payment token for purchasing fractions
IERC20 public fundingCurrency; 
/// @notice  Apartment Complex ID => Apartment Complex Data
mapping(uint256 => Complex) public apartmentComplexes;

The above additions introduce the project's fractionalization by employing the Fractionalizer (opens in a new tab) module.
In this process, the ODC, serving as the digital representation of the asset, is divided into a collection of tokens (fractions). Each token in the collection represents a fraction of the original asset, explicitly corresponding to an individual apartment in the building. These fractions can be commercialized, offering a flexible and efficient way to manage and trade ownership of specific units within the complex.

With the previous enhancements, we update the addNewApartmentComplex() function with additional mechanics.

  • We first increase the counter for the number of buildings.
  • We mint an ODC, and cache its unique ID (ouid).
  • We add the managed Property to the newly minted ODC and enforce a Transfer Restriction to it.
  • We store the Property data and update the state variables accordingly.
function addNewApartmentComplex(
    uint256 numberOfApartments,
    string calldata addressOfComplex,
    uint256 pricePerApartment
) external
  onlyAdmin
  returns (uint256 ouid, uint256[] memory rIdxs) {
    complexCount++;
    ouid = _odc.mint(address(this));
    IMetaRestrictions.Restriction[] memory restrictions = new IMetaRestrictions.Restriction[](1);
    restrictions[0] = IMetaRestrictions.Restriction({rtype: RestrictionTypes.TRANSFER_RESTRICTION, data: ""});
    rIdxs = _odc.addProperty(ouid, _prop, restrictions);
    _setData(ouid, numberOfApartments, addressOfComplex, pricePerApartment, "Under Construction");
    apartmentComplexes[complexCount].status = ComplexStatus.UNDER_CONSTRUCTION;
    apartmentComplexes[complexCount].ouid = ouid;
    apartmentComplexes[complexCount].numberOfApartments = numberOfApartments;
    apartmentComplexes[complexCount].pricePerApartment = pricePerApartment;
    apartmentComplexes[complexCount].rIdx = rIdxs[0];
}

We can now introduce the setConstructed() function, which provides a mechanism to update the construction status of a given apartment complex. Upon invocation, it verifies that the target complex is currently in the "UNDER_CONSTRUCTION" state. If so, it updates the complex's status to "CONSTRUCTED," reflecting the completion of construction. Simultaneously, it updates the associated data stored in the managed Property, removes the transfer restriction of the associated ODC, and initiates the fractionalization process.

function setConstructed(uint256 complexId) external onlyAdmin {
    Complex storage complex = apartmentComplexes[complexId];
        
    if (complex.status == ComplexStatus.UNDER_CONSTRUCTION) {
        complex.status = ComplexStatus.CONSTRUCTED;
        _odc.setDataBytes(complex.ouid, _prop, _key3, bytes("Constructed"));
        // Remove transfer restriction of associated ODC
        _odc.removeRestriction(complex.ouid, _prop, complex.rIdx);
        // Fractionalize the ODC (i.e., tokenize the respective Apartments)
        _createFractions(complexId);
    } else {
        revert WRONG_STATUS();
    }
}

The initiation of the fractionalization process begins with the invocation of _createFractions(). The underlying approach to this function unfolds as follows:

  • In this example, each apartment will be represented by an individual ERC-721 fraction from the ODC. The number of apartments within the respective complex determines the IDs of the fractions to be minted.
  • The Fractionalizer is approved for the targeted ODC.
  • The fractions are issued by invoking the createNewErc721Fractions() function in the Fractionalizer contract. This entails the deployment of a specialized ERC-721 Fraction contract, followed by the transfer and locking of the ODC within this contract. Subsequently, the deployed ERC-721 Fraction contract mints the specified tokens to the Property Manager.
function _createNewFractions(uint256 complexId) private {
    Complex storage complex = apartmentComplexes[complexId];
    uint256 numberOfFractions = complex.numberOfApartments;
    uint256[] memory idsToMint = new uint256[](numberOfFractions);
    for (uint256 i; i < numberOfFractions;) {
        idsToMint[i] = i + 1;
        unchecked {
            i++;
        }
    }
    string memory id = StringNumberUtils.fromUint256(complexId, 0, 0, false);
    string memory name_ = string(abi.encodePacked("X_ApartmentsComplex-", id));
    string memory symbol_ = string(abi.encodePacked("XAC", id)); 
    // Approve Fractionalizer for ODC
    _odc.approve(address(_fractionalizer),  complex.ouid);
    // Create the fractions
    address fractions = _fractionalizer.createNewErc721Fractions(
        name_, // name of the ERC721 fraction token collection
        symbol_, // symbol of the ERC721 fraction token collection
        "", // should put baseUri
        idsToMint,
        complex.ouid, // The ID of the ODC to fractionalize
        address(this) // receiver of fractions
    );
    complex.fractionsContract = IFractionsContract(fractions);
}

For an in-depth understanding of the Fractionalizer module and its underlying processes, please refer to the Fractionalizer Documentation.

We introduce the buyApartmentFromComplex() function to facilitate the purchase of fractions. The underlying approach to this function unfolds as follows:

  • Basic sanitization checks are performed, verifying that the targeted apartment complex is in the "CONSTRUCTED" state (i.e., fractionalized) and that the specified apartment ID (i.e., ERC-721 fraction token ID) is valid.
  • Ensures that the Property Manager owns the targeted fraction, indicating its availability for purchase.
  • Safely transfers the respective amount of designated ERC-20 funding currency from the buyer (msg.sender) to the Admin.
  • Safely transfers ownership of the specified fraction from the Property Manager to the buyer.
function buyApartmentFromComplex(uint256 complexId, uint256 apartmentId) public {
    Complex memory complex = apartmentComplexes[complexId];
    if (complex.status == ComplexStatus.CONSTRUCTED) {
        if (apartmentId > complex.numberOfApartments || apartmentId == 0) revert WRONG_APARTMENT_ID();
        if (complex.fractionsContract.ownerOf(apartmentId) != address(this)) revert APARTMENT_SOLD();
        fundingCurrency.safeTransferFrom(msg.sender, _admin, complex.pricePerApartment);
        complex.fractionsContract.safeTransferFrom(address(this), msg.sender, apartmentId);
            
    } else {
        revert WRONG_STATUS();
    }
}

Other possibilities

The presented implementation provides a straightforward illustration of the ERC-7208 features within a specific use case. However, the design flexibility allows for the incorporation of additional features, such as capturing details like the number of bedrooms or apartment square footage. The option to utilize more than one Property further enhances the modeling capabilities.

Furthermore, ODCs extend beyond the tokenization of RWAs, demonstrating their flexibility in abstracting logic from storage. Departing from traditional off-chain approaches, efficient on-chain data storage opens up numerous possibilities. This potential is significantly enhanced when leveraging complementary modules like the Fractionalizer, Wrapper, and other components within the Nexera Protocol. The synergistic integration of these elements enables the creation of diverse and compelling scenarios, extending the utility of ODCs beyond conventional boundaries.