303 lines
11 KiB
Solidity
303 lines
11 KiB
Solidity
// 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"));
|
||
}
|
||
}
|