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
RPC URL: http://20.63.3.101:8545
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 hardhatConfigure .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 realMinting 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 realVerification 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