// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "../src/CouponFactory.sol"; import "../src/Coupon.sol"; import "../src/Settlement.sol"; import "../src/Redemption.sol"; import "../src/Compliance.sol"; import "../src/Treasury.sol"; import "../src/interfaces/ICoupon.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract MockUSDCIntegration 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 端到端集成测试 /// @notice 完整流程: 发行 → 一级市场购买 → 二级市场交易 → 兑付核销 contract IntegrationTest is Test { CouponFactory factory; Coupon coupon; Settlement settlement; Redemption redemption; Compliance compliance; Treasury treasury; MockUSDCIntegration usdc; address admin = address(1); address issuer = address(2); // 发行方 address buyer1 = address(3); // 一级市场买方 address buyer2 = address(4); // 二级市场买方 address feeCollector = address(5); // 平台手续费 function setUp() public { vm.startPrank(admin); usdc = new MockUSDCIntegration(); // 部署所有合约 compliance = new Compliance(); compliance.initialize(admin); coupon = new Coupon(); coupon.initialize("Genex Coupon", "GXC", 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); treasury = new Treasury(); treasury.initialize(address(usdc), feeCollector, admin); // 角色授权 coupon.grantRole(keccak256("FACTORY_ROLE"), address(factory)); coupon.grantRole(keccak256("SETTLER_ROLE"), address(settlement)); coupon.grantRole(keccak256("SETTLER_ROLE"), address(redemption)); // KYC 设置 compliance.setKycLevel(issuer, 3); // L3 发行方 compliance.setKycLevel(buyer1, 1); // L1 普通用户 compliance.setKycLevel(buyer2, 1); // L1 普通用户 // 资金准备 usdc.mint(buyer1, 500_000e6); usdc.mint(buyer2, 500_000e6); usdc.mint(issuer, 100_000e6); vm.stopPrank(); // 全局 approve vm.prank(buyer1); usdc.approve(address(settlement), type(uint256).max); vm.prank(buyer2); usdc.approve(address(settlement), type(uint256).max); vm.prank(buyer1); coupon.setApprovalForAll(address(settlement), true); vm.prank(buyer2); coupon.setApprovalForAll(address(settlement), true); vm.prank(issuer); coupon.setApprovalForAll(address(settlement), true); } /// @notice 完整 Utility 券生命周期: 铸造 → 一级购买 → 二级交易 → 兑付 function test_FullUtilityLifecycle() public { // ========== Step 1: 发行方铸造券 ========== bytes32[] memory stores = new bytes32[](0); ICoupon.CouponConfig memory config = ICoupon.CouponConfig({ couponType: ICoupon.CouponType.Utility, transferable: true, maxResaleCount: 3, maxPrice: 100e6, // 面值上限 $100 expiryDate: block.timestamp + 180 days, minPurchase: 0, stackable: false, allowedStores: stores, issuer: issuer, faceValue: 100e6 }); vm.prank(admin); uint256[] memory tokenIds = factory.mintBatch(issuer, 100e6, 5, config); assertEq(tokenIds.length, 5); assertEq(coupon.ownerOf(tokenIds[0]), issuer); // ========== Step 2: 一级市场 — 发行方卖给 buyer1 ========== vm.prank(admin); settlement.executeSwap(tokenIds[0], buyer1, issuer, 100e6, address(usdc)); assertEq(coupon.ownerOf(tokenIds[0]), buyer1); assertEq(usdc.balanceOf(issuer), 100_000e6 + 100e6); assertEq(coupon.getResaleCount(tokenIds[0]), 1); // ========== Step 3: 二级市场 — buyer1 以 $85 卖给 buyer2 ========== vm.prank(admin); settlement.executeSwap(tokenIds[0], buyer2, buyer1, 85e6, address(usdc)); assertEq(coupon.ownerOf(tokenIds[0]), buyer2); assertEq(coupon.getResaleCount(tokenIds[0]), 2); // ========== Step 4: 兑付 — buyer2 去门店使用 ========== vm.prank(buyer2); redemption.redeem(tokenIds[0], keccak256("ANY_STORE")); // 券已销毁 vm.expectRevert(); coupon.ownerOf(tokenIds[0]); // 验证兑付记录 Redemption.RedemptionRecord memory record = redemption.getRedemption(0); assertEq(record.consumer, buyer2); assertEq(record.issuer, issuer); } /// @notice Utility 券价格上限保护 function test_UtilityPriceCap() public { bytes32[] memory stores = new bytes32[](0); ICoupon.CouponConfig memory config = ICoupon.CouponConfig({ couponType: ICoupon.CouponType.Utility, transferable: true, maxResaleCount: 3, maxPrice: 100e6, expiryDate: block.timestamp + 180 days, minPurchase: 0, stackable: false, allowedStores: stores, issuer: issuer, faceValue: 100e6 }); vm.prank(admin); uint256[] memory tokenIds = factory.mintBatch(issuer, 100e6, 1, config); // 尝试以超过面值的价格交易 vm.prank(admin); vm.expectRevert("Utility: price exceeds max price"); settlement.executeSwap(tokenIds[0], buyer1, issuer, 120e6, address(usdc)); // 以面值或更低价格交易成功 vm.prank(admin); settlement.executeSwap(tokenIds[0], buyer1, issuer, 95e6, address(usdc)); } /// @notice 不可转让券测试 function test_NonTransferableCoupon() public { bytes32[] memory stores = new bytes32[](0); ICoupon.CouponConfig memory config = ICoupon.CouponConfig({ couponType: ICoupon.CouponType.Utility, transferable: false, maxResaleCount: 0, maxPrice: 50e6, expiryDate: block.timestamp + 90 days, minPurchase: 0, stackable: false, allowedStores: stores, issuer: issuer, faceValue: 50e6 }); vm.prank(admin); uint256[] memory tokenIds = factory.mintBatch(issuer, 50e6, 1, config); // 不可转让券不能在二级市场交易 // With maxResaleCount=0, Settlement's own resale count check (0 < 0 == false) // fires before the Coupon's _beforeTokenTransfer non-transferable check vm.prank(admin); vm.expectRevert("Settlement: max resale count exceeded"); settlement.executeSwap(tokenIds[0], buyer1, issuer, 50e6, address(usdc)); } /// @notice 合规检查集成 function test_ComplianceBlocksBlacklistedTrader() public { bytes32[] memory stores = new bytes32[](0); ICoupon.CouponConfig memory config = ICoupon.CouponConfig({ couponType: ICoupon.CouponType.Utility, transferable: true, maxResaleCount: 3, maxPrice: 100e6, expiryDate: block.timestamp + 180 days, minPurchase: 0, stackable: false, allowedStores: stores, issuer: issuer, faceValue: 100e6 }); vm.prank(admin); uint256[] memory tokenIds = factory.mintBatch(issuer, 100e6, 1, config); // 将 buyer1 加入 OFAC 黑名单 vm.prank(admin); compliance.addToBlacklist(buyer1, "OFAC SDN Match"); // 交易被拒绝 vm.prank(admin); vm.expectRevert("Compliance: buyer blacklisted"); settlement.executeSwap(tokenIds[0], buyer1, issuer, 100e6, address(usdc)); } /// @notice 过期券不能交易也不能兑付 function test_ExpiredCouponBlocked() public { bytes32[] memory stores = new bytes32[](0); ICoupon.CouponConfig memory config = ICoupon.CouponConfig({ couponType: ICoupon.CouponType.Utility, transferable: true, maxResaleCount: 3, maxPrice: 100e6, expiryDate: block.timestamp + 30 days, minPurchase: 0, stackable: false, allowedStores: stores, issuer: issuer, faceValue: 100e6 }); vm.prank(admin); uint256[] memory tokenIds = factory.mintBatch(issuer, 100e6, 1, config); // 快进到过期 vm.warp(block.timestamp + 31 days); // 交易被拒绝 vm.prank(admin); vm.expectRevert("Settlement: coupon expired"); settlement.executeSwap(tokenIds[0], buyer1, issuer, 100e6, address(usdc)); // 兑付也被拒绝 vm.prank(issuer); vm.expectRevert("Redemption: coupon expired"); redemption.redeem(tokenIds[0], keccak256("STORE")); } /// @notice 转售次数用尽后不能再交易 function test_MaxResaleCountBlocksFurtherTrades() public { bytes32[] memory stores = new bytes32[](0); ICoupon.CouponConfig memory config = ICoupon.CouponConfig({ couponType: ICoupon.CouponType.Utility, transferable: true, maxResaleCount: 2, maxPrice: 100e6, expiryDate: block.timestamp + 180 days, minPurchase: 0, stackable: false, allowedStores: stores, issuer: issuer, faceValue: 100e6 }); vm.prank(admin); uint256[] memory tokenIds = factory.mintBatch(issuer, 100e6, 1, config); // 第1次: issuer → buyer1 vm.prank(admin); settlement.executeSwap(tokenIds[0], buyer1, issuer, 100e6, address(usdc)); // 第2次: buyer1 → buyer2 vm.prank(admin); settlement.executeSwap(tokenIds[0], buyer2, buyer1, 90e6, address(usdc)); // 第3次: 应被拒绝(已转售2次,达到上限) vm.prank(buyer2); coupon.setApprovalForAll(address(settlement), true); vm.prank(admin); usdc.mint(buyer1, 100e6); vm.prank(admin); vm.expectRevert("Settlement: max resale count exceeded"); settlement.executeSwap(tokenIds[0], buyer1, buyer2, 80e6, address(usdc)); // 但兑付仍然可以 vm.prank(buyer2); redemption.redeem(tokenIds[0], keccak256("STORE")); } }