Launch a tokenised asset
Job: tokenise an asset. Time: about 45 minutes. Chain: Chiado testnet.
Prerequisites: deploy your first contract, Foundry installed, and an account funded on Chiado.
Tokenising an asset means issuing a token whose supply is bound to something that exists off-chain: a property, a bond, a warehouse of coffee. The on-chain part is the easy half, and this guide covers it honestly: a capped-supply ERC-20, a documented reference to the asset, and controls over who can hold it.
Read the scope note before you build anything.
Scope: what this guide is not
The contract below is the ledger. It is not the product.
A real tokenised asset needs a legal structure that makes the token a claim on the asset: a special purpose vehicle, a custodian, a prospectus or an exemption from one, and a transfer agent. Without that, holders own a token that references an asset they have no enforceable right to. Which structure applies depends on the asset, the jurisdiction, and who you sell to, and getting it wrong is a securities matter rather than an engineering one.
The RWA projects in the ecosystem directory have done this work. Backed issues tokens tracking listed securities against a custodian; that arrangement, not its Solidity, is what makes the token meaningful.
Build the contract to understand the mechanics. Get advice before you issue anything to anyone.
Set up the project
forge init gnosis-tokenised-asset
cd gnosis-tokenised-asset
forge install OpenZeppelin/openzeppelin-contracts
Add the remapping in remappings.txt:
@openzeppelin/=lib/openzeppelin-contracts/
Confirm it builds:
forge build
Compiler run successful.
Decide what the token represents
Before writing code, fix three things, because they determine the contract:
- The unit. Does one token equal one unit of the asset, or a fractional share of a whole? This guide uses fractional shares of a single asset.
- The cap. A tokenised asset has a fixed supply matching the asset. Minting beyond it dilutes existing holders' claim, so the cap is enforced in code rather than by policy.
- The reference. Something on-chain must point at the off-chain documents. A URI to the legal documentation and a hash of them is the minimum: the URI can be re-hosted, the hash proves the document has not changed.
The example: a property valued at £500,000, divided into 500,000 shares of £1.
Write the contract
Create src/TokenisedAsset.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
/// @notice Capped-supply token representing fractional shares in one off-chain asset.
/// @dev The on-chain ledger only. The claim it represents lives in the legal
/// documentation referenced by documentUri and documentHash.
contract TokenisedAsset is ERC20, Ownable {
/// @notice Maximum tokens that can ever exist, set at deployment.
uint256 public immutable cap;
/// @notice Where the legal documentation is published.
string public documentUri;
/// @notice keccak256 of the documentation, so re-hosting cannot change the terms.
bytes32 public documentHash;
event DocumentUpdated(string uri, bytes32 hash);
error CapExceeded(uint256 requested, uint256 remaining);
constructor(
string memory name_,
string memory symbol_,
uint256 cap_,
string memory documentUri_,
bytes32 documentHash_
) ERC20(name_, symbol_) Ownable(msg.sender) {
cap = cap_;
documentUri = documentUri_;
documentHash = documentHash_;
}
/// @notice Issue shares to a holder, up to the cap.
function issue(address to, uint256 amount) external onlyOwner {
uint256 remaining = cap - totalSupply();
if (amount > remaining) revert CapExceeded(amount, remaining);
_mint(to, amount);
}
/// @notice Redeem shares, for example when the asset is sold.
function redeem(address from, uint256 amount) external onlyOwner {
_burn(from, amount);
}
/// @notice Point at re-published documentation. The hash must match the new document.
function setDocument(string calldata uri, bytes32 hash) external onlyOwner {
documentUri = uri;
documentHash = hash;
emit DocumentUpdated(uri, hash);
}
}
The owner can issue and redeem but cannot exceed the cap. That limit is immutable and holds even against the issuer. redeem burning from an arbitrary holder is a deliberate choice: a transfer agent needs it to settle a real-world redemption. It is also a power holders must be told about, which is the sort of thing the documentation exists to state.
Build it:
forge build
Test the cap
The cap is the contract's only real guarantee, so test it. Create test/TokenisedAsset.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {TokenisedAsset} from "../src/TokenisedAsset.sol";
contract TokenisedAssetTest is Test {
TokenisedAsset asset;
address holder = address(0xBEEF);
function setUp() public {
asset = new TokenisedAsset(
"12 Example Street", "EX12", 500_000e18, "ipfs://placeholder", bytes32(uint256(1))
);
}
function test_IssuesUpToCap() public {
asset.issue(holder, 500_000e18);
assertEq(asset.totalSupply(), 500_000e18);
assertEq(asset.balanceOf(holder), 500_000e18);
}
function test_RevertsAboveCap() public {
asset.issue(holder, 499_999e18);
vm.expectRevert(abi.encodeWithSelector(TokenisedAsset.CapExceeded.selector, 2e18, 1e18));
asset.issue(holder, 2e18);
}
function test_OnlyOwnerIssues() public {
vm.prank(holder);
vm.expectRevert();
asset.issue(holder, 1e18);
}
}
Run them:
forge test
Three tests pass. The middle one is the important one: it proves the cap holds against the owner.
Hash the documentation
The token points at a document. Produce the hash from the actual file so the on-chain value means something:
cast keccak "$(cat legal/offering.pdf)"
For this walkthrough, hash a placeholder so you have a real value to deploy with:
cast keccak "placeholder-offering-document-v1"
Copy the resulting 0x… value as <DOCHASH>.
Deploy to Chiado
forge create src/TokenisedAsset.sol:TokenisedAsset \
--rpc-url https://rpc.chiadochain.net \
--account chiado-dev \
--broadcast \
--constructor-args \
"12 Example Street" \
"EX12" \
500000000000000000000000 \
"ipfs://placeholder" \
<DOCHASH>
Note the address as <ASSET>. Verify it:
forge verify-contract <ASSET> src/TokenisedAsset.sol:TokenisedAsset \
--chain 10200 \
--verifier blockscout \
--verifier-url https://gnosis-chiado.blockscout.com/api/ \
--constructor-args $(cast abi-encode \
"constructor(string,string,uint256,string,bytes32)" \
"12 Example Street" "EX12" 500000000000000000000000 "ipfs://placeholder" <DOCHASH>)
Contract successfully verified.
Issue shares
Issue 100,000 shares to yourself:
cast send <ASSET> "issue(address,uint256)" <YOUR_ADDRESS> 100000000000000000000000 \
--rpc-url https://rpc.chiadochain.net \
--account chiado-dev
status 1 (success).
Now try to exceed the cap, to see the guarantee hold on-chain rather than only in a test:
cast send <ASSET> "issue(address,uint256)" <YOUR_ADDRESS> 500000000000000000000000 \
--rpc-url https://rpc.chiadochain.net \
--account chiado-dev
This reverts with CapExceeded. A failed transaction here is the correct result.
Read the supply on-chain
Check what exists against what can ever exist:
cast call <ASSET> "totalSupply()(uint256)" --rpc-url https://rpc.chiadochain.net
cast call <ASSET> "cap()(uint256)" --rpc-url https://rpc.chiadochain.net
The first prints 100000000000000000000000 and the second 500000000000000000000000. 100,000 of 500,000 shares issued, and the remainder cannot be exceeded.
Confirm the document reference survived deployment:
cast call <ASSET> "documentHash()(bytes32)" --rpc-url https://rpc.chiadochain.net
It matches <DOCHASH>.
Through the MCP server, chain_read_contract gives your agent the same reading:
{
"address": "<ASSET>",
"abi_fragment": "function totalSupply() view returns (uint256)",
"function": "totalSupply",
"chain_id": 10200
}
And chain_get_token_info returns the name, symbol, decimals and holder count as Blockscout has indexed them.
Switch to mainnet
The contract deploys unchanged against https://rpc.gnosischain.com with --chain 100.
What changes is everything around it. Before issuing on mainnet you need the legal structure from the scope note, a real document at a durable URI whose hash you have committed, and a decision about transfer restrictions. Most regulated instruments cannot trade freely to any address, which means an allowlist or a permissioned transfer hook this contract does not have.
What you have
A capped, verified token on Chiado with an immutable supply limit, an on-chain reference to its documentation, and issuance controls you have tested against the owner. That is a sound ledger.
The claim it represents is a legal artefact, not a Solidity one. Look at how the RWA projects in the ecosystem directory have structured theirs before going further.