커스텀 게이트웨이
Last updated
Last updated
표준 게이트웨이 (Standard Gateway)는 별도의 추가 작업이나 허가 없이 입출금을 수행할 수 있는 옵션으로, 간편하고 효율적인 온보딩을 제공합니다.
범용적 커스텀 게이트웨이 (Generic-Custom Gateway)는 개발자가 구현한 ERC-20 컨트랙트에 추가 기능을 포함하거나, 디카르고 체인에서 특정 ERC-20 컨트랙트와 페어링되도록 설정하기 위해 선택할 수 있는 옵션입니다.
두 게이트웨이는 모두 디카르고의 토큰 브릿지에서 제공하는 스마트 컨트랙트입니다. 개발자 또는 프로젝트 빌더가 기존에 배포된 게이트웨이가 아닌, 추가적인 기능이나 커스텀된 기능이 포함된 게이트웨이를 사용하고자 할 경우 커스텀 게이트웨이 (Custom Gateway)를 선택하면 됩니다.
정말 커스텀 게이트웨이가 필요할까요?
커스텀 게이트웨이를 구현하고 배포하기 전에, 디카르고 토큰 브릿지가 제공하는 표준 게이트웨이와 범용적 커스텀 게이트웨이를 분석할 것을 강력히 권장합니다.
먼저, 아비트럼의 를 학습한 후 커스텀 게이트웨이를 검토하면, 이후 다루는 코드와 내용을 훨씬 수월하게 이해할 수 있습니다.
앞서 의 기능에 대해 설명한 것처럼, L2 게이트웨이는 입금 시 L2 토큰을 잠그고(lock), 출금 시 잠긴 토큰을 해제(release)하며, L3 게이트웨이는 입금 시 L3 토큰을 발행(mint)하고 출금 시 해당 토큰을 소각(burn)합니다.
이러한 역할을 수행하는 게이트웨이에 추가적인 기능을 구현하고자 할 경우, 커스텀 게이트웨이를 이용하면 적합합니다.
아래는 커스텀 게이트웨이를 통해 추가할 수 있는 기능의 예시입니다.
입출금 시 허가된 사용자인지를 검증하는 화이트리스트 기능
입출금 시 브릿지 이용에 대한 수수료를 사용자로부터 청구하는 기능
입출금 시 유동된 잔액을 기록하는 기능
커스텀 게이트웨이를 활용하면 이러한 요구 사항을 만족하는 맞춤형 기능을 구현할 수 있습니다.
입금과 출금을 관리자(owner)가 활성화하거나 비활성화할 수 있는 기능이 추가된 커스텀 게이트웨이를 구현한 예시를 통해 활용하는 방법을 안내하겠습니다.
게이트웨이는 기본적으로 L2와 L3에 배포되어 있어야 합니다. 사용자의 입금 요청은 L2 커스텀 게이트웨이를 통해 처리되므로, 입금 활성화/비활성화 기능은 L2 커스텀 게이트웨이에 구현합니다. 반면, 출금 요청은 L3 커스텀 게이트웨이를 통해 처리되므로, 출금 활성화/비활성화 기능은 L3 커스텀 게이트웨이에 구현합니다.
// 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;
}
}
// 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;
}
}
또한, 토큰 브릿지와 호환 가능한 인터페이스와 메서드는 아래 제공된 코드를 통해 참고할 수 있습니다.
// 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;
}
}
// 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
);
}
예시 코드의 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 토큰을 발행(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);
}
}
커스텀 게이트웨이와 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}`
페어링을 설정하기 위한 등록 과정은 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}`
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();
이제 커스텀 게이트웨이를 통해 아비트럼 체인과 디카르고 체인 간의 입출금을 수행할 준비가 완료되었습니다.
커스텀 게이트웨이를 이용하기 위한 ERC20 토큰 컨트랙트는 와 매우 흡사하지만, 게이트웨이에 토큰을 등록하는 과정에 생략되어 있습니다. 이는 사용자가 직접 커스텀 게이트웨이를 배포하면서 직접 ERC-20 토큰을 등록하기 때문입니다.
직접 배포한 커스텀 게이트웨이를 사용하기 위해서는 각 체인의 게이트웨이 라우터에 등록 및 페어링 설정이 필요합니다. 이 과정에서 게이트웨이 라우터는 두 체인에 배포되어 있기 때문에, 을 활용하여 페어링을 진행하며, 이 과정에서 L2 ERC-20 DKA가 수수료로 지불됩니다.
상태는 에서 확인할 수 있습니다.
이후 입출금 과정은 모두 동일한 방식으로 동작하며, 의 입출금 방식을 참고하여 진행하면 됩니다.