前言本文围绕ERC-2981版税标准展开,先系统梳理其核心定义、功能、解决的行业痛点及典型使用场景,再基于OpenZeppelin库整合ERC-721与ERC-2981标准实现版税NFT智能合约,最后通过HardhatV3完成合约的开发、测试、部署全流程落地。概述
本文围绕 ERC-2981 版税标准展开,先系统梳理其核心定义、功能、解决的行业痛点及典型使用场景,再基于 OpenZeppelin 库整合 ERC-721 与 ERC-2981 标准实现版税 NFT 智能合约,最后通过 Hardhat V3 完成合约的开发、测试、部署全流程落地。
概述
ERC-2981 是以太坊 NFT 的链上版税标准,为 ERC-721/ERC-1155 合约提供统一的版税查询接口,让市场能自动获取分成规则并向创作者支付二级市场收益,核心解决早期版税碎片化、不可靠与跨平台不兼容问题,广泛用于数字艺术、游戏道具等需持续收益的 NFT 场景
ERC-2981 是什么
- 定义:以太坊改进提案 EIP-2981(又称 ERC-2981),是 NFT 领域的标准化版税查询接口标准,兼容 ERC-721 与 ERC-1155,通过 EIP-165 接口识别,不强制市场执行版税,而是提供统一的链上版税信息查询能力。
核心接口(IERC2981) :
royaltyInfo(uint256 tokenId, uint256 salePrice):返回版税接收地址与应付金额(以基点计算,1 基点 = 0.01%)。_setTokenRoyalty(单 token 版税)、_setDefaultRoyalty(全局默认版税),用于设置版税规则。关键特性:链上透明、可组合、兼容主流 NFT 标准,不依赖平台规则,由合约自主定义版税策略。
royaltyInfo计算分成,自动将版税划转至创作者 / 权利人,剩余款项给卖家。| 痛点 | 解决方案 |
|---|---|
| 版税碎片化 | 统一接口替代各平台专有规则,开发者无需重复适配 |
| 收益不可靠 | 链上存储规则,减少依赖平台中心化结算的信用风险 |
| 跨平台不兼容 | 标准接口让市场无缝读取版税,保障创作者跨平台收益 |
| 信息不透明 | 公开可查询的版税比例与接收地址,避免暗箱操作 |
| 开发成本高 | 复用 OpenZeppelin 等库的实现,降低版税功能开发门槛 |
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts/token/common/ERC2981.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/utils/Strings.sol"; // 新增:用于tokenURI
contract MyRoyaltyNFT is ERC721, ERC2981, Ownable { // 使用 uint256 替代 Counters.Counter(5.x版本已移除该库) uint256 private _nextTokenId;
string private _baseTokenURI;
uint96 private constant MAX_ROYALTY_BPS = 1000; // 10% 版税上限
// 新增:可选的最大供应量限制(设为0则无限制)
uint256 public immutable maxSupply;
// 新增:合约部署事件
event Minted(address indexed to, uint256 indexed tokenId);
constructor(
string memory name,
string memory symbol,
string memory baseURI,
address royaltyReceiver,
uint96 royaltyBps,
uint256 _maxSupply // 新增参数,设为0表示无上限
) ERC721(name, symbol) Ownable(msg.sender) {
_baseTokenURI = baseURI;
maxSupply = _maxSupply;
// 设置默认版税(basis points: 100 = 1%)
require(royaltyBps <= MAX_ROYALTY_BPS, "Royalty too high");
_setDefaultRoyalty(royaltyReceiver, royaltyBps);
}
// ======================== 铸造功能 ========================
// 优化:原生递增 + 可选供应上限
function safeMint(address to) public onlyOwner {
require(
maxSupply == 0 || _nextTokenId < maxSupply,
"Max supply reached"
);
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
emit Minted(to, tokenId); // 记录铸造事件
}
// 批量铸造(新增:高效铸造多个)
function safeMintBatch(address[] calldata recipients) external onlyOwner {
for (uint256 i = 0; i < recipients.length; i++) {
safeMint(recipients[i]);
}
}
// 获取已铸造总量
function totalSupply() external view returns (uint256) {
return _nextTokenId;
}
// ======================== 版税管理 ========================
// 优化:统一版税验证逻辑
function setTokenRoyalty(
uint256 tokenId,
address receiver,
uint96 feeNumerator
) external onlyOwner {
_validateRoyalty(feeNumerator);
_setTokenRoyalty(tokenId, receiver, feeNumerator);
}
function setDefaultRoyalty(
address receiver,
uint96 feeNumerator
) external onlyOwner {
_validateRoyalty(feeNumerator);
_setDefaultRoyalty(receiver, feeNumerator);
}
function resetTokenRoyalty(uint256 tokenId) external onlyOwner {
_resetTokenRoyalty(tokenId);
}
// 内部函数:验证版税比例
function _validateRoyalty(uint96 feeNumerator) internal pure {
require(feeNumerator <= MAX_ROYALTY_BPS, "Royalty exceeds 10%");
}
// ======================== 元数据 ========================
// 优化:自动拼接tokenId
function tokenURI(uint256 tokenId)
public
view
virtual
override
returns (string memory)
{
_requireOwned(tokenId); // 5.x推荐:替代require(_exists())
string memory baseURI = _baseURI();
return bytes(baseURI).length > 0
? string.concat(baseURI, Strings.toString(tokenId), ".json") // 自动添加.json扩展名
: "";
}
function _baseURI() internal view virtual override returns (string memory) {
return _baseTokenURI;
}
function setBaseURI(string memory newBaseURI) external onlyOwner {
_baseTokenURI = newBaseURI;
}
// ======================== 接口支持 ========================
// 必须重写 supportsInterface 以支持 ERC165 接口检测
function supportsInterface(bytes4 interfaceId)
public
view
virtual
override(ERC721, ERC2981)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
### 编译指令
npx hardhat compile
## 智能合约部署脚本
// 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 artifact = await artifacts.readArtifact("MyRoyaltyNFT"); const ipfsjsonuri="https://zygomorphic-magenta-bobolink.myfilebase.com/ipfs/QmQT8VpmWQVhUhoDCEK1mdHXaFaJ3KawkRxHm96GUhrXLB" // 部署(构造函数参数:recipient, initialOwner) const hash = await deployer.deployContract({ abi: artifact.abi,//获取abi bytecode: artifact.bytecode,//硬编码 args: ["MyRoyaltyNFT","MRNFT",ipfsjsonuri,deployerAddress,100,0],//nft名称,nft符号,ipfsjsonuri,部署者地址, royaltiesNumerator,royaltiesDenominator });
// 等待确认并打印地址 const receipt = await publicClient.waitForTransactionReceipt({ hash }); console.log("合约地址:", receipt.contractAddress); }
main().catch(console.error);
### 部署指令
npx hardhat run ./scripts/xxx.ts
## 智能合约测试脚本
import assert from "node:assert/strict"; import { describe, it,beforeEach } from "node:test"; import { formatEther,parseEther } from 'viem' import { network } from "hardhat"; describe("MyRoyaltyNFT", async function () { let viem: any; let publicClient: any; let owner: any, user1: any, user2: any, user3: any; let deployerAddress: string; let MyRoyaltyNFT: any; beforeEach (async function () { const { viem } = await network.connect(); publicClient = await viem.getPublicClient();//创建一个公共客户端实例用于读取链上数据(无需私钥签名)。 [owner,user1,user2,user3] = await viem.getWalletClients();//获取第一个钱包客户端 写入联合交易 deployerAddress = owner.account.address;//钱包地址 const ipfsjsonuri="https://zygomorphic-magenta-bobolink.myfilebase.com/ipfs/QmQT8VpmWQVhUhoDCEK1mdHXaFaJ3KawkRxHm96GUhrXLB";
MyRoyaltyNFT = await viem.deployContract("MyRoyaltyNFT", [
"My Royalty NFT",
"MRNFT",
ipfsjsonuri,
deployerAddress,
200,//版税1%
0,
]);//部署合约
console.log("MyRoyaltyNFT合约地址:", MyRoyaltyNFT.address);
});
it("测试MyRoyaltyNFT", async function () {
//查询nft名称和符号
const name= await publicClient.readContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "name",
args: [],
});
const symbol= await publicClient.readContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "symbol",
args: [],
});
//查询总供应量和最大供应量
const totalSupply= await publicClient.readContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "totalSupply",
args: [],
});
const maxSupply= await publicClient.readContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "maxSupply",
args: [],
});
//查询合约拥有者
const ownerAddress= await publicClient.readContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "owner",
args: [],
});
console.log(name,symbol,totalSupply,maxSupply,ownerAddress)
//铸造单个nft
await owner.writeContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "safeMint",
args: [user1.account.address],
});
//批量铸造nft
const nftaddress=[user1.account.address,user2.account.address,user3.account.address]
await owner.writeContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "safeMintBatch",
args: [nftaddress],
});
//查询单个nft的tokenURI
const TokenURI= await publicClient.readContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "tokenURI",
args: [0],
});
console.log(TokenURI)
//查询余额和拥有者
const balanceOf=await publicClient.readContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "balanceOf",
args: [user1.account.address],
});
console.log(balanceOf)
//查询nft的拥有者
const ownerOf=await publicClient.readContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "ownerOf",
args: [0],
});
console.log(ownerOf)
//查询版税信息
const royaltyInfo=await publicClient.readContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "royaltyInfo",
args: [0,parseEther("2")],
});
console.log(royaltyInfo)
const GETAPPROVED=await publicClient.readContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "getApproved",
args: [0],
});
console.log(GETAPPROVED)
//设置BaseURI
const ipfsjsonuri1="https://zygomorphic-magenta-bobolink.myfilebase.com/ipfs/QmcN49MKt4MbSXSGckAcpvFqtea43uuPD2tvmuER1mG67s";
const setBaseURI=await owner.writeContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "setBaseURI",
args: [ipfsjsonuri1],
});
console.log(setBaseURI)
//查询更新后的tokenURI
const TokenURI1= await publicClient.readContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "tokenURI",
args: [0],
});
console.log("更新后",TokenURI1)
//设置默认版税
// const SETDEFAULTROYALTY=await owner.writeContract({
// address: MyRoyaltyNFT.address,
// abi: MyRoyaltyNFT.abi,
// functionName: "setDefaultRoyalty",
// args: [user3.account.address,"500"],
// });
// console.log(SETDEFAULTROYALTY)
//设置版税
const setTokenRoyalty = await owner.writeContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "setTokenRoyalty",
args: [0,user3.account.address,"500"],
});
//查询版税信息
const royaltyInfo1=await publicClient.readContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "royaltyInfo",
args: [0,parseEther("3")],
});
console.log("更新后版税信息",royaltyInfo1)
//转账nft
const TRANSFERFROM=await user1.writeContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "transferFrom",
args: [user1.account.address,user2.account.address,0],
});
//查询nft的新拥有者
const ownerOf1=await publicClient.readContract({
address: MyRoyaltyNFT.address,
abi: MyRoyaltyNFT.abi,
functionName: "ownerOf",
args: [0],
});
console.log(ownerOf1)
});
});
### 测试指令
npx hardhat test ./test/xxx.ts
# 总结
至此,关于ERC-2981 版税标准从理论梳理到代码实现、工程落地的全流程实践,既验证了该标准的核心价值,也为开发者提供了可直接复用的版税 NFT 开发范式,是 NFT 生态从 “交易” 向 “持续价值分配” 演进的重要落地参考。 如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!