HomeBlockchainBlockchain DIYHow to Build a Tokenized RWA Testnet Demo

How to Build a Tokenized RWA Testnet Demo


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:

  1. Issuer role
    Can mint tokens.
  2. Compliance role
    Can verify investors, pause transfers, and unpause transfers.
  3. Valuation role
    Can update a mocked asset valuation.
  4. Verified investor list
    Only verified wallets can receive or transfer tokens.
  5. Asset metadata
    Stores asset name, asset type, jurisdiction, document hash, and valuation.
  6. 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:

  1. Investor requests redemption.
  2. Issuer reviews off-chain.
  3. Issuer pays investor off-chain.
  4. Issuer burns redeemed tokens.
  5. 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.

Most Popular