Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add an L2 token retriever contract which calls exported spoke pool functions #605

Open
wants to merge 14 commits into
base: bz/l2ForwarderInterface
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 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
58 changes: 58 additions & 0 deletions contracts/Intermediate_TokenRetriever.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.19;

import "./Lockable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol";
import "@uma/core/contracts/common/implementation/MultiCaller.sol";

interface IBridgeAdapter {
function withdrawToken(
address recipient,
uint256 amountToReturn,
address l2Token
) external;
}

/**
* @notice Contract deployed on an arbitrary L2 to act as an intermediate contract for withdrawals from L3 to L1.
* @dev Since each network has its own bridging requirements, this contract delegates that logic to a bridge adapter contract
* which performs the necessary withdraw action.
*/
contract Intermediate_TokenRetriever is Lockable, MultiCaller {
bmzig marked this conversation as resolved.
Show resolved Hide resolved
using SafeERC20Upgradeable for IERC20Upgradeable;

// Should be set to the bridge adapter which contains the proper logic to withdraw tokens on
// the deployed L2
address public immutable bridgeAdapter;
// Should be set to the L1 address which will receive withdrawn tokens.
address public immutable tokenRetriever;

error WithdrawalFailed(address l2Token);

/**
* @notice Constructs the Intermediate_TokenRetriever
* @param _bridgeAdapter contract which contains network's bridging logic.
* @param _tokenRetriever L1 address of the recipient of withdrawn tokens.
*/
constructor(address _bridgeAdapter, address _tokenRetriever) {
//slither-disable-next-line missing-zero-check
bridgeAdapter = _bridgeAdapter;
tokenRetriever = _tokenRetriever;
}

/**
* @notice delegatecalls the contract's stored bridge adapter to bridge tokens back to the defined token retriever
* @notice This follows the bridging logic of the corresponding bridge adapter.
* @param l2Token (current network's) contract address of the token to be withdrawn.
*/
function retrieve(address l2Token) public nonReentrant {
(bool success, ) = bridgeAdapter.delegatecall(
abi.encodeCall(
IBridgeAdapter.withdrawToken,
(tokenRetriever, IERC20Upgradeable(l2Token).balanceOf(address(this)), l2Token)
)
);
if (!success) revert WithdrawalFailed(l2Token);
}
}
100 changes: 100 additions & 0 deletions contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: BUSL-1.1

// Arbitrum only supports v0.8.19
// See https://docs.arbitrum.io/for-devs/concepts/differences-between-arbitrum-ethereum/solidity-support#differences-from-solidity-on-ethereum
pragma solidity ^0.8.19;

import "../../libraries/CircleCCTPAdapter.sol";
import { StandardBridgeLike } from "../../Arbitrum_SpokePool.sol";

/**
* @notice AVM specific bridge adapter. Implements logic to bridge tokens back to mainnet.
* @custom:security-contact [email protected]
*/

/*
* @notice Interface for the Across Arbitrum_SpokePool contract. Used to access state which
* can only be modified by admin functions.
*/
interface IArbitrum_SpokePool {
function whitelistedTokens(address) external view returns (address);
}

/**
* @title Adapter for interacting with bridges from the Arbitrum One L2 to Ethereum mainnet.
* @notice This contract is used to share L2-L1 bridging logic with other L2 Across contracts.
*/
contract Arbitrum_WithdrawalAdapter is CircleCCTPAdapter {
struct WithdrawalInformation {
// L1 address of the recipient.
address recipient;
// Address of l2 token to withdraw.
address l2TokenAddress;
// Amount of l2 Token to return.
uint256 amountToReturn;
}

IArbitrum_SpokePool public immutable spokePool;
address public immutable l2GatewayRouter;

/*
* @notice constructs the withdrawal adapter.
* @param _l2Usdc address of native USDC on the L2.
* @param _cctpTokenMessenger address of the CCTP token messenger contract on L2.
* @param _spokePool address of the spoke pool on L2.
* @param _l2GatewayRouter address of the Arbitrum l2 gateway router contract.
*/
constructor(
IERC20 _l2Usdc,
ITokenMessenger _cctpTokenMessenger,
IArbitrum_SpokePool _spokePool,
address _l2GatewayRouter
) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) {
spokePool = _spokePool;
l2GatewayRouter = _l2GatewayRouter;
}

/*
* @notice withdraws tokens to Ethereum given the input parameters.
* @param withdrawalInformation array containing information to withdraw a token. Includes the L1 recipient
* address, the amount to withdraw, and the token address of the L2 token to withdraw.
*/
function withdrawTokens(WithdrawalInformation[] calldata withdrawalInformation) external {
uint256 informationLength = withdrawalInformation.length;
WithdrawalInformation calldata withdrawal;
for (uint256 i = 0; i < informationLength; ++i) {
withdrawal = withdrawalInformation[i];
withdrawToken(withdrawal.recipient, withdrawal.amountToReturn, withdrawal.l2TokenAddress);
}
}

/*
* @notice Calls CCTP or the Arbitrum gateway router to withdraw tokens back to the `tokenRetriever`. The
* bridge will not be called if the token is not in the Arbitrum_SpokePool's `whitelistedTokens` mapping.
* @param recipient L1 address of the recipient.
* @param amountToReturn amount of l2Token to send back.
* @param l2TokenAddress address of the l2Token to send back.
*/
function withdrawToken(
address recipient,
uint256 amountToReturn,
address l2TokenAddress
) public {
// If the l2TokenAddress is UDSC, we need to use the CCTP bridge.
if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) {
_transferUsdc(recipient, amountToReturn);
} else {
// Check that the Ethereum counterpart of the L2 token is stored on this contract.
// Tokens will only be bridged if they are whitelisted by the spoke pool.
address ethereumTokenToBridge = spokePool.whitelistedTokens(l2TokenAddress);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love the dependency on the spoke pool, especially since we may end up wanting the spoke pool to call into this contract.

But I can't really think of a way to get this list elsewhere without adding a lot of complexity or additional args that wouldn't apply on non-arbitrum chains... maybe we could add a bytes arg that can be used to supply extra context when needed, but that makes the interface sort of convoluted.

Any ideas?

require(ethereumTokenToBridge != address(0), "Uninitialized mainnet token");
//slither-disable-next-line unused-return
StandardBridgeLike(l2GatewayRouter).outboundTransfer(
ethereumTokenToBridge, // _l1Token. Address of the L1 token to bridge over.
recipient, // _to. Withdraw, over the bridge, to the l1 hub pool contract.
amountToReturn, // _amount.
"" // _data. We don't need to send any data for the bridging action.
);
}
}
}
136 changes: 136 additions & 0 deletions test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

import { Test } from "forge-std/Test.sol";
import { MockERC20 } from "forge-std/mocks/MockERC20.sol";
import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { Arbitrum_SpokePool, ITokenMessenger } from "../../../contracts/Arbitrum_SpokePool.sol";
import { Arbitrum_WithdrawalAdapter, IArbitrum_SpokePool } from "../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol";
import { Intermediate_TokenRetriever } from "../../../contracts/Intermediate_TokenRetriever.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol";

import "forge-std/console.sol";

contract Token_ERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}

function mint(address to, uint256 value) public virtual {
_mint(to, value);
}

function burn(address from, uint256 value) public virtual {
_burn(from, value);
}
}

contract ArbitrumGatewayRouter {
function outboundTransfer(
address tokenToBridge,
address recipient,
uint256 amountToReturn,
bytes memory
) external returns (bytes memory) {
Token_ERC20(tokenToBridge).burn(msg.sender, amountToReturn);
Token_ERC20(tokenToBridge).mint(recipient, amountToReturn);
return "";
}
}

contract Arbitrum_WithdrawalAdapterTest is Test {
Arbitrum_WithdrawalAdapter arbitrumWithdrawalAdapter;
Intermediate_TokenRetriever tokenRetriever;
Arbitrum_SpokePool arbitrumSpokePool;
Token_ERC20 whitelistedToken;
Token_ERC20 usdc;
ArbitrumGatewayRouter gatewayRouter;

// HubPool should receive funds.
address hubPool;
address owner;
address aliasedOwner;
address wrappedNativeToken;

// Token messenger is set so CCTP is activated.
ITokenMessenger tokenMessenger;

error WithdrawalFailed(address l2Token);

function setUp() public {
// Initialize mintable/burnable tokens.
whitelistedToken = new Token_ERC20("TOKEN", "TOKEN");
usdc = new Token_ERC20("USDC", "USDC");
// Initialize mock bridge.
gatewayRouter = new ArbitrumGatewayRouter();

// Instantiate all other addresses used in the system.
tokenMessenger = ITokenMessenger(vm.addr(1));
owner = vm.addr(2);
wrappedNativeToken = vm.addr(3);
hubPool = vm.addr(4);
aliasedOwner = _applyL1ToL2Alias(owner);

// Create the spoke pool.
vm.startPrank(owner);
Arbitrum_SpokePool implementation = new Arbitrum_SpokePool(wrappedNativeToken, 0, 0, usdc, tokenMessenger);
address proxy = address(
new ERC1967Proxy(
address(implementation),
abi.encodeCall(Arbitrum_SpokePool.initialize, (0, address(gatewayRouter), owner, owner))
)
);
vm.stopPrank();
arbitrumSpokePool = Arbitrum_SpokePool(payable(proxy));
arbitrumWithdrawalAdapter = new Arbitrum_WithdrawalAdapter(
usdc,
tokenMessenger,
IArbitrum_SpokePool(proxy),
address(gatewayRouter)
);

// Create the token retriever contract.
tokenRetriever = new Intermediate_TokenRetriever(address(arbitrumWithdrawalAdapter), hubPool);
}

function testWithdrawWhitelistedTokenNonCCTP(uint256 amountToReturn) public {
// There should be no balance in any contract/EOA.
assertEq(whitelistedToken.balanceOf(hubPool), 0);
assertEq(whitelistedToken.balanceOf(owner), 0);
assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0);

// Whitelist tokens in the spoke pool and simulate a L3 -> L2 withdrawal into the token retriever.
vm.startPrank(aliasedOwner);
arbitrumSpokePool.whitelistToken(address(whitelistedToken), address(whitelistedToken));
vm.stopPrank();
whitelistedToken.mint(address(tokenRetriever), amountToReturn);

// Attempt to withdraw the token.
tokenRetriever.retrieve(address(whitelistedToken));

// Ensure that the balances are updated (i.e. the token bridge contract was called).
assertEq(whitelistedToken.balanceOf(hubPool), amountToReturn);
assertEq(whitelistedToken.balanceOf(owner), 0);
assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0);
}

function testWithdrawOtherTokenNonCCTP(uint256 amountToReturn) public {
// There should be no balance in any contract/EOA.
assertEq(whitelistedToken.balanceOf(hubPool), 0);
assertEq(whitelistedToken.balanceOf(owner), 0);
assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0);

// Simulate a L3 -> L2 withdrawal of an non-whitelisted token to the tokenRetriever contract.
whitelistedToken.mint(address(tokenRetriever), amountToReturn);

// Attempt to withdraw the token.
vm.expectRevert(abi.encodeWithSelector(WithdrawalFailed.selector, address(whitelistedToken)));
tokenRetriever.retrieve(address(whitelistedToken));
}

function _applyL1ToL2Alias(address l1Address) internal pure returns (address l2Address) {
// Allows overflows as explained above.
unchecked {
l2Address = address(uint160(l1Address) + uint160(0x1111000000000000000000000000000000001111));
}
}
}
Loading