前言
随着区块链技术与真实世界资产(RWA, Real World Assets)融合趋势的加速,房产代币化凭借其庞大的市场规模和明确的收益模式,成为最具落地潜力的 RWA 赛道之一。传统房产投资存在门槛高、流动性差、分红不透明等痛点,而区块链技术可通过资产代币化、去中心化交易和自动化分红,重构房产投资的底层逻辑。
本文将深度拆解一套经实战验证的链上房产 RWA 技术架构,完整覆盖资产代币化、二级市场交易和租金自动分红三大核心模块,提供基于 Solidity 0.8.24 的合约实现、Hardhat+Viem 的测试方案及自动化部署脚本,为开发者落地房产 RWA 项目提供可直接复用的技术范式。
一、系统架构设计
1.1 代币标准选型:为何选择 ERC-1155?
房产 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% 的房产所有权),用户持有份额与房产收益分红直接挂钩。
1.2 系统组件全景
┌─────────────────────────────────────────────────────────┐
│ RWAAsset (ERC-1155) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 房产A (ID=1) │ │ 房产B (ID=2) │ │ 房产C (ID=3) │ │
│ │ 份额: 1000 │ │ 份额: 2000 │ │ 份额: 1500 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ RWAMarketplace│ │RentalDistributor│ │ Checkpoint │
│ 二级市场 │ │ 租金分红 │ │ 历史快照查询 │
└──────────────┘ └──────────────┘ └──────────────┘
-
RWAAsset:核心资产合约,基于 ERC-1155 实现多房产代币化发行,内置持仓快照功能,为分红提供数据支撑;
-
RWAMarketplace:去中心化交易市场,支持房产份额挂单、购买,保障交易安全与透明;
-
RentalDistributor:分红引擎,基于历史快照计算用户应得租金,支持单资产 / 多资产批量领取;
-
Checkpoint:快照模块,记录指定区块高度的用户持仓和总供应量,确保分红计算的公平性。
二、核心合约实现
2.1 测试用稳定币合约(TestUSDT)
为适配测试网环境,实现无权限铸币功能,便于快速验证合约逻辑:
//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);
}
}
2.2 核心业务合约(三合一)
整合资产发行、交易、分红三大核心能力,兼顾安全性与可扩展性:
- RWAAsset:带历史快照的ERC-1155资产合约
- RWAMarketplace:去中心化房产份额交易
- RentalDistributor:基于快照的租金分红引擎
// 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资产上链提供了可复用的技术范式与工程参考。