Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 23 additions & 30 deletions contracts/Linea_SpokePool.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;

/**
Expand All @@ -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.
Expand All @@ -50,16 +50,20 @@ 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.
* @param _initialDepositId Starting deposit ID. Set to 0 unless this is a re-deployment in order to mitigate
* 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.
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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 *
**************************************/
Expand All @@ -139,27 +133,32 @@ 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.
WETH9Interface(l2TokenAddress).withdraw(amountToReturn + msg.value); // Unwrap into ETH.
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);
// If the l2Token is USDC, then we need sent it via the USDC Bridge.
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);
}
Expand All @@ -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;
Expand Down
21 changes: 9 additions & 12 deletions contracts/chain-adapters/Linea_Adapter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions contracts/external/interfaces/CCTPInterfaces.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 51 additions & 4 deletions contracts/libraries/CircleCCTPAdapter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ library CircleDomainIds {
uint32 public constant Base = 6;
uint32 public constant Polygon = 7;
uint32 public constant DoctorWho = 10;
// 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 Linea = 11;
uint32 public constant UNINITIALIZED = type(uint32).max;
}

Expand Down Expand Up @@ -50,6 +49,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.
Expand All @@ -59,12 +65,30 @@ abstract contract CircleCCTPAdapter {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor(
IERC20 _usdcToken,
/// @dev This should ideally be an address but it's 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()")
);
// In case of a call to nonexistent contract or a call to a contract with a fallback function which
// doesn't return any data, feeRecipient can be empty so check its length.
// Even with this check, it's possible that the contract has implemented a fallback function that returns
// 32 bytes of data but its not actually the feeRecipient address. This is extremely low risk but worth
// mentioning that the following check is not 100% safe.
cctpV2 = (success &&
feeRecipient.length == 32 &&
address(uint160(uint256(bytes32(feeRecipient)))) != address(0));
}

/**
Expand Down Expand Up @@ -94,14 +118,37 @@ abstract contract CircleCCTPAdapter {
function _transferUsdc(bytes32 to, uint256 amount) internal {
// Only approve the exact amount to be transferred
usdcToken.safeIncreaseAllowance(address(cctpTokenMessenger), amount);
// Submit the amount to be transferred to bridged via the TokenMessenger.
// Submit the amount to be transferred to bridge via the TokenMessenger.
// If the amount to send exceeds the burn limit per message, then split the message into smaller parts.
// @dev We do not care about casting cctpTokenMessenger to ITokenMessengerV2 since both V1 and V2
// expose a localMinter() view function that returns either an ITokenMinterV1 or ITokenMinterV2. Regardless,
// we only care about the burnLimitsPerMessage function which is available in both versions and performs
// the same logic, therefore we purposefully do not re-cast the cctpTokenMessenger and cctpMinter
// to the specific version.
ITokenMinter cctpMinter = cctpTokenMessenger.localMinter();
uint256 burnLimit = cctpMinter.burnLimitsPerMessage(address(usdcToken));
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 2000 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;
}
}
Expand Down
7 changes: 5 additions & 2 deletions deploy/028_deploy_linea_adapter.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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,
],
});
};
Expand Down
13 changes: 10 additions & 3 deletions deploy/029_deploy_linea_spokepool.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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);
};
Expand Down
Loading
Loading