A hands-on Blockchain DIY guide to creating a fictional real-world asset token with allowlisted transfers, asset metadata, document hashes, mocked valuation updates, and investor-style dashboard logic.
Real-world asset tokenization is one of the most important practical use cases in blockchain.
The idea is simple: take an off-chain asset — such as real estate, treasury bills, invoices, carbon credits, commodities, art, private credit, or fund units — and represent some economic or ownership-related interest on-chain through tokens.
The implementation is not simple.
A real-world asset does not become legally owned by someone just because a smart contract says so. A tokenized asset needs legal agreements, custodians, issuer controls, compliance rules, investor verification, asset servicing, pricing data, dispute processes, redemption workflows, and clear off-chain documentation. The blockchain token is only one part of the system.
That is exactly why students should build a testnet RWA demo.
A good demo helps you understand what tokenization actually requires beyond minting a normal ERC-20 token. In this tutorial, you will build a fictional RWA token system that includes:
- a mock real-world asset profile;
- a token representing fractional participation in that asset;
- verified investor allowlisting;
- restricted transfers;
- issuer-controlled minting;
- compliance-controlled pause and verification;
- document-hash storage;
- mocked valuation updates;
- investor balance tracking;
- redemption-request events;
- deployment and interaction scripts;
- testing exercises;
- production-risk discussion.
This is not a real investment product. It is not legal advice. It does not create ownership in any actual asset. It is a student project designed to teach how real-world asset tokenization systems are structured.
This article is part of Blockgeni’s Blockchain DIY series. For market context, Blockgeni has also covered why tokenization is part of crypto’s infrastructure era, how tokenized funds challenge stablecoins, and why tokenized stocks on Hyperliquid signal a new RWA market shift.
1. What You Will Build
You will build a fictional tokenized real-world asset called:
Blockgeni Testnet Warehouse Token
Symbol:
BTWT
The fictional asset:
- Asset type: warehouse;
- Location: demo-only jurisdiction;
- Mock asset value:
$100,000; - Token supply:
100,000 BTWT; - Demo assumption:
1 BTWT = 1 simulated asset unit; - Network: local Hardhat network first, then optional Sepolia/Base Sepolia testnet;
- Legal status: no real ownership, no real asset, no financial value.
The token contract will support:
- Issuer role
Can mint tokens. - Compliance role
Can verify investors, pause transfers, and unpause transfers. - Valuation role
Can update a mocked asset valuation. - Verified investor list
Only verified wallets can receive or transfer tokens. - Asset metadata
Stores asset name, asset type, jurisdiction, document hash, and valuation. - Redemption request event
Investors can request redemption, but actual redemption is off-chain and not automatic.
This gives students a realistic mental model: RWA tokenization is not only “mint token and trade.” It is a controlled asset-recording and compliance system.
2. Why a Normal ERC-20 Token Is Not Enough for RWA
A normal ERC-20 token is freely transferable. Anyone can receive it. Anyone can send it. That works for many crypto-native tokens.
It does not work for many real-world asset tokens.
A tokenized private credit note, real estate share, money-market fund unit, or invoice claim may need rules such as:
- only verified investors can hold the token;
- some jurisdictions are restricted;
- transfers may need issuer approval;
- tokens may need to be frozen during legal disputes;
- redemption may require off-chain settlement;
- asset documentation must be auditable;
- valuation updates may come from an oracle or issuer;
- investors may need KYC or accreditation checks;
- the issuer must maintain a legal register.
That is why RWA tokenization often uses permissioned token standards and controlled transfer logic. ERC-3643, for example, is designed around permissioned-token issuance and management for compliant RWA use cases. This tutorial does not implement full ERC-3643. Instead, it builds a simplified educational version so students can understand the underlying design pattern.
3. The Important Legal Warning
Before coding, understand this clearly:
A token is not automatically the asset.
If you tokenize a building, invoice, bond, or fund, the token only has meaning if a valid legal structure connects the token to the real-world claim.
That structure may include:
- a company or trust;
- legal contracts;
- asset custody documents;
- investor agreements;
- transfer restrictions;
- regulatory disclosures;
- tax documentation;
- redemption rules;
- dispute-resolution mechanisms;
- jurisdiction-specific compliance.
For this tutorial, we are using a fictional asset. The smart contract stores a document hash, but that hash does not prove legal ownership. It only proves that a certain document existed in a certain form when the hash was stored.
This is the first major lesson of RWA development: blockchain can improve transparency and automation, but it does not remove the need for legal infrastructure.
4. System Architecture
Our RWA demo has five layers:
Off-chain asset file
|
| hash document
v
Smart contract metadata
|
| mint token supply
v
Verified investor wallets
|
| restricted transfers
v
Mock valuation updates
|
| dashboard / analytics
v
Redemption request events
The system includes:
| Layer | Purpose |
|---|---|
| Legal/document layer | Stores fictional asset agreement, valuation memo, or ownership note off-chain |
| Hash layer | Stores document hash on-chain to detect tampering |
| Token layer | Represents fractional participation in the demo asset |
| Compliance layer | Controls who can hold or transfer the token |
| Valuation layer | Updates mocked asset value for student analytics |
| Event layer | Emits actions for backend dashboards and audit logs |
This mirrors how many RWA systems are designed: the asset remains off-chain, but important representations, permissions, events, and settlement instructions are recorded on-chain.
5. Tools You Need
Install:
- Node.js 20 or later;
- npm;
- Git;
- VS Code;
- MetaMask;
- Hardhat;
- OpenZeppelin Contracts;
- ethers.js.
We will use:
- Solidity
0.8.24; - OpenZeppelin Contracts;
- Hardhat local network;
- optional Sepolia/Base Sepolia deployment;
- JavaScript scripts for deployment and interaction.
OpenZeppelin Contracts provides reusable smart-contract components, including ERC-20 implementations and access-control modules. OpenZeppelin’s current contracts documentation describes the library as modular, reusable, and secure smart contracts for Ethereum. Hardhat is a development environment for compiling, deploying, testing, and debugging Ethereum software.
6. Create the Project
Create a new project folder:
mkdir rwa-token-testnet-demo
cd rwa-token-testnet-demo
npm init -y
Install dependencies:
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts ethers dotenv
Initialize Hardhat:
npx hardhat init
Choose a JavaScript project.
Your folder should look like this:
rwa-token-testnet-demo/
contracts/
RWATokenDemo.sol
scripts/
deploy.js
verifyInvestors.js
transferDemo.js
updateValuation.js
test/
RWATokenDemo.test.js
hardhat.config.js
package.json
.env
7. Create the RWA Token Smart Contract
Create contracts/RWATokenDemo.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
contract RWATokenDemo is ERC20, AccessControl, Pausable {
bytes32 public constant ISSUER_ROLE = keccak256("ISSUER_ROLE");
bytes32 public constant COMPLIANCE_ROLE = keccak256("COMPLIANCE_ROLE");
bytes32 public constant VALUATION_ROLE = keccak256("VALUATION_ROLE");
struct AssetMetadata {
string assetName;
string assetType;
string jurisdiction;
string documentHash;
uint256 valuationUSD;
uint256 lastValuationUpdate;
}
AssetMetadata public assetMetadata;
mapping(address => bool) public verifiedInvestor;
mapping(address => string) private investorReference;
event InvestorVerified(address indexed investor, string reference);
event InvestorRemoved(address indexed investor);
event AssetMetadataUpdated(
string assetName,
string assetType,
string jurisdiction,
string documentHash
);
event ValuationUpdated(uint256 oldValuationUSD, uint256 newValuationUSD, uint256 updatedAt);
event RedemptionRequested(address indexed investor, uint256 amount, uint256 requestedAt);
constructor(
address admin,
string memory assetName,
string memory assetType,
string memory jurisdiction,
string memory documentHash,
uint256 initialValuationUSD
) ERC20("Blockgeni Testnet Warehouse Token", "BTWT") {
require(admin != address(0), "Invalid admin");
require(initialValuationUSD > 0, "Invalid valuation");
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(ISSUER_ROLE, admin);
_grantRole(COMPLIANCE_ROLE, admin);
_grantRole(VALUATION_ROLE, admin);
assetMetadata = AssetMetadata({
assetName: assetName,
assetType: assetType,
jurisdiction: jurisdiction,
documentHash: documentHash,
valuationUSD: initialValuationUSD,
lastValuationUpdate: block.timestamp
});
verifiedInvestor[admin] = true;
investorReference[admin] = "ISSUER_ADMIN";
}
function decimals() public pure override returns (uint8) {
return 18;
}
function verifyInvestor(
address investor,
string calldata reference
) external onlyRole(COMPLIANCE_ROLE) {
require(investor != address(0), "Invalid investor");
verifiedInvestor[investor] = true;
investorReference[investor] = reference;
emit InvestorVerified(investor, reference);
}
function removeInvestor(
address investor
) external onlyRole(COMPLIANCE_ROLE) {
require(investor != address(0), "Invalid investor");
verifiedInvestor[investor] = false;
investorReference[investor] = "";
emit InvestorRemoved(investor);
}
function investorStatus(
address investor
) external view returns (bool isVerified, string memory reference) {
return (verifiedInvestor[investor], investorReference[investor]);
}
function mint(
address to,
uint256 amount
) external onlyRole(ISSUER_ROLE) {
require(verifiedInvestor[to], "Recipient not verified");
require(amount > 0, "Invalid amount");
_mint(to, amount);
}
function updateAssetMetadata(
string calldata assetName,
string calldata assetType,
string calldata jurisdiction,
string calldata documentHash
) external onlyRole(COMPLIANCE_ROLE) {
assetMetadata.assetName = assetName;
assetMetadata.assetType = assetType;
assetMetadata.jurisdiction = jurisdiction;
assetMetadata.documentHash = documentHash;
emit AssetMetadataUpdated(assetName, assetType, jurisdiction, documentHash);
}
function updateValuation(
uint256 newValuationUSD
) external onlyRole(VALUATION_ROLE) {
require(newValuationUSD > 0, "Invalid valuation");
uint256 oldValuation = assetMetadata.valuationUSD;
assetMetadata.valuationUSD = newValuationUSD;
assetMetadata.lastValuationUpdate = block.timestamp;
emit ValuationUpdated(oldValuation, newValuationUSD, block.timestamp);
}
function requestRedemption(
uint256 amount
) external {
require(verifiedInvestor[msg.sender], "Investor not verified");
require(amount > 0, "Invalid amount");
require(balanceOf(msg.sender) >= amount, "Insufficient balance");
emit RedemptionRequested(msg.sender, amount, block.timestamp);
}
function pause() external onlyRole(COMPLIANCE_ROLE) {
_pause();
}
function unpause() external onlyRole(COMPLIANCE_ROLE) {
_unpause();
}
function assetValuePerTokenUSD()
external
view
returns (uint256)
{
uint256 supply = totalSupply();
if (supply == 0) {
return 0;
}
return (assetMetadata.valuationUSD * 1e18) / supply;
}
function _update(
address from,
address to,
uint256 value
) internal override whenNotPaused {
if (from != address(0)) {
require(verifiedInvestor[from], "Sender not verified");
}
if (to != address(0)) {
require(verifiedInvestor[to], "Recipient not verified");
}
super._update(from, to, value);
}
}
8. What This Contract Teaches
This contract teaches several RWA-specific concepts.
1. Permissioned ownership
Only verified investors can receive or send tokens. This is different from a normal ERC-20 token.
2. Issuer-controlled minting
Only wallets with ISSUER_ROLE can issue tokens.
3. Compliance control
A compliance wallet can verify or remove investors and pause transfers.
4. Asset metadata
The contract stores a fictional asset profile and document hash.
5. Valuation updates
A valuation role can update the mocked asset value.
6. Redemption request
Investors can request redemption, but the actual payout is off-chain. This is important because many RWA systems require off-chain settlement.
7. Transfer restrictions
The _update function prevents unverified transfers. In OpenZeppelin Contracts v5, _update is the modern function used internally for token transfers, minting, and burning.
9. Why We Store a Document Hash
A document hash helps prove that a specific document existed in a specific form at the time the hash was recorded.
For example, imagine an off-chain document called:
Blockgeni_Testnet_Warehouse_Asset_Memo.pdf
You can hash the document with:
sha256sum Blockgeni_Testnet_Warehouse_Asset_Memo.pdf
You may get:
9a7f3c8f4f4d9f5c3f2c77a1a6c9d87d4f6e56a89dfb0c2f1234567890abcdef
Store that hash in the contract as:
sha256:9a7f3c8f4f4d9f5c3f2c77a1a6c9d87d4f6e56a89dfb0c2f1234567890abcdef
If someone later changes the PDF, its hash changes. That does not make the PDF legally binding by itself, but it creates tamper-evidence.
This is a major RWA lesson: blockchain can anchor evidence, but the legal meaning of that evidence remains off-chain.
10. Configure Hardhat
Create or update hardhat.config.js:
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
const SEPOLIA_RPC_URL = process.env.SEPOLIA_RPC_URL || "";
const DEPLOYER_PRIVATE_KEY = process.env.DEPLOYER_PRIVATE_KEY || "";
module.exports = {
solidity: "0.8.24",
networks: {
sepolia: {
url: SEPOLIA_RPC_URL,
accounts: DEPLOYER_PRIVATE_KEY ? [DEPLOYER_PRIVATE_KEY] : [],
chainId: 11155111,
},
},
};
Create .env:
SEPOLIA_RPC_URL=YOUR_SEPOLIA_RPC_URL
DEPLOYER_PRIVATE_KEY=YOUR_TESTNET_PRIVATE_KEY
Add .gitignore:
node_modules
.env
artifacts
cache
Never commit .env to GitHub. Use a testnet-only wallet. Do not use your main wallet or seed phrase for any tutorial project.
11. Deploy the Contract
Create scripts/deploy.js:
const hre = require("hardhat");
async function main() {
const [deployer] = await hre.ethers.getSigners();
console.log("Deploying RWA demo with:", deployer.address);
const assetName = "Blockgeni Testnet Warehouse Asset";
const assetType = "Fictional Warehouse";
const jurisdiction = "Demo Jurisdiction";
const documentHash =
"sha256:9a7f3c8f4f4d9f5c3f2c77a1a6c9d87d4f6e56a89dfb0c2f1234567890abcdef";
// $100,000 mock valuation.
const initialValuationUSD = 100000;
const RWATokenDemo = await hre.ethers.getContractFactory("RWATokenDemo");
const rwaToken = await RWATokenDemo.deploy(
deployer.address,
assetName,
assetType,
jurisdiction,
documentHash,
initialValuationUSD
);
await rwaToken.waitForDeployment();
const address = await rwaToken.getAddress();
console.log("RWATokenDemo deployed to:", address);
console.log("Token name:", await rwaToken.name());
console.log("Token symbol:", await rwaToken.symbol());
const mintAmount = hre.ethers.parseUnits("100000", 18);
await rwaToken.mint(deployer.address, mintAmount);
console.log("Minted 100,000 BTWT to issuer/admin wallet");
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Run locally:
npx hardhat node
In a second terminal:
npx hardhat run scripts/deploy.js --network localhost
Expected output:
Deploying RWA demo with: 0x...
RWATokenDemo deployed to: 0x...
Token name: Blockgeni Testnet Warehouse Token
Token symbol: BTWT
Minted 100,000 BTWT to issuer/admin wallet
At this point, the issuer/admin wallet owns all tokens and is verified.
12. Verify Student Investor Wallets
Create scripts/verifyInvestors.js:
const hre = require("hardhat");
async function main() {
const [admin, studentA, studentB] = await hre.ethers.getSigners();
const contractAddress = "PASTE_DEPLOYED_CONTRACT_ADDRESS";
const rwaToken = await hre.ethers.getContractAt(
"RWATokenDemo",
contractAddress
);
console.log("Admin:", admin.address);
console.log("Student A:", studentA.address);
console.log("Student B:", studentB.address);
let tx = await rwaToken.verifyInvestor(studentA.address, "KYC-DEMO-STUDENT-A");
await tx.wait();
tx = await rwaToken.verifyInvestor(studentB.address, "KYC-DEMO-STUDENT-B");
await tx.wait();
console.log("Verified Student A and Student B");
const statusA = await rwaToken.investorStatus(studentA.address);
const statusB = await rwaToken.investorStatus(studentB.address);
console.log("Student A status:", statusA);
console.log("Student B status:", statusB);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Run:
npx hardhat run scripts/verifyInvestors.js --network localhost
This script adds two student wallets to the verified investor list.
The key lesson: RWA transfers often depend on permissioned participants.
13. Transfer Tokens to Verified Investors
Create scripts/transferDemo.js:
const hre = require("hardhat");
async function main() {
const [admin, studentA, studentB, unverifiedUser] = await hre.ethers.getSigners();
const contractAddress = "PASTE_DEPLOYED_CONTRACT_ADDRESS";
const rwaToken = await hre.ethers.getContractAt(
"RWATokenDemo",
contractAddress
);
const amountToStudentA = hre.ethers.parseUnits("1000", 18);
const amountToStudentB = hre.ethers.parseUnits("500", 18);
let tx = await rwaToken.transfer(studentA.address, amountToStudentA);
await tx.wait();
tx = await rwaToken.transfer(studentB.address, amountToStudentB);
await tx.wait();
console.log("Transferred 1,000 BTWT to Student A");
console.log("Transferred 500 BTWT to Student B");
const balanceA = await rwaToken.balanceOf(studentA.address);
const balanceB = await rwaToken.balanceOf(studentB.address);
console.log("Student A balance:", hre.ethers.formatUnits(balanceA, 18));
console.log("Student B balance:", hre.ethers.formatUnits(balanceB, 18));
console.log("Trying transfer to unverified user. This should fail.");
try {
tx = await rwaToken.transfer(unverifiedUser.address, hre.ethers.parseUnits("10", 18));
await tx.wait();
} catch (error) {
console.log("Transfer failed as expected:", error.shortMessage || error.message);
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Run:
npx hardhat run scripts/transferDemo.js --network localhost
This demonstrates the most important transfer rule:
- verified investor receives tokens;
- unverified wallet cannot receive tokens.
For students, this is the difference between a normal token and a compliance-aware RWA token.
14. Update the Mock Asset Valuation
Real RWA systems need valuation updates. A tokenized Treasury product may track reserve value. A tokenized real estate fund may update net asset value. A tokenized invoice pool may update repayment status.
In this demo, we use a mocked valuation.
Create scripts/updateValuation.js:
const hre = require("hardhat");
async function main() {
const contractAddress = "PASTE_DEPLOYED_CONTRACT_ADDRESS";
const rwaToken = await hre.ethers.getContractAt(
"RWATokenDemo",
contractAddress
);
const oldMetadata = await rwaToken.assetMetadata();
console.log("Old valuation USD:", oldMetadata.valuationUSD.toString());
const newValuationUSD = 112500;
const tx = await rwaToken.updateValuation(newValuationUSD);
await tx.wait();
const newMetadata = await rwaToken.assetMetadata();
const valuePerToken = await rwaToken.assetValuePerTokenUSD();
console.log("New valuation USD:", newMetadata.valuationUSD.toString());
console.log(
"Mock value per token:",
hre.ethers.formatUnits(valuePerToken, 18),
"USD"
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Run:
npx hardhat run scripts/updateValuation.js --network localhost
If the token supply is 100,000 and the mocked valuation is updated to $112,500, the value per token becomes:
1.125 USD
This is only a simulated value. It is not a price feed, legal appraisal, market price, or investment promise.
15. Add Redemption Requests
In many RWA systems, investors need a way to request redemption. Redemption may not happen automatically because the issuer may need to settle funds off-chain.
Our contract emits a RedemptionRequested event.
Create scripts/requestRedemption.js:
const hre = require("hardhat");
async function main() {
const [admin, studentA] = await hre.ethers.getSigners();
const contractAddress = "PASTE_DEPLOYED_CONTRACT_ADDRESS";
const rwaToken = await hre.ethers.getContractAt(
"RWATokenDemo",
contractAddress
);
const redemptionAmount = hre.ethers.parseUnits("100", 18);
const tx = await rwaToken
.connect(studentA)
.requestRedemption(redemptionAmount);
const receipt = await tx.wait();
console.log("Redemption requested by:", studentA.address);
console.log("Transaction hash:", receipt.hash);
console.log("Amount:", hre.ethers.formatUnits(redemptionAmount, 18), "BTWT");
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
A real system would connect this event to:
- an investor portal;
- redemption queue;
- bank payment system;
- fund administrator;
- compliance review;
- transfer agent;
- settlement engine.
For this demo, the event is enough to teach the flow.
16. Add Tests
Create test/RWATokenDemo.test.js:
const { expect } = require("chai");
const hre = require("hardhat");
describe("RWATokenDemo", function () {
async function deployFixture() {
const [admin, studentA, studentB, unverifiedUser] =
await hre.ethers.getSigners();
const RWATokenDemo = await hre.ethers.getContractFactory("RWATokenDemo");
const rwaToken = await RWATokenDemo.deploy(
admin.address,
"Blockgeni Testnet Warehouse Asset",
"Fictional Warehouse",
"Demo Jurisdiction",
"sha256:demo-document-hash",
100000
);
await rwaToken.waitForDeployment();
await rwaToken.mint(admin.address, hre.ethers.parseUnits("100000", 18));
return { rwaToken, admin, studentA, studentB, unverifiedUser };
}
it("mints tokens to a verified admin", async function () {
const { rwaToken, admin } = await deployFixture();
const balance = await rwaToken.balanceOf(admin.address);
expect(balance).to.equal(hre.ethers.parseUnits("100000", 18));
});
it("allows compliance role to verify investors", async function () {
const { rwaToken, studentA } = await deployFixture();
await rwaToken.verifyInvestor(studentA.address, "KYC-DEMO-A");
const status = await rwaToken.investorStatus(studentA.address);
expect(status[0]).to.equal(true);
expect(status[1]).to.equal("KYC-DEMO-A");
});
it("allows transfers only to verified investors", async function () {
const { rwaToken, admin, studentA, unverifiedUser } = await deployFixture();
await rwaToken.verifyInvestor(studentA.address, "KYC-DEMO-A");
await rwaToken.transfer(studentA.address, hre.ethers.parseUnits("1000", 18));
await expect(
rwaToken.transfer(unverifiedUser.address, hre.ethers.parseUnits("10", 18))
).to.be.revertedWith("Recipient not verified");
});
it("blocks transfers from removed investors", async function () {
const { rwaToken, studentA, studentB } = await deployFixture();
await rwaToken.verifyInvestor(studentA.address, "KYC-DEMO-A");
await rwaToken.verifyInvestor(studentB.address, "KYC-DEMO-B");
await rwaToken.transfer(studentA.address, hre.ethers.parseUnits("1000", 18));
await rwaToken.removeInvestor(studentA.address);
await expect(
rwaToken
.connect(studentA)
.transfer(studentB.address, hre.ethers.parseUnits("100", 18))
).to.be.revertedWith("Sender not verified");
});
it("allows valuation updates", async function () {
const { rwaToken } = await deployFixture();
await rwaToken.updateValuation(125000);
const metadata = await rwaToken.assetMetadata();
expect(metadata.valuationUSD).to.equal(125000);
});
it("allows verified investor to request redemption", async function () {
const { rwaToken, studentA } = await deployFixture();
await rwaToken.verifyInvestor(studentA.address, "KYC-DEMO-A");
await rwaToken.transfer(studentA.address, hre.ethers.parseUnits("1000", 18));
await expect(
rwaToken
.connect(studentA)
.requestRedemption(hre.ethers.parseUnits("100", 18))
).to.emit(rwaToken, "RedemptionRequested");
});
it("allows compliance role to pause transfers", async function () {
const { rwaToken, studentA } = await deployFixture();
await rwaToken.verifyInvestor(studentA.address, "KYC-DEMO-A");
await rwaToken.pause();
await expect(
rwaToken.transfer(studentA.address, hre.ethers.parseUnits("100", 18))
).to.be.reverted;
});
});
Run:
npx hardhat test
The tests prove:
- issuer can mint;
- compliance can verify investors;
- unverified wallets cannot receive tokens;
- removed investors cannot transfer;
- valuation updates work;
- redemption requests emit events;
- pause blocks transfers.
17. Deploy to Sepolia
Once your local tests pass, deploy to Sepolia.
Make sure .env has:
SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY
DEPLOYER_PRIVATE_KEY=YOUR_TESTNET_PRIVATE_KEY
Deploy:
npx hardhat run scripts/deploy.js --network sepolia
Important testnet safety rules:
- use a new testnet-only wallet;
- never use your main wallet private key;
- never commit
.env; - use faucet ETH only;
- verify contract addresses before interacting;
- label your contract clearly as test/demo;
- never market testnet tokens as investments.
18. Optional: Add a Simple Investor Dashboard
A frontend dashboard can display:
- wallet address;
- verification status;
- token balance;
- total supply;
- asset name;
- asset type;
- jurisdiction;
- document hash;
- mock valuation;
- mock value per token;
- redemption button.
A minimal frontend can call these contract methods:
const name = await rwaToken.name();
const symbol = await rwaToken.symbol();
const balance = await rwaToken.balanceOf(userAddress);
const supply = await rwaToken.totalSupply();
const metadata = await rwaToken.assetMetadata();
const valuePerToken = await rwaToken.assetValuePerTokenUSD();
const status = await rwaToken.investorStatus(userAddress);
Display logic:
console.log("Token:", name, symbol);
console.log("Balance:", ethers.formatUnits(balance, 18));
console.log("Total supply:", ethers.formatUnits(supply, 18));
console.log("Asset name:", metadata.assetName);
console.log("Asset type:", metadata.assetType);
console.log("Jurisdiction:", metadata.jurisdiction);
console.log("Document hash:", metadata.documentHash);
console.log("Valuation USD:", metadata.valuationUSD.toString());
console.log("Mock value per token:", ethers.formatUnits(valuePerToken, 18));
console.log("Verified:", status[0]);
console.log("Reference:", status[1]);
This helps students understand that most RWA apps are dashboards around on-chain and off-chain data.
19. How to Think About Token Supply
In this demo:
100,000 BTWT = 100,000 simulated units
But real RWA token supply can be designed in several ways:
| Model | Example | Notes |
|---|---|---|
| Fixed supply | 100,000 tokens represent fixed fund units | Simple but less flexible |
| NAV-adjusted value | Supply fixed, value per token changes | Common fund-style model |
| Rebasing supply | Balance changes as asset value changes | More complex and risky |
| Redemption-based burn | Tokens burn when investor redeems | Useful for closed-loop systems |
| Interest-accruing token | Exchange rate increases over time | Similar to yield-bearing tokens |
For students, fixed supply with a mocked valuation is easiest to understand.
20. Why Valuation Is Hard
Updating a number on-chain is easy.
Knowing whether that number is trustworthy is hard.
A real RWA valuation may require:
- appraisals;
- market prices;
- reserve reports;
- bank statements;
- trustee confirmations;
- auditor reports;
- data providers;
- pricing committees;
- proof-of-reserve feeds;
- oracle infrastructure.
Chainlink explains that tokenized assets need data such as market prices, ownership details, and reserve valuations to make them functional on-chain. Proof-of-reserve systems are one approach for improving transparency around collateral backing.
This is why the VALUATION_ROLE in our demo is only a teaching tool. In production, valuation should not depend on a random wallet updating numbers without oversight.
21. Why Redemption Is Off-Chain
The contract emits:
event RedemptionRequested(address indexed investor, uint256 amount, uint256 requestedAt);
It does not automatically pay the investor.
Why?
Because many RWA redemptions depend on off-chain processes:
- verifying investor identity;
- confirming bank details;
- checking sanctions lists;
- calculating fees;
- applying lockup periods;
- selling or settling underlying assets;
- updating legal records;
- paying through banking rails;
- burning tokens after settlement.
If tokens represent a real claim, redemption is a legal and operational process, not only a smart-contract process.
22. Add a Burn After Redemption
A more advanced version can burn tokens after redemption is processed.
Add:
function burnAfterRedemption(
address investor,
uint256 amount
) external onlyRole(ISSUER_ROLE) {
require(verifiedInvestor[investor], "Investor not verified");
_burn(investor, amount);
}
This teaches the lifecycle:
- Investor requests redemption.
- Issuer reviews off-chain.
- Issuer pays investor off-chain.
- Issuer burns redeemed tokens.
- Supply decreases.
Do not burn tokens before settlement unless the legal and operational flow is clear.
23. Add Transfer Approval Logic
Some RWA products may require issuer approval for every secondary transfer.
You can add a transfer approval mapping:
mapping(bytes32 => bool) public approvedTransfers;
function approveTransfer(
address from,
address to,
uint256 amount
) external onlyRole(COMPLIANCE_ROLE) {
bytes32 transferId = keccak256(abi.encode(from, to, amount));
approvedTransfers[transferId] = true;
}
Then in _update:
if (from != address(0) && to != address(0)) {
bytes32 transferId = keccak256(abi.encode(from, to, value));
require(approvedTransfers[transferId], "Transfer not approved");
approvedTransfers[transferId] = false;
}
This is stricter than our base demo. It teaches students how permissioned secondary markets can be controlled.
The downside is poor user experience. Every transfer needs prior approval.
24. Add Investor Categories
Real-world compliance is rarely binary.
Instead of only:
verified = true / false
You may need categories:
0 = unverified
1 = retail eligible
2 = accredited investor
3 = institutional investor
4 = restricted
A contract could store:
mapping(address => uint8) public investorCategory;
Then apply rules:
- retail investors can hold up to a cap;
- accredited investors can hold more;
- restricted investors cannot receive tokens;
- institutional investors can trade in larger sizes.
This gives students a realistic view of why RWA compliance logic becomes complex quickly.
25. Add Holding Limits
Some tokenized assets may need wallet-level caps.
Example:
uint256 public maxHoldingPerInvestor = 5000 * 1e18;
In _update:
if (to != address(0)) {
require(
balanceOf(to) + value <= maxHoldingPerInvestor,
"Holding limit exceeded"
);
}
This can simulate investor caps, concentration limits, or regulatory thresholds.
26. Add a Mock Proof-of-Reserve Field
You can store a reserve report hash:
string public reserveReportHash;
event ReserveReportUpdated(string reportHash, uint256 updatedAt);
function updateReserveReport(
string calldata reportHash
) external onlyRole(VALUATION_ROLE) {
reserveReportHash = reportHash;
emit ReserveReportUpdated(reportHash, block.timestamp);
}
This does not prove reserves by itself, but it teaches the idea of linking off-chain reports to on-chain evidence.
Production systems may use oracle networks, auditor attestations, custodial confirmations, proof-of-reserve feeds, or regulated trustees.
27. Student Exercises
Use these exercises to deepen the project.
Exercise 1: Add holding limits
Prevent any investor from holding more than 5% of the token supply.
Exercise 2: Add transfer approval
Require compliance approval before any investor-to-investor transfer.
Exercise 3: Add document versioning
Store multiple document hashes instead of one.
Exercise 4: Add redemption burning
Let the issuer burn tokens after an off-chain redemption is processed.
Exercise 5: Add investor categories
Replace boolean verification with investor classes.
Exercise 6: Add a dashboard
Build a React, wagmi, or ethers.js dashboard showing asset data and investor balances.
Exercise 7: Add valuation history
Store an array of past valuation updates.
Exercise 8: Add oracle simulation
Create a script that updates valuation from a local JSON file.
Exercise 9: Add role separation
Use different wallets for admin, issuer, compliance, and valuation.
Exercise 10: Deploy and verify on a testnet explorer
Deploy to Sepolia and verify the contract source code.
28. Production Checklist
Before building a real RWA product, think far beyond the smart contract.
Legal
- What real asset backs the token?
- Who owns the asset?
- What legal rights does the token holder have?
- Which jurisdiction applies?
- What happens in bankruptcy?
- What happens in a dispute?
- Is the token a security?
- Is investor accreditation required?
Compliance
- KYC/AML process;
- sanctions screening;
- investor eligibility;
- transfer restrictions;
- recordkeeping;
- jurisdictional limits;
- tax reporting;
- disclosure obligations.
Technical
- audited smart contracts;
- transfer restrictions;
- upgrade controls;
- admin key security;
- oracle security;
- document hash versioning;
- redemption controls;
- event indexing;
- pause and recovery tools.
Operational
- asset custody;
- valuation updates;
- investor support;
- redemption processing;
- payment rails;
- audit reports;
- reserve attestations;
- emergency procedures.
User Experience
- clear risk disclosures;
- readable asset dashboard;
- investor status display;
- transaction history;
- redemption status;
- document access;
- support workflow.
The smart contract is the easy part. The legal, compliance, custody, and operations layers are where real RWA systems succeed or fail.
29. Common Mistakes
Mistake 1: Saying the token is the asset
A token is a representation. The legal documents define the claim.
Mistake 2: Ignoring transfer restrictions
Many RWA tokens cannot be freely transferable like meme coins.
Mistake 3: Trusting valuation updates blindly
A valuation field is only as trustworthy as the process behind it.
Mistake 4: Forgetting redemption
If users can buy or receive tokens, they will eventually ask how to exit.
Mistake 5: Overusing decentralization language
Many RWA systems are intentionally hybrid. They combine on-chain transparency with off-chain legal authority.
Mistake 6: Building without auditability
Events, document hashes, and role histories matter because investors, auditors, and regulators need records.
Mistake 7: Confusing testnet tokens with value
Testnet tokens have no real financial value. Never sell testnet tokens or present them as investments.
30. What This Demo Teaches
This project teaches the most important RWA development lesson:
Tokenization is not only token creation.
It is a full system involving:
- asset definition;
- legal representation;
- investor eligibility;
- controlled transfers;
- metadata;
- valuation;
- redemption;
- compliance;
- auditability;
- user dashboards.
That is why RWA tokenization is more complicated than launching a normal ERC-20 token. It connects blockchain engineering with finance, law, operations, and data infrastructure.
For students, this makes it one of the most valuable blockchain projects to build. It gives hands-on experience with smart contracts while forcing you to think about real-world constraints.
Final Takeaway
A tokenized RWA demo is useful because it shows both the power and the limits of blockchain.
The power is clear: tokens can represent fractional interests, transfer records, investor permissions, valuation updates, redemption requests, and document hashes in a transparent system.
The limit is just as important: the real asset remains off-chain. Legal rights, custody, valuation, and redemption still require institutions, contracts, auditors, administrators, and compliance processes.
This is why serious RWA tokenization is not about replacing the real world with code. It is about connecting real-world financial assets to programmable infrastructure in a more transparent and automated way.
Students who understand that difference will be much better prepared to build practical blockchain products.
This testnet demo is a safe first step.











