// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "../ccip/IRouterClient.sol"; interface IERC20 { function transferFrom(address from, address to, uint256 amount) external returns (bool); function transfer(address to, uint256 amount) external returns (bool); function approve(address spender, uint256 amount) external returns (bool); function balanceOf(address account) external view returns (uint256); } /** * @title TwoWayTokenBridgeL1 * @notice L1/LMain chain side: locks canonical tokens and triggers CCIP message to mint on L2 * @dev Uses escrow for locked tokens; release on inbound messages */ contract TwoWayTokenBridgeL1 { IRouterClient public immutable ccipRouter; address public immutable canonicalToken; address public feeToken; // LINK address public admin; struct DestinationConfig { uint64 chainSelector; address l2Bridge; bool enabled; } mapping(uint64 => DestinationConfig) public destinations; uint64[] public destinationChains; mapping(bytes32 => bool) public processed; // replay protection event Locked(address indexed user, uint256 amount); event Released(address indexed recipient, uint256 amount); event CcipSend(bytes32 indexed messageId, uint64 destChain, address recipient, uint256 amount); event DestinationAdded(uint64 chainSelector, address l2Bridge); event DestinationUpdated(uint64 chainSelector, address l2Bridge); event DestinationRemoved(uint64 chainSelector); modifier onlyAdmin() { require(msg.sender == admin, "only admin"); _; } modifier onlyRouter() { require(msg.sender == address(ccipRouter), "only router"); _; } constructor(address _router, address _token, address _feeToken) { require(_router != address(0) && _token != address(0) && _feeToken != address(0), "zero addr"); ccipRouter = IRouterClient(_router); canonicalToken = _token; feeToken = _feeToken; admin = msg.sender; } function addDestination(uint64 chainSelector, address l2Bridge) external onlyAdmin { require(l2Bridge != address(0), "zero l2"); require(!destinations[chainSelector].enabled, "exists"); destinations[chainSelector] = DestinationConfig(chainSelector, l2Bridge, true); destinationChains.push(chainSelector); emit DestinationAdded(chainSelector, l2Bridge); } function updateDestination(uint64 chainSelector, address l2Bridge) external onlyAdmin { require(destinations[chainSelector].enabled, "missing"); require(l2Bridge != address(0), "zero l2"); destinations[chainSelector].l2Bridge = l2Bridge; emit DestinationUpdated(chainSelector, l2Bridge); } function removeDestination(uint64 chainSelector) external onlyAdmin { require(destinations[chainSelector].enabled, "missing"); destinations[chainSelector].enabled = false; for (uint256 i = 0; i < destinationChains.length; i++) { if (destinationChains[i] == chainSelector) { destinationChains[i] = destinationChains[destinationChains.length - 1]; destinationChains.pop(); break; } } emit DestinationRemoved(chainSelector); } function updateFeeToken(address newFee) external onlyAdmin { require(newFee != address(0), "zero"); feeToken = newFee; } function changeAdmin(address newAdmin) external onlyAdmin { require(newAdmin != address(0), "zero"); admin = newAdmin; } function getDestinationChains() external view returns (uint64[] memory) { return destinationChains; } // User-facing: lock canonical tokens and send CCIP to mint on L2 function lockAndSend(uint64 destSelector, address recipient, uint256 amount) external returns (bytes32 messageId) { require(amount > 0 && recipient != address(0), "bad args"); DestinationConfig memory dest = destinations[destSelector]; require(dest.enabled, "dest disabled"); // Pull tokens into escrow require(IERC20(canonicalToken).transferFrom(msg.sender, address(this), amount), "pull fail"); emit Locked(msg.sender, amount); // Encode payload bytes memory data = abi.encode(recipient, amount); // Build message IRouterClient.EVM2AnyMessage memory m = IRouterClient.EVM2AnyMessage({ receiver: abi.encode(dest.l2Bridge), data: data, tokenAmounts: new IRouterClient.TokenAmount[](0), feeToken: feeToken, extraArgs: "" }); // Get fee and pay in LINK held by user: bridge expects to have LINK pre-funded by admin or via separate topup uint256 fee = ccipRouter.getFee(destSelector, m); if (fee > 0) { // Expect admin has prefunded LINK to this contract; otherwise approvals/pull pattern can be added require(IERC20(feeToken).approve(address(ccipRouter), fee), "fee approve"); } (messageId, ) = ccipRouter.ccipSend(destSelector, m); emit CcipSend(messageId, destSelector, recipient, amount); return messageId; } // Inbound from L2: release canonical tokens to recipient function ccipReceive(IRouterClient.Any2EVMMessage calldata message) external onlyRouter { require(!processed[message.messageId], "replayed"); processed[message.messageId] = true; (address recipient, uint256 amount) = abi.decode(message.data, (address, uint256)); require(recipient != address(0) && amount > 0, "bad msg"); require(IERC20(canonicalToken).transfer(recipient, amount), "release fail"); emit Released(recipient, amount); } }