dKargo Docs
dKargo.ioWhitepaper
Docs KOR
Docs KOR
  • Documentation
  • 노드 구축
    • 체인 RPC
    • 공통 설치 가이드
    • 체인 노드 종류
    • 풀 노드
    • 아카이브 풀 노드
    • 밸리데이터 노드
  • 지갑 설정
    • MetaMask 연결
    • 지갑 생성
    • 지갑 가져오기
    • 네트워크 추가
  • 밸리데이터 운영
    • 밸리데이터 스테이킹
    • 스테이킹
    • 언스테이킹
    • 클레임
  • 컨트랙트 배포
    • 컨트랙트 배포 방식
    • Remix-IDE를 이용한 컨트랙트 배포
    • Hardhat을 이용한 컨트랙트 배포
    • Foundry를 이용한 컨트랙트 배포
  • ERC-20 브릿징
    • ERC-20 브릿징이란?
    • 표준 게이트웨이
    • 범용적 커스텀 게이트웨이
    • 커스텀 게이트웨이
  • DKA 브릿징
    • DKA 브릿징이란?
    • DKA 입금
    • DKA 출금
  • 포우셋
    • 포우셋이란?
    • 포우셋 사용하기
  • 체인 스냅샷
    • 체인 스냅샷 다운로드
  • 컨트랙트 주소
    • 디카르고 컨트랙트 주소
  • 버그 바운티
    • 버그 바운티 프로그램
Powered by GitBook
On this page
  • STEP 1 - 사용 예시
  • STEP 2 - 커스텀 게이트웨이 설정
  • L2 커스텀 게이트웨이 예시
  • L3 커스텀 게이트웨이 예시
  • CrosschainMessenger.sol
  • ICustomGateway.sol
  • STEP 3 - 토큰 설정
  • L2 ERC20 토큰 예시
  • L3 ERC-20 토큰 예시
  • STEP 4 - 토큰 등록과 커스텀 게이트웨이
  • 컨트랙트 주소 저장
  • 페어링 설정
  • Gateway 등록
  • 토큰 입출금
  1. ERC-20 브릿징

커스텀 게이트웨이

Previous범용적 커스텀 게이트웨이NextDKA 브릿징이란?

Last updated 2 months ago

표준 게이트웨이 (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 - 토큰 설정

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}`

페어링 설정

페어링을 설정하기 위한 등록 과정은 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();

토큰 입출금

이제 커스텀 게이트웨이를 통해 아비트럼 체인과 디카르고 체인 간의 입출금을 수행할 준비가 완료되었습니다.

커스텀 게이트웨이를 이용하기 위한 ERC20 토큰 컨트랙트는 와 매우 흡사하지만, 게이트웨이에 토큰을 등록하는 과정에 생략되어 있습니다. 이는 사용자가 직접 커스텀 게이트웨이를 배포하면서 직접 ERC-20 토큰을 등록하기 때문입니다.

직접 배포한 커스텀 게이트웨이를 사용하기 위해서는 각 체인의 게이트웨이 라우터에 등록 및 페어링 설정이 필요합니다. 이 과정에서 게이트웨이 라우터는 두 체인에 배포되어 있기 때문에, 을 활용하여 페어링을 진행하며, 이 과정에서 L2 ERC-20 DKA가 수수료로 지불됩니다.

상태는 에서 확인할 수 있습니다.

이후 입출금 과정은 모두 동일한 방식으로 동작하며, 의 입출금 방식을 참고하여 진행하면 됩니다.

토큰 브릿지 문서
토큰 브릿지
범용적 커스텀 게이트웨이
retryable ticket
dScanner의 L2 ➔ L3 Transactions 페이지
표준 게이트웨이