Tokenizing Real World Assets

Overview

Tokenizing real-world assets (RWAs) allows physical or legal assets such as property titles, certificates, products, or ownership rights to be represented digitally on the blockchain. By anchoring key information (ownership details, provenance, legal documents) into a tamper-proof NFT, the Real Network makes these assets transferable, auditable, and programmable. This guide explains how to create a non‑fungible token (NFT) on the Real Network that represents a real‑world asset (RWA) such as a document, product, or ownership right.

⚠️ Disclaimer: Tokenizing real‑world assets may have legal and regulatory implications. Consult legal advisors before launching a live product. This guide covers the technical workflow only.


Overview

On the Real Network (an EVM‑compatible Layer 1), you can represent an RWA by linking:

  • An NFT contract (ERC‑721) → The on‑chain representation.

  • Provenance data → Document hashes, attestations, or certificates that tie the NFT to its real‑world counterpart.

  • Metadata storage → IPFS or another decentralized file system for documents and media.


Prerequisites

  • Environment: Ubuntu 22.04 / macOS with Node.js 18+

  • Wallet: A funded account on the Real Network (testnet or mainnet)

  • Tools: Hardhat, OpenZeppelin Contracts


Project Setup

# Create project
mkdir real-rwa-nft && cd real-rwa-nft
npm init -y
npm i -D hardhat @nomicfoundation/hardhat-toolbox dotenv
npm i @openzeppelin/contracts
npx hardhat

Configure .env:

REAL_RPC_URL=https://<your-real-rpc>
PRIVATE_KEY=0x<deployer-private-key>
CHAIN_ID=<real-chain-id>

Update hardhat.config.ts:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import * as dotenv from "dotenv"; dotenv.config();

const config: HardhatUserConfig = {
  solidity: "0.8.24",
  networks: {
    real: {
      url: process.env.REAL_RPC_URL!,
      chainId: Number(process.env.CHAIN_ID || 13371),
      accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
    },
  },
};
export default config;

Metadata Schema

Use a JSON schema for provenance and asset details. Example (metadata.json):

{
  "name": "Villa A — Ownership Certificate",
  "description": "On-chain representation of the notarized title certificate for Villa A.",
  "image": "ipfs://<cid>/preview.png",
  "external_url": "https://issuer.example/records/villa-a",
  "attributes": [
    { "trait_type": "Jurisdiction", "value": "Dubai, UAE" },
    { "trait_type": "Category", "value": "Title Document" }
  ],
  "provenance": {
    "document_sha256": "0x...",
    "document_uri": "ipfs://<cid>/villa-a.pdf",
    "issued_by": "did:web:issuer.example",
    "issued_at": 1726531200,
    "attestation_sig": "0x<issuer-signature>",
    "terms_uri": "https://issuer.example/terms/v1"
  }
}

NFT Contract Example

contracts/RealAssetNFT.sol:

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

/**
 * @title RealAssetNFT
 * @notice ERC-721 contract for representing Real-World Assets (RWAs) on the Real Network.
 *         Each token anchors a tamper-evident hash of an authoritative off-chain record
 *         (e.g., a title deed PDF, certificate, bill of lading) and an IPFS/HTTPS metadata URI.
 *
 *         Key features:
 *         - On-chain "Anchor" (documentHash, issuedAt, issuer, dataURI) per tokenId
 *         - Role-based access control for minting/administration
 *         - Optional Revocation/Compliance registry integration
 *         - Pausable transfers for incident response
 *         - Upgrade-friendly storage layout (no inheritance diamonds)
 *
 * @dev Uses OpenZeppelin contracts. Link versions that match your toolchain.
 */

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC721Burnable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";

/// @notice Optional external registry for compliance and revocation checks.
interface IRevocationRegistry {
    /// @dev Returns true if a given document hash has been revoked.
    function isRevoked(bytes32 documentHash) external view returns (bool);

    /// @dev Returns true if the address is blocked from sending/receiving.
    function isBlacklisted(address account) external view returns (bool);
}

contract RealAssetNFT is ERC721, ERC721Burnable, AccessControl, Pausable {
    using Strings for uint256;

    // ----------------------------
    // Roles
    // ----------------------------
    bytes32 public constant ADMIN_ROLE  = DEFAULT_ADMIN_ROLE; // full control
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");   // can mint/update dataURI
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");   // can pause/unpause

    // ----------------------------
    // Storage
    // ----------------------------
    struct Anchor {
        bytes32 documentHash;  // keccak256/SHA-256 of authoritative document blob
        uint64  issuedAt;      // unix seconds when anchor was recorded
        address issuer;        // account that anchored the document
        string  dataURI;       // ipfs://CID/metadata.json or https://... JSON
    }

    // tokenId => Anchor
    mapping(uint256 => Anchor) private _anchors;

    // Optional global base URI (used only if per-token dataURI is empty)
    string public baseURI;

    // Optional revocation/compliance registry
    IRevocationRegistry public registry;

    // ----------------------------
    // Events
    // ----------------------------
    event Anchored(uint256 indexed tokenId, bytes32 indexed documentHash, address indexed issuer);
    event DataURIUpdated(uint256 indexed tokenId, string dataURI);
    event RegistryUpdated(address indexed newRegistry);
    event BaseURIUpdated(string newBaseURI);

    // ----------------------------
    // Constructor
    // ----------------------------
    constructor(
        string memory name_,
        string memory symbol_,
        string memory baseURI_,
        address admin
    ) ERC721(name_, symbol_) {
        baseURI = baseURI_;
        _grantRole(ADMIN_ROLE, admin);
        _grantRole(MINTER_ROLE, admin);
        _grantRole(PAUSER_ROLE, admin);
    }

    // ----------------------------
    // Admin / Config
    // ----------------------------
    function setRegistry(address _registry) external onlyRole(ADMIN_ROLE) {
        registry = IRevocationRegistry(_registry);
        emit RegistryUpdated(_registry);
    }

    function setBaseURI(string calldata _base) external onlyRole(ADMIN_ROLE) {
        baseURI = _base;
        emit BaseURIUpdated(_base);
    }

    function pause() external onlyRole(PAUSER_ROLE) { _pause(); }
    function unpause() external onlyRole(PAUSER_ROLE) { _unpause(); }

    // ----------------------------
    // Minting & Anchoring
    // ----------------------------

    /**
     * @notice Mints a new token and writes its anchor in a single transaction.
     * @param to Recipient of the NFT
     * @param tokenId Unique token identifier
     * @param documentHash keccak256/SHA-256 hash of the authoritative document
     * @param dataURI Metadata URI (ipfs:// or https://) pointing to JSON with provenance
     */
    function mintWithAnchor(
        address to,
        uint256 tokenId,
        bytes32 documentHash,
        string calldata dataURI
    ) external onlyRole(MINTER_ROLE) {
        require(documentHash != bytes32(0), "RealAssetNFT: bad hash");
        _safeMint(to, tokenId);
        _anchors[tokenId] = Anchor({
            documentHash: documentHash,
            issuedAt: uint64(block.timestamp),
            issuer: _msgSender(),
            dataURI: dataURI
        });
        emit Anchored(tokenId, documentHash, _msgSender());
        if (bytes(dataURI).length != 0) emit DataURIUpdated(tokenId, dataURI);
    }

    /**
     * @notice Batch mint helper for operational efficiency.
     */
    function batchMintWithAnchor(
        address[] calldata recipients,
        uint256[] calldata tokenIds,
        bytes32[] calldata docHashes,
        string[] calldata uris
    ) external onlyRole(MINTER_ROLE) {
        uint256 n = recipients.length;
        require(
            n == tokenIds.length && n == docHashes.length && n == uris.length,
            "RealAssetNFT: length mismatch"
        );
        for (uint256 i = 0; i < n; i++) {
            mintWithAnchor(recipients[i], tokenIds[i], docHashes[i], uris[i]);
        }
    }

    /**
     * @notice Update the metadata URI without changing the anchored hash.
     *         Useful for rotating IPFS CIDs while preserving the anchor.
     */
    function updateDataURI(uint256 tokenId, string calldata dataURI)
        external
        onlyRole(MINTER_ROLE)
    {
        require(_exists(tokenId), "RealAssetNFT: nonexistent token");
        _anchors[tokenId].dataURI = dataURI;
        emit DataURIUpdated(tokenId, dataURI);
    }

    // ----------------------------
    // Views
    // ----------------------------

    /**
     * @return Anchor struct and whether it's revoked per the registry (if set).
     */
    function anchorOf(uint256 tokenId)
        external
        view
        returns (Anchor memory a, bool revoked)
    {
        require(_exists(tokenId), "RealAssetNFT: nonexistent token");
        a = _anchors[tokenId];
        revoked = address(registry) != address(0) && registry.isRevoked(a.documentHash);
    }

    /**
     * @dev ERC721 metadata resolver. Uses per-token dataURI if present, otherwise baseURI + tokenId.
     */
    function tokenURI(uint256 tokenId) public view override returns (string memory) {
        require(_exists(tokenId), "RealAssetNFT: nonexistent token");
        string memory u = _anchors[tokenId].dataURI;
        if (bytes(u).length > 0) return u; // e.g., ipfs://CID/metadata.json
        if (bytes(baseURI).length == 0) return "";
        return string(abi.encodePacked(baseURI, tokenId.toString()));
    }

    // ----------------------------
    // Hooks & Compliance
    // ----------------------------

    function _update(address to, uint256 tokenId, address auth)
        internal
        override(ERC721)
        returns (address)
    {
        // Pause guard
        require(!paused(), "RealAssetNFT: paused");

        // Compliance checks (if registry set)
        if (address(registry) != address(0)) {
            Anchor memory a = _anchors[tokenId];
            require(!registry.isRevoked(a.documentHash), "RealAssetNFT: anchor revoked");
            if (auth != address(0)) {
                require(!registry.isBlacklisted(auth), "RealAssetNFT: sender blocked");
            }
            if (to != address(0)) {
                require(!registry.isBlacklisted(to), "RealAssetNFT: recipient blocked");
            }
        }
        return super._update(to, tokenId, auth);
    }

    // Required by Solidity for multiple inheritance
    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721, AccessControl)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

Deployment Script

scripts/deploy.ts:

import { ethers } from "hardhat";

async function main() {
  const C = await ethers.getContractFactory("RealAssetNFT");
  const c = await C.deploy("Real Asset NFT", "RWA", "ipfs://");
  await c.deployed();
  console.log("Deployed RealAssetNFT at:", c.address);
}

main().catch((e) => { console.error(e); process.exit(1); });

Deploy:

npx hardhat run scripts/deploy.ts --network real

Minting Example

scripts/mint.ts:

import { ethers } from "hardhat";

async function main() {
  const contractAddr = "<DEPLOYED_CONTRACT>";
  const [signer] = await ethers.getSigners();
  const C = await ethers.getContractAt("RealAssetNFT", contractAddr);

  const tokenId = 1;
  const documentHash = ethers.utils.keccak256(Buffer.from("villa-a.pdf"));
  const dataURI = "ipfs://<cid>/metadata.json";

  const tx = await C.connect(signer).mintWithAnchor(signer.address, tokenId, documentHash, dataURI);
  await tx.wait();
  console.log("Minted token #", tokenId);
}

main().catch(console.error);

Run:

npx hardhat run scripts/mint.ts --network real

Verification and Usage

  • Check on Explorer: Use Real Explorer (https://explorer.reallayer.com) to view token details

  • Read anchor: Call anchorOf(tokenId) to confirm provenance data.

  • Resolve metadata: Load tokenURI(tokenId) to fetch IPFS JSON.


Next Steps

  • Integrate revocation registry to invalidate expired/forged assets.

  • Add role-based access control (AccessControl) for multiple issuers.

  • Extend to ERC‑1155 if representing fungible + non‑fungible hybrids.

Last updated