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:
- Institutional demand ā BlackRock's BUIDL fund, Franklin Templeton's BENJI, and Ondo Finance have proven tokenized Treasuries work at scale.
- Regulatory clarity ā The GENIUS Act (US) and UK tokenization frameworks gave institutions the green light.
- 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
Install OpenZeppelin contracts:
npm install @openzeppelin/contracts
Step 1: Project Setup
mkdir rwa-token && cd rwa-token
npx hardhat init
# Choose: Create a JavaScript project
Your folder structure will look like:
rwa-token/
āāā contracts/
ā āāā RWAToken.sol
āāā scripts/
ā āāā deploy.js
āāā test/
ā āāā RWAToken.test.js
āāā hardhat.config.js
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;
}
}
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;
});
Deploy to the Sepolia testnet:
npx hardhat run scripts/deploy.js --network sepolia
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);
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;
});
});
Run tests:
npx hardhat test
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
}
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) ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Security Checklist Before Mainnet
Before you deploy with real money on the line, make sure you've covered:
- [ ] Reentrancy guards ā use
ReentrancyGuardfrom OpenZeppelin if you add payment logic - [ ] Access control audit ā every
onlyOwnerfunction 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
TransparentUpgradeableProxyif 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
- OpenZeppelin Contracts
- Chainlink Documentation
- Hardhat Documentation
- ERC-1400 Security Token Standard
- RWA.xyz ā Track Real-World Assets On-Chain
- Ondo Finance ā Tokenized Treasuries
Top comments (1)
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.