Bridge ERC20 through the Custom Gateway

This guide will walk through how to use Scroll’s bridge for ERC20s that need custom functionality by using the Custom Gateway.

Step 1: Launch a token on Ethereum

First, we need a token to bridge. There is no need for a particular ERC20 implementation in order for a token to be compatible with L2. If you already have a token, feel free to skip this step. If you want to deploy a new token, use the following contract of a simple ERC20 token that mints 1 million tokens to the deployer when launched.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract L1Token is ERC20 {
constructor() ERC20("My Token L1", "MTL1") {
_mint(msg.sender, 1_000_000 ether);
}
}

Step 2: Launch the counterpart token on Scroll

Next, you’ll launch a counterpart to this token on Scroll, which will represent the original token on Ethereum. This token can implement custom logic to match that of the L1 token or even add additional features beyond those of the L1 token.

For this to work:

  • The token must implement the IScrollStandardERC20 interface in order to be compatible with the bridge.
  • The contract should provide the gateway address and the counterpart token addresses (the L1 token we just launched) under the gateway() and counterpart() functions. It should also allow the L2 gateway to call the token mint() and burn() functions, which are called when a token is deposited and withdrawn.

The following is a complete example of a token compatible with the bridge. To the constructor, you will pass the official Scroll Custom Gateway address (0x64CCBE37c9A82D85A1F2E74649b7A42923067988) and the address of the token launched on Ethereum.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@scroll-tech/contracts@0.1.0/libraries/token/IScrollERC20Extension.sol";
contract L2Token is ERC20, IScrollERC20Extension {
// We store the gateway and the L1 token address to provide the gateway() and counterpart() functions which are needed from the Scroll Standard ERC20 interface
address _gateway;
address _counterpart;
// In the constructor we pass as parameter the Custom L2 Gateway and the L1 token address as parameters
constructor(address gateway_, address counterpart_) ERC20("My Token L2", "MTL2") {
_gateway = gateway_;
_counterpart = counterpart_;
}
function gateway() public view returns (address) {
return _gateway;
}
function counterpart() external view returns (address) {
return _counterpart;
}
// We allow minting only to the Gateway so it can mint new tokens when bridged from L1
function transferAndCall(address receiver, uint256 amount, bytes calldata data) external returns (bool success) {
transfer(receiver, amount);
data;
return true;
}
// We allow minting only to the Gateway so it can mint new tokens when bridged from L1
function mint(address _to, uint256 _amount) external onlyGateway {
_mint(_to, _amount);
}
// Similarly to minting, the Gateway is able to burn tokens when bridged from L2 to L1
function burn(address _from, uint256 _amount) external onlyGateway {
_burn(_from, _amount);
}
modifier onlyGateway() {
require(gateway() == _msgSender(), "Ownable: caller is not the gateway");
_;
}
}

Step 3: Add the token to the Scroll Bridge

You need to contact the Scroll team to add the token to L2CustomERC20Gateway contract in Scroll and L1CustomERC20Gateway contract in L1. In addition, follow the instructions on the token lists repository to add your token to the Scroll official bridge frontend.

Step 4: Deposit tokens

Once your token has been approved by the Scroll team, you should be able to deposit tokens from L1. To do so, you must first approve the L1CustomGateway contract address on Ethereum (0xb2b10a289A229415a124EFDeF310C10cb004B6ff). Then, deposit tokens by calling the depositERC20 function from the L1CustomGateway contract. This can be done using our bridge UI, Etherscan, or a smart contract.

Step 5: Withdraw tokens

You will follow similar steps to send tokens back from L2 to L1. First, approve the L2CustomGateway address (0x64CCBE37c9A82D85A1F2E74649b7A42923067988) and then withdraw the tokens calling the withdrawERC20 from the L2CustomGateway contract.

Alternative Approach: Launch and set up a custom L1 gateway contract

Adding your token to the Scroll official bridge (as described above) is the recommended method of bridging tokens to and from Scroll. This approach will make them easier to discover and safer for holders. However, it will require approval from the Scroll team. If you want to launch a custom token without the official approval process, you can launch a custom gateway yourself. To do so, you will need to deploy an L1CustomERC20Gateway contract on L1 and an L2CustomERC20Gateway on L2.

Launch an L1 Custom Gateway

Let’s start by launching the following contract on Ethereum.

// SPDX-License-Identifier: MIT
// Although it's possible to use other Solidity versions, we recommend using version 0.8.16 because that's where our contracts were audited
pragma solidity =0.8.16;
import "@openzeppelin/contracts/access/Ownable.sol";
import { IL2ERC20Gateway } from "@scroll-tech/contracts@0.1.0/L2/gateways/IL2ERC20Gateway.sol";
import { IL1ScrollMessenger } from "@scroll-tech/contracts@0.1.0/L1/IL1ScrollMessenger.sol";
import { IL1ERC20Gateway } from "@scroll-tech/contracts@0.1.0/L1/gateways/IL1ERC20Gateway.sol";
import { ScrollGatewayBase } from "@scroll-tech/contracts@0.1.0/libraries/gateway/ScrollGatewayBase.sol";
import { L1ERC20Gateway } from "@scroll-tech/contracts@0.1.0/L1/gateways/L1ERC20Gateway.sol";
// This contract will be used to send and receive tokens from L2
contract L1CustomERC20Gateway is L1ERC20Gateway, Ownable {
// Tokens must be mapped to "bind" them to a token that represents the original token on the original. This event will be emitted when the token mapping for ERC20 token is updated.
event UpdateTokenMapping(address indexed l1Token, address indexed oldL2Token, address indexed newL2Token);
mapping(address => address) public tokenMapping;
constructor() {}
// This function must be called once after both the L1 and L2 contract was deployed
function initialize(address _counterpart, address _router, address _messenger) external {
require(_router != address(0), "zero router address");
ScrollGatewayBase._initialize(_counterpart, _router, _messenger);
}
/// This function returns the address of the token on L2
function getL2ERC20Address(address _l1Token) public view override returns (address) {
return tokenMapping[_l1Token];
}
// Updates the token mapping that "binds" a token with another one on the other chain
function updateTokenMapping(address _l1Token, address _l2Token) external onlyOwner {
require(_l2Token != address(0), "token address cannot be 0");
address _oldL2Token = tokenMapping[_l1Token];
tokenMapping[_l1Token] = _l2Token;
emit UpdateTokenMapping(_l1Token, _oldL2Token, _l2Token);
}
// Callback called before a token is withdrawn on L1
function _beforeFinalizeWithdrawERC20(
address _l1Token,
address _l2Token,
address,
address,
uint256,
bytes calldata
) internal virtual override {
require(msg.value == 0, "nonzero msg.value");
require(_l2Token != address(0), "token address cannot be 0");
require(_l2Token == tokenMapping[_l1Token], "l2 token mismatch");
}
// Token bridged can be "canceled" or dropped. This callback is called before that happens.
function _beforeDropMessage(address, address, uint256) internal virtual override {
require(msg.value == 0, "nonzero msg.value");
}
// Internal function holding the deposit logic
function _deposit(
address _token,
address _to,
uint256 _amount,
bytes memory _data,
uint256 _gasLimit
) internal virtual override nonReentrant {
address _l2Token = tokenMapping[_token];
require(_l2Token != address(0), "no corresponding l2 token");
// 1. Transfer token into this contract.
address _from;
(_from, _amount, _data) = _transferERC20In(_token, _amount, _data);
// 2. Generate message passed to L2CustomERC20Gateway.
bytes memory _message = abi.encodeCall(
IL2ERC20Gateway.finalizeDepositERC20,
(_token, _l2Token, _from, _to, _amount, _data)
);
// 3. Send message to L1ScrollMessenger.
IL1ScrollMessenger(messenger).sendMessage{ value: msg.value }(counterpart, 0, _message, _gasLimit, _from);
emit DepositERC20(_token, _l2Token, _from, _to, _amount, _data);
}
}

Launch an L2 Custom Gateway

Now let’s launch the counterpart contract on Scroll.

// SPDX-License-Identifier: MIT
pragma solidity =0.8.16;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@scroll-tech/contracts@0.1.0/L2/gateways/L2ERC20Gateway.sol";
import { IL2ScrollMessenger } from "@scroll-tech/contracts@0.1.0/L2/IL2ScrollMessenger.sol";
import { IL1ERC20Gateway } from "@scroll-tech/contracts@0.1.0/L1/gateways/IL1ERC20Gateway.sol";
import { ScrollGatewayBase } from "@scroll-tech/contracts@0.1.0/libraries/gateway/ScrollGatewayBase.sol";
import "@scroll-tech/contracts@0.1.0/libraries/token/IScrollERC20Extension.sol";
import { IL2ERC20Gateway } from "@scroll-tech/contracts@0.1.0/L2/gateways/IL2ERC20Gateway.sol";
// This contract will be used to send and receive tokens from L1
contract L2CustomERC20Gateway is L2ERC20Gateway, ScrollGatewayBase, Ownable {
event UpdateTokenMapping(address indexed l2Token, address indexed oldL1Token, address indexed newL1Token);
// solhint-disable-next-line var-name-mixedcase
mapping(address => address) public tokenMapping;
constructor() {}
// Like with the L1 version of the Gateway, this must be called once after both the L1 and L2 gateways are deployed
function initialize(address _counterpart, address _router, address _messenger) external {
require(_router != address(0), "zero router address");
ScrollGatewayBase._initialize(_counterpart, _router, _messenger);
}
/// Returns the address of the token representing the token on L2
function getL1ERC20Address(address _l2Token) external view override returns (address) {
return tokenMapping[_l2Token];
}
// This returns the L2 token address
function getL2ERC20Address(address) public pure override returns (address) {
revert("unimplemented");
}
// This function finalizes the token deposit on L2 when the deposit was not finalized due to not enough gas sent from L1
function finalizeDepositERC20(
address _l1Token,
address _l2Token,
address _from,
address _to,
uint256 _amount,
bytes calldata _data
) external payable override onlyCallByCounterpart nonReentrant {
require(msg.value == 0, "nonzero msg.value");
require(_l1Token != address(0), "token address cannot be 0");
require(_l1Token == tokenMapping[_l2Token], "l1 token mismatch");
IScrollERC20Extension(_l2Token).mint(_to, _amount);
_doCallback(_to, _data);
emit FinalizeDepositERC20(_l1Token, _l2Token, _from, _to, _amount, _data);
}
// Same as in the L1 version of this contract, this function "binds" a token with a token on the other chain
function updateTokenMapping(address _l2Token, address _l1Token) external onlyOwner {
require(_l1Token != address(0), "token address cannot be 0");
address _oldL1Token = tokenMapping[_l2Token];
tokenMapping[_l2Token] = _l1Token;
emit UpdateTokenMapping(_l2Token, _oldL1Token, _l1Token);
}
// Internal function holding the withdraw logic
function _withdraw(
address _token,
address _to,
uint256 _amount,
bytes memory _data,
uint256 _gasLimit
) internal virtual override nonReentrant {
address _l1Token = tokenMapping[_token];
require(_l1Token != address(0), "no corresponding l1 token");
require(_amount > 0, "withdraw zero amount");
// 1. Extract real sender if this call is from L2GatewayRouter.
address _from = msg.sender;
if (router == msg.sender) {
(_from, _data) = abi.decode(_data, (address, bytes));
}
// 2. Burn token.
IScrollERC20Extension(_token).burn(_from, _amount);
// 3. Generate message passed to L1StandardERC20Gateway.
bytes memory _message = abi.encodeCall(
IL1ERC20Gateway.finalizeWithdrawERC20,
(_l1Token, _token, _from, _to, _amount, _data)
);
// 4. send message to L2ScrollMessenger
IL2ScrollMessenger(messenger).sendMessage{ value: msg.value }(counterpart, 0, _message, _gasLimit);
emit WithdrawERC20(_l1Token, _token, _from, _to, _amount, _data);
}
}

Setup your Gateway contract on Ethereum

Once the contracts are deployed, call the following functions to initialize the contracts and bind them to the corresponding tokens and the gateway on the other side of the bridge.

First, call the initialize function on the MyL1Gateway contract with the following parameters:

  • _counterpart: The address of MyL2Gateway we just launched on Scroll.
  • _router: Set it to 0xF8B1378579659D8F7EE5f3C929c2f3E332E41Fd6, the L1GatewayRouter contract on Ethereum.
  • _messenger: Set it to 0x6774Bcbd5ceCeF1336b5300fb5186a12DDD8b367, the L1ScrollMessenger contract on Ethereum.

A custom gateway can host multiple token bridges. In this case, we will only be allowing bridging between L1Token and L2Token by calling the updateTokenMapping function on the MyL1Gateway contract with the following parameters:

  • _l1Token: The address of the L1Token contract we previously launched on Ethereum.
  • _l2Token: The address of the L2Token contract we previously launched on Scroll.

Setup your Gateway contract on Scroll

Now let’s switch to the Scroll chain and initialize MyL2Gateway, following similar steps.

First, call the initialize function from MyL2Gateway:

  • _counterpart: The address of MyL1Gateway we just launched on Ethereum.
  • _router: Set it to 0x4C0926FF5252A435FD19e10ED15e5a249Ba19d79, the L2GatewayRouter contract on Scroll.
  • _messenger: Set it 0x781e90f1c8Fc4611c9b7497C3B47F99Ef6969CbC, the L2ScrollMessenger contract on Scroll.

Next, call updateTokenMapping on the MyL2Gateway contract:

  • _l2Token: The address of the L2Token contract we previously launched on Scroll.
  • _l1Token: The address of the L1Token contract we previously launched on Ethereum.

Bridging tokens

We can now call depositERC20 from MyL1Gateway and withdrawERC20 from MyL2Gateway just as with the official Scroll bridge.

What's Next

Resources

Follow Us