140 lines
5.0 KiB
Solidity
140 lines
5.0 KiB
Solidity
// SPDX-License-Identifier: MIT
|
||
pragma solidity ^0.8.20;
|
||
|
||
import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
|
||
import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
|
||
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
|
||
import "./interfaces/ICoupon.sol";
|
||
|
||
/// @title Coupon — 券 NFT 合约
|
||
/// @notice ERC-721 实现,支持不可转让限制、转售计数、批量操作
|
||
/// @dev 使用 Transparent Proxy 部署,券类型铸造后不可修改(安全红线)
|
||
contract Coupon is Initializable, ERC721Upgradeable, AccessControlUpgradeable {
|
||
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
|
||
bytes32 public constant SETTLER_ROLE = keccak256("SETTLER_ROLE");
|
||
bytes32 public constant FACTORY_ROLE = keccak256("FACTORY_ROLE");
|
||
|
||
uint256 private _nextTokenId;
|
||
|
||
// tokenId → 券配置(铸造后 couponType 不可修改 — 安全红线)
|
||
mapping(uint256 => ICoupon.CouponConfig) private _configs;
|
||
// tokenId → 面值
|
||
mapping(uint256 => uint256) private _faceValues;
|
||
// tokenId → 转售次数(安全红线:不可被升级绕过)
|
||
mapping(uint256 => uint256) private _resaleCount;
|
||
|
||
// --- Events ---
|
||
event CouponMinted(uint256 indexed tokenId, address indexed issuer, uint256 faceValue, ICoupon.CouponType couponType);
|
||
event CouponBurned(uint256 indexed tokenId, address indexed burner);
|
||
event ResaleCountIncremented(uint256 indexed tokenId, uint256 newCount);
|
||
|
||
function initialize(string memory name, string memory symbol, address admin) external initializer {
|
||
__ERC721_init(name, symbol);
|
||
__AccessControl_init();
|
||
|
||
_grantRole(DEFAULT_ADMIN_ROLE, admin);
|
||
_grantRole(MINTER_ROLE, admin);
|
||
_nextTokenId = 1;
|
||
}
|
||
|
||
/// @notice 铸造单个券 NFT
|
||
function mint(
|
||
address to,
|
||
uint256 faceValue,
|
||
ICoupon.CouponConfig calldata config
|
||
) external onlyRole(FACTORY_ROLE) returns (uint256 tokenId) {
|
||
tokenId = _nextTokenId++;
|
||
_safeMint(to, tokenId);
|
||
_configs[tokenId] = config;
|
||
_faceValues[tokenId] = faceValue;
|
||
_resaleCount[tokenId] = 0;
|
||
|
||
emit CouponMinted(tokenId, config.issuer, faceValue, config.couponType);
|
||
}
|
||
|
||
/// @notice 销毁券(兑付时调用)
|
||
function burn(uint256 tokenId) external {
|
||
require(
|
||
_isApprovedOrOwner(msg.sender, tokenId) || hasRole(SETTLER_ROLE, msg.sender),
|
||
"Coupon: not authorized to burn"
|
||
);
|
||
address owner = ownerOf(tokenId);
|
||
_burn(tokenId);
|
||
emit CouponBurned(tokenId, owner);
|
||
}
|
||
|
||
/// @notice 获取券配置
|
||
function getConfig(uint256 tokenId) external view returns (ICoupon.CouponConfig memory) {
|
||
require(_exists(tokenId), "Coupon: nonexistent token");
|
||
return _configs[tokenId];
|
||
}
|
||
|
||
/// @notice 获取面值
|
||
function getFaceValue(uint256 tokenId) external view returns (uint256) {
|
||
require(_exists(tokenId), "Coupon: nonexistent token");
|
||
return _faceValues[tokenId];
|
||
}
|
||
|
||
/// @notice 获取转售次数
|
||
function getResaleCount(uint256 tokenId) external view returns (uint256) {
|
||
return _resaleCount[tokenId];
|
||
}
|
||
|
||
/// @notice 增加转售次数(仅 Settlement 合约可调用)
|
||
function incrementResaleCount(uint256 tokenId) external onlyRole(SETTLER_ROLE) {
|
||
_resaleCount[tokenId]++;
|
||
emit ResaleCountIncremented(tokenId, _resaleCount[tokenId]);
|
||
}
|
||
|
||
/// @notice 批量转移
|
||
function batchTransfer(
|
||
address from,
|
||
address to,
|
||
uint256[] calldata tokenIds
|
||
) external {
|
||
for (uint256 i = 0; i < tokenIds.length; i++) {
|
||
safeTransferFrom(from, to, tokenIds[i]);
|
||
}
|
||
}
|
||
|
||
/// @notice 重写 transfer 钩子 — 不可转让券直接 revert
|
||
/// @dev 铸造(from=0)和销毁(to=0)不受限
|
||
function _beforeTokenTransfer(
|
||
address from,
|
||
address to,
|
||
uint256 tokenId,
|
||
uint256 batchSize
|
||
) internal virtual override {
|
||
super._beforeTokenTransfer(from, to, tokenId, batchSize);
|
||
|
||
// 铸造和销毁不受限
|
||
if (from == address(0) || to == address(0)) return;
|
||
|
||
ICoupon.CouponConfig memory config = _configs[tokenId];
|
||
|
||
// 不可转让券:revert
|
||
require(config.transferable, "Coupon: non-transferable");
|
||
|
||
// 转售次数检查(安全红线)
|
||
require(
|
||
_resaleCount[tokenId] < config.maxResaleCount,
|
||
"Coupon: max resale count exceeded"
|
||
);
|
||
}
|
||
|
||
/// @dev 内部辅助:检查授权(override OZ v4)
|
||
function _isApprovedOrOwner(address spender, uint256 tokenId) internal view override returns (bool) {
|
||
return super._isApprovedOrOwner(spender, tokenId);
|
||
}
|
||
|
||
// --- ERC165 ---
|
||
function supportsInterface(bytes4 interfaceId)
|
||
public
|
||
view
|
||
override(ERC721Upgradeable, AccessControlUpgradeable)
|
||
returns (bool)
|
||
{
|
||
return super.supportsInterface(interfaceId);
|
||
}
|
||
}
|