DEV Community

Cover image for How to Build a Real-World Asset Tokenization Contract on Ethereum (2026 Guide)
Soumabha Mahapatra
Soumabha Mahapatra

Posted on

How to Build a Real-World Asset Tokenization Contract on Ethereum (2026 Guide)

Imagine owning a fraction of a skyscraper in Dubai, a Picasso painting, or a US Treasury bond - from your phone, with $50. That's not science fiction anymore. It's what Real-World Asset (RWA) tokenization is doing right now, and in 2026, it's the hottest space in all of blockchain.

Total on-chain RWA value has surpassed $373 billion as of early 2026. BlackRock, Fidelity, JPMorgan - they're all here. And the smart contracts making this happen? They're built by developers like you.

In this guide, you'll learn what RWA tokenization actually is, how it works under the hood, and how to write a production-ready ERC-20 tokenization contract on Ethereum from scratch.

Let's build. šŸš€


What Is Real-World Asset (RWA) Tokenization?

RWA tokenization is the process of creating a blockchain-based digital token that represents ownership or a claim on a physical or traditional financial asset.

Think of it like this:

A building worth $10,000,000 → split into 10,000,000 tokens → each token = $1 of ownership

These tokens live on-chain. They can be:

  • Transferred peer-to-peer globally
  • Traded on decentralized exchanges
  • Used as DeFi collateral
  • Held in any Ethereum wallet

The asset itself stays in the real world (a custodian holds it), but ownership rights are tracked transparently on the blockchain.


Why Is This Exploding in 2026?

Three forces are colliding:

  1. Institutional demand — BlackRock's BUIDL fund, Franklin Templeton's BENJI, and Ondo Finance have proven tokenized Treasuries work at scale.
  2. Regulatory clarity — The GENIUS Act (US) and UK tokenization frameworks gave institutions the green light.
  3. Better infrastructure — Layer-2 networks (Arbitrum, Base) make gas fees negligible.

Core Components of an RWA Token

Before writing any Solidity, understand the three pillars of every RWA contract:

Component What it does
ERC-20 Token Represents fractional ownership on-chain
Access Control KYC/AML — only whitelisted wallets can hold tokens
Oracle / Custodian Bridge Links on-chain token to off-chain asset value/proof

Prerequisites

Make sure you have these ready:

node -v       # v18+ required
npm install -g hardhat
Enter fullscreen mode Exit fullscreen mode

Install OpenZeppelin contracts:

npm install @openzeppelin/contracts
Enter fullscreen mode Exit fullscreen mode

Step 1: Project Setup

mkdir rwa-token && cd rwa-token
npx hardhat init
# Choose: Create a JavaScript project
Enter fullscreen mode Exit fullscreen mode

Your folder structure will look like:

rwa-token/
ā”œā”€ā”€ contracts/
│   └── RWAToken.sol
ā”œā”€ā”€ scripts/
│   └── deploy.js
ā”œā”€ā”€ test/
│   └── RWAToken.test.js
└── hardhat.config.js
Enter fullscreen mode Exit fullscreen mode

Step 2: Writing the RWA Token Contract

This is the heart of everything. Our contract will:

  • āœ… Be an ERC-20 token (fungible, transferable)
  • āœ… Have a whitelist (only KYC-verified wallets)
  • āœ… Store asset metadata on-chain
  • āœ… Allow the admin to mint/burn tokens (representing asset lifecycle)
  • āœ… Emit events for every key action
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";

/**
 * @title RWAToken
 * @dev ERC-20 token representing fractional ownership of a real-world asset.
 *      Only whitelisted (KYC-verified) addresses can hold or transfer tokens.
 */
contract RWAToken is ERC20, Ownable, Pausable {

    // ─────────────────────────────────────────────
    //  Asset Metadata
    // ─────────────────────────────────────────────

    struct AssetInfo {
        string assetType;       // e.g. "Real Estate", "US Treasury", "Commodity"
        string assetName;       // e.g. "Dubai Tower Block A"
        uint256 totalValue;     // Total asset value in USD (in cents to avoid decimals)
        string custodian;       // Legal custodian holding the real-world asset
        string legalDocHash;    // IPFS hash of legal ownership document
        bool isActive;
    }

    AssetInfo public asset;

    // ─────────────────────────────────────────────
    //  Whitelist (KYC/AML Compliance)
    // ─────────────────────────────────────────────

    mapping(address => bool) private _whitelist;

    event AddressWhitelisted(address indexed account);
    event AddressRemovedFromWhitelist(address indexed account);

    // ─────────────────────────────────────────────
    //  Asset Events
    // ─────────────────────────────────────────────

    event AssetValueUpdated(uint256 oldValue, uint256 newValue);
    event TokensMinted(address indexed to, uint256 amount);
    event TokensBurned(address indexed from, uint256 amount);

    // ─────────────────────────────────────────────
    //  Constructor
    // ─────────────────────────────────────────────

    constructor(
        string memory tokenName,
        string memory tokenSymbol,
        string memory _assetType,
        string memory _assetName,
        uint256 _totalValue,
        string memory _custodian,
        string memory _legalDocHash
    ) ERC20(tokenName, tokenSymbol) Ownable(msg.sender) {
        asset = AssetInfo({
            assetType: _assetType,
            assetName: _assetName,
            totalValue: _totalValue,
            custodian: _custodian,
            legalDocHash: _legalDocHash,
            isActive: true
        });
    }

    // ─────────────────────────────────────────────
    //  Whitelist Management
    // ─────────────────────────────────────────────

    /**
     * @dev Add an address to the whitelist after KYC verification.
     */
    function addToWhitelist(address account) external onlyOwner {
        require(account != address(0), "RWA: zero address");
        require(!_whitelist[account], "RWA: already whitelisted");
        _whitelist[account] = true;
        emit AddressWhitelisted(account);
    }

    /**
     * @dev Remove an address from the whitelist (e.g., sanction hit).
     */
    function removeFromWhitelist(address account) external onlyOwner {
        require(_whitelist[account], "RWA: not whitelisted");
        _whitelist[account] = false;
        emit AddressRemovedFromWhitelist(account);
    }

    function isWhitelisted(address account) public view returns (bool) {
        return _whitelist[account];
    }

    // ─────────────────────────────────────────────
    //  Mint & Burn (Asset Lifecycle)
    // ─────────────────────────────────────────────

    /**
     * @dev Mint tokens to a whitelisted address.
     *      Called when new investor buys into the asset.
     */
    function mint(address to, uint256 amount) external onlyOwner whenNotPaused {
        require(_whitelist[to], "RWA: recipient not whitelisted");
        _mint(to, amount);
        emit TokensMinted(to, amount);
    }

    /**
     * @dev Burn tokens from a whitelisted address.
     *      Called when investor exits or asset is liquidated.
     */
    function burn(address from, uint256 amount) external onlyOwner whenNotPaused {
        _burn(from, amount);
        emit TokensBurned(from, amount);
    }

    // ─────────────────────────────────────────────
    //  Asset Value Update (Oracle Feed)
    // ─────────────────────────────────────────────

    /**
     * @dev Update the real-world asset value.
     *      In production, this would be called by a Chainlink oracle.
     */
    function updateAssetValue(uint256 newValue) external onlyOwner {
        uint256 oldValue = asset.totalValue;
        asset.totalValue = newValue;
        emit AssetValueUpdated(oldValue, newValue);
    }

    // ─────────────────────────────────────────────
    //  Compliance: Override ERC-20 Transfer
    // ─────────────────────────────────────────────

    /**
     * @dev Override transfer hooks to enforce whitelist on every transfer.
     *      This runs before every mint, burn, and transfer.
     */
    function _update(
        address from,
        address to,
        uint256 amount
    ) internal override whenNotPaused {
        // Allow mint (from = zero address) and burn (to = zero address)
        if (from != address(0) && to != address(0)) {
            require(_whitelist[from], "RWA: sender not whitelisted");
            require(_whitelist[to], "RWA: recipient not whitelisted");
        }
        super._update(from, to, amount);
    }

    // ─────────────────────────────────────────────
    //  Emergency Pause
    // ─────────────────────────────────────────────

    function pause() external onlyOwner { _pause(); }
    function unpause() external onlyOwner { _unpause(); }

    // ─────────────────────────────────────────────
    //  Token Price View
    // ─────────────────────────────────────────────

    /**
     * @dev Returns the current value per token in USD cents.
     *      E.g., if asset = $10M and supply = 10M tokens → $1 per token
     */
    function tokenValueUSD() public view returns (uint256) {
        uint256 supply = totalSupply();
        if (supply == 0) return 0;
        return asset.totalValue / supply;
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Deployment Script

// scripts/deploy.js
const { ethers } = require("hardhat");

async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("Deploying with account:", deployer.address);

  const RWAToken = await ethers.getContractFactory("RWAToken");

  const token = await RWAToken.deploy(
    "Dubai Tower Token",          // Token name
    "DTT",                        // Token symbol
    "Real Estate",                // Asset type
    "Dubai Marina Tower Block A", // Asset name
    1_000_000_000_00,             // $10,000,000.00 in cents
    "Emirates REIT Custodians",   // Custodian
    "QmXyz123...ipfsHash"         // Legal doc IPFS hash
  );

  await token.waitForDeployment();
  const address = await token.getAddress();

  console.log("RWAToken deployed to:", address);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

Deploy to the Sepolia testnet:

npx hardhat run scripts/deploy.js --network sepolia
Enter fullscreen mode Exit fullscreen mode

Step 4: Interacting with the Contract

Here's a quick script to whitelist an investor and mint them tokens:

// scripts/onboard-investor.js
const { ethers } = require("hardhat");

const CONTRACT_ADDRESS = "0xYourDeployedAddress";
const INVESTOR_ADDRESS = "0xInvestorWalletAddress";

async function main() {
  const token = await ethers.getContractAt("RWAToken", CONTRACT_ADDRESS);

  // Step 1: Whitelist the investor (after KYC passes off-chain)
  const whitelistTx = await token.addToWhitelist(INVESTOR_ADDRESS);
  await whitelistTx.wait();
  console.log("Investor whitelisted āœ…");

  // Step 2: Mint tokens (1000 tokens = $1,000 stake in the asset)
  const mintTx = await token.mint(
    INVESTOR_ADDRESS,
    ethers.parseUnits("1000", 18)
  );
  await mintTx.wait();
  console.log("1000 DTT minted to investor āœ…");

  // Step 3: Check token value
  const value = await token.tokenValueUSD();
  console.log("Current token value (USD cents):", value.toString());
}

main().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Step 5: Writing Tests

Never deploy financial contracts without tests. Here's a solid test suite:

// test/RWAToken.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("RWAToken", function () {
  let token, owner, investor, stranger;

  beforeEach(async function () {
    [owner, investor, stranger] = await ethers.getSigners();

    const RWAToken = await ethers.getContractFactory("RWAToken");
    token = await RWAToken.deploy(
      "Test Asset Token", "TAT",
      "Real Estate", "Test Building",
      1_000_000_00, "Test Custodian", "QmTestHash"
    );
  });

  it("should deploy with correct asset metadata", async function () {
    const asset = await token.asset();
    expect(asset.assetName).to.equal("Test Building");
    expect(asset.totalValue).to.equal(1_000_000_00);
  });

  it("should only allow whitelisted addresses to receive tokens", async function () {
    await expect(
      token.mint(investor.address, ethers.parseUnits("100", 18))
    ).to.be.revertedWith("RWA: recipient not whitelisted");
  });

  it("should mint tokens to whitelisted investor", async function () {
    await token.addToWhitelist(investor.address);
    await token.mint(investor.address, ethers.parseUnits("100", 18));
    expect(await token.balanceOf(investor.address)).to.equal(
      ethers.parseUnits("100", 18)
    );
  });

  it("should block transfer to non-whitelisted address", async function () {
    await token.addToWhitelist(investor.address);
    await token.mint(investor.address, ethers.parseUnits("100", 18));

    await expect(
      token.connect(investor).transfer(stranger.address, ethers.parseUnits("50", 18))
    ).to.be.revertedWith("RWA: recipient not whitelisted");
  });

  it("should update asset value correctly", async function () {
    await token.updateAssetValue(2_000_000_00);
    const asset = await token.asset();
    expect(asset.totalValue).to.equal(2_000_000_00);
  });

  it("should pause all transfers when paused", async function () {
    await token.addToWhitelist(investor.address);
    await token.pause();
    await expect(
      token.mint(investor.address, ethers.parseUnits("100", 18))
    ).to.be.reverted;
  });
});
Enter fullscreen mode Exit fullscreen mode

Run tests:

npx hardhat test
Enter fullscreen mode Exit fullscreen mode

Step 6: Connecting to a Chainlink Price Oracle (Production Pattern)

In production, you don't want the admin manually updating asset values. You connect to a Chainlink oracle:

import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

AggregatorV3Interface internal priceFeed;

constructor(...) {
    // Chainlink ETH/USD feed on mainnet
    priceFeed = AggregatorV3Interface(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419);
}

function getLatestAssetPrice() public view returns (int) {
    (, int price, , , ) = priceFeed.latestRoundData();
    return price; // 8 decimals
}
Enter fullscreen mode Exit fullscreen mode

For real estate or commodity assets, you'd use a custom Chainlink Any API job or a Chainlink Functions call to fetch valuations from an off-chain data provider.


Architecture Overview

Here's the full system architecture of a production RWA platform:

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│              INVESTOR (Web3 Wallet)          │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
                  │ buy / transfer / redeem
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│          RWAToken.sol (Ethereum)             │
│  • ERC-20 token                              │
│  • Whitelist enforcement                     │
│  • Mint / Burn controls                      │
│  • Asset metadata storage                   │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
        │                     │
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”    ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  KYC Service  │    │  Chainlink Oracle  │
│  (off-chain)  │    │  (asset valuation) │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜    ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
        │
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│          Legal Custodian                     │
│  (holds the physical / financial asset)      │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
Enter fullscreen mode Exit fullscreen mode

Security Checklist Before Mainnet

Before you deploy with real money on the line, make sure you've covered:

  • [ ] Reentrancy guards — use ReentrancyGuard from OpenZeppelin if you add payment logic
  • [ ] Access control audit — every onlyOwner function should be documented and reviewed
  • [ ] Multisig for ownership — replace EOA owner with a Gnosis Safe multisig
  • [ ] Emergency pause — already in our contract āœ…
  • [ ] Professional audit — hire Trail of Bits, Certik, or OpenZeppelin Audits for real assets
  • [ ] Test on Sepolia/Holesky — run for at least 2 weeks before mainnet
  • [ ] Upgrade proxy pattern — use OpenZeppelin's TransparentUpgradeableProxy if you need upgradability

What's Next? Level Up Your RWA Contract

Once your basic tokenization is working, here are the natural extensions to explore:

1. ERC-1400 (Security Token Standard)
The ERC-1400 standard adds partition-based transfers and regulatory hooks — better suited for institutional-grade security tokens.

2. Dividend Distribution
Add a distributeDividends() function that proportionally sends yield to all token holders — perfect for tokenized bonds or real estate rental income.

3. Soulbound KYC NFT
Instead of a simple mapping whitelist, issue a non-transferable ERC-5484 "soulbound" KYC NFT to investors. The token contract checks for NFT ownership instead.

4. Cross-chain with LayerZero or CCIP
Deploy your token on multiple chains using Chainlink CCIP or LayerZero's OFT (Omnichain Fungible Token) standard for cross-chain liquidity.

5. Layer-2 Deployment
Reduce gas costs dramatically by deploying on Arbitrum, Base, or Polygon. Same Solidity code, fraction of the cost.


Key Takeaways

  • RWA tokenization converts real-world assets into ERC-20 tokens on Ethereum
  • Compliance (KYC/whitelist) is non-negotiable - enforce it at the contract level
  • Always store legal documentation references (IPFS hashes) on-chain for auditability
  • Use Chainlink oracles for reliable, tamper-proof asset valuations
  • Never skip testing and auditing - these contracts hold real financial value

The $373 billion on-chain RWA market is still in its early innings. The developers building this infrastructure today are shaping the future of global finance.

Now go build something. šŸ—ļø


Resources


Top comments (1)

Collapse
 
bridgexapi profile image
BridgeXAPI

Nice breakdown.

What surprised us while building monitoring infrastructure was how quickly systems move beyond the contract itself.

Writing the ERC-20 is straightforward.

Explaining provenance, ownership history, custody changes, valuation updates and operational context is where things become interesting.

The token is usually the smallest part of the system.