前言随着区块链技术与真实世界资产(RWA,RealWorldAssets)融合趋势的加速,房产代币化凭借其庞大的市场规模和明确的收益模式,成为最具落地潜力的RWA赛道之一。传统房产投资存在门槛高、流动性差、分红不透明等痛点,而区块链技术可通过资产代币化、去中心化交易和自动化分红,重构房
随着区块链技术与真实世界资产(RWA, Real World Assets)融合趋势的加速,房产代币化凭借其庞大的市场规模和明确的收益模式,成为最具落地潜力的 RWA 赛道之一。传统房产投资存在门槛高、流动性差、分红不透明等痛点,而区块链技术可通过资产代币化、去中心化交易和自动化分红,重构房产投资的底层逻辑。
本文将深度拆解一套经实战验证的链上房产 RWA 技术架构,完整覆盖资产代币化、二级市场交易和租金自动分红三大核心模块,提供基于 Solidity 0.8.24 的合约实现、Hardhat+Viem 的测试方案及自动化部署脚本,为开发者落地房产 RWA 项目提供可直接复用的技术范式。
一、系统架构设计
房产 RWA 场景需要同时支持 "单套房产碎片化持有" 和 "多套房产统一管理",ERC-20(同质化代币)和 ERC-721(非同质化代币)均无法高效满足需求,而 ERC-1155 的多资产特性成为最优解。
| 特性 | ERC-20 | ERC-721 | ERC-1155 |
|---|---|---|---|
| 多房产支持 | 需部署多合约,管理成本高 | 需部署多合约,Gas 成本高 | 单合约多 ID,统一管理 |
| 碎片化持有 | 支持,但无法区分资产 ID | 不支持,仅代表完整所有权 | 支持,精准对应单套房产 |
| Gas 效率 | 高,但批量操作无优化 | 低,单资产单交易 | 批量操作优化,成本降低 30%+ |
| 元数据灵活性 | 低,仅支持全局元数据 | 高,单资产独立元数据 | 高,兼容多资产元数据规范 |
核心设计逻辑:每个tokenId对应一套独立房产,该 ID 下的代币数量代表房产所有权份额(如 tokenId=1 发行 1000 枚,每枚代表 0.1% 的房产所有权),用户持有份额与房产收益分红直接挂钩。
┌─────────────────────────────────────────────────────────┐
│ RWAAsset (ERC-1155) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 房产A (ID=1) │ │ 房产B (ID=2) │ │ 房产C (ID=3) │ │
│ │ 份额: 1000 │ │ 份额: 2000 │ │ 份额: 1500 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ RWAMarketplace│ │RentalDistributor│ │ Checkpoint │
│ 二级市场 │ │ 租金分红 │ │ 历史快照查询 │
└──────────────┘ └──────────────┘ └──────────────┘
RWAAsset:核心资产合约,基于 ERC-1155 实现多房产代币化发行,内置持仓快照功能,为分红提供数据支撑;
RWAMarketplace:去中心化交易市场,支持房产份额挂单、购买,保障交易安全与透明;
RentalDistributor:分红引擎,基于历史快照计算用户应得租金,支持单资产 / 多资产批量领取;
Checkpoint:快照模块,记录指定区块高度的用户持仓和总供应量,确保分红计算的公平性。
为适配测试网环境,实现无权限铸币功能,便于快速验证合约逻辑:
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/**
* @dev 测试网专用 USDT,任意人都能 mint
*/
contract TestUSDT is ERC20 {
uint8 private _decimals;
constructor(
string memory name,
string memory symbol,
uint8 decimals_
) ERC20(name, symbol) {
_decimals = decimals_;
}
function decimals() public view override returns (uint8) {
return _decimals;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
整合资产发行、交易、分红三大核心能力,兼顾安全性与可扩展性:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/utils/structs/Checkpoints.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol"; /**
@title RWA房产资产合约 */ contract RWAAsset is ERC1155, ERC1155Supply, AccessControl { using Checkpoints for Checkpoints.Trace224;
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
mapping(uint256 => Checkpoints.Trace224) private _totalCheckpoints; mapping(uint256 => mapping(address => Checkpoints.Trace224)) private _checkpoints;
constructor() ERC1155("https://api.rwa.com{id}.json") { _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); }
function mint(address to, uint256 id, uint256 amount) external onlyRole(MINTER_ROLE) { _mint(to, id, amount, ""); }
function getPastBalanceOf(address account, uint256 id, uint32 blockNumber) public view returns (uint256) {
// blockNumber 必须小于当前区块
require(blockNumber < block.number, "RWA: Future block");
// 在 OZ V5 中,使用 upperLookupRecent 获取历史快照值
return _checkpoints[id][account].upperLookupRecent(blockNumber);
}
function getPastTotalSupply(uint256 id, uint32 blockNumber) public view returns (uint256) { require(blockNumber < block.number, "RWA: Future block");
// 同样替换为 upperLookupRecent
return _totalCheckpoints[id].upperLookupRecent(blockNumber);
}
// 核心修复:重写 _update 以包含 ERC1155Supply 的逻辑 function _update(address from, address to, uint256[] memory ids, uint256[] memory values) internal override(ERC1155, ERC1155Supply) { // 先调用父类更新逻辑 super._update(from, to, ids, values);
uint32 currentBlock = uint32(block.number);
for (uint256 i = 0; i < ids.length; ++i) {
uint256 id = ids[i];
// 记录用户持仓
if (from != address(0)) {
Checkpoints.push(_checkpoints[id][from], currentBlock, uint224(balanceOf(from, id)));
}
if (to != address(0)) {
Checkpoints.push(_checkpoints[id][to], currentBlock, uint224(balanceOf(to, id)));
}
// 核心修复点:调用 ERC1155Supply 提供的 totalSupply(id)
// 并在每次变动时强制写入总供应量快照
Checkpoints.push(_totalCheckpoints[id], currentBlock, uint224(totalSupply(id)));
}
}
// 必须重写支持接口 function supportsInterface(bytes4 interfaceId) public view override(ERC1155, AccessControl) returns (bool) { return super.supportsInterface(interfaceId); } }
contract RWAMarketplace is ReentrancyGuard { using SafeERC20 for IERC20; struct Listing { address seller; uint256 propertyId; uint256 amount; uint256 pricePerShare; bool active; } RWAAsset public immutable asset; IERC20 public immutable paymentToken; mapping(uint256 => Listing) public listings; uint256 public nextListingId; constructor(address _asset, address _paymentToken) { asset = RWAAsset(_asset); paymentToken = IERC20(_paymentToken); } function list(uint256 propertyId, uint256 amount, uint256 price) external { asset.safeTransferFrom(msg.sender, address(this), propertyId, amount, ""); listings[nextListingId] = Listing(msg.sender, propertyId, amount, price, true); nextListingId++; } function buy(uint256 listingId, uint256 buyAmount) external nonReentrant { Listing storage l = listings[listingId]; uint256 cost = buyAmount * l.pricePerShare; l.amount -= buyAmount; if(l.amount == 0) l.active = false; paymentToken.safeTransferFrom(msg.sender, l.seller, cost); asset.safeTransferFrom(address(this), msg.sender, l.propertyId, buyAmount, ""); } function onERC1155Received(address, address, uint256, uint256, bytes memory) public pure returns (bytes4) { return this.onERC1155Received.selector; } }
contract RentalDistributor is AccessControl,ReentrancyGuard { using SafeERC20 for IERC20; RWAAsset public asset; IERC20 public rewardToken; struct Distribution { uint32 snapshotBlock; uint256 amountPerShare; } mapping(uint256 => uint256) public currentDistId; mapping(uint256 => mapping(uint256 => Distribution)) public history; mapping(uint256 => mapping(address => uint256)) public userLastClaimed; constructor(address _asset, address _rewardToken) { asset = RWAAsset(_asset); rewardToken = IERC20(_rewardToken); _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); } function distribute(uint256 propertyId, uint256 amount, uint32 snapBlock) external onlyRole(DEFAULT_ADMIN_ROLE) { rewardToken.safeTransferFrom(msg.sender, address(this), amount); uint256 totalAtSnap = asset.getPastTotalSupply(propertyId, snapBlock);
// 增加此校验
require(totalAtSnap > 0, "RWA: Total supply at snapshot is zero");
uint256 id = ++currentDistId[propertyId];
history[propertyId][id] = Distribution(snapBlock, (amount * 1e12) / totalAtSnap);
} // 1. 新增:批量领取函数 function batchClaim(uint256[] calldata propertyIds) external nonReentrant { uint256 totalBatchReward = 0;
for (uint256 i = 0; i < propertyIds.length; i++) {
uint256 pId = propertyIds[i];
totalBatchReward += _calculateAndUpdateReward(pId, msg.sender);
}
require(totalBatchReward > 0, "RWA: No rewards to claim");
rewardToken.safeTransfer(msg.sender, totalBatchReward);
}
// 2. 内部逻辑:计算并更新用户的领取状态
function _calculateAndUpdateReward(uint256 propertyId, address user) internal returns (uint256) {
uint256 last = userLastClaimed[propertyId][user];
uint256 current = currentDistId[propertyId];
if (last >= current) return 0;
uint256 reward = 0;
for (uint256 i = last + 1; i <= current; i++) {
Distribution storage d = history[propertyId][i];
uint256 bal = asset.getPastBalanceOf(user, propertyId, d.snapshotBlock);
reward += (bal * d.amountPerShare) / 1e12;
}
userLastClaimed[propertyId][user] = current;
return reward;
}
// 3. 保留单体领取(调用内部逻辑)
function claim(uint256 propertyId) external nonReentrant {
uint256 reward = _calculateAndUpdateReward(propertyId, msg.sender);
require(reward > 0, "RWA: No reward");
rewardToken.safeTransfer(msg.sender, reward);
}
// function claim(uint256 propertyId) external { uint256 last = userLastClaimed[propertyId][msg.sender]; uint256 current = currentDistId[propertyId]; uint256 totalReward = 0; for (uint256 i = last + 1; i <= current; i++) { Distribution storage d = history[propertyId][i]; uint256 bal = asset.getPastBalanceOf(msg.sender, propertyId, d.snapshotBlock); totalReward += (bal * d.amountPerShare) / 1e12; } userLastClaimed[propertyId][msg.sender] = current; if (totalReward > 0) rewardToken.safeTransfer(msg.sender, totalReward); }
}
# 测试脚本
**测试用例:房产RWA交易流程完整测试**
* **✔ 应该成功运行完整的 铸造->交易->快照->分红 流程**
* **✔ 应该支持跨多个房产资产的一次性批量领取 (Batch Claim)**
* **✔ 应该能够累积多次分红后一次性领取**
* **✔ 如果资产在快照后发生转移,分红应归属于快照时的持有者**
import assert from "node:assert/strict"; import { describe, it, beforeEach } from "node:test"; import { network } from "hardhat"; import { parseUnits,} from "viem";
describe("房产RWA交易流程完整测试", function () { let asset: any, market: any, dist: any, usdc: any; let admin: any, alice: any, bob: any; let publicClient: any; let testClient: any; // 用于手动挖矿 const PROP_ID = 1n; const PROP_A = 101n; // 使用不同的 ID 防止冲突 const PROP_B = 102n; beforeEach(async function () { const { viem } = await (network as any).connect();
// 1. 获取客户端
publicClient = await viem.getPublicClient();
[admin, alice, bob] = await viem.getWalletClients();
// 2. 初始化 Test Client 用于控制区块链高度 (mine)
testClient = await viem.getTestClient();
// 3. 部署合约 (确保你已经写好了 MockERC20)
usdc = await viem.deployContract("TestUSDT", ["USDC", "USDC",18]);
asset = await viem.deployContract("RWAAsset", []);
market = await viem.deployContract("RWAMarketplace", [asset.address, usdc.address]);
dist = await viem.deployContract("RentalDistributor", [asset.address, usdc.address]);
const MINTER = await asset.read.MINTER_ROLE();
await asset.write.grantRole([MINTER, admin.account.address]);
});
it("应该成功运行完整的 铸造->交易->快照->分红 流程", async function () {
// --- 1. 初始状态:区块 N ---
await asset.write.mint([alice.account.address, PROP_ID, 1000n]);
// --- 2. 交易状态:区块 N+1, N+2 ---
await asset.write.setApprovalForAll([market.address, true], { account: alice.account });
await market.write.list([PROP_ID, 500n, parseUnits("10", 18)], { account: alice.account });
await usdc.write.mint([bob.account.address, parseUnits("5000", 18)]);
await usdc.write.approve([market.address, parseUnits("5000", 18)], { account: bob.account });
// 这一步买入操作执行在区块 M
await market.write.buy([0n, 200n], { account: bob.account });
// --- 核心修复点:确保快照已封存 ---
// 1. 先挖一个新块,确保上面的 buy 操作所在的区块已经完全“结束”并写回快照
await testClient.mine({ blocks: 1 });
// 2. 现在获取“上一个区块”作为快照基准块
const lastBlock = await publicClient.getBlockNumber();
const snapBlock = Number(lastBlock) - 1;
// 3. 再次手动挖矿,确保当前区块高度 > snapBlock (查询要求)
await testClient.mine({ blocks: 1 });
// 4. 调试打印:验证供应量是否已记录
const ts = await asset.read.getPastTotalSupply([PROP_ID, snapBlock]);
console.log(`基准区块: ${snapBlock}, 该区块供应量: ${ts}`);
assert.ok(ts > 0n, "供应量应大于0");
// --- 3. 分发租金 ---
// 建议统一使用 18 位或根据部署参数对齐
const rent = parseUnits("10000", 18);
await usdc.write.mint([admin.account.address, rent]);
await usdc.write.approve([dist.address, rent]);
// 执行分红
await dist.write.distribute([PROP_ID, rent, snapBlock]);
// --- 4. 验证分红 ---
await dist.write.claim([PROP_ID], { account: alice.account });
const aliceBal = await usdc.read.balanceOf([alice.account.address]);
// 如果原本 Alice 卖 100 块赚了钱,这里断言需要加上初始卖房所得,或者只查分红增量
console.log(`Alice 余额: ${aliceBal}`);
await dist.write.claim([PROP_ID], { account: bob.account });
const bobBal = await usdc.read.balanceOf([bob.account.address]);
console.log(`Bob 余额: ${bobBal}`);
assert.ok(bobBal > 0n, "Bob 应该领到收益");
});
it("应该支持跨多个房产资产的一次性批量领取 (Batch Claim)", async function () {
// 确保在这个测试实例中 Alice 有钱
await asset.write.mint([alice.account.address, PROP_A, 1000n]);
await asset.write.mint([alice.account.address, PROP_B, 2000n]);
await testClient.mine({ blocks: 1 });
const snapBlock = Number(await publicClient.getBlockNumber());
await testClient.mine({ blocks: 1 });
const rentA = parseUnits("5000", 18);
const rentB = parseUnits("12000", 18);
await usdc.write.mint([admin.account.address, rentA + rentB]);
await usdc.write.approve([dist.address, rentA + rentB]);
await dist.write.distribute([PROP_A, rentA, snapBlock]);
await dist.write.distribute([PROP_B, rentB, snapBlock]);
const initBal = await usdc.read.balanceOf([alice.account.address]);
await dist.write.batchClaim([[PROP_A, PROP_B]], { account: alice.account });
const endBal = await usdc.read.balanceOf([alice.account.address]);
assert.equal(endBal - initBal, rentA + rentB);
});
it("应该能够累积多次分红后一次性领取", async function () {
// 1. 铸造并确保快照记录了供应量
await asset.write.mint([alice.account.address, PROP_A, 1000n]);
await testClient.mine({ blocks: 1 });
// 2. 第一次分红
const snap1 = Number(await publicClient.getBlockNumber());
await testClient.mine({ blocks: 1 });
const rent1 = parseUnits("1000", 18);
await usdc.write.mint([admin.account.address, rent1]);
await usdc.write.approve([dist.address, rent1]);
await dist.write.distribute([PROP_A, rent1, snap1]);
// 3. 第二次分红
await testClient.mine({ blocks: 2 });
const snap2 = Number(await publicClient.getBlockNumber());
await testClient.mine({ blocks: 1 });
const rent2 = parseUnits("2000", 18);
await usdc.write.mint([admin.account.address, rent2]);
await usdc.write.approve([dist.address, rent2]);
await dist.write.distribute([PROP_A, rent2, snap2]);
// 4. 领取
const initBal = await usdc.read.balanceOf([alice.account.address]);
await dist.write.claim([PROP_A], { account: alice.account });
const endBal = await usdc.read.balanceOf([alice.account.address]);
assert.ok(endBal - initBal >= parseUnits("3000", 18));
});
it("如果资产在快照后发生转移,分红应归属于快照时的持有者", async function () {
// 1. 初始准备
await asset.write.mint([alice.account.address, PROP_A, 1000n]);
await testClient.mine({ blocks: 1 });
const snapBeforeTransfer = Number(await publicClient.getBlockNumber());
await testClient.mine({ blocks: 1 });
// 2. Alice 转移给 Bob
await asset.write.safeTransferFrom([
alice.account.address,
bob.account.address,
PROP_A,
1000n,
"0x"
], { account: alice.account });
await testClient.mine({ blocks: 1 });
// 3. 执行分红(基于转移前的快照)
const rent = parseUnits("1000", 18);
await usdc.write.mint([admin.account.address, rent]);
await usdc.write.approve([dist.address, rent]);
await dist.write.distribute([PROP_A, rent, snapBeforeTransfer]);
// 4. Alice 领取(她在快照时是持有者)
await dist.write.claim([PROP_A], { account: alice.account });
const aliceBal = await usdc.read.balanceOf([alice.account.address]);
assert.ok(aliceBal > 0n);
// 5. Bob 尝试领取应失败
await assert.rejects(
dist.write.claim([PROP_A], { account: bob.account }),
/RWA: No reward/
);
});
});
# 部署脚本
// scripts/deploy.js import { network, artifacts } from "hardhat"; async function main() { // 连接网络 const { viem } = await network.connect({ network: network.name });//指定网络进行链接
// 获取客户端 const [deployer] = await viem.getWalletClients(); const publicClient = await viem.getPublicClient();
const deployerAddress = deployer.account.address; console.log("部署者的地址:", deployerAddress); // 加载合约 const USDC_DECIMALS = 18; const USDCAfter=await artifacts.readArtifact("TestUSDT"); const RWAAssetArtifact = await artifacts.readArtifact("RWAAsset"); const RWAMarketplaceArtifact = await artifacts.readArtifact("RWAMarketplace"); const RentalDistributorArtifact = await artifacts.readArtifact("RentalDistributor"); const USDCHash=await deployer.deployContract({ abi: USDCAfter.abi,//获取abi bytecode: USDCAfter.bytecode,//硬编码 args: ["USDC", "USDC",USDC_DECIMALS] }); const USDCReceipt=await publicClient.waitForTransactionReceipt({ hash : USDCHash }); console.log("USDC合约地址:", USDCReceipt.contractAddress); // 部署(构造函数参数:recipient, initialOwner) const RWAAssetHash = await deployer.deployContract({ abi: RWAAssetArtifact.abi,//获取abi bytecode: RWAAssetArtifact.bytecode,//硬编码 args: [],//USDC合约地址 });
// 等待确认并打印地址 const RWAAssetReceipt = await publicClient.waitForTransactionReceipt({ hash : RWAAssetHash }); console.log("RWAAsset合约地址:", RWAAssetReceipt.contractAddress); const RWAMarketplaceHash = await deployer.deployContract({ abi: RWAMarketplaceArtifact.abi,//获取abi bytecode: RWAMarketplaceArtifact.bytecode,//硬编码 args: [RWAAssetReceipt.contractAddress,USDCReceipt.contractAddress],//USDC合约地址 }); const RWAMarketplaceReceipt = await publicClient.waitForTransactionReceipt({ hash : RWAMarketplaceHash }); console.log("RWAMarketplace合约地址:", RWAMarketplaceReceipt.contractAddress); const RentalDistributorHash = await deployer.deployContract({ abi: RentalDistributorArtifact.abi, bytecode: RentalDistributorArtifact.bytecode, args: [RWAMarketplaceReceipt.contractAddress,USDCReceipt.contractAddress], }); const RentalDistributorReceipt = await publicClient.waitForTransactionReceipt({ hash: RentalDistributorHash }); console.log("RentalDistributor合约地址:", RentalDistributorReceipt.contractAddress);
}
main().catch(console.error);
# 总结
至此,关于构建链上房产RWA系统的最小可行单元(MVP)已全部落地。本文从架构设计出发,完整梳理了基于OpenZeppelin V5与Solidity 0.8.24的合约开发、Hardhat+Viem的测试验证、以及自动化部署的全流程实践,为RWA资产上链提供了可复用的技术范式与工程参考。 如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!