feat: ERC-1155 同质化券系统 + denom统一为agnx (18位EVM标准)
新增ERC-1155双模式券系统(Utility Track专用): - ICouponBatch接口 + CouponBatch核心合约(FACTORY/BURNER角色) - Redemption1155兑付合约(余额/过期/门店/黑名单验证) - CouponFactory新增mintBatch1155路由(上限100,000张O(1)铸造) - Deploy.s.sol部署CouponBatch+Redemption1155+角色授权 - 46个新测试(CouponBatch 16 + Redemption1155 9 + Factory 11 + Integration 5 + fuzz) Denom统一: ugnx(6位)→agnx(18位EVM标准), 金额×10^12 全量测试143/143通过,编译零错误。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0ea869ac46
commit
4c1d907df6
|
|
@ -4,7 +4,7 @@
|
|||
# ========================
|
||||
# 基本配置
|
||||
# ========================
|
||||
minimum-gas-prices = "0ugnx" # 平台全额补贴,用户零 Gas
|
||||
minimum-gas-prices = "0agnx" # 平台全额补贴,用户零 Gas(agnx = atto GNX, 18位精度)
|
||||
pruning = "default"
|
||||
pruning-keep-recent = "100"
|
||||
pruning-keep-every = "0"
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@
|
|||
},
|
||||
"supply": [
|
||||
{
|
||||
"denom": "ugnx",
|
||||
"amount": "1000000000000000"
|
||||
"denom": "agnx",
|
||||
"amount": "1000000000000000000000000000"
|
||||
}
|
||||
],
|
||||
"balances": [
|
||||
|
|
@ -48,35 +48,35 @@
|
|||
"_comment": "平台运营/Gas补贴池 (40%)",
|
||||
"address": "genex1platform_operations_address",
|
||||
"coins": [
|
||||
{ "denom": "ugnx", "amount": "400000000000000" }
|
||||
{ "denom": "agnx", "amount": "400000000000000000000000000" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"_comment": "团队与顾问 (20%) — 4年线性释放,1年锁定",
|
||||
"address": "genex1team_vesting_address",
|
||||
"coins": [
|
||||
{ "denom": "ugnx", "amount": "200000000000000" }
|
||||
{ "denom": "agnx", "amount": "200000000000000000000000000" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"_comment": "生态基金 (15%)",
|
||||
"address": "genex1ecosystem_fund_address",
|
||||
"coins": [
|
||||
{ "denom": "ugnx", "amount": "150000000000000" }
|
||||
{ "denom": "agnx", "amount": "150000000000000000000000000" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"_comment": "未来融资预留 (15%)",
|
||||
"address": "genex1future_financing_address",
|
||||
"coins": [
|
||||
{ "denom": "ugnx", "amount": "150000000000000" }
|
||||
{ "denom": "agnx", "amount": "150000000000000000000000000" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"_comment": "社区治理 DAO (10%)",
|
||||
"address": "genex1community_dao_address",
|
||||
"coins": [
|
||||
{ "denom": "ugnx", "amount": "100000000000000" }
|
||||
{ "denom": "agnx", "amount": "100000000000000000000000000" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -84,11 +84,11 @@
|
|||
{
|
||||
"description": "Genex Chain 原生代币",
|
||||
"denom_units": [
|
||||
{ "denom": "ugnx", "exponent": 0, "aliases": ["micrognx"] },
|
||||
{ "denom": "mgnx", "exponent": 3, "aliases": ["millignx"] },
|
||||
{ "denom": "gnx", "exponent": 6, "aliases": ["GNX"] }
|
||||
{ "denom": "agnx", "exponent": 0, "aliases": ["attognx"] },
|
||||
{ "denom": "ngnx", "exponent": 9, "aliases": ["nanognx"] },
|
||||
{ "denom": "gnx", "exponent": 18, "aliases": ["GNX"] }
|
||||
],
|
||||
"base": "ugnx",
|
||||
"base": "agnx",
|
||||
"display": "gnx",
|
||||
"name": "Genex Token",
|
||||
"symbol": "GNX"
|
||||
|
|
@ -101,7 +101,7 @@
|
|||
"max_validators": 100,
|
||||
"max_entries": 7,
|
||||
"historical_entries": 10000,
|
||||
"bond_denom": "ugnx",
|
||||
"bond_denom": "agnx",
|
||||
"min_commission_rate": "0.050000000000000000"
|
||||
},
|
||||
"validators": []
|
||||
|
|
@ -118,7 +118,7 @@
|
|||
"gov": {
|
||||
"params": {
|
||||
"min_deposit": [
|
||||
{ "denom": "ugnx", "amount": "10000000" }
|
||||
{ "denom": "agnx", "amount": "10000000000000000000" }
|
||||
],
|
||||
"max_deposit_period": "172800s",
|
||||
"voting_period": "172800s",
|
||||
|
|
@ -129,7 +129,7 @@
|
|||
},
|
||||
"evm": {
|
||||
"params": {
|
||||
"evm_denom": "ugnx",
|
||||
"evm_denom": "agnx",
|
||||
"enable_create": true,
|
||||
"enable_call": true,
|
||||
"extra_eips": [],
|
||||
|
|
@ -171,7 +171,7 @@
|
|||
"_comment": "Genex 自定义合规模块创世状态",
|
||||
"params": {
|
||||
"enabled": true,
|
||||
"travel_rule_threshold": "3000000000",
|
||||
"travel_rule_threshold": "3000000000000000000000",
|
||||
"structuring_window": "86400",
|
||||
"ofac_update_interval": "3600"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import "../src/Treasury.sol";
|
|||
import "../src/Governance.sol";
|
||||
import "../src/ExchangeRateOracle.sol";
|
||||
import "../src/CouponBackedSecurity.sol";
|
||||
import "../src/CouponBatch.sol";
|
||||
import "../src/Redemption1155.sol";
|
||||
|
||||
/// @title Deploy — Genex 合约系统完整部署脚本
|
||||
/// @notice 部署所有合约(Transparent Proxy 模式) + 角色授权 + 初始化
|
||||
|
|
@ -29,6 +31,8 @@ contract Deploy is Script {
|
|||
address public governance;
|
||||
address public oracle;
|
||||
address public cbs;
|
||||
address public couponBatch;
|
||||
address public redemption1155;
|
||||
|
||||
function run() external {
|
||||
uint256 deployerKey = vm.envUint("DEPLOYER_PRIVATE_KEY");
|
||||
|
|
@ -133,7 +137,27 @@ contract Deploy is Script {
|
|||
console.log("ExchangeRateOracle:", oracle);
|
||||
|
||||
// ==========================================
|
||||
// 10. 部署 CouponBackedSecurity
|
||||
// 10. 部署 CouponBatch (ERC-1155)
|
||||
// ==========================================
|
||||
CouponBatch couponBatchImpl = new CouponBatch();
|
||||
couponBatch = _deployProxy(
|
||||
address(couponBatchImpl),
|
||||
abi.encodeCall(CouponBatch.initialize, ("https://genex.io/api/coupon-batch/{id}.json", deployer))
|
||||
);
|
||||
console.log("CouponBatch:", couponBatch);
|
||||
|
||||
// ==========================================
|
||||
// 11. 部署 Redemption1155
|
||||
// ==========================================
|
||||
Redemption1155 redemption1155Impl = new Redemption1155();
|
||||
redemption1155 = _deployProxy(
|
||||
address(redemption1155Impl),
|
||||
abi.encodeCall(Redemption1155.initialize, (couponBatch, compliance, deployer))
|
||||
);
|
||||
console.log("Redemption1155:", redemption1155);
|
||||
|
||||
// ==========================================
|
||||
// 12. 部署 CouponBackedSecurity
|
||||
// ==========================================
|
||||
CouponBackedSecurity cbsImpl = new CouponBackedSecurity();
|
||||
cbs = _deployProxy(
|
||||
|
|
@ -143,7 +167,12 @@ contract Deploy is Script {
|
|||
console.log("CouponBackedSecurity:", cbs);
|
||||
|
||||
// ==========================================
|
||||
// 11. 角色授权
|
||||
// 13. CouponFactory 关联 CouponBatch
|
||||
// ==========================================
|
||||
CouponFactory(couponFactory).setCouponBatchContract(couponBatch);
|
||||
|
||||
// ==========================================
|
||||
// 14. 角色授权
|
||||
// ==========================================
|
||||
_grantRoles(deployer);
|
||||
|
||||
|
|
@ -160,6 +189,8 @@ contract Deploy is Script {
|
|||
console.log("Treasury: ", treasury);
|
||||
console.log("Governance: ", governance);
|
||||
console.log("ExchangeRateOracle: ", oracle);
|
||||
console.log("CouponBatch: ", couponBatch);
|
||||
console.log("Redemption1155: ", redemption1155);
|
||||
console.log("CouponBackedSecurity:", cbs);
|
||||
console.log("=========================================");
|
||||
}
|
||||
|
|
@ -191,6 +222,18 @@ contract Deploy is Script {
|
|||
// Redemption 需要通过 Coupon.burn 权限(owner 或 approved)
|
||||
// — Redemption 合约中 burn 通过 ownerOf 检查,无需额外角色
|
||||
|
||||
// CouponFactory 需要 CouponBatch 的 FACTORY_ROLE
|
||||
CouponBatch(couponBatch).grantRole(
|
||||
keccak256("FACTORY_ROLE"),
|
||||
couponFactory
|
||||
);
|
||||
|
||||
// Redemption1155 需要 CouponBatch 的 BURNER_ROLE
|
||||
CouponBatch(couponBatch).grantRole(
|
||||
keccak256("BURNER_ROLE"),
|
||||
redemption1155
|
||||
);
|
||||
|
||||
// Governance 的 MULTISIG_ROLE 在 initialize 中已设置
|
||||
|
||||
console.log("Roles granted successfully");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import "@openzeppelin/contracts-upgradeable/token/ERC1155/ERC1155Upgradeable.sol";
|
||||
import "@openzeppelin/contracts-upgradeable/token/ERC1155/extensions/ERC1155SupplyUpgradeable.sol";
|
||||
import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
|
||||
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
|
||||
import "./interfaces/ICouponBatch.sol";
|
||||
|
||||
/// @title CouponBatch — ERC-1155 同质化券合约
|
||||
/// @notice 支持批量铸造相同配置的券,适用于促销折扣券、空投券等场景
|
||||
/// @dev Utility Track 专用,无转售次数追踪。Transparent Proxy 部署。
|
||||
contract CouponBatch is
|
||||
Initializable,
|
||||
ERC1155Upgradeable,
|
||||
ERC1155SupplyUpgradeable,
|
||||
AccessControlUpgradeable
|
||||
{
|
||||
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
|
||||
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
|
||||
bytes32 public constant FACTORY_ROLE = keccak256("FACTORY_ROLE");
|
||||
|
||||
uint256 private _nextTypeId;
|
||||
|
||||
/// @dev typeId → 券配置(铸造后不可更改)
|
||||
mapping(uint256 => ICouponBatch.BatchCouponConfig) private _configs;
|
||||
/// @dev typeId → 原始铸造数量(用于统计)
|
||||
mapping(uint256 => uint256) private _mintedQuantities;
|
||||
|
||||
// --- Events ---
|
||||
event BatchCouponMinted(
|
||||
uint256 indexed typeId,
|
||||
address indexed issuer,
|
||||
uint256 faceValue,
|
||||
uint256 quantity
|
||||
);
|
||||
event BatchCouponBurned(
|
||||
uint256 indexed typeId,
|
||||
address indexed account,
|
||||
uint256 amount
|
||||
);
|
||||
|
||||
function initialize(string memory uri_, address admin) external initializer {
|
||||
__ERC1155_init(uri_);
|
||||
__ERC1155Supply_init();
|
||||
__AccessControl_init();
|
||||
|
||||
_grantRole(DEFAULT_ADMIN_ROLE, admin);
|
||||
_grantRole(MINTER_ROLE, admin);
|
||||
_nextTypeId = 1;
|
||||
}
|
||||
|
||||
/// @notice 创建新券类型并铸造 `quantity` 单位到 `to`
|
||||
/// @param to 接收者地址
|
||||
/// @param faceValue 面值
|
||||
/// @param quantity 数量
|
||||
/// @param config 券配置
|
||||
/// @return typeId 新创建的 token type ID
|
||||
function mint(
|
||||
address to,
|
||||
uint256 faceValue,
|
||||
uint256 quantity,
|
||||
ICouponBatch.BatchCouponConfig calldata config
|
||||
) external onlyRole(FACTORY_ROLE) returns (uint256 typeId) {
|
||||
require(to != address(0), "CouponBatch: zero address");
|
||||
require(faceValue > 0, "CouponBatch: zero face value");
|
||||
require(quantity > 0, "CouponBatch: zero quantity");
|
||||
|
||||
typeId = _nextTypeId++;
|
||||
|
||||
// 存储配置(确保 faceValue 和 issuer 字段一致)
|
||||
_configs[typeId] = ICouponBatch.BatchCouponConfig({
|
||||
transferable: config.transferable,
|
||||
maxPrice: config.maxPrice,
|
||||
expiryDate: config.expiryDate,
|
||||
minPurchase: config.minPurchase,
|
||||
stackable: config.stackable,
|
||||
allowedStores: config.allowedStores,
|
||||
issuer: config.issuer,
|
||||
faceValue: faceValue
|
||||
});
|
||||
_mintedQuantities[typeId] = quantity;
|
||||
|
||||
// 单次 ERC-1155 mint — O(1) 存储
|
||||
_mint(to, typeId, quantity, "");
|
||||
|
||||
emit BatchCouponMinted(typeId, config.issuer, faceValue, quantity);
|
||||
}
|
||||
|
||||
/// @notice 销毁 `amount` 单位的 typeId 券
|
||||
/// @param account 被销毁的地址
|
||||
/// @param typeId 券类型 ID
|
||||
/// @param amount 销毁数量
|
||||
function burn(
|
||||
address account,
|
||||
uint256 typeId,
|
||||
uint256 amount
|
||||
) external {
|
||||
require(
|
||||
account == _msgSender()
|
||||
|| isApprovedForAll(account, _msgSender())
|
||||
|| hasRole(BURNER_ROLE, _msgSender()),
|
||||
"CouponBatch: not authorized to burn"
|
||||
);
|
||||
_burn(account, typeId, amount);
|
||||
emit BatchCouponBurned(typeId, account, amount);
|
||||
}
|
||||
|
||||
// --- 查询函数 ---
|
||||
|
||||
/// @notice 获取券类型配置
|
||||
function getConfig(uint256 typeId) external view returns (ICouponBatch.BatchCouponConfig memory) {
|
||||
require(exists(typeId), "CouponBatch: nonexistent type");
|
||||
return _configs[typeId];
|
||||
}
|
||||
|
||||
/// @notice 获取面值
|
||||
function getFaceValue(uint256 typeId) external view returns (uint256) {
|
||||
require(exists(typeId), "CouponBatch: nonexistent type");
|
||||
return _configs[typeId].faceValue;
|
||||
}
|
||||
|
||||
/// @notice 获取原始铸造数量
|
||||
function getMintedQuantity(uint256 typeId) external view returns (uint256) {
|
||||
return _mintedQuantities[typeId];
|
||||
}
|
||||
|
||||
// --- 转让限制 ---
|
||||
|
||||
/// @dev 重写 _beforeTokenTransfer 强制非转让检查
|
||||
function _beforeTokenTransfer(
|
||||
address operator,
|
||||
address from,
|
||||
address to,
|
||||
uint256[] memory ids,
|
||||
uint256[] memory amounts,
|
||||
bytes memory data
|
||||
) internal virtual override(ERC1155Upgradeable, ERC1155SupplyUpgradeable) {
|
||||
super._beforeTokenTransfer(operator, from, to, ids, amounts, data);
|
||||
|
||||
// 铸造 (from=0) 和销毁 (to=0) 不受限
|
||||
if (from == address(0) || to == address(0)) return;
|
||||
|
||||
// 检查每个 token type 的可转让性
|
||||
for (uint256 i = 0; i < ids.length; i++) {
|
||||
ICouponBatch.BatchCouponConfig memory config = _configs[ids[i]];
|
||||
require(config.transferable, "CouponBatch: non-transferable");
|
||||
}
|
||||
}
|
||||
|
||||
// --- ERC165 ---
|
||||
|
||||
function supportsInterface(bytes4 interfaceId)
|
||||
public
|
||||
view
|
||||
override(ERC1155Upgradeable, AccessControlUpgradeable)
|
||||
returns (bool)
|
||||
{
|
||||
return super.supportsInterface(interfaceId);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,16 +5,18 @@ import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
|
|||
import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
|
||||
import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol";
|
||||
import "./interfaces/ICoupon.sol";
|
||||
import "./interfaces/ICouponBatch.sol";
|
||||
import "./interfaces/ICompliance.sol";
|
||||
|
||||
/// @title CouponFactory — 券发行工厂
|
||||
/// @notice 负责批量铸造券 NFT,支持 Utility/Security 双轨制
|
||||
/// @notice 负责批量铸造券,支持 ERC-721(逐张追踪)和 ERC-1155(同质化批量)双模式
|
||||
/// @dev 使用 Transparent Proxy 部署,可升级
|
||||
contract CouponFactory is Initializable, AccessControlUpgradeable, ReentrancyGuardUpgradeable {
|
||||
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
|
||||
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
|
||||
|
||||
ICoupon public couponContract;
|
||||
ICouponBatch public couponBatchContract;
|
||||
ICompliance public compliance;
|
||||
|
||||
uint256 public nextBatchId;
|
||||
|
|
@ -40,7 +42,15 @@ contract CouponFactory is Initializable, AccessControlUpgradeable, ReentrancyGua
|
|||
ICoupon.CouponType couponType
|
||||
);
|
||||
event CouponContractUpdated(address indexed newCouponContract);
|
||||
event CouponBatchContractUpdated(address indexed newCouponBatchContract);
|
||||
event ComplianceContractUpdated(address indexed newCompliance);
|
||||
event CouponBatch1155Minted(
|
||||
address indexed issuer,
|
||||
uint256 indexed batchId,
|
||||
uint256 typeId,
|
||||
uint256 faceValue,
|
||||
uint256 quantity
|
||||
);
|
||||
|
||||
function initialize(
|
||||
address _couponContract,
|
||||
|
|
@ -142,4 +152,60 @@ contract CouponFactory is Initializable, AccessControlUpgradeable, ReentrancyGua
|
|||
compliance = ICompliance(_compliance);
|
||||
emit ComplianceContractUpdated(_compliance);
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// ERC-1155 同质化券铸造(Utility Track 专用)
|
||||
// =====================================================
|
||||
|
||||
/// @notice 铸造 ERC-1155 同质化券(Utility Track 专用)
|
||||
/// @param issuer 发行方地址
|
||||
/// @param faceValue 面值(USDC 精度,6位小数)
|
||||
/// @param quantity 数量(上限 100,000,ERC-1155 为 O(1) 铸造)
|
||||
/// @param config 券配置
|
||||
/// @return typeId ERC-1155 token type ID
|
||||
function mintBatch1155(
|
||||
address issuer,
|
||||
uint256 faceValue,
|
||||
uint256 quantity,
|
||||
ICouponBatch.BatchCouponConfig calldata config
|
||||
) external onlyRole(MINTER_ROLE) nonReentrant returns (uint256 typeId) {
|
||||
require(issuer != address(0), "CouponFactory: zero address issuer");
|
||||
require(faceValue > 0, "CouponFactory: zero face value");
|
||||
require(quantity > 0 && quantity <= 100000, "CouponFactory: invalid quantity");
|
||||
require(
|
||||
address(couponBatchContract) != address(0),
|
||||
"CouponFactory: batch contract not set"
|
||||
);
|
||||
|
||||
// Utility Track 强制(ERC-1155 仅限 Utility)
|
||||
require(config.maxPrice <= faceValue, "Utility: maxPrice <= faceValue");
|
||||
require(
|
||||
config.expiryDate <= block.timestamp + 365 days,
|
||||
"Utility: max 12 months"
|
||||
);
|
||||
require(config.expiryDate > block.timestamp, "Utility: expiry must be future");
|
||||
|
||||
// 通过 CouponBatch 合约铸造
|
||||
typeId = couponBatchContract.mint(issuer, faceValue, quantity, config);
|
||||
|
||||
// 记录批次(tokenIds 为空,ERC-1155 使用 typeId + quantity)
|
||||
uint256 batchId = nextBatchId++;
|
||||
batches[batchId] = BatchInfo({
|
||||
issuer: issuer,
|
||||
faceValue: faceValue,
|
||||
quantity: quantity,
|
||||
couponType: ICoupon.CouponType.Utility,
|
||||
createdAt: block.timestamp,
|
||||
tokenIds: new uint256[](0)
|
||||
});
|
||||
|
||||
emit CouponBatch1155Minted(issuer, batchId, typeId, faceValue, quantity);
|
||||
}
|
||||
|
||||
/// @notice 设置 CouponBatch (ERC-1155) 合约地址
|
||||
function setCouponBatchContract(address _couponBatchContract) external onlyRole(ADMIN_ROLE) {
|
||||
require(_couponBatchContract != address(0), "CouponFactory: zero address");
|
||||
couponBatchContract = ICouponBatch(_couponBatchContract);
|
||||
emit CouponBatchContractUpdated(_couponBatchContract);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
|
||||
import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
|
||||
import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol";
|
||||
import "./interfaces/ICouponBatch.sol";
|
||||
import "./interfaces/ICompliance.sol";
|
||||
|
||||
/// @title Redemption1155 — ERC-1155 券兑付合约
|
||||
/// @notice 支持多单位一次性兑付(burn),平台不介入资金
|
||||
/// @dev Transparent Proxy 部署。消费者持券到店兑付,合约负责销毁。
|
||||
contract Redemption1155 is Initializable, AccessControlUpgradeable, ReentrancyGuardUpgradeable {
|
||||
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
|
||||
|
||||
ICouponBatch public couponBatchContract;
|
||||
ICompliance public compliance;
|
||||
|
||||
struct RedemptionRecord {
|
||||
uint256 typeId;
|
||||
uint256 amount; // 本次兑付数量
|
||||
address consumer;
|
||||
address issuer;
|
||||
bytes32 storeId;
|
||||
uint256 redeemedAt;
|
||||
}
|
||||
|
||||
uint256 public totalRedemptions;
|
||||
mapping(uint256 => RedemptionRecord) public redemptions;
|
||||
|
||||
// --- Events ---
|
||||
event BatchCouponRedeemed(
|
||||
uint256 indexed typeId,
|
||||
uint256 amount,
|
||||
address indexed consumer,
|
||||
address indexed issuer,
|
||||
bytes32 storeId,
|
||||
uint256 redemptionId
|
||||
);
|
||||
|
||||
function initialize(
|
||||
address _couponBatchContract,
|
||||
address _compliance,
|
||||
address admin
|
||||
) external initializer {
|
||||
__AccessControl_init();
|
||||
__ReentrancyGuard_init();
|
||||
|
||||
couponBatchContract = ICouponBatch(_couponBatchContract);
|
||||
compliance = ICompliance(_compliance);
|
||||
|
||||
_grantRole(DEFAULT_ADMIN_ROLE, admin);
|
||||
_grantRole(ADMIN_ROLE, admin);
|
||||
}
|
||||
|
||||
/// @notice 兑付 `amount` 单位的 typeId 券
|
||||
/// @param typeId ERC-1155 token type ID
|
||||
/// @param amount 兑付数量
|
||||
/// @param storeId 门店 ID 哈希
|
||||
function redeem(
|
||||
uint256 typeId,
|
||||
uint256 amount,
|
||||
bytes32 storeId
|
||||
) external nonReentrant {
|
||||
require(amount > 0, "Redemption1155: zero amount");
|
||||
|
||||
// 余额检查(隐含所有权验证)
|
||||
require(
|
||||
couponBatchContract.balanceOf(msg.sender, typeId) >= amount,
|
||||
"Redemption1155: insufficient balance"
|
||||
);
|
||||
|
||||
// 合规检查
|
||||
require(!compliance.isBlacklisted(msg.sender), "Redemption1155: blacklisted");
|
||||
|
||||
ICouponBatch.BatchCouponConfig memory config = couponBatchContract.getConfig(typeId);
|
||||
|
||||
// 到期检查
|
||||
require(block.timestamp <= config.expiryDate, "Redemption1155: coupon expired");
|
||||
|
||||
// 门店限制检查
|
||||
if (config.allowedStores.length > 0) {
|
||||
require(
|
||||
_isAllowedStore(storeId, config.allowedStores),
|
||||
"Redemption1155: store not allowed"
|
||||
);
|
||||
}
|
||||
|
||||
// 销毁券
|
||||
couponBatchContract.burn(msg.sender, typeId, amount);
|
||||
|
||||
// 记录兑付
|
||||
uint256 redemptionId = totalRedemptions++;
|
||||
redemptions[redemptionId] = RedemptionRecord({
|
||||
typeId: typeId,
|
||||
amount: amount,
|
||||
consumer: msg.sender,
|
||||
issuer: config.issuer,
|
||||
storeId: storeId,
|
||||
redeemedAt: block.timestamp
|
||||
});
|
||||
|
||||
emit BatchCouponRedeemed(
|
||||
typeId, amount, msg.sender, config.issuer, storeId, redemptionId
|
||||
);
|
||||
}
|
||||
|
||||
/// @notice 查询兑付记录
|
||||
function getRedemption(uint256 redemptionId) external view returns (RedemptionRecord memory) {
|
||||
return redemptions[redemptionId];
|
||||
}
|
||||
|
||||
/// @dev 门店白名单检查
|
||||
function _isAllowedStore(
|
||||
bytes32 storeId,
|
||||
bytes32[] memory allowedStores
|
||||
) internal pure returns (bool) {
|
||||
for (uint256 i = 0; i < allowedStores.length; i++) {
|
||||
if (allowedStores[i] == storeId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
/// @title ICouponBatch — ERC-1155 批量券接口
|
||||
/// @notice 定义 CouponBatch ERC-1155 合约的外部调用接口
|
||||
/// @dev ERC-1155 券仅限 Utility Track,不支持转售次数追踪
|
||||
interface ICouponBatch {
|
||||
struct BatchCouponConfig {
|
||||
bool transferable; // 是否可转让
|
||||
uint256 maxPrice; // 价格上限(Utility = 面值)
|
||||
uint256 expiryDate; // 到期日(Unix timestamp)
|
||||
uint256 minPurchase; // 最低消费数量
|
||||
bool stackable; // 是否可叠加使用
|
||||
bytes32[] allowedStores; // 限定门店(空 = 任意门店)
|
||||
address issuer; // 发行方地址
|
||||
uint256 faceValue; // 面值(USDC 6位精度)
|
||||
}
|
||||
|
||||
function getConfig(uint256 typeId) external view returns (BatchCouponConfig memory);
|
||||
function getFaceValue(uint256 typeId) external view returns (uint256);
|
||||
function totalSupply(uint256 typeId) external view returns (uint256);
|
||||
function balanceOf(address account, uint256 typeId) external view returns (uint256);
|
||||
|
||||
/// @notice 铸造新券类型
|
||||
/// @return typeId 新创建的 ERC-1155 token type ID
|
||||
function mint(
|
||||
address to,
|
||||
uint256 faceValue,
|
||||
uint256 quantity,
|
||||
BatchCouponConfig calldata config
|
||||
) external returns (uint256 typeId);
|
||||
|
||||
/// @notice 销毁指定数量的券
|
||||
function burn(address account, uint256 typeId, uint256 amount) external;
|
||||
|
||||
/// @notice ERC-1155 转移
|
||||
function safeTransferFrom(
|
||||
address from,
|
||||
address to,
|
||||
uint256 typeId,
|
||||
uint256 amount,
|
||||
bytes calldata data
|
||||
) external;
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import "forge-std/Test.sol";
|
||||
import "../src/CouponBatch.sol";
|
||||
import "../src/interfaces/ICouponBatch.sol";
|
||||
|
||||
contract CouponBatchTest is Test {
|
||||
CouponBatch batch;
|
||||
|
||||
address admin = address(1);
|
||||
address factory = address(2);
|
||||
address burner = address(3);
|
||||
address issuer = address(4);
|
||||
address user1 = address(5);
|
||||
address user2 = address(6);
|
||||
|
||||
bytes32 storeA = keccak256("STORE_A");
|
||||
bytes32 storeB = keccak256("STORE_B");
|
||||
|
||||
function setUp() public {
|
||||
vm.startPrank(admin);
|
||||
|
||||
batch = new CouponBatch();
|
||||
batch.initialize("https://genex.io/api/coupon-batch/{id}.json", admin);
|
||||
|
||||
batch.grantRole(keccak256("FACTORY_ROLE"), factory);
|
||||
batch.grantRole(keccak256("BURNER_ROLE"), burner);
|
||||
|
||||
vm.stopPrank();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 铸造测试
|
||||
// ==========================================
|
||||
|
||||
function test_MintCreatesNewType() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
|
||||
vm.prank(factory);
|
||||
uint256 typeId = batch.mint(issuer, 100e6, 1000, config);
|
||||
|
||||
assertEq(typeId, 1);
|
||||
assertEq(batch.balanceOf(issuer, typeId), 1000);
|
||||
assertEq(batch.totalSupply(typeId), 1000);
|
||||
assertEq(batch.getMintedQuantity(typeId), 1000);
|
||||
}
|
||||
|
||||
function test_MintMultipleTypes() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
|
||||
vm.startPrank(factory);
|
||||
uint256 typeId1 = batch.mint(issuer, 100e6, 500, config);
|
||||
uint256 typeId2 = batch.mint(issuer, 50e6, 2000, config);
|
||||
vm.stopPrank();
|
||||
|
||||
assertEq(typeId1, 1);
|
||||
assertEq(typeId2, 2);
|
||||
assertEq(batch.balanceOf(issuer, typeId1), 500);
|
||||
assertEq(batch.balanceOf(issuer, typeId2), 2000);
|
||||
}
|
||||
|
||||
function test_MintStoresConfig() public {
|
||||
bytes32[] memory stores = new bytes32[](2);
|
||||
stores[0] = storeA;
|
||||
stores[1] = storeB;
|
||||
|
||||
ICouponBatch.BatchCouponConfig memory config = ICouponBatch.BatchCouponConfig({
|
||||
transferable: false,
|
||||
maxPrice: 50e6,
|
||||
expiryDate: block.timestamp + 90 days,
|
||||
minPurchase: 10,
|
||||
stackable: true,
|
||||
allowedStores: stores,
|
||||
issuer: issuer,
|
||||
faceValue: 50e6
|
||||
});
|
||||
|
||||
vm.prank(factory);
|
||||
uint256 typeId = batch.mint(issuer, 50e6, 100, config);
|
||||
|
||||
ICouponBatch.BatchCouponConfig memory stored = batch.getConfig(typeId);
|
||||
assertEq(stored.transferable, false);
|
||||
assertEq(stored.maxPrice, 50e6);
|
||||
assertEq(stored.minPurchase, 10);
|
||||
assertEq(stored.stackable, true);
|
||||
assertEq(stored.allowedStores.length, 2);
|
||||
assertEq(stored.issuer, issuer);
|
||||
assertEq(stored.faceValue, 50e6);
|
||||
}
|
||||
|
||||
function test_MintZeroAddressReverts() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
|
||||
vm.prank(factory);
|
||||
vm.expectRevert("CouponBatch: zero address");
|
||||
batch.mint(address(0), 100e6, 1000, config);
|
||||
}
|
||||
|
||||
function test_MintZeroFaceValueReverts() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
|
||||
vm.prank(factory);
|
||||
vm.expectRevert("CouponBatch: zero face value");
|
||||
batch.mint(issuer, 0, 1000, config);
|
||||
}
|
||||
|
||||
function test_MintZeroQuantityReverts() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
|
||||
vm.prank(factory);
|
||||
vm.expectRevert("CouponBatch: zero quantity");
|
||||
batch.mint(issuer, 100e6, 0, config);
|
||||
}
|
||||
|
||||
function test_MintOnlyFactoryRole() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
|
||||
vm.prank(user1);
|
||||
vm.expectRevert();
|
||||
batch.mint(issuer, 100e6, 1000, config);
|
||||
}
|
||||
|
||||
function test_GetFaceValue() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
|
||||
vm.prank(factory);
|
||||
uint256 typeId = batch.mint(issuer, 250e6, 100, config);
|
||||
|
||||
assertEq(batch.getFaceValue(typeId), 250e6);
|
||||
}
|
||||
|
||||
function test_GetConfigNonexistentReverts() public {
|
||||
vm.expectRevert("CouponBatch: nonexistent type");
|
||||
batch.getConfig(999);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 销毁测试
|
||||
// ==========================================
|
||||
|
||||
function test_BurnByOwner() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
|
||||
vm.prank(factory);
|
||||
uint256 typeId = batch.mint(issuer, 100e6, 1000, config);
|
||||
|
||||
vm.prank(issuer);
|
||||
batch.burn(issuer, typeId, 300);
|
||||
|
||||
assertEq(batch.balanceOf(issuer, typeId), 700);
|
||||
assertEq(batch.totalSupply(typeId), 700);
|
||||
}
|
||||
|
||||
function test_BurnByBurnerRole() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
|
||||
vm.prank(factory);
|
||||
uint256 typeId = batch.mint(issuer, 100e6, 1000, config);
|
||||
|
||||
vm.prank(burner);
|
||||
batch.burn(issuer, typeId, 500);
|
||||
|
||||
assertEq(batch.balanceOf(issuer, typeId), 500);
|
||||
}
|
||||
|
||||
function test_BurnByApproved() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
|
||||
vm.prank(factory);
|
||||
uint256 typeId = batch.mint(issuer, 100e6, 1000, config);
|
||||
|
||||
vm.prank(issuer);
|
||||
batch.setApprovalForAll(user1, true);
|
||||
|
||||
vm.prank(user1);
|
||||
batch.burn(issuer, typeId, 200);
|
||||
|
||||
assertEq(batch.balanceOf(issuer, typeId), 800);
|
||||
}
|
||||
|
||||
function test_BurnUnauthorizedReverts() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
|
||||
vm.prank(factory);
|
||||
uint256 typeId = batch.mint(issuer, 100e6, 1000, config);
|
||||
|
||||
vm.prank(user1);
|
||||
vm.expectRevert("CouponBatch: not authorized to burn");
|
||||
batch.burn(issuer, typeId, 100);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 转让限制测试
|
||||
// ==========================================
|
||||
|
||||
function test_TransferableTokenCanTransfer() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
config.transferable = true;
|
||||
|
||||
vm.prank(factory);
|
||||
uint256 typeId = batch.mint(issuer, 100e6, 100, config);
|
||||
|
||||
vm.prank(issuer);
|
||||
batch.safeTransferFrom(issuer, user1, typeId, 50, "");
|
||||
|
||||
assertEq(batch.balanceOf(issuer, typeId), 50);
|
||||
assertEq(batch.balanceOf(user1, typeId), 50);
|
||||
}
|
||||
|
||||
function test_NonTransferableTokenReverts() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
config.transferable = false;
|
||||
|
||||
vm.prank(factory);
|
||||
uint256 typeId = batch.mint(issuer, 100e6, 100, config);
|
||||
|
||||
vm.prank(issuer);
|
||||
vm.expectRevert("CouponBatch: non-transferable");
|
||||
batch.safeTransferFrom(issuer, user1, typeId, 10, "");
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Fuzz 测试
|
||||
// ==========================================
|
||||
|
||||
function testFuzz_MintQuantity(uint256 quantity) public {
|
||||
quantity = bound(quantity, 1, 100_000);
|
||||
ICouponBatch.BatchCouponConfig memory config = _defaultConfig();
|
||||
|
||||
vm.prank(factory);
|
||||
uint256 typeId = batch.mint(issuer, 100e6, quantity, config);
|
||||
|
||||
assertEq(batch.balanceOf(issuer, typeId), quantity);
|
||||
assertEq(batch.totalSupply(typeId), quantity);
|
||||
assertEq(batch.getMintedQuantity(typeId), quantity);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Helpers
|
||||
// ==========================================
|
||||
|
||||
function _defaultConfig() internal view returns (ICouponBatch.BatchCouponConfig memory) {
|
||||
bytes32[] memory stores = new bytes32[](0);
|
||||
return ICouponBatch.BatchCouponConfig({
|
||||
transferable: true,
|
||||
maxPrice: 100e6,
|
||||
expiryDate: block.timestamp + 180 days,
|
||||
minPurchase: 0,
|
||||
stackable: false,
|
||||
allowedStores: stores,
|
||||
issuer: issuer,
|
||||
faceValue: 100e6
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -6,11 +6,14 @@ import "../src/CouponFactory.sol";
|
|||
import "../src/Coupon.sol";
|
||||
import "../src/Compliance.sol";
|
||||
import "../src/interfaces/ICoupon.sol";
|
||||
import "../src/CouponBatch.sol";
|
||||
import "../src/interfaces/ICouponBatch.sol";
|
||||
|
||||
contract CouponFactoryTest is Test {
|
||||
CouponFactory factory;
|
||||
Coupon coupon;
|
||||
Compliance compliance;
|
||||
CouponBatch couponBatch;
|
||||
|
||||
address admin = address(1);
|
||||
address issuer = address(2);
|
||||
|
|
@ -31,9 +34,17 @@ contract CouponFactoryTest is Test {
|
|||
factory = new CouponFactory();
|
||||
factory.initialize(address(coupon), address(compliance), admin);
|
||||
|
||||
// 部署 CouponBatch
|
||||
couponBatch = new CouponBatch();
|
||||
couponBatch.initialize("https://genex.io/api/coupon-batch/{id}.json", admin);
|
||||
|
||||
// 授权
|
||||
coupon.grantRole(keccak256("FACTORY_ROLE"), address(factory));
|
||||
factory.grantRole(keccak256("MINTER_ROLE"), minter);
|
||||
couponBatch.grantRole(keccak256("FACTORY_ROLE"), address(factory));
|
||||
|
||||
// 关联 CouponBatch
|
||||
factory.setCouponBatchContract(address(couponBatch));
|
||||
|
||||
// 设置发行方 KYC
|
||||
compliance.setKycLevel(issuer, 3);
|
||||
|
|
@ -177,4 +188,120 @@ contract CouponFactoryTest is Test {
|
|||
faceValue: 1000e6
|
||||
});
|
||||
}
|
||||
|
||||
function _batchConfig() internal view returns (ICouponBatch.BatchCouponConfig memory) {
|
||||
bytes32[] memory stores = new bytes32[](0);
|
||||
return ICouponBatch.BatchCouponConfig({
|
||||
transferable: true,
|
||||
maxPrice: 100e6,
|
||||
expiryDate: block.timestamp + 180 days,
|
||||
minPurchase: 0,
|
||||
stackable: false,
|
||||
allowedStores: stores,
|
||||
issuer: issuer,
|
||||
faceValue: 100e6
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ERC-1155 (mintBatch1155) 测试
|
||||
// ==========================================
|
||||
|
||||
function test_MintBatch1155Success() public {
|
||||
vm.prank(minter);
|
||||
ICouponBatch.BatchCouponConfig memory config = _batchConfig();
|
||||
uint256 typeId = factory.mintBatch1155(issuer, 100e6, 10000, config);
|
||||
|
||||
assertEq(typeId, 1);
|
||||
assertEq(couponBatch.balanceOf(issuer, typeId), 10000);
|
||||
}
|
||||
|
||||
function test_MintBatch1155LargeQuantity() public {
|
||||
vm.prank(minter);
|
||||
ICouponBatch.BatchCouponConfig memory config = _batchConfig();
|
||||
uint256 typeId = factory.mintBatch1155(issuer, 100e6, 100000, config);
|
||||
|
||||
assertEq(couponBatch.balanceOf(issuer, typeId), 100000);
|
||||
}
|
||||
|
||||
function test_MintBatch1155RecordsBatchInfo() public {
|
||||
vm.prank(minter);
|
||||
ICouponBatch.BatchCouponConfig memory config = _batchConfig();
|
||||
factory.mintBatch1155(issuer, 100e6, 5000, config);
|
||||
|
||||
CouponFactory.BatchInfo memory info = factory.getBatchInfo(0);
|
||||
assertEq(info.issuer, issuer);
|
||||
assertEq(info.faceValue, 100e6);
|
||||
assertEq(info.quantity, 5000);
|
||||
assertEq(info.tokenIds.length, 0); // ERC-1155 uses typeId, not tokenIds
|
||||
}
|
||||
|
||||
function test_MintBatch1155ZeroIssuerReverts() public {
|
||||
vm.prank(minter);
|
||||
ICouponBatch.BatchCouponConfig memory config = _batchConfig();
|
||||
vm.expectRevert("CouponFactory: zero address issuer");
|
||||
factory.mintBatch1155(address(0), 100e6, 1000, config);
|
||||
}
|
||||
|
||||
function test_MintBatch1155ZeroFaceValueReverts() public {
|
||||
vm.prank(minter);
|
||||
ICouponBatch.BatchCouponConfig memory config = _batchConfig();
|
||||
vm.expectRevert("CouponFactory: zero face value");
|
||||
factory.mintBatch1155(issuer, 0, 1000, config);
|
||||
}
|
||||
|
||||
function test_MintBatch1155ExcessiveQuantityReverts() public {
|
||||
vm.prank(minter);
|
||||
ICouponBatch.BatchCouponConfig memory config = _batchConfig();
|
||||
vm.expectRevert("CouponFactory: invalid quantity");
|
||||
factory.mintBatch1155(issuer, 100e6, 100001, config);
|
||||
}
|
||||
|
||||
function test_MintBatch1155MaxPriceExceedsFaceValueReverts() public {
|
||||
vm.prank(minter);
|
||||
ICouponBatch.BatchCouponConfig memory config = _batchConfig();
|
||||
config.maxPrice = 200e6; // 超过面值
|
||||
|
||||
vm.expectRevert("Utility: maxPrice <= faceValue");
|
||||
factory.mintBatch1155(issuer, 100e6, 1000, config);
|
||||
}
|
||||
|
||||
function test_MintBatch1155ExpiryTooFarReverts() public {
|
||||
vm.prank(minter);
|
||||
ICouponBatch.BatchCouponConfig memory config = _batchConfig();
|
||||
config.expiryDate = block.timestamp + 400 days;
|
||||
|
||||
vm.expectRevert("Utility: max 12 months");
|
||||
factory.mintBatch1155(issuer, 100e6, 1000, config);
|
||||
}
|
||||
|
||||
function test_MintBatch1155ExpiredReverts() public {
|
||||
vm.prank(minter);
|
||||
ICouponBatch.BatchCouponConfig memory config = _batchConfig();
|
||||
config.expiryDate = block.timestamp - 1;
|
||||
|
||||
vm.expectRevert("Utility: expiry must be future");
|
||||
factory.mintBatch1155(issuer, 100e6, 1000, config);
|
||||
}
|
||||
|
||||
function test_MintBatch1155BatchContractNotSetReverts() public {
|
||||
// 部署新工厂,不设置 CouponBatch
|
||||
vm.startPrank(admin);
|
||||
CouponFactory factory2 = new CouponFactory();
|
||||
factory2.initialize(address(coupon), address(compliance), admin);
|
||||
factory2.grantRole(keccak256("MINTER_ROLE"), minter);
|
||||
vm.stopPrank();
|
||||
|
||||
vm.prank(minter);
|
||||
ICouponBatch.BatchCouponConfig memory config = _batchConfig();
|
||||
vm.expectRevert("CouponFactory: batch contract not set");
|
||||
factory2.mintBatch1155(issuer, 100e6, 1000, config);
|
||||
}
|
||||
|
||||
function test_MintBatch1155OnlyMinterRole() public {
|
||||
vm.prank(issuer);
|
||||
ICouponBatch.BatchCouponConfig memory config = _batchConfig();
|
||||
vm.expectRevert();
|
||||
factory.mintBatch1155(issuer, 100e6, 1000, config);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,289 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import "forge-std/Test.sol";
|
||||
import "../src/CouponFactory.sol";
|
||||
import "../src/Coupon.sol";
|
||||
import "../src/CouponBatch.sol";
|
||||
import "../src/Redemption.sol";
|
||||
import "../src/Redemption1155.sol";
|
||||
import "../src/Settlement.sol";
|
||||
import "../src/Compliance.sol";
|
||||
import "../src/interfaces/ICoupon.sol";
|
||||
import "../src/interfaces/ICouponBatch.sol";
|
||||
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
||||
|
||||
contract MockUSDC1155 is ERC20 {
|
||||
constructor() ERC20("USD Coin", "USDC") {}
|
||||
function decimals() public pure override returns (uint8) { return 6; }
|
||||
function mint(address to, uint256 amount) external { _mint(to, amount); }
|
||||
}
|
||||
|
||||
/// @title ERC-721 + ERC-1155 混合端到端集成测试
|
||||
contract Integration1155Test is Test {
|
||||
CouponFactory factory;
|
||||
Coupon coupon;
|
||||
CouponBatch couponBatch;
|
||||
Settlement settlement;
|
||||
Redemption redemption;
|
||||
Redemption1155 redemption1155;
|
||||
Compliance compliance;
|
||||
MockUSDC1155 usdc;
|
||||
|
||||
address admin = address(1);
|
||||
address issuer = address(2);
|
||||
address consumer1 = address(3);
|
||||
address consumer2 = address(4);
|
||||
|
||||
bytes32 storeA = keccak256("STORE_A");
|
||||
|
||||
function setUp() public {
|
||||
vm.startPrank(admin);
|
||||
|
||||
usdc = new MockUSDC1155();
|
||||
|
||||
// 部署合约
|
||||
compliance = new Compliance();
|
||||
compliance.initialize(admin);
|
||||
|
||||
coupon = new Coupon();
|
||||
coupon.initialize("Genex Coupon", "GXC", admin);
|
||||
|
||||
couponBatch = new CouponBatch();
|
||||
couponBatch.initialize("https://genex.io/api/coupon-batch/{id}.json", admin);
|
||||
|
||||
factory = new CouponFactory();
|
||||
factory.initialize(address(coupon), address(compliance), admin);
|
||||
|
||||
settlement = new Settlement();
|
||||
settlement.initialize(address(coupon), address(compliance), address(usdc), admin);
|
||||
|
||||
redemption = new Redemption();
|
||||
redemption.initialize(address(coupon), address(compliance), admin);
|
||||
|
||||
redemption1155 = new Redemption1155();
|
||||
redemption1155.initialize(address(couponBatch), address(compliance), admin);
|
||||
|
||||
// 角色授权
|
||||
coupon.grantRole(keccak256("FACTORY_ROLE"), address(factory));
|
||||
coupon.grantRole(keccak256("SETTLER_ROLE"), address(settlement));
|
||||
coupon.grantRole(keccak256("SETTLER_ROLE"), address(redemption));
|
||||
couponBatch.grantRole(keccak256("FACTORY_ROLE"), address(factory));
|
||||
couponBatch.grantRole(keccak256("BURNER_ROLE"), address(redemption1155));
|
||||
|
||||
// 关联 CouponBatch
|
||||
factory.setCouponBatchContract(address(couponBatch));
|
||||
|
||||
// KYC
|
||||
compliance.setKycLevel(issuer, 3);
|
||||
compliance.setKycLevel(consumer1, 1);
|
||||
compliance.setKycLevel(consumer2, 1);
|
||||
|
||||
// 资金
|
||||
usdc.mint(consumer1, 500_000e6);
|
||||
usdc.mint(consumer2, 500_000e6);
|
||||
|
||||
vm.stopPrank();
|
||||
|
||||
// Approvals
|
||||
vm.prank(consumer1);
|
||||
usdc.approve(address(settlement), type(uint256).max);
|
||||
vm.prank(consumer1);
|
||||
coupon.setApprovalForAll(address(settlement), true);
|
||||
vm.prank(issuer);
|
||||
coupon.setApprovalForAll(address(settlement), true);
|
||||
}
|
||||
|
||||
/// @notice ERC-1155 完整生命周期: Factory铸造 → 分发 → 兑付
|
||||
function test_ERC1155FullLifecycle() public {
|
||||
// Step 1: 通过 Factory 铸造 ERC-1155 券
|
||||
bytes32[] memory stores = new bytes32[](0);
|
||||
ICouponBatch.BatchCouponConfig memory batchConfig = ICouponBatch.BatchCouponConfig({
|
||||
transferable: true,
|
||||
maxPrice: 50e6,
|
||||
expiryDate: block.timestamp + 180 days,
|
||||
minPurchase: 0,
|
||||
stackable: false,
|
||||
allowedStores: stores,
|
||||
issuer: issuer,
|
||||
faceValue: 50e6
|
||||
});
|
||||
|
||||
vm.prank(admin);
|
||||
uint256 typeId = factory.mintBatch1155(issuer, 50e6, 10000, batchConfig);
|
||||
|
||||
assertEq(couponBatch.balanceOf(issuer, typeId), 10000);
|
||||
assertEq(couponBatch.totalSupply(typeId), 10000);
|
||||
|
||||
// Step 2: 发行方分发给消费者
|
||||
vm.prank(issuer);
|
||||
couponBatch.safeTransferFrom(issuer, consumer1, typeId, 100, "");
|
||||
|
||||
assertEq(couponBatch.balanceOf(consumer1, typeId), 100);
|
||||
assertEq(couponBatch.balanceOf(issuer, typeId), 9900);
|
||||
|
||||
// Step 3: 消费者兑付
|
||||
vm.prank(consumer1);
|
||||
redemption1155.redeem(typeId, 5, storeA);
|
||||
|
||||
assertEq(couponBatch.balanceOf(consumer1, typeId), 95);
|
||||
assertEq(couponBatch.totalSupply(typeId), 9995); // 5 已销毁
|
||||
|
||||
Redemption1155.RedemptionRecord memory record = redemption1155.getRedemption(0);
|
||||
assertEq(record.consumer, consumer1);
|
||||
assertEq(record.amount, 5);
|
||||
}
|
||||
|
||||
/// @notice 同一 Factory 同时管理 ERC-721 和 ERC-1155 券
|
||||
function test_MixedERC721AndERC1155() public {
|
||||
// 铸造 ERC-721 券
|
||||
ICoupon.CouponConfig memory config721 = ICoupon.CouponConfig({
|
||||
couponType: ICoupon.CouponType.Utility,
|
||||
transferable: true,
|
||||
maxResaleCount: 3,
|
||||
maxPrice: 100e6,
|
||||
expiryDate: block.timestamp + 180 days,
|
||||
minPurchase: 0,
|
||||
stackable: false,
|
||||
allowedStores: new bytes32[](0),
|
||||
issuer: issuer,
|
||||
faceValue: 100e6
|
||||
});
|
||||
|
||||
vm.prank(admin);
|
||||
uint256[] memory tokenIds = factory.mintBatch(issuer, 100e6, 3, config721);
|
||||
|
||||
// 铸造 ERC-1155 券
|
||||
ICouponBatch.BatchCouponConfig memory config1155 = ICouponBatch.BatchCouponConfig({
|
||||
transferable: true,
|
||||
maxPrice: 50e6,
|
||||
expiryDate: block.timestamp + 180 days,
|
||||
minPurchase: 0,
|
||||
stackable: false,
|
||||
allowedStores: new bytes32[](0),
|
||||
issuer: issuer,
|
||||
faceValue: 50e6
|
||||
});
|
||||
|
||||
vm.prank(admin);
|
||||
uint256 typeId = factory.mintBatch1155(issuer, 50e6, 5000, config1155);
|
||||
|
||||
// 验证两种标准互不干扰
|
||||
assertEq(coupon.ownerOf(tokenIds[0]), issuer);
|
||||
assertEq(couponBatch.balanceOf(issuer, typeId), 5000);
|
||||
|
||||
// BatchInfo 共享 nextBatchId 计数器
|
||||
CouponFactory.BatchInfo memory batch0 = factory.getBatchInfo(0);
|
||||
assertEq(batch0.tokenIds.length, 3); // ERC-721
|
||||
|
||||
CouponFactory.BatchInfo memory batch1 = factory.getBatchInfo(1);
|
||||
assertEq(batch1.tokenIds.length, 0); // ERC-1155
|
||||
assertEq(batch1.quantity, 5000);
|
||||
}
|
||||
|
||||
/// @notice ERC-721 走 Settlement 二级市场,ERC-1155 走 Redemption1155 兑付
|
||||
function test_DualTrackSettlementAndRedemption() public {
|
||||
// ERC-721: 铸造 + 一级购买 + 兑付
|
||||
ICoupon.CouponConfig memory config721 = ICoupon.CouponConfig({
|
||||
couponType: ICoupon.CouponType.Utility,
|
||||
transferable: true,
|
||||
maxResaleCount: 3,
|
||||
maxPrice: 100e6,
|
||||
expiryDate: block.timestamp + 180 days,
|
||||
minPurchase: 0,
|
||||
stackable: false,
|
||||
allowedStores: new bytes32[](0),
|
||||
issuer: issuer,
|
||||
faceValue: 100e6
|
||||
});
|
||||
|
||||
vm.prank(admin);
|
||||
uint256[] memory tokenIds = factory.mintBatch(issuer, 100e6, 1, config721);
|
||||
|
||||
vm.prank(admin);
|
||||
settlement.executeSwap(tokenIds[0], consumer1, issuer, 100e6, address(usdc));
|
||||
assertEq(coupon.ownerOf(tokenIds[0]), consumer1);
|
||||
|
||||
vm.prank(consumer1);
|
||||
redemption.redeem(tokenIds[0], storeA);
|
||||
|
||||
// ERC-1155: 铸造 + 分发 + 兑付
|
||||
ICouponBatch.BatchCouponConfig memory config1155 = ICouponBatch.BatchCouponConfig({
|
||||
transferable: true,
|
||||
maxPrice: 20e6,
|
||||
expiryDate: block.timestamp + 90 days,
|
||||
minPurchase: 0,
|
||||
stackable: true,
|
||||
allowedStores: new bytes32[](0),
|
||||
issuer: issuer,
|
||||
faceValue: 20e6
|
||||
});
|
||||
|
||||
vm.prank(admin);
|
||||
uint256 typeId = factory.mintBatch1155(issuer, 20e6, 50000, config1155);
|
||||
|
||||
vm.prank(issuer);
|
||||
couponBatch.safeTransferFrom(issuer, consumer1, typeId, 200, "");
|
||||
|
||||
vm.prank(consumer1);
|
||||
redemption1155.redeem(typeId, 10, storeA);
|
||||
|
||||
assertEq(couponBatch.balanceOf(consumer1, typeId), 190);
|
||||
}
|
||||
|
||||
/// @notice 合规系统同时影响两种标准
|
||||
function test_ComplianceAffectsBothStandards() public {
|
||||
// 铸造 ERC-1155
|
||||
ICouponBatch.BatchCouponConfig memory config1155 = ICouponBatch.BatchCouponConfig({
|
||||
transferable: true,
|
||||
maxPrice: 100e6,
|
||||
expiryDate: block.timestamp + 180 days,
|
||||
minPurchase: 0,
|
||||
stackable: false,
|
||||
allowedStores: new bytes32[](0),
|
||||
issuer: issuer,
|
||||
faceValue: 100e6
|
||||
});
|
||||
|
||||
vm.prank(admin);
|
||||
uint256 typeId = factory.mintBatch1155(issuer, 100e6, 1000, config1155);
|
||||
|
||||
vm.prank(issuer);
|
||||
couponBatch.safeTransferFrom(issuer, consumer1, typeId, 50, "");
|
||||
|
||||
// 将 consumer1 加入黑名单
|
||||
vm.prank(admin);
|
||||
compliance.addToBlacklist(consumer1, "OFAC");
|
||||
|
||||
// ERC-1155 兑付被拒绝
|
||||
vm.prank(consumer1);
|
||||
vm.expectRevert("Redemption1155: blacklisted");
|
||||
redemption1155.redeem(typeId, 1, storeA);
|
||||
}
|
||||
|
||||
/// @notice 非可转让 ERC-1155 券不能转移
|
||||
function test_NonTransferableERC1155() public {
|
||||
ICouponBatch.BatchCouponConfig memory config = ICouponBatch.BatchCouponConfig({
|
||||
transferable: false,
|
||||
maxPrice: 30e6,
|
||||
expiryDate: block.timestamp + 90 days,
|
||||
minPurchase: 0,
|
||||
stackable: false,
|
||||
allowedStores: new bytes32[](0),
|
||||
issuer: issuer,
|
||||
faceValue: 30e6
|
||||
});
|
||||
|
||||
vm.prank(admin);
|
||||
uint256 typeId = factory.mintBatch1155(issuer, 30e6, 1000, config);
|
||||
|
||||
// 不可转让
|
||||
vm.prank(issuer);
|
||||
vm.expectRevert("CouponBatch: non-transferable");
|
||||
couponBatch.safeTransferFrom(issuer, consumer1, typeId, 10, "");
|
||||
|
||||
// 但发行方自己可以兑付(通过 BURNER_ROLE 销毁)
|
||||
// — 不过 Redemption1155 检查的是 msg.sender 的 balance
|
||||
assertEq(couponBatch.balanceOf(issuer, typeId), 1000);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import "forge-std/Test.sol";
|
||||
import "../src/Redemption1155.sol";
|
||||
import "../src/CouponBatch.sol";
|
||||
import "../src/Compliance.sol";
|
||||
import "../src/interfaces/ICouponBatch.sol";
|
||||
|
||||
contract Redemption1155Test is Test {
|
||||
Redemption1155 redemption;
|
||||
CouponBatch batch;
|
||||
Compliance compliance;
|
||||
|
||||
address admin = address(1);
|
||||
address factory = address(2);
|
||||
address consumer = address(4);
|
||||
address issuer = address(5);
|
||||
|
||||
bytes32 storeA = keccak256("STORE_A");
|
||||
bytes32 storeB = keccak256("STORE_B");
|
||||
|
||||
function setUp() public {
|
||||
vm.startPrank(admin);
|
||||
|
||||
// 部署 Compliance
|
||||
compliance = new Compliance();
|
||||
compliance.initialize(admin);
|
||||
|
||||
// 部署 CouponBatch
|
||||
batch = new CouponBatch();
|
||||
batch.initialize("https://genex.io/api/coupon-batch/{id}.json", admin);
|
||||
|
||||
// 部署 Redemption1155
|
||||
redemption = new Redemption1155();
|
||||
redemption.initialize(address(batch), address(compliance), admin);
|
||||
|
||||
// 角色授权
|
||||
batch.grantRole(keccak256("FACTORY_ROLE"), factory);
|
||||
batch.grantRole(keccak256("BURNER_ROLE"), address(redemption));
|
||||
|
||||
compliance.setKycLevel(consumer, 1);
|
||||
|
||||
vm.stopPrank();
|
||||
}
|
||||
|
||||
function test_RedeemSuccess() public {
|
||||
uint256 typeId = _mintBatch(consumer, 100e6, 10, false);
|
||||
|
||||
vm.prank(consumer);
|
||||
redemption.redeem(typeId, 3, storeA);
|
||||
|
||||
// 余额减少
|
||||
assertEq(batch.balanceOf(consumer, typeId), 7);
|
||||
|
||||
// 记录已创建
|
||||
Redemption1155.RedemptionRecord memory record = redemption.getRedemption(0);
|
||||
assertEq(record.typeId, typeId);
|
||||
assertEq(record.amount, 3);
|
||||
assertEq(record.consumer, consumer);
|
||||
assertEq(record.issuer, issuer);
|
||||
assertEq(record.storeId, storeA);
|
||||
}
|
||||
|
||||
function test_RedeemAllUnits() public {
|
||||
uint256 typeId = _mintBatch(consumer, 50e6, 5, false);
|
||||
|
||||
vm.prank(consumer);
|
||||
redemption.redeem(typeId, 5, storeA);
|
||||
|
||||
assertEq(batch.balanceOf(consumer, typeId), 0);
|
||||
assertEq(redemption.totalRedemptions(), 1);
|
||||
}
|
||||
|
||||
function test_RedeemMultipleTimes() public {
|
||||
uint256 typeId = _mintBatch(consumer, 100e6, 100, false);
|
||||
|
||||
vm.startPrank(consumer);
|
||||
redemption.redeem(typeId, 30, storeA);
|
||||
redemption.redeem(typeId, 50, storeA);
|
||||
vm.stopPrank();
|
||||
|
||||
assertEq(batch.balanceOf(consumer, typeId), 20);
|
||||
assertEq(redemption.totalRedemptions(), 2);
|
||||
|
||||
Redemption1155.RedemptionRecord memory r0 = redemption.getRedemption(0);
|
||||
assertEq(r0.amount, 30);
|
||||
Redemption1155.RedemptionRecord memory r1 = redemption.getRedemption(1);
|
||||
assertEq(r1.amount, 50);
|
||||
}
|
||||
|
||||
function test_RedeemZeroAmountReverts() public {
|
||||
uint256 typeId = _mintBatch(consumer, 100e6, 10, false);
|
||||
|
||||
vm.prank(consumer);
|
||||
vm.expectRevert("Redemption1155: zero amount");
|
||||
redemption.redeem(typeId, 0, storeA);
|
||||
}
|
||||
|
||||
function test_RedeemInsufficientBalanceReverts() public {
|
||||
uint256 typeId = _mintBatch(consumer, 100e6, 5, false);
|
||||
|
||||
vm.prank(consumer);
|
||||
vm.expectRevert("Redemption1155: insufficient balance");
|
||||
redemption.redeem(typeId, 10, storeA);
|
||||
}
|
||||
|
||||
function test_RedeemBlacklistedReverts() public {
|
||||
uint256 typeId = _mintBatch(consumer, 100e6, 10, false);
|
||||
|
||||
vm.prank(admin);
|
||||
compliance.addToBlacklist(consumer, "OFAC");
|
||||
|
||||
vm.prank(consumer);
|
||||
vm.expectRevert("Redemption1155: blacklisted");
|
||||
redemption.redeem(typeId, 1, storeA);
|
||||
}
|
||||
|
||||
function test_RedeemExpiredReverts() public {
|
||||
uint256 typeId = _mintBatch(consumer, 100e6, 10, false);
|
||||
|
||||
// 快进到过期
|
||||
vm.warp(block.timestamp + 200 days);
|
||||
|
||||
vm.prank(consumer);
|
||||
vm.expectRevert("Redemption1155: coupon expired");
|
||||
redemption.redeem(typeId, 1, storeA);
|
||||
}
|
||||
|
||||
function test_RedeemStoreRestricted() public {
|
||||
uint256 typeId = _mintBatch(consumer, 100e6, 10, true);
|
||||
|
||||
// 不在允许门店列表中
|
||||
bytes32 storeC = keccak256("STORE_C");
|
||||
vm.prank(consumer);
|
||||
vm.expectRevert("Redemption1155: store not allowed");
|
||||
redemption.redeem(typeId, 1, storeC);
|
||||
|
||||
// 使用允许的门店
|
||||
vm.prank(consumer);
|
||||
redemption.redeem(typeId, 1, storeA);
|
||||
assertEq(batch.balanceOf(consumer, typeId), 9);
|
||||
}
|
||||
|
||||
function test_RedeemNoBalanceReverts() public {
|
||||
uint256 typeId = _mintBatch(issuer, 100e6, 10, false);
|
||||
|
||||
// consumer 没有该 typeId 的余额
|
||||
vm.prank(consumer);
|
||||
vm.expectRevert("Redemption1155: insufficient balance");
|
||||
redemption.redeem(typeId, 1, storeA);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Helpers
|
||||
// ==========================================
|
||||
|
||||
function _mintBatch(
|
||||
address to,
|
||||
uint256 faceValue,
|
||||
uint256 quantity,
|
||||
bool withStores
|
||||
) internal returns (uint256) {
|
||||
bytes32[] memory stores;
|
||||
if (withStores) {
|
||||
stores = new bytes32[](2);
|
||||
stores[0] = storeA;
|
||||
stores[1] = storeB;
|
||||
} else {
|
||||
stores = new bytes32[](0);
|
||||
}
|
||||
|
||||
ICouponBatch.BatchCouponConfig memory config = ICouponBatch.BatchCouponConfig({
|
||||
transferable: true,
|
||||
maxPrice: faceValue,
|
||||
expiryDate: block.timestamp + 180 days,
|
||||
minPurchase: 0,
|
||||
stackable: false,
|
||||
allowedStores: stores,
|
||||
issuer: issuer,
|
||||
faceValue: faceValue
|
||||
});
|
||||
|
||||
vm.prank(factory);
|
||||
return batch.mint(to, faceValue, quantity, config);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
| 出块时间 | **≤ 1秒**(CometBFT即时终结性) |
|
||||
| TPS | ≥ 5,000(Block-STM并行执行) |
|
||||
| Gas策略 | 平台前期全额补贴,用户零Gas |
|
||||
| 原生代币 | GNX(Gas + 治理;前期Gas补贴,用户不接触) |
|
||||
| 原生代币 | GNX(Gas + 治理;前期Gas补贴,用户不接触);基础单位 agnx(atto GNX, 18位精度,EVM标准) |
|
||||
| 节点运营 | 平台运营验证节点 + 合格机构验证节点 |
|
||||
| 跨链 | IBC连接Cosmos生态 + Axelar桥连接Ethereum |
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ make build
|
|||
|
||||
# 创建创世账户
|
||||
./genexd keys add validator
|
||||
./genexd genesis add-genesis-account validator 1000000000ugnx
|
||||
./genexd genesis add-genesis-account validator 1000000000000000000000000000agnx
|
||||
|
||||
# 启动本地节点
|
||||
./genexd start
|
||||
|
|
@ -618,7 +618,11 @@ forge verify-contract \
|
|||
### 13.2 代币分配
|
||||
|
||||
```
|
||||
总供应量: 1,000,000,000 GNX
|
||||
总供应量: 1,000,000,000 GNX(= 10^27 agnx)
|
||||
基础单位: agnx(atto GNX, 18位精度,与EVM wei对齐)
|
||||
中间单位: ngnx(nano GNX, 10^9 agnx,类似gwei)
|
||||
显示单位: GNX(10^18 agnx)
|
||||
|
||||
├── 平台运营/Gas补贴: 40%(4亿)— 前期Gas支出从此池扣除
|
||||
├── 团队与顾问: 20%(2亿)— 4年线性释放,1年锁定期
|
||||
├── 生态基金: 15%(1.5亿)— 开发者激励、做市商激励
|
||||
|
|
|
|||
Loading…
Reference in New Issue