// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Test} from "forge-std/Test.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {CompliantFiatTokenV2} from "../../contracts/tokens/CompliantFiatTokenV2.sol"; contract CompliantFiatTokenV2Test is Test { using ECDSA for bytes32; bytes32 private constant PERMIT_TYPEHASH = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); bytes32 private constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = keccak256( "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" ); bytes32 private constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = keccak256( "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" ); bytes32 private constant CANCEL_AUTHORIZATION_TYPEHASH = keccak256("CancelAuthorization(address authorizer,bytes32 nonce)"); event CompliantOperationDeclared( bytes32 indexed operationType, address indexed initiator, address indexed authorizer, address executor, address from, address to, uint256 value, bytes32 reasonHash, bytes32 accountingRef, bytes32 messageCorrelationId, bytes32 legalReferenceHash ); CompliantFiatTokenV2 internal token; uint256 internal ownerPk; uint256 internal adminPk; address internal owner; address internal admin; address internal spender; address internal recipient; address internal burner; address internal governanceExecutor; function setUp() public { ownerPk = 0xA11CE; adminPk = 0xB0B; owner = vm.addr(ownerPk); admin = vm.addr(adminPk); spender = address(0xCAFE); recipient = address(0xBEEF); burner = address(0xD00D); governanceExecutor = address(0x7777); token = new CompliantFiatTokenV2( "Euro Coin (Compliant V2)", "cEURC", 6, "EUR", "2", owner, admin, 1_000_000 * 10 ** 6, true ); bytes32 burnerRole = token.BURNER_ROLE(); vm.prank(admin); token.grantRole(burnerRole, burner); vm.prank(admin); token.setGovernanceController(governanceExecutor); } function testPermitWorks() public { uint256 value = 25_000 * 10 ** 6; uint256 deadline = block.timestamp + 1 days; bytes32 structHash = keccak256( abi.encode(PERMIT_TYPEHASH, owner, spender, value, token.nonces(owner), deadline) ); bytes32 digest = keccak256( abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash) ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, digest); token.permit(owner, spender, value, deadline, v, r, s); assertEq(token.allowance(owner, spender), value); assertEq(token.nonces(owner), 1); } function testTransferWithAuthorizationWorks() public { uint256 value = 5_000 * 10 ** 6; uint256 validAfter = block.timestamp - 1; uint256 validBefore = block.timestamp + 1 days; bytes32 nonce = keccak256("auth-1"); bytes32 structHash = keccak256( abi.encode( TRANSFER_WITH_AUTHORIZATION_TYPEHASH, owner, recipient, value, validAfter, validBefore, nonce ) ); bytes32 digest = keccak256( abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash) ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, digest); vm.prank(spender); token.transferWithAuthorization(owner, recipient, value, validAfter, validBefore, nonce, v, r, s); assertEq(token.balanceOf(recipient), value); assertEq(token.balanceOf(owner), 1_000_000 * 10 ** 6 - value); assertTrue(token.authorizationState(owner, nonce)); } function testReceiveWithAuthorizationRequiresPayeeCallerAndWorks() public { uint256 value = 7_500 * 10 ** 6; uint256 validAfter = block.timestamp - 1; uint256 validBefore = block.timestamp + 1 days; bytes32 nonce = keccak256("receive-auth-1"); bytes32 structHash = keccak256( abi.encode( RECEIVE_WITH_AUTHORIZATION_TYPEHASH, owner, recipient, value, validAfter, validBefore, nonce ) ); bytes32 digest = keccak256( abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash) ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, digest); vm.prank(spender); vm.expectRevert( abi.encodeWithSelector( CompliantFiatTokenV2.AuthorizationMustBeUsedByPayee.selector, recipient, spender ) ); token.receiveWithAuthorization(owner, recipient, value, validAfter, validBefore, nonce, v, r, s); vm.prank(recipient); token.receiveWithAuthorization(owner, recipient, value, validAfter, validBefore, nonce, v, r, s); assertEq(token.balanceOf(recipient), value); assertTrue(token.authorizationState(owner, nonce)); } function testCancelAuthorizationBlocksFutureUse() public { uint256 value = 1_000 * 10 ** 6; uint256 validAfter = block.timestamp - 1; uint256 validBefore = block.timestamp + 1 days; bytes32 nonce = keccak256("cancel-auth-1"); bytes32 cancelStructHash = keccak256( abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, owner, nonce) ); bytes32 cancelDigest = keccak256( abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), cancelStructHash) ); (uint8 cancelV, bytes32 cancelR, bytes32 cancelS) = vm.sign(ownerPk, cancelDigest); token.cancelAuthorization(owner, nonce, cancelV, cancelR, cancelS); assertTrue(token.authorizationState(owner, nonce)); bytes32 transferStructHash = keccak256( abi.encode( TRANSFER_WITH_AUTHORIZATION_TYPEHASH, owner, recipient, value, validAfter, validBefore, nonce ) ); bytes32 transferDigest = keccak256( abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), transferStructHash) ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, transferDigest); vm.prank(spender); vm.expectRevert( abi.encodeWithSelector( CompliantFiatTokenV2.AuthorizationAlreadyUsed.selector, owner, nonce ) ); token.transferWithAuthorization(owner, recipient, value, validAfter, validBefore, nonce, v, r, s); } function testMintAndBurnReasonHashesEmitStructuredEvents() public { bytes32 mintReason = keccak256("mint-reason"); bytes32 burnReason = keccak256("burn-reason"); uint256 mintAmount = 10_000 * 10 ** 6; vm.expectEmit(true, true, true, false); emit CompliantOperationDeclared( token.OPERATION_MINT(), admin, admin, admin, address(0), recipient, mintAmount, mintReason, bytes32(0), bytes32(0), bytes32(0) ); vm.prank(admin); token.mint(recipient, mintAmount, mintReason); assertEq(token.balanceOf(recipient), mintAmount); vm.expectEmit(true, true, true, false); emit CompliantOperationDeclared( token.OPERATION_BURN(), recipient, recipient, burner, recipient, address(0), mintAmount / 2, burnReason, bytes32(0), bytes32(0), bytes32(0) ); vm.prank(burner); token.burn(recipient, mintAmount / 2, burnReason); assertEq(token.balanceOf(recipient), mintAmount / 2); } function testPauseIsRoleGated() public { vm.prank(spender); vm.expectRevert(); token.pause(); vm.prank(admin); token.pause(); assertTrue(token.paused()); } function testSupplyControlsEnforceCap() public { uint256 configuredCap = token.totalSupply() + (1_000 * 10 ** 6); vm.prank(admin); token.setSupplyControls(configuredCap, 0, 0); vm.prank(admin); token.mint(recipient, 1_000 * 10 ** 6, keccak256("under-cap")); uint256 attemptedTotalSupply = token.totalSupply() + 1; vm.prank(admin); vm.expectRevert( abi.encodeWithSelector( CompliantFiatTokenV2.SupplyCapExceeded.selector, configuredCap, attemptedTotalSupply ) ); token.mint(recipient, 1, keccak256("over-cap")); } function testLegacyAliasAndForwardCanonicalMetadata() public { vm.prank(admin); vm.expectRevert(); token.setForwardCanonical(false); vm.startPrank(governanceExecutor); token.addLegacyAlias("cEURC-legacy"); token.setForwardCanonical(false); token.setSymbolDisplay("cEURC"); vm.stopPrank(); string[] memory aliases = token.legacyAliases(); assertEq(aliases.length, 1); assertEq(aliases[0], "cEURC-legacy"); assertFalse(token.forwardCanonical()); assertEq(token.symbolDisplay(), "cEURC"); } function testGovernanceAndSupervisionMetadataCanBeManaged() public { bytes32 governanceProfileId = keccak256("gov-profile"); bytes32 supervisionProfileId = keccak256("supervision-profile"); bytes32 storageNamespace = keccak256("storage-namespace"); vm.prank(admin); vm.expectRevert(); token.setGovernanceProfileId(governanceProfileId); vm.startPrank(governanceExecutor); token.setGovernanceProfileId(governanceProfileId); token.setSupervisionProfileId(supervisionProfileId); token.setStorageNamespace(storageNamespace); token.setPrimaryJurisdiction("EU"); token.setRegulatoryDisclosureURI("ipfs://disclosure"); token.setReportingURI("ipfs://reporting"); token.setCanonicalUnderlyingAsset(address(0x1234)); token.setSupervisionConfiguration(true, true, 14 days); vm.stopPrank(); assertEq(token.governanceProfileId(), governanceProfileId); assertEq(token.supervisionProfileId(), supervisionProfileId); assertEq(token.storageNamespace(), storageNamespace); assertEq(token.primaryJurisdiction(), "EU"); assertEq(token.regulatoryDisclosureURI(), "ipfs://disclosure"); assertEq(token.reportingURI(), "ipfs://reporting"); assertEq(token.canonicalUnderlyingAsset(), address(0x1234)); assertTrue(token.supervisionRequired()); assertTrue(token.governmentApprovalRequired()); assertEq(token.minimumUpgradeNoticePeriod(), 14 days); assertFalse(token.wrappedTransport()); } function testEip5267DomainIntrospectionMatchesTokenMetadata() public view { ( bytes1 fields, string memory name, string memory version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] memory extensions ) = token.eip712Domain(); assertEq(uint8(fields), uint8(0x0f)); assertEq(name, "Euro Coin (Compliant V2)"); assertEq(version, "2"); assertEq(chainId, block.chainid); assertEq(verifyingContract, address(token)); assertEq(salt, bytes32(0)); assertEq(extensions.length, 0); } function testEmergencyMetadataOverridesRemainAvailableOutsideGovernance() public { vm.prank(admin); token.emergencySetPresentationMetadata(true, "ipfs://emergency-token", "cEURC-emergency"); vm.prank(admin); token.emergencySetGovernanceMetadata( keccak256("emergency-gov"), keccak256("emergency-sup"), keccak256("emergency-storage"), "Emergency Jurisdiction", address(0x9999), true, true, 30 days ); vm.prank(admin); token.emergencySetDisclosureMetadata("ipfs://emergency-disclosure", "ipfs://emergency-reporting"); assertTrue(token.forwardCanonical()); assertEq(token.tokenURI(), "ipfs://emergency-token"); assertEq(token.symbolDisplay(), "cEURC-emergency"); assertEq(token.primaryJurisdiction(), "Emergency Jurisdiction"); assertEq(token.canonicalUnderlyingAsset(), address(0x9999)); assertEq(token.regulatoryDisclosureURI(), "ipfs://emergency-disclosure"); assertEq(token.reportingURI(), "ipfs://emergency-reporting"); assertEq(token.minimumUpgradeNoticePeriod(), 30 days); } }