커스텀 게이트웨이
표준 게이트웨이 (Standard Gateway)는 별도의 추가 작업이나 허가 없이 입출금을 수행할 수 있는 옵션으로, 간편하고 효율적인 온보딩을 제공합니다.
범용적 커스텀 게이트웨이 (Generic-Custom Gateway)는 개발자가 구현한 ERC-20 컨트랙트에 추가 기능을 포함하거나, 디카르고 체인에서 특정 ERC-20 컨트랙트와 페어링되도록 설정하기 위해 선택할 수 있는 옵션입니다.
두 게이트웨이는 모두 디카르고의 토큰 브릿지에서 제공하는 스마트 컨트랙트입니다. 개발자 또는 프로젝트 빌더가 기존에 배포된 게이트웨이가 아닌, 추가적인 기능이나 커스텀된 기능이 포함된 게이트웨이를 사용하고자 할 경우 커스텀 게이트웨이 (Custom Gateway)를 선택하면 됩니다.
정말 커스텀 게이트웨이가 필요할까요?
커스텀 게이트웨이를 구현하고 배포하기 전에, 디카르고 토큰 브릿지가 제공하는 표준 게이트웨이와 범용적 커스텀 게이트웨이를 분석할 것을 강력히 권장합니다.
먼저, 아비트럼의 토큰 브릿지 문서를 학습한 후 커스텀 게이트웨이를 검토하면, 이후 다루는 코드와 내용을 훨씬 수월하게 이해할 수 있습니다.
STEP 1 - 사용 예시
앞서 토큰 브릿지의 기능에 대해 설명한 것처럼, L2 게이트웨이는 입금 시 L2 토큰을 잠그고(lock), 출금 시 잠긴 토큰을 해제(release)하며, L3 게이트웨이는 입금 시 L3 토큰을 발행(mint)하고 출금 시 해당 토큰을 소각(burn)합니다.
이러한 역할을 수행하는 게이트웨이에 추가적인 기능을 구현하고자 할 경우, 커스텀 게이트웨이를 이용하면 적합합니다.
아래는 커스텀 게이트웨이를 통해 추가할 수 있는 기능의 예시입니다.
입출금 시 허가된 사용자인지를 검증하는 화이트리스트 기능
입출금 시 브릿지 이용에 대한 수수료를 사용자로부터 청구하는 기능
입출금 시 유동된 잔액을 기록하는 기능
커스텀 게이트웨이를 활용하면 이러한 요구 사항을 만족하는 맞춤형 기능을 구현할 수 있습니다.
STEP 2 - 커스텀 게이트웨이 설정
입금과 출금을 관리자(owner)가 활성화하거나 비활성화할 수 있는 기능이 추가된 커스텀 게이트웨이를 구현한 예시를 통해 활용하는 방법을 안내하겠습니다.
게이트웨이는 기본적으로 L2와 L3에 배포되어 있어야 합니다. 사용자의 입금 요청은 L2 커스텀 게이트웨이를 통해 처리되므로, 입금 활성화/비활성화 기능은 L2 커스텀 게이트웨이에 구현합니다. 반면, 출금 요청은 L3 커스텀 게이트웨이를 통해 처리되므로, 출금 활성화/비활성화 기능은 L3 커스텀 게이트웨이에 구현합니다.
L2 커스텀 게이트웨이 예시
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;
import "./interfaces/ICustomGateway.sol";
import "./CrosschainMessenger.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import {L1ArbitrumMessenger} from "@arbitrum/token-bridge-contracts/contracts/tokenbridge/ethereum/L1ArbitrumMessenger.sol";
import {GatewayMessageHandler} from "@arbitrum/token-bridge-contracts/contracts/tokenbridge/libraries/gateway/GatewayMessageHandler.sol";
import {IERC20Inbox} from "@arbitrum/nitro-contracts/src/bridge/IERC20Inbox.sol";
import {IERC20Bridge} from "@arbitrum/nitro-contracts/src/bridge/IERC20Bridge.sol";
/**
* @title Example implementation of a custom gateway to be deployed on L2
* @dev Inheritance of Ownable is optional. In this case we use it to call the function setTokenBridgeInformation
* and simplify the test
*/
contract ParentChainCustomGateway is L1ArbitrumMessenger, IL2CustomGateway, L2CrosschainMessenger, Ownable {
using Address for address;
using SafeERC20 for IERC20;
// Token bridge state variables
address public l2CustomToken;
address public l3CustomToken;
address public l3Gateway;
address public router;
// Custom functionality
bool public allowsDeposits;
/**
* Contract constructor, sets the L2 router to be used in the contract's functions and calls L1CrosschainMessenger's constructor
* @param router_ l3GatewayRouter address
* @param inbox_ Inbox address
*/
constructor(
address router_,
address inbox_
) L2CrosschainMessenger(inbox_) {
router = router_;
allowsDeposits = false;
}
/**
* Sets the information needed to use the gateway. To simplify the process of testing, this function can be called once
* by the owner of the contract to set these addresses.
* @param l2CustomToken_ address of the custom token on L2
* @param l3CustomToken_ address of the custom token on L3
* @param l3Gateway_ address of the counterpart gateway (on L3)
*/
function setTokenBridgeInformation(
address l2CustomToken_,
address l3CustomToken_,
address l3Gateway_
) public onlyOwner {
require(l2CustomToken == address(0), "Token bridge information already set");
l2CustomToken = l2CustomToken_;
l3CustomToken = l3CustomToken_;
l3Gateway = l3Gateway_;
// Allows deposits after the information has been set
allowsDeposits = true;
}
/// @dev See {ICustomGateway-outboundTransfer}
function outboundTransfer(
address l2Token,
address to,
uint256 amount,
uint256 maxGas,
uint256 gasPriceBid,
bytes calldata data
) public payable override returns (bytes memory) {
return outboundTransferCustomRefund(l2Token, to, to, amount, maxGas, gasPriceBid, data);
}
function outboundTransferCustomRefund(
address _l2Token,
address _refundTo,
address _to,
uint256 _amount,
uint256 _maxGas,
uint256 _gasPriceBid,
bytes calldata _data
) public payable virtual override returns (bytes memory res) {
// Only execute if deposits are allowed
require(allowsDeposits == true, "Deposits are currently disabled");
// Only allow calls from the router
require(msg.sender == router, "Call not received from router");
// Only allow the custom token to be bridged through this gateway
require(_l2Token == l2CustomToken, "Token is not allowed through this gateway");
// This function is set as public and virtual so that subclasses can override
// it and add custom validation for callers (ie only whitelisted users)
address _from;
uint256 seqNum;
bytes memory extraData;
{
// unpack user encoded data
uint256 maxSubmissionCost;
uint256 tokenTotalFeeAmount;
if (isRouter(msg.sender)) {
// router encoded
(_from, extraData) = GatewayMessageHandler.parseFromRouterToGateway(_data);
} else {
_from = msg.sender;
extraData = _data;
}
(maxSubmissionCost, extraData, tokenTotalFeeAmount) = _parseUserEncodedData(extraData);
// the inboundEscrowAndCall functionality has been disabled, so no data is allowed
require(extraData.length == 0, "EXTRA_DATA_DISABLED");
require(_l2Token.isContract(), "L1_NOT_CONTRACT");
address l2Token = calculateL2TokenAddress(_l2Token);
require(l2Token != address(0), "NO_L2_TOKEN_SET");
_amount = outboundEscrowTransfer(_l2Token, _from, _amount);
// we override the res field to save on the stack
res = getOutboundCalldata(_l2Token, _from, _to, _amount, extraData);
seqNum = _initiateDeposit(
_refundTo,
_from,
_amount,
_maxGas,
_gasPriceBid,
maxSubmissionCost,
tokenTotalFeeAmount,
res
);
}
emit DepositInitiated(_l2Token, _from, _to, seqNum, _amount);
return abi.encode(seqNum);
}
/// @dev See {ICustomGateway-finalizeInboundTransfer}
function finalizeInboundTransfer(
address l2Token,
address from,
address to,
uint256 amount,
bytes calldata data
) public payable override onlyCounterpartGateway(l3Gateway) {
// Only allow the custom token to be bridged through this gateway
require(l2Token == l2CustomToken, "Token is not allowed through this gateway");
// Decoding exitNum
(uint256 exitNum, ) = abi.decode(data, (uint256, bytes));
// Releasing the tokens in the gateway
IERC20(l2Token).transfer(to, amount);
emit WithdrawalFinalized(l2Token, from, to, exitNum, amount);
}
/// @dev See {ICustomGateway-getOutboundCalldata}
function getOutboundCalldata(
address l2Token,
address from,
address to,
uint256 amount,
bytes memory data
) public pure override returns (bytes memory outboundCalldata) {
bytes memory emptyBytes = "";
outboundCalldata = abi.encodeWithSelector(
ICustomGateway.finalizeInboundTransfer.selector,
l2Token,
from,
to,
amount,
abi.encode(emptyBytes, data)
);
return outboundCalldata;
}
/// @dev See {ICustomGateway-calculateL2TokenAddress}
function calculateL2TokenAddress(address l2Token) public view override returns (address) {
if (l2Token == l2CustomToken) {
return l3CustomToken;
}
return address(0);
}
/// @dev See {ICustomGateway-counterpartGateway}
function counterpartGateway() public view override returns (address) {
return l3Gateway;
}
function _parseUserEncodedData(bytes memory data)
internal
pure
returns (
uint256 maxSubmissionCost,
bytes memory callHookData,
uint256 tokenTotalFeeAmount
)
{
(maxSubmissionCost, callHookData, tokenTotalFeeAmount) = abi.decode(
data,
(uint256, bytes, uint256)
);
}
function outboundEscrowTransfer(
address _l2Token,
address _from,
uint256 _amount
) internal virtual returns (uint256 amountReceived) {
// this method is virtual since different subclasses can handle escrow differently
// user funds are escrowed on the gateway using this function
uint256 prevBalance = IERC20(_l2Token).balanceOf(address(this));
IERC20(_l2Token).safeTransferFrom(_from, address(this), _amount);
uint256 postBalance = IERC20(_l2Token).balanceOf(address(this));
return postBalance - prevBalance;
}
function _initiateDeposit(
address _refundTo,
address _from,
uint256, // _amount, this info is already contained in _data
uint256 _maxGas,
uint256 _gasPriceBid,
uint256 _maxSubmissionCost,
uint256 tokenTotalFeeAmount,
bytes memory _data
) internal returns (uint256) {
return
sendTxToL2CustomRefund(
address(inbox),
counterpartGateway(),
_refundTo,
_from,
tokenTotalFeeAmount,
0,
L2GasParams({
_maxSubmissionCost: _maxSubmissionCost,
_maxGas: _maxGas,
_gasPriceBid: _gasPriceBid
}),
_data
);
}
function _createRetryable(
address _inbox,
address _to,
address _refundTo,
address _user,
uint256 _totalFeeAmount,
uint256 _l2CallValue,
uint256 _maxSubmissionCost,
uint256 _maxGas,
uint256 _gasPriceBid,
bytes memory _data
) internal override returns (uint256) {
{
// Transfer native token amount needed to pay for retryable fees to the inbox.
// Fee tokens will be transferred from user who initiated the action - that's `_user` account in
// case call was routed by router, or msg.sender in case gateway's entrypoint was called directly.
address nativeFeeToken = IERC20Bridge(address(getBridge(_inbox))).nativeToken();
uint256 inboxNativeTokenBalance = IERC20(nativeFeeToken).balanceOf(_inbox);
if (inboxNativeTokenBalance < _totalFeeAmount) {
address transferFrom = isRouter(msg.sender) ? _user : msg.sender;
IERC20(nativeFeeToken).safeTransferFrom(
transferFrom,
_inbox,
_totalFeeAmount - inboxNativeTokenBalance
);
}
}
return
IERC20Inbox(_inbox).createRetryableTicket(
_to,
_l2CallValue,
_maxSubmissionCost,
_refundTo,
_user,
_maxGas,
_gasPriceBid,
_totalFeeAmount,
_data
);
}
function isRouter(address _target) internal view returns (bool isTargetRouter) {
return _target == router;
}
// --------------------
// Custom methods
// --------------------
/**
* Disables the ability to deposit funds
*/
function disableDeposits() external onlyOwner {
allowsDeposits = false;
}
/**
* Enables the ability to deposit funds
*/
function enableDeposits() external onlyOwner {
require(l2CustomToken != address(0), "Token bridge information has not been set yet");
allowsDeposits = true;
}
}
L3 커스텀 게이트웨이 예시
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;
import "./interfaces/ICustomGateway.sol";
import "./CrosschainMessenger.sol";
import "@arbitrum/token-bridge-contracts/contracts/tokenbridge/arbitrum/IArbToken.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title Example implementation of a custom gateway to be deployed on L3
* @dev Inheritance of Ownable is optional. In this case we use it to call the function setTokenBridgeInformation
* and simplify the test
*/
contract ChildChainCustomGateway is IL3CustomGateway, L3CrosschainMessenger, Ownable {
// Exit number (used for tradeable exits)
uint256 public exitNum;
// Token bridge state variables
address public l2CustomToken;
address public l3CustomToken;
address public l2Gateway;
address public router;
// Custom functionality
bool public allowsWithdrawals;
/**
* Contract constructor, sets the L3 router to be used in the contract's functions
* @param router_ L3GatewayRouter address
*/
constructor(address router_) {
router = router_;
allowsWithdrawals = false;
}
/**
* Sets the information needed to use the gateway. To simplify the process of testing, this function can be called once
* by the owner of the contract to set these addresses.
* @param l2CustomToken_ address of the custom token on L2
* @param l3CustomToken_ address of the custom token on L3
* @param l2Gateway_ address of the counterpart gateway (on L2)
*/
function setTokenBridgeInformation(
address l2CustomToken_,
address l3CustomToken_,
address l2Gateway_
) public onlyOwner {
require(l2CustomToken == address(0), "Token bridge information already set");
l2CustomToken = l2CustomToken_;
l3CustomToken = l3CustomToken_;
l2Gateway = l2Gateway_;
// Allows withdrawals after the information has been set
allowsWithdrawals = true;
}
/// @dev See {ICustomGateway-outboundTransfer}
function outboundTransfer(
address l2Token,
address to,
uint256 amount,
bytes calldata data
) public payable returns (bytes memory) {
return outboundTransfer(l2Token, to, amount, 0, 0, data);
}
/// @dev See {ICustomGateway-outboundTransfer}
function outboundTransfer(
address l2Token,
address to,
uint256 amount,
uint256, /* _maxGas */
uint256, /* _gasPriceBid */
bytes calldata data
) public payable override returns (bytes memory res) {
// Only execute if deposits are allowed
require(allowsWithdrawals == true, "Withdrawals are currently disabled");
// The function is marked as payable to conform to the inheritance setup
// This particular code path shouldn't have a msg.value > 0
require(msg.value == 0, "NO_VALUE");
// Only allow the custom token to be bridged through this gateway
require(l2Token == l2CustomToken, "Token is not allowed through this gateway");
(address from, bytes memory extraData) = _parseOutboundData(data);
// The inboundEscrowAndCall functionality has been disabled, so no data is allowed
require(extraData.length == 0, "EXTRA_DATA_DISABLED");
// Burns L2 tokens in order to release escrowed L1 tokens
IArbToken(l3CustomToken).bridgeBurn(from, amount);
// Current exit number for this operation
uint256 currExitNum = exitNum++;
// We override the res field to save on the stack
res = getOutboundCalldata(l2Token, from, to, amount, extraData);
// Trigger the crosschain message
uint256 id = _sendTxToL2(
from,
l2Gateway,
res
);
emit WithdrawalInitiated(l2Token, from, to, id, currExitNum, amount);
return abi.encode(id);
}
/// @dev See {ICustomGateway-finalizeInboundTransfer}
function finalizeInboundTransfer(
address l2Token,
address from,
address to,
uint256 amount,
bytes calldata data
) public payable override onlyCounterpartGateway(l2Gateway) {
// Only allow the custom token to be bridged through this gateway
require(l2Token == l2CustomToken, "Token is not allowed through this gateway");
// Abi decode may revert, but the encoding is done by L2 gateway, so we trust it
(, bytes memory callHookData) = abi.decode(data, (bytes, bytes));
if (callHookData.length != 0) {
// callHookData should always be 0 since inboundEscrowAndCall is disabled
callHookData = bytes("");
}
// Mints L3 tokens
IArbToken(l3CustomToken).bridgeMint(to, amount);
emit DepositFinalized(l2Token, from, to, amount);
}
/// @dev See {ICustomGateway-getOutboundCalldata}
function getOutboundCalldata(
address l2Token,
address from,
address to,
uint256 amount,
bytes memory data
) public view override returns (bytes memory outboundCalldata) {
outboundCalldata = abi.encodeWithSelector(
ICustomGateway.finalizeInboundTransfer.selector,
l2Token,
from,
to,
amount,
abi.encode(exitNum, data)
);
return outboundCalldata;
}
/// @dev See {ICustomGateway-calculateL2TokenAddress}
function calculateL2TokenAddress(address l2Token) public view override returns (address) {
if (l2Token == l2CustomToken) {
return l3CustomToken;
}
return address(0);
}
/// @dev See {ICustomGateway-counterpartGateway}
function counterpartGateway() public view override returns (address) {
return l2Gateway;
}
/**
* Parse data received in outboundTransfer
* @param data encoded data received
* @return from account that initiated the deposit,
* extraData decoded data
*/
function _parseOutboundData(bytes memory data)
internal
view
returns (
address from,
bytes memory extraData
)
{
if (msg.sender == router) {
// Router encoded
(from, extraData) = abi.decode(data, (address, bytes));
} else {
from = msg.sender;
extraData = data;
}
}
// --------------------
// Custom methods
// --------------------
/**
* Disables the ability to deposit funds
*/
function disableWithdrawals() external onlyOwner {
allowsWithdrawals = false;
}
/**
* Enables the ability to deposit funds
*/
function enableWithdrawals() external onlyOwner {
require(l2CustomToken != address(0), "Token bridge information has not been set yet");
allowsWithdrawals = true;
}
}
또한, 토큰 브릿지와 호환 가능한 인터페이스와 메서드는 아래 제공된 코드를 통해 참고할 수 있습니다.
CrosschainMessenger.sol
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;
import "@arbitrum/nitro-contracts/src/precompiles/ArbSys.sol";
import "@arbitrum/nitro-contracts/src/libraries/AddressAliasHelper.sol";
import {IERC20Inbox} from "@arbitrum/nitro-contracts/src/bridge/IERC20Inbox.sol";
import {IBridge} from "@arbitrum/nitro-contracts/src/bridge/IBridge.sol";
/**
* @title Interface needed to call function `l2ToL1Sender` of the Outbox
*/
interface IOutbox {
function l2ToL1Sender() external view returns (address);
}
/**
* @title Minimum expected implementation of a crosschain messenger contract to be deployed on L1
*/
abstract contract L2CrosschainMessenger {
IERC20Inbox public immutable inbox;
constructor(address inbox_) {
inbox = IERC20Inbox(inbox_);
}
modifier onlyCounterpartGateway(address l3Counterpart) {
// A message coming from the counterpart gateway was executed by the bridge
IBridge bridge = inbox.bridge();
require(msg.sender == address(bridge), "NOT_FROM_BRIDGE");
// And the outbox reports that the L2 address of the sender is the counterpart gateway
address l2ToL1Sender = IOutbox(bridge.activeOutbox()).l2ToL1Sender();
require(l2ToL1Sender == l3Counterpart, "ONLY_COUNTERPART_GATEWAY");
_;
}
}
/**
* @title Minimum expected implementation of a crosschain messenger contract to be deployed on L3
*/
abstract contract L3CrosschainMessenger {
address internal constant ARB_SYS_ADDRESS = address(100);
/**
* Emitted when calling sendTxToL1
* @param from account that submits the L3-to-L2 message
* @param to account recipient of the L3-to-L2 message
* @param id id for the L3-to-L2 message
* @param data data of the L3-to-L2 message
*/
event TxToL1(
address indexed from,
address indexed to,
uint256 indexed id,
bytes data
);
modifier onlyCounterpartGateway(address l2Counterpart) {
require(
msg.sender == AddressAliasHelper.applyL1ToL2Alias(l2Counterpart),
"ONLY_COUNTERPART_GATEWAY"
);
_;
}
/**
* Creates an L3-to-L2 message to send over to L2 through ArbSys
* @param from account that is sending funds from L3
* @param to account to be credited with the tokens in the destination layer
* @param data encoded data for the L3-to-L2 message
* @return id id for the L3-to-L2 message
*/
function _sendTxToL2(
address from,
address to,
bytes memory data
) internal returns (uint256) {
uint256 id = ArbSys(ARB_SYS_ADDRESS).sendTxToL1(to, data);
emit TxToL1(from, to, id, data);
return id;
}
}
ICustomGateway.sol
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;
/**
* @title Minimum expected interface for a custom gateway
*/
interface ICustomGateway {
function outboundTransfer(
address l1Token,
address to,
uint256 amount,
uint256 maxGas,
uint256 gasPriceBid,
bytes calldata data
) external payable returns (bytes memory);
function finalizeInboundTransfer(
address l1Token,
address from,
address to,
uint256 amount,
bytes calldata data
) external payable;
function getOutboundCalldata(
address l1Token,
address from,
address to,
uint256 amount,
bytes memory data
) external view returns (bytes memory);
function calculateL2TokenAddress(address l1Token) external view returns (address);
function counterpartGateway() external view returns (address);
}
/**
* @title Minimum expected interface for a custom gateway to be deployed on L1
*/
interface IL2CustomGateway is ICustomGateway {
event DepositInitiated(
address l1Token,
address indexed from,
address indexed to,
uint256 indexed sequenceNumber,
uint256 amount
);
event WithdrawalFinalized(
address l1Token,
address indexed from,
address indexed to,
uint256 indexed exitNum,
uint256 amount
);
function outboundTransferCustomRefund(
address l1Token,
address refundTo,
address to,
uint256 amount,
uint256 maxGas,
uint256 gasPriceBid,
bytes calldata data
) external payable returns (bytes memory);
}
/**
* @title Minimum expected interface for a custom gateway to be deployed on L2
*/
interface IL3CustomGateway is ICustomGateway {
event WithdrawalInitiated(
address l1Token,
address indexed from,
address indexed to,
uint256 indexed l2ToL1Id,
uint256 exitNum,
uint256 amount
);
event DepositFinalized(
address indexed l1Token,
address indexed from,
address indexed to,
uint256 amount
);
}
STEP 3 - 토큰 설정
커스텀 게이트웨이를 이용하기 위한 ERC20 토큰 컨트랙트는 범용적 커스텀 게이트웨이 와 매우 흡사하지만, 게이트웨이에 토큰을 등록하는 과정에 생략되어 있습니다. 이는 사용자가 직접 커스텀 게이트웨이를 배포하면서 직접 ERC-20 토큰을 등록하기 때문입니다.
L2 ERC20 토큰 예시
예시 코드의 registerTokenOnL2()
메서드를 보면 L1OrbitCustomGateway(gateway).registerTokenToL2()
메서드가 생략되고, L1OrbitGatewayRouter(router).setGateway()
만 호출되는 것을 확인할 수 있습니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {ICustomToken} from "@arbitrum/token-bridge-contracts/contracts/tokenbridge/ethereum/ICustomToken.sol";
import "@arbitrum/token-bridge-contracts/contracts/tokenbridge/ethereum/gateway/L1CustomGateway.sol";
import {L1OrbitGatewayRouter} from "@arbitrum/token-bridge-contracts/contracts/tokenbridge/ethereum/gateway/L1OrbitGatewayRouter.sol";
import {L1OrbitCustomGateway} from "@arbitrum/token-bridge-contracts/contracts/tokenbridge/ethereum/gateway/L1OrbitCustomGateway.sol";
import {IL1GatewayRouter} from "@arbitrum/token-bridge-contracts/contracts/tokenbridge/ethereum/gateway/IL1GatewayRouter.sol";
import { IERC20Bridge } from "@arbitrum/token-bridge-contracts/contracts/tokenbridge/libraries/IERC20Bridge.sol";
import "@openzeppelin/contracts/utils/Context.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract L2TokenCustomGasToken is Ownable, ERC20, ICustomToken {
using SafeERC20 for IERC20;
address public gateway;
address public router;
bool internal shouldRegisterGateway;
constructor(string memory name_, string memory symbol_,uint256 _initialSupply,address _gateway, address _router) ERC20(name_, symbol_) {
gateway = _gateway;
router = _router;
_mint(msg.sender, _initialSupply * 10 ** decimals());
}
function mint() external {
_mint(msg.sender, 50000000);
}
/// @dev See {ERC20-transferFrom}
function transferFrom(
address sender,
address recipient,
uint256 amount
) public override(ICustomToken, ERC20) returns (bool) {
return super.transferFrom(sender, recipient, amount);
}
/// @dev See {ERC20-balanceOf}
function balanceOf(address account) public view override(ICustomToken, ERC20) returns (uint256) {
return super.balanceOf(account);
}
/// @dev we only set shouldRegisterGateway to true when in `registerTokenOnL2`
function isArbitrumEnabled() external view override returns (uint8) {
require(shouldRegisterGateway, "NOT_EXPECTED_CALL");
return uint8(0xb1);
}
/**
* @dev See {ICustomToken-registerTokenOnL2}
* In this case, we don't need to call IL1CustomGateway.registerTokenToL2, because our
* custom gateway works for a single token it already knows.
*/
function registerTokenOnL2(
address, /* l2CustomTokenAddress */
uint256, /* maxSubmissionCostForCustomGateway */
uint256 maxSubmissionCostForRouter,
uint256, /* maxGasForCustomGateway */
uint256 maxGasForRouter,
uint256 gasPriceBid,
uint256, /* valueForGateway */
uint256 valueForRouter,
address creditBackAddress
) public override payable onlyOwner {
// we temporarily set `shouldRegisterGateway` to true for the callback in registerTokenToL2 to succeed
bool prev = shouldRegisterGateway;
shouldRegisterGateway = true;
address inbox = IL1GatewayRouter(router).inbox();
address bridge = address(IInbox(inbox).bridge());
// transfer fees from user to here, and approve router to use it
{
address nativeToken = IERC20Bridge(bridge).nativeToken();
IERC20(nativeToken).safeTransferFrom(
msg.sender,
address(this),
valueForRouter
);
IERC20(nativeToken).approve(router, valueForRouter);
}
L1OrbitGatewayRouter(router).setGateway(
gateway,
maxGasForRouter,
gasPriceBid,
maxSubmissionCostForRouter,
creditBackAddress,
valueForRouter
);
shouldRegisterGateway = prev;
}
}
L3에 배포되는 ERC-20 토큰은 범용적 커스텀 게이트웨이와 똑같이 구현합니다.
L3 ERC-20 토큰 예시
핵심 포인트는 사용자가 입출금 시 게이트웨이가 L3 ERC-20 토큰을 발행(mint)하거나 소각(burn)할 수 있도록 bridgeMint()
와 bridgeBurn()
메서드가 반드시 구현되어 있어야 한다는 점입니다.
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;
import "@arbitrum/token-bridge-contracts/contracts/tokenbridge/arbitrum/IArbToken.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract L3Token is ERC20, IArbToken {
address public l3Gateway;
address public override l1Address; /** override by arbitrum */
modifier onlyL3Gateway() {
require(msg.sender == l3Gateway, "NOT_GATEWAY");
_;
}
constructor(string memory name_, string memory symbol_,address _l3Gateway, address _l2TokenAddress) ERC20(name_, symbol_) {
l3Gateway = _l3Gateway;
l1Address = _l2TokenAddress;
}
/**
* @notice should increase token supply by amount, and should only be callable by the L2Gateway.
*/
function bridgeMint(address account, uint256 amount) external override onlyL3Gateway {
_mint(account, amount);
}
/**
* @notice should decrease token supply by amount, and should only be callable by the L2Gateway.
*/
function bridgeBurn(address account, uint256 amount) external override onlyL3Gateway {
_burn(account, amount);
}
}
STEP 4 - 토큰 등록과 커스텀 게이트웨이
커스텀 게이트웨이와 ERC-20 토큰을 L2, L3에 각각 배포했다면 (총 4개의 컨트랙트가 배포됬습니다. ) 게이트웨이와 게이트웨이 라우터에게 컨트랙트에 대한 정보를 등록할 차례입니다.
컨트랙트 주소 저장
직접 배포한 커스텀 게이트웨이에서 setTokenBridgeInformation()
메서드를 통해 서로의 주소와 토큰 컨트랙트 주소를 저장합니다.
let res = await parentCustomGateway.setTokenBridgeInformation(
parentERC20.address,
childERC20.address,
childCustomGateway.address
);
let receipt = await res.wait();
console.log(`setTokenBridgeInformation L2 tx hash: ${receipt.transactionHash}`
res = await childCustomGateway.setTokenBridgeInformation(
parentERC20.address,
childERC20.address,
parentCustomGateway.address
);
receipt = await res.wait();
console.log(`setTokenBridgeInformation L3 tx hash: ${receipt.transactionHash}`
페어링 설정
직접 배포한 커스텀 게이트웨이를 사용하기 위해서는 각 체인의 게이트웨이 라우터에 등록 및 페어링 설정이 필요합니다. 이 과정에서 게이트웨이 라우터는 두 체인에 배포되어 있기 때문에, retryable ticket을 활용하여 페어링을 진행하며, 이 과정에서 L2 ERC-20 DKA가 수수료로 지불됩니다.
페어링을 설정하기 위한 등록 과정은 L2 ERC-20 토큰 컨트랙트의 메서드를 호출하면서 시작되며, 이를 위해 L2 ERC-20 토큰 컨트랙트가 L2 ERC-20 DKA를 사용할 수 있도록 사전에 승인을 완료해야 합니다.
디카르고가 제공하는 토큰 브릿지는 아비트럼의 Retryable ticket을 활용하여 구축된 디앱입니다.
Retryable ticket은 L2에서 트랜잭션을 생성하고 이를 L3에서 실행할 수 있도록 전달하는 메커니즘입니다.
사용자는 이 과정을 통해 L3에서 원하는 작업을 L2에서 수행할 수 있으며, 트랜잭션 처리에 필요한 수수료는 L2의 ERC-20 DKA로 지불됩니다.
const res = await customTokenBridge.approveGasTokenForCustomTokenRegistration({
erc20ParentAddress: parentERC20.address,
parentSigner,
});
const receipt = await res.wait();
console.log(`approve gas token to L2 ERC20 Contract tx hash: ${receipt.transactionHash}`
const allowance = await customTokenBridge.allowanceGasTokenToParentERC20(
parentERC20.address,
parentSigner.address,
parentProvider
);
console.log(`allowance amount: ${allowance}`
Gateway 등록
L2 ERC-20에 구현된 registerTokenOnL2
메서드를 통해 router.setGateway
메서드를 호출하여, L2 게이트웨이 라우터에 L2 ERC-20 토큰의 게이트웨이가 직접 배포한 L2 커스텀 게이트웨이임을 등록합니다. 이 과정은 디카르고 체인으로 메시지를 전달하여, L3 커스텀 게이트웨이를 L3 게이트웨이 라우터에 등록하는 동일한 작업을 수행합니다.
const res = await customTokenBridge.registerCustomToken(
parentERC20.address,
childERC20.address,
parentSigner,
childProvider
);
const receipt = await res.wait();
console.log(`register tx hash: ${receipt.transactionHash}`
두 커스텀 게이트웨이는 각각 독립적인 두 체인에 등록되고 최종적으로 페어링되기까지는 일정 시간이 필요합니다. 등록 요청 상태는 "대기"로 표시되며, 약 10분 후 최종적으로 디카르고 체인에서 호출됩니다.
const l2ToL3Msgs = await receipt.getParentToChildMessages(childProvider);
// 메시지가 디카르고 체인에서 호출될 때 까지 대기합니다.
await l2ToL3Msgs[0].waitForStatus();
상태는 dScanner의 L2 ➔ L3 Transactions 페이지에서 확인할 수 있습니다.
토큰 입출금
이제 커스텀 게이트웨이를 통해 아비트럼 체인과 디카르고 체인 간의 입출금을 수행할 준비가 완료되었습니다.
이후 입출금 과정은 모두 동일한 방식으로 동작하며, 표준 게이트웨이의 입출금 방식을 참고하여 진행하면 됩니다.
Last updated