// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {Test} from "forge-std/Test.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {IRouterClient} from "../../contracts/ccip/IRouterClient.sol"; import {CWMultiTokenBridgeL1} from "../../contracts/bridge/CWMultiTokenBridgeL1.sol"; import {CWMultiTokenBridgeL2} from "../../contracts/bridge/CWMultiTokenBridgeL2.sol"; import {CompliantWrappedToken} from "../../contracts/tokens/CompliantWrappedToken.sol"; import {CCIPRelayRouter} from "../../contracts/relay/CCIPRelayRouter.sol"; contract MockCanonicalToken is ERC20 { constructor() ERC20("Mock Canonical", "MCAN") {} function mint(address to, uint256 amount) external { _mint(to, amount); } } contract MockRouter is IRouterClient { uint256 public fee; bytes32 public nextMessageId = keccak256("message"); EVM2AnyMessage internal _lastMessage; uint64 public lastDestinationChainSelector; function setFee(uint256 newFee) external { fee = newFee; } function ccipSend( uint64 destinationChainSelector, EVM2AnyMessage memory message ) external payable returns (bytes32 messageId, uint256 fees) { fees = fee; if (message.feeToken == address(0)) { require(msg.value >= fees, "native fee"); } lastDestinationChainSelector = destinationChainSelector; _lastMessage = message; emit MessageSent( nextMessageId, destinationChainSelector, msg.sender, message.receiver, message.data, message.tokenAmounts, message.feeToken, message.extraArgs ); return (nextMessageId, fees); } function getFee(uint64, EVM2AnyMessage memory) external view returns (uint256) { return fee; } function getSupportedTokens(uint64) external pure returns (address[] memory tokens) { tokens = new address[](0); } function lastMessage() external view returns (bytes memory receiver, bytes memory data, address feeToken, bytes memory extraArgs) { return (_lastMessage.receiver, _lastMessage.data, _lastMessage.feeToken, _lastMessage.extraArgs); } } contract MockReceiveBridge { bytes public lastData; function ccipReceive(IRouterClient.Any2EVMMessage calldata message) external { lastData = message.data; } } contract CWMultiTokenBridgeTest is Test { uint64 internal constant CHAIN138_SELECTOR = 138; uint64 internal constant AVALANCHE_SELECTOR = 6433500567565415381; address internal user = address(0xBEEF); MockRouter internal router138; MockRouter internal routerAvax; MockCanonicalToken internal canonical; CompliantWrappedToken internal wrapped; CWMultiTokenBridgeL1 internal l1Bridge; CWMultiTokenBridgeL2 internal l2Bridge; function setUp() public { router138 = new MockRouter(); routerAvax = new MockRouter(); canonical = new MockCanonicalToken(); wrapped = new CompliantWrappedToken("Wrapped Canonical", "cWMOCK", 6, address(this)); l1Bridge = new CWMultiTokenBridgeL1(address(router138), address(0x138138), address(0)); l2Bridge = new CWMultiTokenBridgeL2(address(routerAvax), address(0x4311443114), address(0)); l1Bridge.configureSupportedCanonicalToken(address(canonical), true); l1Bridge.configureDestination(address(canonical), AVALANCHE_SELECTOR, address(l2Bridge), true); l2Bridge.configureDestination(CHAIN138_SELECTOR, address(l1Bridge), true); l2Bridge.configureTokenPair(address(canonical), address(wrapped)); wrapped.grantRole(wrapped.MINTER_ROLE(), address(l2Bridge)); wrapped.grantRole(wrapped.BURNER_ROLE(), address(l2Bridge)); canonical.mint(user, 1_000_000e18); } function testRoundTripLockMintBurnRelease() public { uint256 amount = 125e6; vm.startPrank(user); canonical.approve(address(l1Bridge), amount); bytes32 outboundMessageId = l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); vm.stopPrank(); assertEq(canonical.balanceOf(address(l1Bridge)), amount); assertEq(canonical.balanceOf(user), 1_000_000e18 - amount); assertEq(l1Bridge.lockedBalance(address(canonical)), amount); assertEq(l1Bridge.totalOutstanding(address(canonical)), amount); assertEq(l1Bridge.outstandingMinted(address(canonical), AVALANCHE_SELECTOR), amount); (bytes memory receiverData, bytes memory outboundData,,) = router138.lastMessage(); assertEq(abi.decode(receiverData, (address)), address(l2Bridge)); assertEq(outboundMessageId, router138.nextMessageId()); vm.prank(address(0x4311443114)); l2Bridge.ccipReceive(_message(outboundMessageId, CHAIN138_SELECTOR, address(l1Bridge), outboundData)); assertEq(wrapped.balanceOf(user), amount); assertEq(wrapped.totalSupply(), amount); assertEq(l2Bridge.mintedTotal(address(wrapped)), amount); assertEq(l2Bridge.burnedTotal(address(wrapped)), 0); vm.prank(user); bytes32 returnMessageId = l2Bridge.burnAndSend(address(wrapped), CHAIN138_SELECTOR, user, amount); assertEq(wrapped.balanceOf(user), 0); assertEq(wrapped.totalSupply(), 0); assertEq(l2Bridge.mintedTotal(address(wrapped)), amount); assertEq(l2Bridge.burnedTotal(address(wrapped)), amount); (, bytes memory returnData,,) = routerAvax.lastMessage(); vm.prank(address(0x138138)); l1Bridge.ccipReceive(_message(returnMessageId, AVALANCHE_SELECTOR, address(l2Bridge), returnData)); assertEq(canonical.balanceOf(user), 1_000_000e18); assertEq(canonical.balanceOf(address(l1Bridge)), 0); assertEq(l1Bridge.lockedBalance(address(canonical)), 0); assertEq(l1Bridge.totalOutstanding(address(canonical)), 0); assertEq(l1Bridge.outstandingMinted(address(canonical), AVALANCHE_SELECTOR), 0); } function testLockAndSendRespectsMaxOutstanding() public { uint256 amount = 125e6; l1Bridge.setMaxOutstanding(address(canonical), AVALANCHE_SELECTOR, 100e6); vm.startPrank(user); canonical.approve(address(l1Bridge), amount); vm.expectRevert(bytes("CWMultiTokenBridgeL1: exceeds escrow capacity")); l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); vm.stopPrank(); } function testSupportedCanonicalTokenCannotBeDisabledWhileFundsOutstanding() public { uint256 amount = 50e6; vm.startPrank(user); canonical.approve(address(l1Bridge), amount); l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); vm.stopPrank(); vm.expectRevert(bytes("CWMultiTokenBridgeL1: token still locked")); l1Bridge.configureSupportedCanonicalToken(address(canonical), false); } function testWithdrawTokenCannotDrainLockedCanonicalEscrow() public { uint256 amount = 50e6; vm.startPrank(user); canonical.approve(address(l1Bridge), amount); l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); vm.stopPrank(); vm.expectRevert(bytes("CWMultiTokenBridgeL1: amount locked")); l1Bridge.withdrawToken(address(canonical), user, 1); canonical.mint(address(l1Bridge), 10e6); l1Bridge.withdrawToken(address(canonical), user, 10e6); assertEq(canonical.balanceOf(user), 1_000_000e18 - amount + 10e6); assertEq(canonical.balanceOf(address(l1Bridge)), amount); } function testReleaseRequiresOutstandingBalance() public { bytes memory returnData = abi.encode(address(canonical), user, uint256(1)); vm.expectRevert(bytes("CWMultiTokenBridgeL1: outstanding underflow")); vm.prank(address(0x138138)); l1Bridge.ccipReceive(_message(keccak256("no-lock"), AVALANCHE_SELECTOR, address(l2Bridge), returnData)); } function testL2PauseBlocksMintAndBurn() public { uint256 amount = 20e6; vm.startPrank(user); canonical.approve(address(l1Bridge), amount); bytes32 outboundMessageId = l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); vm.stopPrank(); (, bytes memory outboundData,,) = router138.lastMessage(); l2Bridge.setTokenPaused(address(wrapped), true); vm.expectRevert(bytes("CWMultiTokenBridgeL2: token paused")); vm.prank(address(0x4311443114)); l2Bridge.ccipReceive(_message(outboundMessageId, CHAIN138_SELECTOR, address(l1Bridge), outboundData)); l2Bridge.setTokenPaused(address(wrapped), false); vm.prank(address(0x4311443114)); l2Bridge.ccipReceive(_message(outboundMessageId, CHAIN138_SELECTOR, address(l1Bridge), outboundData)); l2Bridge.setTokenPaused(address(wrapped), true); vm.expectRevert(bytes("CWMultiTokenBridgeL2: token paused")); vm.prank(user); l2Bridge.burnAndSend(address(wrapped), CHAIN138_SELECTOR, user, amount); } function testL2CirculatingSupplyTracksMintMinusBurn() public { uint256 amount = 35e6; vm.startPrank(user); canonical.approve(address(l1Bridge), amount); bytes32 outboundMessageId = l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); vm.stopPrank(); (, bytes memory outboundData,,) = router138.lastMessage(); vm.prank(address(0x4311443114)); l2Bridge.ccipReceive(_message(outboundMessageId, CHAIN138_SELECTOR, address(l1Bridge), outboundData)); assertEq(l2Bridge.circulatingSupply(address(wrapped)), amount); vm.prank(user); l2Bridge.burnAndSend(address(wrapped), CHAIN138_SELECTOR, user, 10e6); assertEq(l2Bridge.circulatingSupply(address(wrapped)), amount - 10e6); } function testL2RejectsReplayOfInboundMintMessage() public { uint256 amount = 15e6; vm.startPrank(user); canonical.approve(address(l1Bridge), amount); bytes32 outboundMessageId = l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); vm.stopPrank(); (, bytes memory outboundData,,) = router138.lastMessage(); vm.prank(address(0x4311443114)); l2Bridge.ccipReceive(_message(outboundMessageId, CHAIN138_SELECTOR, address(l1Bridge), outboundData)); vm.expectRevert(bytes("CWMultiTokenBridgeL2: replayed")); vm.prank(address(0x4311443114)); l2Bridge.ccipReceive(_message(outboundMessageId, CHAIN138_SELECTOR, address(l1Bridge), outboundData)); } function testFrozenL2ConfigCannotBeRewritten() public { CompliantWrappedToken wrapped2 = new CompliantWrappedToken("Wrapped Canonical 2", "cWMOCK2", 6, address(this)); l2Bridge.freezeTokenPair(address(canonical)); vm.expectRevert(bytes("CWMultiTokenBridgeL2: token pair frozen")); l2Bridge.configureTokenPair(address(canonical), address(wrapped2)); l2Bridge.freezeDestination(CHAIN138_SELECTOR); vm.expectRevert(bytes("CWMultiTokenBridgeL2: destination frozen")); l2Bridge.configureDestination(CHAIN138_SELECTOR, address(0x1234), true); } function testL1RejectsReplayOfReturnMessage() public { uint256 amount = 25e6; vm.startPrank(user); canonical.approve(address(l1Bridge), amount); l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); vm.stopPrank(); bytes32 returnMessageId = keccak256("return-replay"); bytes memory returnData = abi.encode(address(canonical), user, amount); vm.prank(address(0x138138)); l1Bridge.ccipReceive(_message(returnMessageId, AVALANCHE_SELECTOR, address(l2Bridge), returnData)); vm.expectRevert(bytes("CWMultiTokenBridgeL1: replayed")); vm.prank(address(0x138138)); l1Bridge.ccipReceive(_message(returnMessageId, AVALANCHE_SELECTOR, address(l2Bridge), returnData)); } function testRelayRouterAcceptsThreeFieldPayload() public { CCIPRelayRouter relayRouter = new CCIPRelayRouter(); MockReceiveBridge receiveBridge = new MockReceiveBridge(); relayRouter.authorizeBridge(address(receiveBridge)); relayRouter.grantRelayerRole(address(this)); IRouterClient.TokenAmount[] memory noTokens = new IRouterClient.TokenAmount[](0); IRouterClient.Any2EVMMessage memory message = IRouterClient.Any2EVMMessage({ messageId: keccak256("three-field"), sourceChainSelector: CHAIN138_SELECTOR, sender: abi.encode(address(0xCAFE)), data: abi.encode(address(canonical), user, uint256(42)), tokenAmounts: noTokens }); relayRouter.relayMessage(address(receiveBridge), message); assertEq(keccak256(receiveBridge.lastData()), keccak256(message.data)); } function _message( bytes32 messageId, uint64 sourceChainSelector, address sender, bytes memory data ) internal pure returns (IRouterClient.Any2EVMMessage memory message) { IRouterClient.TokenAmount[] memory noTokens = new IRouterClient.TokenAmount[](0); message = IRouterClient.Any2EVMMessage({ messageId: messageId, sourceChainSelector: sourceChainSelector, sender: abi.encode(sender), data: data, tokenAmounts: noTokens }); } }