From 4c1d907df67e17e4d86cc98cea9e1c7282cc5487 Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 15 Feb 2026 22:34:30 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20ERC-1155=20=E5=90=8C=E8=B4=A8=E5=8C=96?= =?UTF-8?q?=E5=88=B8=E7=B3=BB=E7=BB=9F=20+=20denom=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=BAagnx=20(18=E4=BD=8DEVM=E6=A0=87=E5=87=86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增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 --- blockchain/genex-chain/config/app.toml | 2 +- blockchain/genex-chain/config/genesis.json | 30 +- .../genex-contracts/script/Deploy.s.sol | 47 ++- .../genex-contracts/src/CouponBatch.sol | 161 ++++++++++ .../genex-contracts/src/CouponFactory.sol | 68 ++++- .../genex-contracts/src/Redemption1155.sol | 123 ++++++++ .../src/interfaces/ICouponBatch.sol | 44 +++ .../genex-contracts/test/CouponBatch.t.sol | 256 ++++++++++++++++ .../genex-contracts/test/CouponFactory.t.sol | 127 ++++++++ .../test/Integration1155.t.sol | 289 ++++++++++++++++++ .../genex-contracts/test/Redemption1155.t.sol | 187 ++++++++++++ docs/guides/06-区块链开发指南.md | 10 +- 12 files changed, 1322 insertions(+), 22 deletions(-) create mode 100644 blockchain/genex-contracts/src/CouponBatch.sol create mode 100644 blockchain/genex-contracts/src/Redemption1155.sol create mode 100644 blockchain/genex-contracts/src/interfaces/ICouponBatch.sol create mode 100644 blockchain/genex-contracts/test/CouponBatch.t.sol create mode 100644 blockchain/genex-contracts/test/Integration1155.t.sol create mode 100644 blockchain/genex-contracts/test/Redemption1155.t.sol diff --git a/blockchain/genex-chain/config/app.toml b/blockchain/genex-chain/config/app.toml index 7413c0a..c048a56 100644 --- a/blockchain/genex-chain/config/app.toml +++ b/blockchain/genex-chain/config/app.toml @@ -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" diff --git a/blockchain/genex-chain/config/genesis.json b/blockchain/genex-chain/config/genesis.json index 6d64b8a..2b511a5 100644 --- a/blockchain/genex-chain/config/genesis.json +++ b/blockchain/genex-chain/config/genesis.json @@ -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" }, diff --git a/blockchain/genex-contracts/script/Deploy.s.sol b/blockchain/genex-contracts/script/Deploy.s.sol index abcad6d..d274109 100644 --- a/blockchain/genex-contracts/script/Deploy.s.sol +++ b/blockchain/genex-contracts/script/Deploy.s.sol @@ -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"); diff --git a/blockchain/genex-contracts/src/CouponBatch.sol b/blockchain/genex-contracts/src/CouponBatch.sol new file mode 100644 index 0000000..5676bb1 --- /dev/null +++ b/blockchain/genex-contracts/src/CouponBatch.sol @@ -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); + } +} diff --git a/blockchain/genex-contracts/src/CouponFactory.sol b/blockchain/genex-contracts/src/CouponFactory.sol index 162707e..e7594d8 100644 --- a/blockchain/genex-contracts/src/CouponFactory.sol +++ b/blockchain/genex-contracts/src/CouponFactory.sol @@ -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); + } } diff --git a/blockchain/genex-contracts/src/Redemption1155.sol b/blockchain/genex-contracts/src/Redemption1155.sol new file mode 100644 index 0000000..4b5cb48 --- /dev/null +++ b/blockchain/genex-contracts/src/Redemption1155.sol @@ -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; + } +} diff --git a/blockchain/genex-contracts/src/interfaces/ICouponBatch.sol b/blockchain/genex-contracts/src/interfaces/ICouponBatch.sol new file mode 100644 index 0000000..c1ee016 --- /dev/null +++ b/blockchain/genex-contracts/src/interfaces/ICouponBatch.sol @@ -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; +} diff --git a/blockchain/genex-contracts/test/CouponBatch.t.sol b/blockchain/genex-contracts/test/CouponBatch.t.sol new file mode 100644 index 0000000..cb697e0 --- /dev/null +++ b/blockchain/genex-contracts/test/CouponBatch.t.sol @@ -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 + }); + } +} diff --git a/blockchain/genex-contracts/test/CouponFactory.t.sol b/blockchain/genex-contracts/test/CouponFactory.t.sol index 50dfff0..24f0058 100644 --- a/blockchain/genex-contracts/test/CouponFactory.t.sol +++ b/blockchain/genex-contracts/test/CouponFactory.t.sol @@ -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); + } } diff --git a/blockchain/genex-contracts/test/Integration1155.t.sol b/blockchain/genex-contracts/test/Integration1155.t.sol new file mode 100644 index 0000000..5933992 --- /dev/null +++ b/blockchain/genex-contracts/test/Integration1155.t.sol @@ -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); + } +} diff --git a/blockchain/genex-contracts/test/Redemption1155.t.sol b/blockchain/genex-contracts/test/Redemption1155.t.sol new file mode 100644 index 0000000..2cb6fe0 --- /dev/null +++ b/blockchain/genex-contracts/test/Redemption1155.t.sol @@ -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); + } +} diff --git a/docs/guides/06-区块链开发指南.md b/docs/guides/06-区块链开发指南.md index c7b063c..2758bb0 100644 --- a/docs/guides/06-区块链开发指南.md +++ b/docs/guides/06-区块链开发指南.md @@ -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亿)— 开发者激励、做市商激励