看到这样一个设想:如果一只基金同时持有代币化黄金、科技股和数字资产,是否能在链上实现统一净值、灵活申赎和透明记账?这个问题的技术答案,比金融答案更早落地。本文将从一个可运行的多资产组合基金 PoC 出发,完整拆解如何用 Solidity 编写三类 ERC-20 代币化资产、一个模拟价格预言机,以及一个负责份额发行与赎回的基金合约。无论你是想了解真实世界资产代币化(RWA)的开发路径,还是需要一份链上基金系统的最小实现,这份教程都可以直接作为起点。
我会尽可能把每个合约、每个函数、每个参数都讲清楚,并重点说明哪些代码只能用于教学演示、哪些地方在生产环境必须替换或加固。文中所有代码和数值均用于技术演示,不构成任何投资建议。
1. 背景:链上基金为什么能同时装下黄金、股票和数字资产
1.1 从“一块金条”到“一串代币”:代币化资产入门
代币化资产(Tokenized Assets)是指把现实世界中的资产,例如黄金、股票、债券、房地产等,通过区块链技术转换为一枚可在链上流通的数字代币。这个转换过程并没有把黄金变成互联网上的数据符号,而是在链下由托管机构保管真实的黄金,在链上通过合约记录“谁拥有多少份额”。
过去购买黄金的最小单位可能是一克、一盎司,但链上的代币可以拆分到小数点后 18 位,这让小额参与成为可能。更重要的是,代币化之后的资产天然支持可编程操作:可以转账、抵押、组合,也可以接入智能合约做自动化管理。这正是“一篮子资产组合”可以跑在链上的基础。
理解代币化资产时,有一个容易混淆的点:代币本身不等于黄金,它只是黄金所有权的记账凭证。只要托管、审计和合约逻辑没有做好,代币就只是一个符号。所以在学习技术的同时,也必须理解资产真实性来自链下体系。
1.2 为什么黄金、科技股和数字资产会被放进同一只基金
传统的投资组合也会同时配置黄金、股票和另类资产,但这类组合通常由中心化金融机构管理,普通用户看到的是一个净值数字,很难实时核对底层资产明细。把黄金、科技股和数字资产代币化,再放入同一个链上基金,好处是组合透明:每种资产的余额、价格、权重都在链上可知,用户可以通过合约计算实时净值。
从技术演示来看,这个组合也很有代表性:
- 代币化黄金代表真实世界资产上链;
- 代币化科技股代表传统金融产品的合约化表达;
- 数字资产本身就在链上,接入成本最低。
三类资产的价格来源、波动特征和交易方式都不一样,而这正好考验基金合约的数据获取、净值计算和赎回分配逻辑。
需要再次强调,本文只讨论技术实现。真实金融市场中,这类产品涉及证券法、商品法、托管、反洗钱等复杂监管要求,不是一个智能合约就能解决的。
1.3 本文的技术目标
读完这篇文章,你会掌握以下能力:
- 创建一个可复用的 ERC-20 资产代币合约,并通过它发行“代币化黄金”“代币化科技股”“代币化数字资产”;
- 编写一个模拟价格预言机,为不同资产提供美元计价价格;
- 编写一个链上基金合约,支持资产存入、份额发行、按比例赎回;
- 使用 Hardhat 完成合约部署、脚本调用和基础结果验证;
- 了解从教学 PoC 到真实产品之间需要补齐的安全、托管和合规能力。
2. 技术底座与开发环境
2.1 技术选型
链上基金的核心逻辑需要跑在可验证的智能合约环境中,因此本文选择 Solidity 作为合约语言,使用 Hardhat 作为开发与部署框架。
Solidity 是目前以太坊生态使用最广泛的智能合约语言,资料多、工具链成熟。OpenZeppelin 提供了 ERC-20 的标准实现和安全工具,可以直接复用。价格部分,真实项目一般接入 Chainlink Price Feeds,本文为了保持示例可离线运行,使用一个自定义的 MockPriceOracle 模拟价格读取,但接口设计尽量贴近真实使用方式。
2.2 开发环境与依赖
本文示例基于以下环境组合:
- Node.js 16 或 18
- Hardhat 2.x
- Solidity 0.8.17
- OpenZeppelin Contracts 4.x
- ethers.js 5.x
版本需要根据你的项目实际情况调整。如果你使用的 Hardhat 3 或 ethers v6,部分 API 名称会有变化,例如ethers.utils.parseEther在 v6 中变成了ethers.parseEther。下面示例以 Hardhat 2.x + ethers v5 这一套经过大量验证的组合为准。
2.3 项目结构
项目目录规划如下:
portfolio-fund-poc/ ├── contracts/ │ ├── AssetToken.sol │ ├── MockPriceOracle.sol │ └── PortfolioFund.sol ├── scripts/ │ ├── deploy.js │ └── demo.js ├── hardhat.config.js └── package.jsoncontracts目录保存三个核心合约,scripts目录保存部署和演示脚本。整体结构非常简单,适合作为学习模板继续扩展。
3. 核心机制拆解
3.1 ERC-20 资产代币怎么表达“黄金份额”
ERC-20 是 Ethereum 上最常见的代币标准。它定义了balanceOf、transfer、approve、transferFrom等接口,让每个地址都能查询余额并进行转账。
如果要表达“代币化黄金”,可以创建一个名为AssetToken的合约,传入不同名称和符号,分别部署为黄金、股票和数字资产。为了简化演示,合约中加入了公开的mint和burn方法。这里必须特别注意:公开mint意味着任何人都能凭空铸造代币,这是非常危险的设计,仅适用于教学演示。生产环境必须把mint权限限制给经过授权的托管人合约,并且结合 KYC 流程。
教学合约的价值在于展示 ERC-20 的最小实现与使用方式,但绝不能直接用来发行真实资产。
3.2 价格预言机:基金怎么知道金价、股价、币价
基金计算净值时,需要知道每种资产的美元价格。如果由基金合约自己保存价格,就会存在两个问题:管理员可以随意修改价格操纵净值;价格数据无法自证来源。
真实产品通常使用去中心化预言机,例如 Chainlink。Chainlink 通过多个独立节点从交易所聚合价格,再上链供合约读取。本文为了本地演示,使用MockPriceOracle合约:
- 管理员设置每个资产代币对应的美元价格;
- 基金合约调用
getPriceUSD(asset)获取价格; - 价格同样使用 18 位小数精度,与资产代币保持一致。
这个设计让基金合约不关心价格从哪来,只负责读取。后续要接入真实预言机,只需要替换oracle地址,基金主逻辑不需要大幅改动。
3.3 基金净值与份额:NAV / pricePerShare / deposit / redeem
基金的核心是净值计算。
基金总资产totalAssetsUSD等于三种资产数量分别乘以对应价格后的总和。每个份额对应的价值pricePerShare等于总资产除以总份额:
pricePerShare = totalAssetsUSD / totalShares用户存入资产时,合约先根据存入资产的美元价值计算可发行份额:
sharesToMint = valueUSD / pricePerShare用户赎回时,合约先计算用户份额对应的美元价值,再按当前三种资产在组合中的权重,分别转出对应数量的资产。这种方式避免了“用户只赎回美元,基金却缺少美元流动性”的问题,保持了底层资产池的完整性。
4. 完整实战:搭建一个多资产代币化基金 PoC
4.1 初始化项目
先在本地创建项目目录并初始化 npm:
mkdir portfolio-fund-poc && cd portfolio-fund-poc npm init -y安装 Hardhat 和 OpenZeppelin 依赖:
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox @openzeppelin/contracts安装完成后,创建hardhat.config.js:
require("@nomicfoundation/hardhat-toolbox"); module.exports = { solidity: "0.8.17", };4.2 编写资产代币合约
资产代币使用统一的AssetToken合约,通过构造参数区分名称和符号。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract AssetToken is ERC20 { constructor( string memory name_, string memory symbol_ ) ERC20(name_, symbol_) {} function mint(address to, uint256 amount) external { _mint(to, amount); } function burn(address from, uint256 amount) external { _burn(from, amount); } }部署时,我们会创建三个AssetToken实例:
- Tokenized Mock Gold,符号
MGLD,模拟代币化黄金; - Tokenized Tech Stock,符号
MTECH,模拟代币化科技股; - Tokenized Digital Asset,符号
MDIG,模拟数字资产。
mint和burn方法没有做权限控制,这是为了让演示脚本可以随时铸币。真实场景下,建议将mint修改为:
function mint(address to, uint256 amount) external onlyMinter { _mint(to, amount); }并通过minter角色管理铸造权限。
4.3 编写 MockPriceOracle
价格预言机保存每个资产代币对应的美元价格。为了方便计算,价格同样使用 18 位小数,60000 * 1e18表示一个 MGLD 价值 60000 美元。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import "@openzeppelin/contracts/access/Ownable.sol"; contract MockPriceOracle is Ownable { mapping(address => uint256) public pricesUSD; function setPrice(address asset, uint256 priceUSD) external onlyOwner { require(priceUSD > 0, "invalid price"); pricesUSD[asset] = priceUSD; } function getPriceUSD(address asset) public view returns (uint256) { uint256 price = pricesUSD[asset]; require(price > 0, "price not set"); return price; } }这个合约使用Ownable,只有合约所有者才能修改价格。真实项目中,价格应该由 Chainlink 数据源维护,而不是管理员直接写入。这里的setPrice只用于模拟行情变化。
4.4 编写 PortfolioFund 基金合约
这是整个 PoC 的核心。合约在构造函数中接收预言机地址,以及三个资产代币地址:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "./MockPriceOracle.sol"; contract PortfolioFund is Ownable { MockPriceOracle public oracle; IERC20 public goldToken; IERC20 public stockToken; IERC20 public digitalToken; uint256 public totalShares; mapping(address => uint256) public shares; bool public initialized; event Deposit(address indexed user, uint256 sharesMinted, uint256 valueUSD); event Redeem(address indexed user, uint256 sharesBurned, uint256 valueUSD); constructor( address _oracle, address _gold, address _stock, address _digital ) { oracle = MockPriceOracle(_oracle); goldToken = IERC20(_gold); stockToken = IERC20(_stock); digitalToken = IERC20(_digital); } function seedFund(address asset, uint256 amount) external onlyOwner { IERC20(asset).transferFrom(msg.sender, address(this), amount); } function initializeFund(uint256 initialShares) external onlyOwner { require(!initialized, "already initialized"); require(initialShares > 0, "invalid initial shares"); require(totalAssetsUSD() > 0, "fund is empty"); initialized = true; shares[msg.sender] = initialShares; totalShares = initialShares; } function totalAssetsUSD() public view returns (uint256) { uint256 goldValue = _assetValue(address(goldToken)); uint256 stockValue = _assetValue(address(stockToken)); uint256 digitalValue = _assetValue(address(digitalToken)); return goldValue + stockValue + digitalValue; } function _assetValue(address asset) internal view returns (uint256) { uint256 balance = IERC20(asset).balanceOf(address(this)); uint256 price = oracle.getPriceUSD(asset); return (balance * price) / 1e18; } function pricePerShare() public view returns (uint256) { require(initialized, "not initialized"); require(totalShares > 0, "no shares"); return (totalAssetsUSD() * 1e18) / totalShares; } function deposit(address asset, uint256 amount) external { require(initialized, "not initialized"); require(amount > 0, "amount is zero"); require(_isSupported(asset), "asset not supported"); uint256 valueUSD = (amount * oracle.getPriceUSD(asset)) / 1e18; uint256 sharesToMint = (valueUSD * 1e18) / pricePerShare(); require(sharesToMint > 0, "shares too small"); IERC20(asset).transferFrom(msg.sender, address(this), amount); shares[msg.sender] += sharesToMint; totalShares += sharesToMint; emit Deposit(msg.sender, sharesToMint, valueUSD); } function redeem(uint256 shareAmount) external { require(initialized, "not initialized"); require(shares[msg.sender] >= shareAmount, "insufficient shares"); uint256 valueUSD = (shareAmount * pricePerShare()) / 1e18; uint256 totalValue = totalAssetsUSD(); require(totalValue > 0, "fund is empty"); shares[msg.sender] -= shareAmount; totalShares -= shareAmount; _payOut(address(goldToken), valueUSD, totalValue); _payOut(address(stockToken), valueUSD, totalValue); _payOut(address(digitalToken), valueUSD, totalValue); emit Redeem(msg.sender, shareAmount, valueUSD); } function _payOut( address asset, uint256 userValueUSD, uint256 totalValue ) internal { uint256 balance = IERC20(asset).balanceOf(address(this)); uint256 assetValue = (balance * oracle.getPriceUSD(asset)) / 1e18; if (assetValue == 0) return; uint256 payValue = (userValueUSD * assetValue) / totalValue; uint256 payAmount = (payValue * 1e18) / oracle.getPriceUSD(asset); if (payAmount > 0) { IERC20(asset).transfer(msg.sender, payAmount); } } function _isSupported(address asset) internal view returns (bool) { return asset == address(goldToken) || asset == address(stockToken) || asset == address(digitalToken); } }合约整体流程如下:
- 管理员通过
seedFund把三种资产转入基金,模拟初始建仓; - 管理员通过
initializeFund设定初始份额,相当于为基金“定价”; - 普通用户调用
deposit存入任意一种受支持资产,获得相应份额; - 用户调用
redeem销毁份额,按当前权重获得三种资产。
_payOut的逻辑需要重点理解。假设用户赎回份额价值 100 美元,基金总资产 1000 美元,其中黄金资产价值 200 美元,那么用户应该从黄金池中分到:
payValue = 100 * 200 / 1000 = 20 美元再根据黄金当前价格换算成黄金代币数量转给用户。这个按比例分配的方法,保证了赎回后剩余用户的组合比例不会被破坏。
不过这个合约只是 PoC,仍有几个简化点:
- 未做滑点控制,用户在价格波动期间赎回可能产生组合偏移;
- 未使用 ReentrancyGuard,虽然代码先更新状态再转账,但生产环境建议显式加保护;
- 未处理价格精度不一致问题,假设所有资产和价格都是 18 位小数;
mint/burn无权限控制,不能用于真实资产。
4.5 编写部署与演示脚本
部署脚本负责创建资产代币、预言机和基金合约,并设置初始模拟价格:
// scripts/deploy.js const hre = require("hardhat"); async function main() { const { ethers } = hre; const AssetToken = await ethers.getContractFactory("AssetToken"); const gold = await AssetToken.deploy("Tokenized Mock Gold", "MGLD"); await gold.deployed(); const stock = await AssetToken.deploy("Tokenized Tech Stock", "MTECH"); await stock.deployed(); const digital = await AssetToken.deploy("Tokenized Digital Asset", "MDIG"); await digital.deployed(); const MockPriceOracle = await ethers.getContractFactory("MockPriceOracle"); const oracle = await MockPriceOracle.deploy(); await oracle.deployed(); // 模拟价格,仅用于演示 await oracle.setPrice(gold.address, ethers.utils.parseEther("60000")); await oracle.setPrice(stock.address, ethers.utils.parseEther("180")); await oracle.setPrice(digital.address, ethers.utils.parseEther("3000")); const PortfolioFund = await ethers.getContractFactory("PortfolioFund"); const fund = await PortfolioFund.deploy( oracle.address, gold.address, stock.address, digital.address ); await fund.deployed(); console.log("MGLD:", gold.address); console.log("MTECH:", stock.address); console.log("MDIG:", digital.address); console.log("Oracle:", oracle.address); console.log("Fund:", fund.address); } main().catch((error) => { console.error(error); process.exitCode = 1; });演示脚本会完成建仓、初始化、申购和赎回的完整链路:
// scripts/demo.js const hre = require("hardhat"); async function main() { const { ethers } = hre; const [admin, user] = await ethers.getSigners(); const AssetToken = await ethers.getContractFactory("AssetToken"); const gold = await AssetToken.deploy("Tokenized Mock Gold", "MGLD"); const stock = await AssetToken.deploy("Tokenized Tech Stock", "MTECH"); const digital = await AssetToken.deploy("Tokenized Digital Asset", "MDIG"); await gold.deployed(); await stock.deployed(); await digital.deployed(); const MockPriceOracle = await ethers.getContractFactory("MockPriceOracle"); const oracle = await MockPriceOracle.deploy(); await oracle.deployed(); await oracle.setPrice(gold.address, ethers.utils.parseEther("60000")); await oracle.setPrice(stock.address, ethers.utils.parseEther("180")); await oracle.setPrice(digital.address, ethers.utils.parseEther("3000")); const PortfolioFund = await ethers.getContractFactory("PortfolioFund"); const fund = await PortfolioFund.deploy( oracle.address, gold.address, stock.address, digital.address ); await fund.deployed(); // 给 admin 和 user 铸造测试代币 await gold.mint(admin.address, ethers.utils.parseEther("1000")); await gold.mint(user.address, ethers.utils.parseEther("100")); await stock.mint(admin.address, ethers.utils.parseEther("1000")); await digital.mint(admin.address, ethers.utils.parseEther("1000")); // 管理员建仓:100 MGLD、200 MTECH、100 MDIG await gold.approve(fund.address, ethers.utils.parseEther("100")); await stock.approve(fund.address, ethers.utils.parseEther("200")); await digital.approve(fund.address, ethers.utils.parseEther("100")); await fund.seedFund(gold.address, ethers.utils.parseEther("100")); await fund.seedFund(stock.address, ethers.utils.parseEther("200")); await fund.seedFund(digital.address, ethers.utils.parseEther("100")); await fund.initializeFund(ethers.utils.parseEther("10000")); const ppsBefore = await fund.pricePerShare(); console.log("pricePerShare before:", ethers.utils.formatEther(ppsBefore)); // 用户存入 1 MGLD await gold.connect(user).approve(fund.address, ethers.utils.parseEther("1")); await fund.connect(user).deposit(gold.address, ethers.utils.parseEther("1")); const userShares = await fund.shares(user.address); console.log("user shares:", ethers.utils.formatEther(userShares)); const totalAssets = await fund.totalAssetsUSD(); console.log("totalAssets after deposit:", ethers.utils.formatEther(totalAssets)); // 用户赎回全部份额 await fund.connect(user).redeem(userShares); const goldBalance = await gold.balanceOf(user.address); console.log("user MGLD balance after redeem:", ethers.utils.formatEther(goldBalance)); } main().catch((error) => { console.error(error); process.exitCode = 1; });这段脚本特意把多个步骤放在一个演示脚本中,方便读者一次看到完整流程。真实项目建议拆分为独立的部署脚本和测试用例。
4.6 运行与验证
在项目根目录执行编译:
npx hardhat compile编译成功后,执行部署脚本:
npx hardhat run scripts/deploy.js部署成功后继续运行演示脚本:
npx hardhat run scripts/demo.js如果没有报错,会在终端看到一系列日志,例如pricePerShare before、user shares、totalAssets after deposit、user MGLD balance after redeem。
如果你希望更系统地验证,可以安装 Hardhat 测试插件,使用chai编写断言,而不是仅依赖脚本输出。这里为了篇幅,不展开测试用例,但建议你在自己的项目中补上。
4.7 预期结果分析
演示脚本的核心数据如下:
初始建仓时,管理员注入了:
- 100 MGLD,单价 60000 美元,价值 600 万美元;
- 200 MTECH,单价 180 美元,价值 3.6 万美元;
- 100 MDIG,单价 3000 美元,价值 30 万美元。
总资产约为 633.6 万美元。initializeFund(10000)后,每份份额价值约为 633.6 美元。
用户存入 1 MGLD,对应 60000 美元价值,可获得的份额约为:
60000 /