diff --git a/contracts/Linea_SpokePool.sol b/contracts/Linea_SpokePool.sol index 4327f40b7..291d8a801 100644 --- a/contracts/Linea_SpokePool.sol +++ b/contracts/Linea_SpokePool.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.19; import "./SpokePool.sol"; +import "./libraries/CircleCCTPAdapter.sol"; import { IMessageService, ITokenBridge, IUSDCBridge } from "./external/interfaces/LineaInterfaces.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -13,7 +14,7 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; * @notice Linea specific SpokePool. * @custom:security-contact bugs@across.to */ -contract Linea_SpokePool is SpokePool { +contract Linea_SpokePool is SpokePool, CircleCCTPAdapter { using SafeERC20 for IERC20; /** @@ -29,14 +30,13 @@ contract Linea_SpokePool is SpokePool { /** * @notice Address of Linea's USDC Bridge contract on L2. */ - IUSDCBridge public l2UsdcBridge; + IUSDCBridge private DEPRECATED_l2UsdcBridge; /************************************** * EVENTS * **************************************/ event SetL2TokenBridge(address indexed newTokenBridge, address oldTokenBridge); event SetL2MessageService(address indexed newMessageService, address oldMessageService); - event SetL2UsdcBridge(address indexed newUsdcBridge, address oldUsdcBridge); /** * @notice Construct Linea-specific SpokePool. @@ -50,8 +50,13 @@ contract Linea_SpokePool is SpokePool { constructor( address _wrappedNativeTokenAddress, uint32 _depositQuoteTimeBuffer, - uint32 _fillDeadlineBuffer - ) SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) {} // solhint-disable-line no-empty-blocks + uint32 _fillDeadlineBuffer, + IERC20 _l2Usdc, + ITokenMessenger _cctpTokenMessenger + ) + SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) + CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) + {} // solhint-disable-line no-empty-blocks /** * @notice Initialize Linea-specific SpokePool. @@ -59,7 +64,6 @@ contract Linea_SpokePool is SpokePool { * relay hash collisions. * @param _l2MessageService Address of Canonical Message Service. Can be reset by admin. * @param _l2TokenBridge Address of Canonical Token Bridge. Can be reset by admin. - * @param _l2UsdcBridge Address of USDC Bridge. Can be reset by admin. * @param _crossDomainAdmin Cross domain admin to set. Can be changed by admin. * @param _withdrawalRecipient Address which receives token withdrawals. Can be changed by admin. For Spoke Pools on L2, this will * likely be the hub pool. @@ -68,14 +72,12 @@ contract Linea_SpokePool is SpokePool { uint32 _initialDepositId, IMessageService _l2MessageService, ITokenBridge _l2TokenBridge, - IUSDCBridge _l2UsdcBridge, address _crossDomainAdmin, address _withdrawalRecipient ) public initializer { __SpokePool_init(_initialDepositId, _crossDomainAdmin, _withdrawalRecipient); _setL2TokenBridge(_l2TokenBridge); _setL2MessageService(_l2MessageService); - _setL2UsdcBridge(_l2UsdcBridge); } /** @@ -106,14 +108,6 @@ contract Linea_SpokePool is SpokePool { _setL2MessageService(_l2MessageService); } - /** - * @notice Change L2 USDC bridge address. Callable only by admin. - * @param _l2UsdcBridge New address of L2 USDC bridge. - */ - function setL2UsdcBridge(IUSDCBridge _l2UsdcBridge) public onlyAdmin nonReentrant { - _setL2UsdcBridge(_l2UsdcBridge); - } - /************************************** * INTERNAL FUNCTIONS * **************************************/ @@ -139,14 +133,15 @@ contract Linea_SpokePool is SpokePool { function _bridgeTokensToHubPool(uint256 amountToReturn, address l2TokenAddress) internal override { // Linea's L2 Canonical Message Service, requires a minimum fee to be set. uint256 minFee = minimumFeeInWei(); - // We require that the caller pass in the fees as msg.value instead of pulling ETH out of this contract's balance. - // Using the contract's balance would require a separate accounting system to keep LP funds separated from system funds - // used to pay for L2->L1 messages. - require(msg.value == minFee, "MESSAGE_FEE_MISMATCH"); // SpokePool is expected to receive ETH from the L1 HubPool, then we need to first unwrap it to ETH and then // send ETH directly via the Canonical Message Service. if (l2TokenAddress == address(wrappedNativeToken)) { + // We require that the caller pass in the fees as msg.value instead of pulling ETH out of this contract's balance. + // Using the contract's balance would require a separate accounting system to keep LP funds separated from system funds + // used to pay for L2->L1 messages. + require(msg.value == minFee, "MESSAGE_FEE_MISMATCH"); + // msg.value is added here because the entire native balance (including msg.value) is auto-wrapped // before the execution of any wrapped token refund leaf. So it must be unwrapped before being sent as a // fee to the l2MessageService. @@ -154,12 +149,16 @@ contract Linea_SpokePool is SpokePool { l2MessageService.sendMessage{ value: amountToReturn + msg.value }(withdrawalRecipient, msg.value, ""); } // If the l1Token is USDC, then we need sent it via the USDC Bridge. - else if (l2TokenAddress == l2UsdcBridge.usdc()) { - IERC20(l2TokenAddress).safeIncreaseAllowance(address(l2UsdcBridge), amountToReturn); - l2UsdcBridge.depositTo{ value: msg.value }(amountToReturn, withdrawalRecipient); + else if (l2TokenAddress == address(usdcToken) && _isCCTPEnabled()) { + _transferUsdc(withdrawalRecipient, amountToReturn); } // For other tokens, we can use the Canonical Token Bridge. else { + // We require that the caller pass in the fees as msg.value instead of pulling ETH out of this contract's balance. + // Using the contract's balance would require a separate accounting system to keep LP funds separated from system funds + // used to pay for L2->L1 messages. + require(msg.value == minFee, "MESSAGE_FEE_MISMATCH"); + IERC20(l2TokenAddress).safeIncreaseAllowance(address(l2TokenBridge), amountToReturn); l2TokenBridge.bridgeToken{ value: msg.value }(l2TokenAddress, amountToReturn, withdrawalRecipient); } @@ -178,12 +177,6 @@ contract Linea_SpokePool is SpokePool { emit SetL2TokenBridge(address(_l2TokenBridge), oldTokenBridge); } - function _setL2UsdcBridge(IUSDCBridge _l2UsdcBridge) internal { - address oldUsdcBridge = address(l2UsdcBridge); - l2UsdcBridge = _l2UsdcBridge; - emit SetL2UsdcBridge(address(_l2UsdcBridge), oldUsdcBridge); - } - function _setL2MessageService(IMessageService _l2MessageService) internal { address oldMessageService = address(l2MessageService); l2MessageService = _l2MessageService; diff --git a/contracts/chain-adapters/Linea_Adapter.sol b/contracts/chain-adapters/Linea_Adapter.sol index 7ede0c142..1af43e06c 100644 --- a/contracts/chain-adapters/Linea_Adapter.sol +++ b/contracts/chain-adapters/Linea_Adapter.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import "./interfaces/AdapterInterface.sol"; import "../external/interfaces/WETH9Interface.sol"; +import "../libraries/CircleCCTPAdapter.sol"; import { IMessageService, ITokenBridge, IUSDCBridge } from "../external/interfaces/LineaInterfaces.sol"; @@ -14,31 +15,29 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; * @custom:security-contact bugs@across.to */ // solhint-disable-next-line contract-name-camelcase -contract Linea_Adapter is AdapterInterface { +contract Linea_Adapter is AdapterInterface, CircleCCTPAdapter { using SafeERC20 for IERC20; WETH9Interface public immutable L1_WETH; IMessageService public immutable L1_MESSAGE_SERVICE; ITokenBridge public immutable L1_TOKEN_BRIDGE; - IUSDCBridge public immutable L1_USDC_BRIDGE; /** * @notice Constructs new Adapter. * @param _l1Weth WETH address on L1. * @param _l1MessageService Canonical message service contract on L1. * @param _l1TokenBridge Canonical token bridge contract on L1. - * @param _l1UsdcBridge L1 USDC Bridge to ConsenSys's L2 Linea. */ constructor( WETH9Interface _l1Weth, IMessageService _l1MessageService, ITokenBridge _l1TokenBridge, - IUSDCBridge _l1UsdcBridge - ) { + IERC20 _l1Usdc, + ITokenMessenger _cctpTokenMessenger + ) CircleCCTPAdapter(_l1Usdc, _cctpTokenMessenger, CircleDomainIds.Linea) { L1_WETH = _l1Weth; L1_MESSAGE_SERVICE = _l1MessageService; L1_TOKEN_BRIDGE = _l1TokenBridge; - L1_USDC_BRIDGE = _l1UsdcBridge; } /** @@ -67,17 +66,15 @@ contract Linea_Adapter is AdapterInterface { uint256 amount, address to ) external payable override { + if (l1Token == address(usdcToken) && _isCCTPEnabled()) { + _transferUsdc(to, amount); + } // If the l1Token is WETH then unwrap it to ETH then send the ETH directly // via the Canoncial Message Service. - if (l1Token == address(L1_WETH)) { + else if (l1Token == address(L1_WETH)) { L1_WETH.withdraw(amount); L1_MESSAGE_SERVICE.sendMessage{ value: amount }(to, 0, ""); } - // If the l1Token is USDC, then we need sent it via the USDC Bridge. - else if (l1Token == L1_USDC_BRIDGE.usdc()) { - IERC20(l1Token).safeIncreaseAllowance(address(L1_USDC_BRIDGE), amount); - L1_USDC_BRIDGE.depositTo(amount, to); - } // For other tokens, we can use the Canonical Token Bridge. else { IERC20(l1Token).safeIncreaseAllowance(address(L1_TOKEN_BRIDGE), amount); diff --git a/contracts/external/interfaces/CCTPInterfaces.sol b/contracts/external/interfaces/CCTPInterfaces.sol index e932d943a..4eb89740b 100644 --- a/contracts/external/interfaces/CCTPInterfaces.sol +++ b/contracts/external/interfaces/CCTPInterfaces.sol @@ -57,6 +57,39 @@ interface ITokenMessenger { function localMinter() external view returns (ITokenMinter minter); } +// Source: https://github.com/circlefin/evm-cctp-contracts/blob/63ab1f0ac06ce0793c0bbfbb8d09816bc211386d/src/v2/TokenMessengerV2.sol#L138C1-L166C15 +interface ITokenMessengerV2 { + /** + * @notice Deposits and burns tokens from sender to be minted on destination domain. + * Emits a `DepositForBurn` event. + * @dev reverts if: + * - given burnToken is not supported + * - given destinationDomain has no TokenMessenger registered + * - transferFrom() reverts. For example, if sender's burnToken balance or approved allowance + * to this contract is less than `amount`. + * - burn() reverts. For example, if `amount` is 0. + * - maxFee is greater than or equal to `amount`. + * - MessageTransmitterV2#sendMessage reverts. + * @param amount amount of tokens to burn + * @param destinationDomain destination domain to receive message on + * @param mintRecipient address of mint recipient on destination domain + * @param burnToken token to burn `amount` of, on local domain + * @param destinationCaller authorized caller on the destination domain, as bytes32. If equal to bytes32(0), + * any address can broadcast the message. + * @param maxFee maximum fee to pay on the destination domain, specified in units of burnToken + * @param minFinalityThreshold the minimum finality at which a burn message will be attested to. + */ + function depositForBurn( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + uint256 maxFee, + uint32 minFinalityThreshold + ) external; +} + /** * A TokenMessenger stores a TokenMinter contract which extends the TokenController contract. The TokenController * contract has a burnLimitsPerMessage public mapping which can be queried to find the per-message burn limit diff --git a/contracts/libraries/CircleCCTPAdapter.sol b/contracts/libraries/CircleCCTPAdapter.sol index 9db21c94b..06c7f1033 100644 --- a/contracts/libraries/CircleCCTPAdapter.sol +++ b/contracts/libraries/CircleCCTPAdapter.sol @@ -14,6 +14,7 @@ library CircleDomainIds { uint32 public constant Base = 6; uint32 public constant Polygon = 7; uint32 public constant DoctorWho = 10; + uint32 public constant Linea = 11; // TODO replace with actual domain once Circle publishes it. // Use this value for placeholder purposes only for adapters that extend this adapter but haven't yet been // assigned a domain ID by Circle. uint32 public constant UNINITIALIZED = type(uint32).max; @@ -50,6 +51,13 @@ abstract contract CircleCCTPAdapter { /// @custom:oz-upgrades-unsafe-allow state-variable-immutable ITokenMessenger public immutable cctpTokenMessenger; + /** + * @notice Indicates if the CCTP V2 TokenMessenger is being used. + * @dev This is determined by checking if the feeRecipient() function exists and returns a non-zero address. + */ + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + bool public immutable cctpV2; + /** * @notice intiailizes the CircleCCTPAdapter contract. * @param _usdcToken USDC address on the current chain. @@ -59,12 +67,23 @@ abstract contract CircleCCTPAdapter { /// @custom:oz-upgrades-unsafe-allow constructor constructor( IERC20 _usdcToken, + /// @dev This should ideally be an address but its kept as an ITokenMessenger to avoid rippling changes to the + /// constructors for every SpokePool/Adapter. ITokenMessenger _cctpTokenMessenger, uint32 _recipientCircleDomainId ) { usdcToken = _usdcToken; cctpTokenMessenger = _cctpTokenMessenger; recipientCircleDomainId = _recipientCircleDomainId; + + // Only the CCTP V2 TokenMessenger has a feeRecipient() function, so we use it to + // figure out if we are using CCTP V2 or V1. `success` can be true even if the contract doesn't + // implement feeRecipient but it has a fallback function so to be extra safe, we check the return value + // of feeRecipient() as well. + (bool success, bytes memory feeRecipient) = address(cctpTokenMessenger).staticcall( + abi.encodeWithSignature("feeRecipient()") + ); + cctpV2 = (success && address(bytes20(feeRecipient)) != address(0)); } /** @@ -101,7 +120,25 @@ abstract contract CircleCCTPAdapter { uint256 remainingAmount = amount; while (remainingAmount > 0) { uint256 partAmount = remainingAmount > burnLimit ? burnLimit : remainingAmount; - cctpTokenMessenger.depositForBurn(partAmount, recipientCircleDomainId, to, address(usdcToken)); + if (cctpV2) { + // Uses the CCTP V2 "standard transfer" speed and + // therefore pays no additional fee for the transfer to be sped up. + ITokenMessengerV2(address(cctpTokenMessenger)).depositForBurn( + partAmount, + recipientCircleDomainId, + to, + address(usdcToken), + // The following parameters are new in this function from V2 to V1, can read more here: + // https://developers.circle.com/stablecoins/evm-smart-contracts + bytes32(0), // destinationCaller is set to bytes32(0) to indicate that anyone can call + // receiveMessage on the destination to finalize the transfer + 0, // maxFee can be set to 0 for a "standard transfer" + 2000 // minFinalityThreshold can be set to 20000 for a "standard transfer", + // https://github.com/circlefin/evm-cctp-contracts/blob/63ab1f0ac06ce0793c0bbfbb8d09816bc211386d/src/v2/FinalityThresholds.sol#L21 + ); + } else { + cctpTokenMessenger.depositForBurn(partAmount, recipientCircleDomainId, to, address(usdcToken)); + } remainingAmount -= partAmount; } } diff --git a/deploy/028_deploy_linea_adapter.ts b/deploy/028_deploy_linea_adapter.ts index b51cd5e0b..e109d4f03 100644 --- a/deploy/028_deploy_linea_adapter.ts +++ b/deploy/028_deploy_linea_adapter.ts @@ -1,4 +1,4 @@ -import { L1_ADDRESS_MAP, WETH } from "./consts"; +import { L1_ADDRESS_MAP, WETH, USDCe } from "./consts"; import { DeployFunction } from "hardhat-deploy/types"; import { HardhatRuntimeEnvironment } from "hardhat/types"; @@ -14,7 +14,10 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { WETH[chainId], L1_ADDRESS_MAP[chainId].lineaMessageService, L1_ADDRESS_MAP[chainId].lineaTokenBridge, - L1_ADDRESS_MAP[chainId].lineaUsdcBridge, + // TODO: USDC.e on Linea will be upgraded to USDC so eventually we should add a USDC entry for Linea in consts + // and read from there instead of using the L1 USDC.e address. + USDCe[chainId], + L1_ADDRESS_MAP[chainId].cctpV2TokenMessenger, ], }); }; diff --git a/deploy/029_deploy_linea_spokepool.ts b/deploy/029_deploy_linea_spokepool.ts index c11f36139..aaa5a3b4c 100644 --- a/deploy/029_deploy_linea_spokepool.ts +++ b/deploy/029_deploy_linea_spokepool.ts @@ -1,7 +1,7 @@ import { DeployFunction } from "hardhat-deploy/types"; import { HardhatRuntimeEnvironment } from "hardhat/types"; import { deployNewProxy, getSpokePoolDeploymentInfo } from "../utils/utils.hre"; -import { FILL_DEADLINE_BUFFER, L2_ADDRESS_MAP, QUOTE_TIME_BUFFER, WETH } from "./consts"; +import { FILL_DEADLINE_BUFFER, L2_ADDRESS_MAP, QUOTE_TIME_BUFFER, WETH, USDCe } from "./consts"; const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { const { hubPool } = await getSpokePoolDeploymentInfo(hre); @@ -14,11 +14,18 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { 1_000_000, L2_ADDRESS_MAP[chainId].lineaMessageService, L2_ADDRESS_MAP[chainId].lineaTokenBridge, - L2_ADDRESS_MAP[chainId].lineaUsdcBridge, hubPool.address, hubPool.address, ]; - const constructorArgs = [WETH[chainId], QUOTE_TIME_BUFFER, FILL_DEADLINE_BUFFER]; + const constructorArgs = [ + WETH[chainId], + QUOTE_TIME_BUFFER, + FILL_DEADLINE_BUFFER, + // TODO: USDC.e on Linea will be upgraded to USDC so eventually we should add a USDC entry for Linea in consts + // and read from there instead of using the L1 USDC.e address. + USDCe[chainId], + L2_ADDRESS_MAP[chainId].cctpV2TokenMessenger, + ]; await deployNewProxy("Linea_SpokePool", constructorArgs, initArgs); }; diff --git a/deploy/consts.ts b/deploy/consts.ts index 719824f9b..e74c3ac84 100644 --- a/deploy/consts.ts +++ b/deploy/consts.ts @@ -31,10 +31,10 @@ export const L1_ADDRESS_MAP: { [key: number]: { [contractName: string]: string } polygonRegistry: "0x33a02E6cC863D393d6Bf231B697b82F6e499cA71", polygonDepositManager: "0x401F6c983eA34274ec46f84D70b31C151321188b", cctpTokenMessenger: "0xBd3fa81B58Ba92a82136038B25aDec7066af3155", + cctpV2TokenMessenger: "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d", cctpMessageTransmitter: "0x0a992d191deec32afe36203ad87d7d289a738f81", lineaMessageService: "0xd19d4B5d358258f05D7B411E21A1460D11B0876F", lineaTokenBridge: "0x051F1D88f0aF5763fB888eC4378b4D8B29ea3319", - lineaUsdcBridge: "0x504a330327a089d8364c4ab3811ee26976d388ce", scrollERC20GatewayRouter: "0xF8B1378579659D8F7EE5f3C929c2f3E332E41Fd6", scrollMessengerRelay: "0x6774Bcbd5ceCeF1336b5300fb5186a12DDD8b367", scrollGasPriceOracle: "0x0d7E906BD9cAFa154b048cFa766Cc1E54E39AF9B", @@ -53,7 +53,6 @@ export const L1_ADDRESS_MAP: { [key: number]: { [contractName: string]: string } usdc: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", lineaMessageService: "0xd19d4B5d358258f05D7B411E21A1460D11B0876F", // No sepolia deploy address lineaTokenBridge: "0x051F1D88f0aF5763fB888eC4378b4D8B29ea3319", // No sepolia deploy address - lineaUsdcBridge: "0x504a330327a089d8364c4ab3811ee26976d388ce", // No sepolia deploy address scrollERC20GatewayRouter: "0x13FBE0D0e5552b8c9c4AE9e2435F38f37355998a", scrollMessengerRelay: "0x50c7d3e7f7c656493D1D76aaa1a836CedfCBB16A", scrollGasPriceOracle: "0x247969F4fad93a33d4826046bc3eAE0D36BdE548", @@ -217,7 +216,7 @@ export const L2_ADDRESS_MAP: { [key: number]: { [contractName: string]: string } }, [CHAIN_IDs.LINEA]: { lineaMessageService: "0x508Ca82Df566dCD1B0DE8296e70a96332cD644ec", - lineaUsdcBridge: "0xA2Ee6Fce4ACB62D95448729cDb781e3BEb62504A", + cctpV2TokenMessenger: "0xunknown", // No official address from Circle yet. lineaTokenBridge: "0x353012dc4a9A6cF55c941bADC267f82004A8ceB9", }, [CHAIN_IDs.SCROLL_SEPOLIA]: { diff --git a/storage-layouts/Linea_SpokePool.json b/storage-layouts/Linea_SpokePool.json index 9dae90739..f664b6e29 100644 --- a/storage-layouts/Linea_SpokePool.json +++ b/storage-layouts/Linea_SpokePool.json @@ -170,7 +170,7 @@ }, { "contract": "contracts/Linea_SpokePool.sol:Linea_SpokePool", - "label": "l2UsdcBridge", + "label": "DEPRECATED_l2UsdcBridge", "offset": 0, "slot": "3164" } diff --git a/test/evm/hardhat/chain-adapters/Linea_Adapter.ts b/test/evm/hardhat/chain-adapters/Linea_Adapter.ts index d3741a6ed..f810dcac5 100644 --- a/test/evm/hardhat/chain-adapters/Linea_Adapter.ts +++ b/test/evm/hardhat/chain-adapters/Linea_Adapter.ts @@ -1,4 +1,11 @@ -import { amountToLp, mockTreeRoot, refundProposalLiveness, bondAmount } from "../constants"; +import { + amountToLp, + mockTreeRoot, + refundProposalLiveness, + bondAmount, + mockRelayerRefundRoot, + mockSlowRelayRoot, +} from "../constants"; import { ethers, expect, @@ -10,10 +17,13 @@ import { randomAddress, toWei, BigNumber, + createFakeFromABI, } from "../../../../utils/utils"; import { hubPoolFixture, enableTokensForLP } from "../fixtures/HubPool.Fixture"; import { constructSingleChainTree } from "../MerkleLib.utils"; import { smock } from "@defi-wonderland/smock"; +import { CCTPTokenV2MessengerInterface, CCTPTokenMinterInterface } from "../../../../utils/abis"; +import { CIRCLE_DOMAIN_IDs } from "../../../../deploy/consts"; let hubPool: Contract, lineaAdapter: Contract, @@ -24,7 +34,8 @@ let hubPool: Contract, mockSpoke: Contract; let l2Weth: string, l2Dai: string, l2Usdc: string; let owner: SignerWithAddress, dataWorker: SignerWithAddress, liquidityProvider: SignerWithAddress; -let lineaMessageService: FakeContract, lineaTokenBridge: FakeContract, lineaUsdcBridge: FakeContract; +let lineaMessageService: FakeContract, lineaTokenBridge: FakeContract; +let cctpMessenger: FakeContract, cctpTokenMinter: FakeContract; const lineaChainId = 59144; @@ -56,31 +67,6 @@ const lineaTokenBridgeAbi = [ }, ]; -const lineaUsdcBridgeAbi = [ - { - inputs: [ - { internalType: "uint256", name: "amount", type: "uint256" }, - { internalType: "address", name: "to", type: "address" }, - ], - name: "depositTo", - outputs: [], - stateMutability: "payable", - type: "function", - }, - { - inputs: [], - name: "usdc", - outputs: [ - { - name: "", - type: "address", - }, - ], - stateMutability: "view", - type: "function", - }, -]; - describe("Linea Chain Adapter", function () { beforeEach(async function () { [owner, dataWorker, liquidityProvider] = await ethers.getSigners(); @@ -99,18 +85,19 @@ describe("Linea Chain Adapter", function () { await hubPool.connect(liquidityProvider).addLiquidity(usdc.address, amountToLp); await usdc.connect(dataWorker).approve(hubPool.address, bondAmount.mul(10)); + cctpMessenger = await createFakeFromABI(CCTPTokenV2MessengerInterface); + cctpTokenMinter = await createFakeFromABI(CCTPTokenMinterInterface); + cctpMessenger.localMinter.returns(cctpTokenMinter.address); + cctpMessenger.feeRecipient.returns(owner.address); + cctpTokenMinter.burnLimitsPerMessage.returns(toWei("1000000")); lineaMessageService = await smock.fake(lineaMessageServiceAbi, { address: "0xd19d4B5d358258f05D7B411E21A1460D11B0876F", }); lineaTokenBridge = await smock.fake(lineaTokenBridgeAbi, { address: "0x051F1D88f0aF5763fB888eC4378b4D8B29ea3319" }); - lineaUsdcBridge = await smock.fake(lineaUsdcBridgeAbi, { - address: "0x504a330327a089d8364c4ab3811ee26976d388ce", - }); - lineaUsdcBridge.usdc.returns(usdc.address); lineaAdapter = await ( await getContractFactory("Linea_Adapter", owner) - ).deploy(weth.address, lineaMessageService.address, lineaTokenBridge.address, lineaUsdcBridge.address); + ).deploy(weth.address, lineaMessageService.address, lineaTokenBridge.address, usdc.address, cctpMessenger.address); // Seed the HubPool some funds so it can send L1->L2 messages. await hubPool.connect(liquidityProvider).loadEthForL2Calls({ value: toWei("100000") }); @@ -142,17 +129,113 @@ describe("Linea Chain Adapter", function () { const expectedErc20L1ToL2BridgeParams = [dai.address, tokensSendToL2, mockSpoke.address]; expect(lineaTokenBridge.bridgeToken).to.have.been.calledWith(...expectedErc20L1ToL2BridgeParams); }); - it("Correctly calls appropriate bridge functions when making USDC cross chain calls", async function () { + it("Correctly calls the CCTP bridge adapter when attempting to bridge USDC", async function () { + const internalChainId = lineaChainId; // Create an action that will send an L1->L2 tokens transfer and bundle. For this, create a relayer repayment bundle // and check that at it's finalization the L2 bridge contracts are called as expected. - const { leaves, tree, tokensSendToL2 } = await constructSingleChainTree(usdc.address, 1, lineaChainId); - await hubPool.connect(dataWorker).proposeRootBundle([3117], 1, tree.getHexRoot(), mockTreeRoot, mockTreeRoot); + const { leaves, tree, tokensSendToL2 } = await constructSingleChainTree(usdc.address, 1, internalChainId); + await hubPool + .connect(dataWorker) + .proposeRootBundle([3117], 1, tree.getHexRoot(), mockRelayerRefundRoot, mockSlowRelayRoot); await timer.setCurrentTime(Number(await timer.getCurrentTime()) + refundProposalLiveness + 1); await hubPool.connect(dataWorker).executeRootBundle(...Object.values(leaves[0]), tree.getHexProof(leaves[0])); - // The correct functions should have been called on the optimism contracts. - const expectedErc20L1ToL2BridgeParams = [tokensSendToL2, mockSpoke.address]; - expect(lineaUsdcBridge.depositTo).to.have.been.calledWith(...expectedErc20L1ToL2BridgeParams); + // Adapter should have approved gateway to spend its ERC20. + expect(await usdc.allowance(hubPool.address, cctpMessenger.address)).to.equal(tokensSendToL2); + + // The correct functions should have been called on the bridge contracts + expect(cctpMessenger.depositForBurn).to.have.been.calledOnce; + expect(cctpMessenger.depositForBurn).to.have.been.calledWith( + ethers.BigNumber.from(tokensSendToL2), + // TODO: Change this once we have the actual Linea domain ID + 11, // CIRCLE_DOMAIN_IDs[internalChainId], + ethers.utils.hexZeroPad(mockSpoke.address, 32).toLowerCase(), + usdc.address, + ethers.constants.HashZero, + ethers.BigNumber.from(0), + 2000 + ); + }); + it("Splits USDC into parts to stay under per-message limit when attempting to bridge USDC", async function () { + const internalChainId = lineaChainId; + // Create an action that will send an L1->L2 tokens transfer and bundle. For this, create a relayer repayment bundle + // and check that at it's finalization the L2 bridge contracts are called as expected. + const { leaves, tree, tokensSendToL2 } = await constructSingleChainTree(usdc.address, 1, internalChainId); + await hubPool + .connect(dataWorker) + .proposeRootBundle([3117], 1, tree.getHexRoot(), mockRelayerRefundRoot, mockSlowRelayRoot); + await timer.setCurrentTime(Number(await timer.getCurrentTime()) + refundProposalLiveness + 1); + + // 1) Set limit below amount to send and where amount does not divide evenly into limit. + let newLimit = tokensSendToL2.div(2).sub(1); + cctpTokenMinter.burnLimitsPerMessage.returns(newLimit); + await hubPool.connect(dataWorker).executeRootBundle(...Object.values(leaves[0]), tree.getHexProof(leaves[0])); + + // The correct functions should have been called on the bridge contracts + expect(cctpMessenger.depositForBurn).to.have.been.calledThrice; + expect(cctpMessenger.depositForBurn.atCall(0)).to.have.been.calledWith( + newLimit, + // TODO: Change this once we have the actual Linea domain ID + 11, // CIRCLE_DOMAIN_IDs[internalChainId], + ethers.utils.hexZeroPad(mockSpoke.address, 32).toLowerCase(), + usdc.address, + ethers.constants.HashZero, + ethers.BigNumber.from(0), + 2000 + ); + expect(cctpMessenger.depositForBurn.atCall(1)).to.have.been.calledWith( + newLimit, + // TODO: Change this once we have the actual Linea domain ID + 11, // CIRCLE_DOMAIN_IDs[internalChainId], + ethers.utils.hexZeroPad(mockSpoke.address, 32).toLowerCase(), + usdc.address, + ethers.constants.HashZero, + ethers.BigNumber.from(0), + 2000 + ); + expect(cctpMessenger.depositForBurn.atCall(2)).to.have.been.calledWith( + 2, // each of the above calls left a remainder of 1 + // TODO: Change this once we have the actual Linea domain ID + 11, // CIRCLE_DOMAIN_IDs[internalChainId], + ethers.utils.hexZeroPad(mockSpoke.address, 32).toLowerCase(), + usdc.address, + ethers.constants.HashZero, + ethers.BigNumber.from(0), + 2000 + ); + + // 2) Set limit below amount to send and where amount divides evenly into limit. + await hubPool + .connect(dataWorker) + .proposeRootBundle([3117], 1, tree.getHexRoot(), mockRelayerRefundRoot, mockSlowRelayRoot); + await timer.setCurrentTime(Number(await timer.getCurrentTime()) + refundProposalLiveness + 1); + + newLimit = tokensSendToL2.div(2); + cctpTokenMinter.burnLimitsPerMessage.returns(newLimit); + await hubPool.connect(dataWorker).executeRootBundle(...Object.values(leaves[0]), tree.getHexProof(leaves[0])); + + // 2 more calls added to prior 3. + expect(cctpMessenger.depositForBurn).to.have.callCount(5); + expect(cctpMessenger.depositForBurn.atCall(3)).to.have.been.calledWith( + newLimit, + // TODO: Change this once we have the actual Linea domain ID + 11, // CIRCLE_DOMAIN_IDs[internalChainId], + ethers.utils.hexZeroPad(mockSpoke.address, 32).toLowerCase(), + usdc.address, + ethers.constants.HashZero, + ethers.BigNumber.from(0), + 2000 + ); + expect(cctpMessenger.depositForBurn.atCall(4)).to.have.been.calledWith( + newLimit, + // TODO: Change this once we have the actual Linea domain ID + 11, // CIRCLE_DOMAIN_IDs[internalChainId], + ethers.utils.hexZeroPad(mockSpoke.address, 32).toLowerCase(), + usdc.address, + ethers.constants.HashZero, + ethers.BigNumber.from(0), + 2000 + ); }); it("Correctly unwraps WETH and bridges ETH", async function () { const { leaves, tree } = await constructSingleChainTree(weth.address, 1, lineaChainId); diff --git a/test/evm/hardhat/chain-specific-spokepools/Linea_SpokePool.ts b/test/evm/hardhat/chain-specific-spokepools/Linea_SpokePool.ts index ba6a9fbb6..49615eb28 100644 --- a/test/evm/hardhat/chain-specific-spokepools/Linea_SpokePool.ts +++ b/test/evm/hardhat/chain-specific-spokepools/Linea_SpokePool.ts @@ -8,16 +8,19 @@ import { getContractFactory, seedContract, toWei, + createFakeFromABI, } from "../../../../utils/utils"; import { hre } from "../../../../utils/utils.hre"; import { hubPoolFixture } from "../fixtures/HubPool.Fixture"; import { constructSingleRelayerRefundTree } from "../MerkleLib.utils"; import { smock } from "@defi-wonderland/smock"; +import { CCTPTokenV2MessengerInterface, CCTPTokenMinterInterface } from "../../../../utils/abis"; -let hubPool: Contract, lineaSpokePool: Contract, dai: Contract, weth: Contract, usdc: Contract; +let hubPool: Contract, lineaSpokePool: Contract, dai: Contract, weth: Contract, usdc: Contract, l2Usdc: string; let owner: SignerWithAddress, relayer: SignerWithAddress, rando: SignerWithAddress; -let lineaMessageService: FakeContract, lineaTokenBridge: FakeContract, lineaUsdcBridge: FakeContract; +let lineaMessageService: FakeContract, lineaTokenBridge: FakeContract; +let l2CctpTokenMessenger: FakeContract, cctpTokenMinter: FakeContract; const lineaMessageServiceAbi = [ { @@ -71,35 +74,10 @@ const lineaTokenBridgeAbi = [ }, ]; -const lineaUsdcBridgeAbi = [ - { - inputs: [ - { internalType: "uint256", name: "amount", type: "uint256" }, - { internalType: "address", name: "to", type: "address" }, - ], - name: "depositTo", - outputs: [], - stateMutability: "payable", - type: "function", - }, - { - inputs: [], - name: "usdc", - outputs: [ - { - name: "", - type: "address", - }, - ], - stateMutability: "view", - type: "function", - }, -]; - describe("Linea Spoke Pool", function () { beforeEach(async function () { [owner, relayer, rando] = await ethers.getSigners(); - ({ weth, dai, usdc, hubPool } = await hubPoolFixture()); + ({ weth, dai, usdc, hubPool, l2Usdc } = await hubPoolFixture()); lineaMessageService = await smock.fake(lineaMessageServiceAbi, { address: "0x508Ca82Df566dCD1B0DE8296e70a96332cD644ec", @@ -107,24 +85,21 @@ describe("Linea Spoke Pool", function () { lineaMessageService.minimumFeeInWei.returns(0); lineaMessageService.sender.reset(); lineaTokenBridge = await smock.fake(lineaTokenBridgeAbi, { address: "0x353012dc4a9A6cF55c941bADC267f82004A8ceB9" }); - lineaUsdcBridge = await smock.fake(lineaUsdcBridgeAbi, { - address: "0xA2Ee6Fce4ACB62D95448729cDb781e3BEb62504A", - }); - lineaUsdcBridge.usdc.returns(usdc.address); + l2CctpTokenMessenger = await createFakeFromABI(CCTPTokenV2MessengerInterface); + cctpTokenMinter = await createFakeFromABI(CCTPTokenMinterInterface); + l2CctpTokenMessenger.localMinter.returns(cctpTokenMinter.address); + cctpTokenMinter.burnLimitsPerMessage.returns(toWei("1000000")); await owner.sendTransaction({ to: lineaMessageService.address, value: toWei("1") }); lineaSpokePool = await hre.upgrades.deployProxy( await getContractFactory("Linea_SpokePool", owner), - [ - 0, - lineaMessageService.address, - lineaTokenBridge.address, - lineaUsdcBridge.address, - owner.address, - hubPool.address, - ], - { kind: "uups", unsafeAllow: ["delegatecall"], constructorArgs: [weth.address, 60 * 60, 9 * 60 * 60] } + [0, lineaMessageService.address, lineaTokenBridge.address, owner.address, hubPool.address], + { + kind: "uups", + unsafeAllow: ["delegatecall"], + constructorArgs: [weth.address, 60 * 60, 9 * 60 * 60, l2Usdc, l2CctpTokenMessenger.address], + } ); await seedContract(lineaSpokePool, relayer, [dai, usdc], weth, amountHeldByPool); @@ -134,13 +109,11 @@ describe("Linea Spoke Pool", function () { const implementation = await hre.upgrades.deployImplementation(await getContractFactory("Linea_SpokePool", owner), { kind: "uups", unsafeAllow: ["delegatecall"], - constructorArgs: [weth.address, 60 * 60, 9 * 60 * 60], + constructorArgs: [weth.address, 60 * 60, 9 * 60 * 60, l2Usdc, l2CctpTokenMessenger.address], }); // upgradeTo fails unless called by cross domain admin - await expect(lineaSpokePool.connect(lineaMessageService.wallet).upgradeTo(implementation)).to.be.revertedWith( - "ONLY_COUNTERPART_GATEWAY" - ); + await expect(lineaSpokePool.upgradeTo(implementation)).to.be.revertedWith("ONLY_COUNTERPART_GATEWAY"); lineaMessageService.sender.returns(owner.address); // msg.sender must be lineaMessageService await expect(lineaSpokePool.connect(owner).upgradeTo(implementation)).to.be.revertedWith( @@ -160,12 +133,6 @@ describe("Linea Spoke Pool", function () { await lineaSpokePool.connect(lineaMessageService.wallet).setL2TokenBridge(rando.address); expect(await lineaSpokePool.l2TokenBridge()).to.equal(rando.address); }); - it("Only cross domain owner can set l2UsdcBridge", async function () { - await expect(lineaSpokePool.setL2UsdcBridge(lineaMessageService.wallet)).to.be.reverted; - lineaMessageService.sender.returns(owner.address); - await lineaSpokePool.connect(lineaMessageService.wallet).setL2UsdcBridge(rando.address); - expect(await lineaSpokePool.l2UsdcBridge()).to.equal(rando.address); - }); it("Only cross domain owner can relay admin root bundles", async function () { const { tree } = await constructSingleRelayerRefundTree(dai.address, await lineaSpokePool.callStatic.chainId()); await expect(lineaSpokePool.relayRootBundle(tree.getHexRoot(), mockTreeRoot)).to.be.revertedWith( @@ -205,23 +172,6 @@ describe("Linea Spoke Pool", function () { // This should have sent tokens back to L1. Check the correct methods on the gateway are correctly called. expect(lineaTokenBridge.bridgeToken).to.have.been.calledWith(dai.address, amountToReturn, hubPool.address); }); - it("Bridge USDC to hub pool correctly calls the L2 USDC Bridge", async function () { - const { leaves, tree } = await constructSingleRelayerRefundTree( - usdc.address, - await lineaSpokePool.callStatic.chainId() - ); - lineaMessageService.sender.returns(owner.address); - await lineaSpokePool.connect(lineaMessageService.wallet).relayRootBundle(tree.getHexRoot(), mockTreeRoot); - const fee = toWei("0.01"); - lineaMessageService.minimumFeeInWei.returns(fee); - await lineaSpokePool - .connect(relayer) - .executeRelayerRefundLeaf(0, leaves[0], tree.getHexProof(leaves[0]), { value: fee }); - - // This should have sent tokens back to L1. Check the correct methods on the gateway are correctly called. - expect(lineaUsdcBridge.depositTo).to.have.been.calledWith(amountToReturn, hubPool.address); - expect(lineaUsdcBridge.depositTo).to.have.been.calledWithValue(fee); - }); it("Bridge ETH to hub pool correctly calls the Standard L2 Bridge for WETH, including unwrap", async function () { const { leaves, tree } = await constructSingleRelayerRefundTree( weth.address, diff --git a/test/evm/hardhat/chain-specific-spokepools/PolygonZkEVM_SpokePool.ts b/test/evm/hardhat/chain-specific-spokepools/PolygonZkEVM_SpokePool.ts index 55bb55c12..29b6c6b04 100644 --- a/test/evm/hardhat/chain-specific-spokepools/PolygonZkEVM_SpokePool.ts +++ b/test/evm/hardhat/chain-specific-spokepools/PolygonZkEVM_SpokePool.ts @@ -72,11 +72,14 @@ describe("Polygon zkEVM Spoke Pool", function () { }); it("Only cross domain owner upgrade logic contract", async function () { - const implementation = await hre.upgrades.deployImplementation(await getContractFactory("Linea_SpokePool", owner), { - kind: "uups", - unsafeAllow: ["delegatecall"], - constructorArgs: [weth.address, 60 * 60, 9 * 60 * 60], - }); + const implementation = await hre.upgrades.deployImplementation( + await getContractFactory("PolygonZkEVM_SpokePool", owner), + { + kind: "uups", + unsafeAllow: ["delegatecall"], + constructorArgs: [weth.address, 60 * 60, 9 * 60 * 60], + } + ); const upgradeData = polygonZkEvmSpokePool.interface.encodeFunctionData("upgradeTo", [implementation]); diff --git a/utils/abis.ts b/utils/abis.ts index f33c69027..1575b0394 100644 --- a/utils/abis.ts +++ b/utils/abis.ts @@ -42,6 +42,66 @@ export const CCTPTokenMessengerInterface = [ }, ]; +export const CCTPTokenV2MessengerInterface = [ + { + inputs: [ + { + internalType: "uint256", + name: "amount", + type: "uint256", + }, + { + internalType: "uint32", + name: "destinationDomain", + type: "uint32", + }, + { + internalType: "bytes32", + name: "mintRecipient", + type: "bytes32", + }, + { + internalType: "address", + name: "burnToken", + type: "address", + }, + { + internalType: "bytes32", + name: "destinationCaller", + type: "bytes32", + }, + { + internalType: "uint256", + name: "maxFee", + type: "uint256", + }, + { + internalType: "uint32", + name: "minFinalityThreshold", + type: "uint32", + }, + ], + name: "depositForBurn", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "localMinter", + outputs: [{ internalType: "contract ITokenMinterV2", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "feeRecipient", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, +]; + export const CCTPTokenMinterInterface = [ { inputs: [{ internalType: "address", name: "", type: "address" }],