Build
Tutorials
Swap

In this tutorial, you will create a cross-chain swap contract. This contract will enable users to exchange a native gas token or a supported ERC-20 token from one connected blockchain for a token on another blockchain. For example, a user will be able to swap USDC from Ethereum to BTC on Bitcoin in a single transaction.

You will learn how to:

  • Define a universal app contract that performs token swaps across chains.
  • Deploy the contract to localnet.
  • Interact with the contract by swapping tokens from a connected EVM blockchain in localnet.

The swap contract will be implemented as a universal app and deployed on ZetaChain.

Universal apps can accept token transfers and contract calls from connected chains. Tokens transferred from connected chains to a universal app contract are represented as ZRC-20. For example, ETH transferred from Ethereum is represented as ZRC-20 ETH. ZRC-20 tokens have the unique property of being able to be withdrawn back to their original chain as native assets.

The swap contract will:

  1. Accept a contract call from a connected chain containing native gas or supported ERC-20 tokens and a message.
  2. Decode the message to extract:
    • The target token's address (represented as ZRC-20).
    • The recipient's address on the destination chain.
  3. Query the withdrawal gas fee for the target token.
  4. Swap part of the input token for ZRC-20 gas tokens to cover the withdrawal fee using Uniswap v2 liquidity pools.
  5. Swap the remaining input token amount for the target ZRC-20 token.
  6. Withdraw the ZRC-20 tokens to the destination chain.

To set up your environment, clone the example contracts repository and install the dependencies by running the following commands:

git clone https://github.com/zeta-chain/example-contracts

cd example-contracts/examples/swap

yarn

The Swap contract is a universal application that facilitates cross-chain token swaps on ZetaChain. It inherits from the UniversalContract interface and handles incoming cross-chain calls, processes token swaps using ZetaChain's liquidity pools, and sends the swapped tokens to the recipient on the target chain.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
 
import {SystemContract, IZRC20} from "@zetachain/toolkit/contracts/SystemContract.sol";
import {SwapHelperLib} from "@zetachain/toolkit/contracts/SwapHelperLib.sol";
import {BytesHelperLib} from "@zetachain/toolkit/contracts/BytesHelperLib.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@uniswap/v2-periphery/contracts/interfaces/IUniswapV2Router02.sol";
 
import {RevertContext, RevertOptions} from "@zetachain/protocol-contracts/contracts/Revert.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/UniversalContract.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/IGatewayZEVM.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/IWZETA.sol";
import {GatewayZEVM} from "@zetachain/protocol-contracts/contracts/zevm/GatewayZEVM.sol";
 
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
 
contract Swap is
    UniversalContract,
    Initializable,
    UUPSUpgradeable,
    OwnableUpgradeable
{
    address public uniswapRouter;
    GatewayZEVM public gateway;
    uint256 constant BITCOIN = 8332;
    uint256 constant BITCOIN_TESTNET = 18332;
    uint256 public gasLimit;
 
    error InvalidAddress();
    error Unauthorized();
    error ApprovalFailed();
    error TransferFailed(string);
    error InsufficientAmount(string);
 
    event TokenSwap(
        address sender,
        bytes indexed recipient,
        address indexed inputToken,
        address indexed targetToken,
        uint256 inputAmount,
        uint256 outputAmount
    );
 
    modifier onlyGateway() {
        if (msg.sender != address(gateway)) revert Unauthorized();
        _;
    }
 
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }
 
    function initialize(
        address payable gatewayAddress,
        address uniswapRouterAddress,
        uint256 gasLimitAmount,
        address owner
    ) public initializer {
        if (gatewayAddress == address(0) || uniswapRouterAddress == address(0))
            revert InvalidAddress();
        __UUPSUpgradeable_init();
        __Ownable_init(owner);
        uniswapRouter = uniswapRouterAddress;
        gateway = GatewayZEVM(gatewayAddress);
        gasLimit = gasLimitAmount;
    }
 
    struct Params {
        address target;
        bytes to;
        bool withdraw;
    }
 
    /**
     * @notice Swap tokens from a connected chain to another connected chain or ZetaChain
     */
    function onCall(
        MessageContext calldata context,
        address zrc20,
        uint256 amount,
        bytes calldata message
    ) external onlyGateway {
        Params memory params = Params({
            target: address(0),
            to: bytes(""),
            withdraw: true
        });
 
        if (context.chainID == BITCOIN_TESTNET || context.chainID == BITCOIN) {
            params.target = BytesHelperLib.bytesToAddress(message, 0);
            params.to = abi.encodePacked(
                BytesHelperLib.bytesToAddress(message, 20)
            );
            if (message.length >= 41) {
                params.withdraw = BytesHelperLib.bytesToBool(message, 40);
            }
        } else {
            (
                address targetToken,
                bytes memory recipient,
                bool withdrawFlag
            ) = abi.decode(message, (address, bytes, bool));
            params.target = targetToken;
            params.to = recipient;
            params.withdraw = withdrawFlag;
        }
 
        (uint256 out, address gasZRC20, uint256 gasFee) = handleGasAndSwap(
            zrc20,
            amount,
            params.target,
            params.withdraw
        );
        emit TokenSwap(
            context.sender,
            params.to,
            zrc20,
            params.target,
            amount,
            out
        );
        withdraw(params, context.sender, gasFee, gasZRC20, out, zrc20);
    }
 
    /**
     * @notice Swap tokens from ZetaChain optionally withdrawing to a connected chain
     */
    function swap(
        address inputToken,
        uint256 amount,
        address targetToken,
        bytes memory recipient,
        bool withdrawFlag
    ) public {
        bool success = IZRC20(inputToken).transferFrom(
            msg.sender,
            address(this),
            amount
        );
        if (!success) {
            revert TransferFailed(
                "Failed to transfer ZRC-20 tokens from the sender to the contract"
            );
        }
 
        (uint256 out, address gasZRC20, uint256 gasFee) = handleGasAndSwap(
            inputToken,
            amount,
            targetToken,
            withdrawFlag
        );
        emit TokenSwap(
            msg.sender,
            recipient,
            inputToken,
            targetToken,
            amount,
            out
        );
        withdraw(
            Params({
                target: targetToken,
                to: recipient,
                withdraw: withdrawFlag
            }),
            msg.sender,
            gasFee,
            gasZRC20,
            out,
            inputToken
        );
    }
 
    /**
     * @notice Swaps enough tokens to pay gas fees, then swaps the remainder to the target token
     */
    function handleGasAndSwap(
        address inputToken,
        uint256 amount,
        address targetToken,
        bool withdraw
    ) internal returns (uint256, address, uint256) {
        uint256 inputForGas;
        address gasZRC20;
        uint256 gasFee = 0;
        uint256 swapAmount = amount;
 
        if (withdraw) {
            (gasZRC20, gasFee) = IZRC20(targetToken).withdrawGasFee();
            uint256 minInput = quoteMinInput(inputToken, targetToken);
            if (amount < minInput) {
                revert InsufficientAmount(
                    "The input amount is less than the min amount required to cover the withdraw gas fee"
                );
            }
            if (gasZRC20 == inputToken) {
                swapAmount = amount - gasFee;
            } else {
                inputForGas = SwapHelperLib.swapTokensForExactTokens(
                    uniswapRouter,
                    inputToken,
                    gasFee,
                    gasZRC20,
                    amount
                );
                swapAmount = amount - inputForGas;
            }
        }
 
        uint256 out = SwapHelperLib.swapExactTokensForTokens(
            uniswapRouter,
            inputToken,
            swapAmount,
            targetToken,
            0
        );
        return (out, gasZRC20, gasFee);
    }
 
    /**
     * @notice Transfer tokens to the recipient on ZetaChain or withdraw to a connected chain
     */
    function withdraw(
        Params memory params,
        address sender,
        uint256 gasFee,
        address gasZRC20,
        uint256 out,
        address inputToken
    ) public {
        if (params.withdraw) {
            if (gasZRC20 == params.target) {
                if (!IZRC20(gasZRC20).approve(address(gateway), out + gasFee)) {
                    revert ApprovalFailed();
                }
            } else {
                if (!IZRC20(gasZRC20).approve(address(gateway), gasFee)) {
                    revert ApprovalFailed();
                }
                if (!IZRC20(params.target).approve(address(gateway), out)) {
                    revert ApprovalFailed();
                }
            }
            gateway.withdraw(
                abi.encodePacked(params.to),
                out,
                params.target,
                RevertOptions({
                    revertAddress: address(this),
                    callOnRevert: true,
                    abortAddress: address(0),
                    revertMessage: abi.encode(sender, inputToken),
                    onRevertGasLimit: gasLimit
                })
            );
        } else {
            bool success = IWETH9(params.target).transfer(
                address(uint160(bytes20(params.to))),
                out
            );
            if (!success) {
                revert TransferFailed(
                    "Failed to transfer target tokens to the recipient on ZetaChain"
                );
            }
        }
    }
 
    /**
     * @notice onRevert handles an edge-case when a swap fails when the recipient
     * on the destination chain is a contract that cannot accept tokens.
     */
    function onRevert(RevertContext calldata context) external onlyGateway {
        (address sender, address zrc20) = abi.decode(
            context.revertMessage,
            (address, address)
        );
        (uint256 out, , ) = handleGasAndSwap(
            context.asset,
            context.amount,
            zrc20,
            true
        );
 
        gateway.withdraw(
            abi.encodePacked(sender),
            out,
            zrc20,
            RevertOptions({
                revertAddress: sender,
                callOnRevert: false,
                abortAddress: address(0),
                revertMessage: "",
                onRevertGasLimit: gasLimit
            })
        );
    }
 
    /**
     * @notice Returns the minimum amount of input tokens required to cover the gas fee for withdrawal
     */
    function quoteMinInput(
        address inputToken,
        address targetToken
    ) public view returns (uint256) {
        (address gasZRC20, uint256 gasFee) = IZRC20(targetToken)
            .withdrawGasFee();
 
        if (inputToken == gasZRC20) {
            return gasFee;
        }
 
        address zeta = IUniswapV2Router01(uniswapRouter).WETH();
 
        address[] memory path;
        if (inputToken == zeta || gasZRC20 == zeta) {
            path = new address[](2);
            path[0] = inputToken;
            path[1] = gasZRC20;
        } else {
            path = new address[](3);
            path[0] = inputToken;
            path[1] = zeta;
            path[2] = gasZRC20;
        }
 
        uint256[] memory amountsIn = IUniswapV2Router02(uniswapRouter)
            .getAmountsIn(gasFee, path);
 
        return amountsIn[0];
    }
 
    function _authorizeUpgrade(
        address newImplementation
    ) internal override onlyOwner {}
}

Decoding the Message

The contract uses a Params struct to store the following pieces of information:

  • address target: The ZRC-20 address of the target token on ZetaChain.
  • bytes to: The recipient's address on the destination chain, stored as bytes to support both EVM chains (e.g., Ethereum, BNB) and non-EVM chains like Bitcoin.
  • bool withdraw: Indicates whether to withdraw the swapped token to the destination chain or transfer it to the recipient on ZetaChain.

When the onCall function is invoked, it receives a message parameter that must be decoded to extract the swap details. The decoding logic adapts to the source chain's specific requirements and limitations.

  • For Bitcoin: Due to Bitcoin's 80-byte OP_RETURN limit, the contract employs an efficient encoding method. The target token address (params.target) is extracted from the first 20 bytes of the message, converted into an address using a helper function. The recipient’s address is extracted from the next 20 bytes and encoded as bytes format.
  • For EVM Chains and Solana: Without strict size limitations on messages, the contract uses abi.decode to extract all parameters directly.

The source chain is identified using context.chainID, which determines the appropriate decoding logic. After decoding, the contract proceeds to handle the token swap by invoking handleGasAndSwap and, if required, initiating a withdrawal.


Handling Gas and Swapping Tokens

The handleGasAndSwap function handles both obtaining gas tokens for withdrawal fees and swapping the remaining tokens for the target token.

The contract ensures sufficient gas tokens to cover the withdrawal fee on the destination chain by calculating the required amount through the ZRC-20 contract's withdrawGasFee method. This method provides the fee amount (gasFee) and the gas token address (gasZRC20).

If the incoming token is already the gas token, the required gas fee is deducted directly. Otherwise, the contract swaps a portion of the incoming tokens for the gas fee using a helper function. This ensures the contract is always prepared for cross-chain withdrawal operations.

After addressing the gas fee, the remaining tokens are swapped for the target token using ZetaChain's internal liquidity pools. This step ensures that the recipient receives the correct token as specified in the Params.

Withdrawing Target Token to Connected Chain

Once the gas and target tokens are prepared, the contract determines the appropriate action based on the withdraw parameter:

  • If withdraw is true: The target token and gas tokens are approved, either combined or separately depending on whether they are the same. The contract calls gateway.withdraw to transfer the tokens to the destination chain. The recipient's address is encoded using abi.encodePacked. The Swap contract is supplied as the revert address, while the sender's address and input token are included as a revert message for potential recovery. The ZRC-20 contract inherently ensures that tokens are withdrawn to the correct connected chain.
  • If withdraw is false: The target token is transferred directly to the recipient on ZetaChain, bypassing the withdrawal process.

Revert Logic

If a withdrawal fails on the destination chain, the onRevert function is invoked to recover the funds. The sender's address and the original token are decoded from the revert message, ensuring the correct data for recovery.

The contract swaps the reverted tokens back to the original token sent from the source chain. Finally, it attempts to withdraw the tokens back to the source chain. If this withdrawal also fails, the tokens are transferred directly to the sender on ZetaChain. This approach minimizes the risk of lost funds and ensures a robust fallback mechanism.

Companion Contract

The Swap contract can be called in two ways:

  1. Directly via depositAndCall: This method uses the EVM gateway on a connected chain, eliminating the need for an intermediary contract. It is suitable for straightforward swaps without additional logic on the connected chain.
  2. Through a companion contract: This approach is useful when additional logic must be executed on the connected chain before initiating the swap. The tutorial provides an example of such a companion contract in SwapCompanion.sol.
npx hardhat compile --force

npx hardhat deploy \
  --gateway 0x6c533f7fe93fae114d0954697069df33c9b74fd7 \
  --uniswap-router 0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe \
  --network zeta_testnet
πŸ”‘ Using account: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32

πŸš€ Successfully deployed contract on zeta_testnet.
πŸ“œ Contract address: 0x162CefCe314726698ac1Ee5895a6c392ba8e20d3
npx hardhat evm-deposit-and-call \
  --receiver 0x162CefCe314726698ac1Ee5895a6c392ba8e20d3 \
  --amount 0.001 \
  --network base_sepolia \
  --gas-price 20000 \
  --gateway-evm 0x0c487a766110c85d301d96e33579c5b317fa4995 \
  --types '["address", "bytes"]' 0x777915D031d1e8144c90D025C594b3b8Bf07a08d 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32

Transaction on Base:

https://sepolia.basescan.org/tx/0xe873b59c2c2398236279aa04fff2134bd3391e8879ae6b99cabd00c97021ae38 (opens in a new tab)

Incoming transaction from Base to ZetaChain:

https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/0xe873b59c2c2398236279aa04fff2134bd3391e8879ae6b99cabd00c97021ae38 (opens in a new tab)

Outgoing transaction from ZetaChain to Polygon:

https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/0xb8e9120290a1a36cfbd0cbdacadab4746d1b47830952bd588330456c62701058 (opens in a new tab)

Transaction on Polygon:

https://amoy.polygonscan.com/tx/0x5a83fe0321c13f386d0953052a0335f06095455fcf1470b8e8fe9c3119adbb40 (opens in a new tab)

npx hardhat solana-deposit-and-call \
  --amount 0.1 \
  --recipient 0x162CefCe314726698ac1Ee5895a6c392ba8e20d3 \
  --types '["address", "bytes", "bool"]' 0x236b0DE675cC8F46AE186897fCCeFe3370C9eDeD 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32 true

Transaction on Solana:

https://solana.fm/tx/4TyLdAt2jcxPwnddbwvHSUzrUTZrLoJ8idTGgNXySFw5vDsW3uo4qwGVxBFD3ziLzbWrVrmfx9boYM9pWooftM5x?cluster=devnet-solana (opens in a new tab)

Incoming transaction from Solana to ZetaChain:

https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/4TyLdAt2jcxPwnddbwvHSUzrUTZrLoJ8idTGgNXySFw5vDsW3uo4qwGVxBFD3ziLzbWrVrmfx9boYM9pWooftM5x (opens in a new tab)

Outgoing transaction from ZetaChain to Base:

https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/0x52e483e4b8d0945a77955cc8abed4ae51f1a2fc0482c43c16f367bd73bf67290 (opens in a new tab)

Transaction on Base:

https://sepolia.basescan.org/tx/0xd70e5a437f730b0e64ae24dd188994c0c2d9cccfc0582942cb0dea4198dc8a15 (opens in a new tab)

Start the local development environment to simulate ZetaChain's behavior by running:

npx hardhat localnet

Compile the contract and deploy it to localnet by running:

npx hardhat deploy \
  --name Swap \
  --network localhost \
  --gateway 0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 \
  --uniswap-router 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0

You should see output similar to:

πŸ”‘ Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

πŸš€ Successfully deployed contract on localhost.
πŸ“œ Contract address: =0x0355B7B8cb128fA5692729Ab3AAa199C1753f726

To swap gas tokens for ERC-20 tokens, run the following command:

npx hardhat evm-swap \
  --network localhost \
  --receiver 0x0355B7B8cb128fA5692729Ab3AAa199C1753f726 \
  --amount 0.1 \
  --gateway-evm 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 \
  --target 0x05BA149A7bd6dC1F937fA9046A9e05C05f3b18b0 \
  --skip-checks \
  --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

This script deposits tokens into the gateway on a connected EVM chain and sends a message to the Swap contract on ZetaChain to execute the swap logic.

In this command, the --receiver parameter is the address of the Swap contract on ZetaChain that will handle the swap. The --amount 1 option indicates that you want to swap 1 ETH. --target is the ZRC-20 address of the destination token (in this example, it's ZRC-20 USDC).

When you execute this command, the script calls the gateway.depositAndCall method on the connected EVM chain, depositing 1 ETH and sending a message to the Swap contract on ZetaChain.

ZetaChain then picks up the event and executes the onCall function of the Swap contract with the provided message.

The Swap contract decodes the message, identifies the target ERC-20 token and recipient, and initiates the swap logic.

Finally, the EVM chain receives the withdrawal request, and the swapped ERC-20 tokens are transferred to the recipient's address:

Swapping ERC-20 Tokens for Gas Tokens

To swap ERC-20 tokens for gas tokens, adjust the command by specifying the ERC-20 token you're swapping from using the --erc20 parameter:

npx hardhat evm-swap \
  --network localhost \
  --receiver 0x0355B7B8cb128fA5692729Ab3AAa199C1753f726 \
  --amount 0.1 \
  --target 0x65a45c57636f9BcCeD4fe193A602008578BcA90b \
  --gateway-evm 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 \
  --skip-checks \
  --erc20 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E \
  --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

Here, the --erc20 option specifies the ERC-20 token address you're swapping from on the source chain. The other parameters remain the same as in the previous command.

When you run the command, the script calls the gateway.depositAndCall method with the specified ERC-20 token and amount, sending a message to the Swap contract on ZetaChain.

ZetaChain picks up the event and executes the onCall function of the Swap contract:

The Swap contract decodes the message, identifies the target gas token and recipient, and initiates the swap logic.

The EVM chain then receives the withdrawal request, and the swapped gas tokens are transferred to the recipient's address.

npx hardhat localnet:sui-deposit-and-call \
  --mnemonic "grape subway rack mean march bubble carry avoid muffin consider thing street" \
  --gateway 0x36f26de84772c7dc8d4b9e291c92c2b067c448e14936aa7bad546ed9a5f348d3 \
  --module 0x7418f0a63ff1d1fdc0b4dd2a1b7fc1760c62e7bc7609e1fb71ec30f0fbfb0a00 \
  --receiver 0x0355B7B8cb128fA5692729Ab3AAa199C1753f726 \
  --amount 1000000 \
  --types '["address", "bytes", "bool"]' 0x777915D031d1e8144c90D025C594b3b8Bf07a08d DrexsvCMH9WWjgnjVbx1iFf3YZcKadupFmxnZLfSyotd true

Swapping Solana SOL for SUI

npx hardhat localnet:solana-deposit-and-call \
  --receiver 0x0355B7B8cb128fA5692729Ab3AAa199C1753f726 \
  --amount 0.0001 \
  --types '["address", "bytes", "bool"]' 0xe573a6e11f8506620F123DBF930222163D46BCB6 0x2fec3fafe08d2928a6b8d9a6a77590856c458d984ae090ccbd4177ac13729e65 true

Before swapping to Sui, make sure you have either deposited tokens from Sui or deposited and made a call from Sui first. Otherwise, there will be no tokens available in custody on Sui.

Swapping Solana SPL for SUI

npx hardhat localnet:solana-deposit-and-call \
  --receiver 0x0355B7B8cb128fA5692729Ab3AAa199C1753f726 \
  --mint 2rJE9EiWx7hrCKiBsAgtmTZbcuDRESkEvw9ZaAwX1YHN \
  --to 7nHtQCVaUMRkwiUhDmoJUJ2p1WMatoz3k22C3xGvid64 \
  --from DdTS9SENotAj5z96TQX5pRKd11TkvHKaSneK1GnCCrKD \
  --amount 0.1 \
  --types '["address", "bytes", "bool"]' 0xe573a6e11f8506620F123DBF930222163D46BCB6 0x2fec3fafe08d2928a6b8d9a6a77590856c458d984ae090ccbd4177ac13729e65 true

Swapping SUI for Solana SPL

npx hardhat localnet:sui-deposit-and-call \
  --mnemonic "grape subway rack mean march bubble carry avoid muffin consider thing street" \
  --gateway 0xb58507c84f8247d2866b393fbd11467211c89e4e4935d01714a868b2dde493ae \
  --module 0xb98bb74211a4f8463d87b61ace7d36f703370dae989baf8c620131d28525a899 \
  --receiver 0x0355B7B8cb128fA5692729Ab3AAa199C1753f726 \
  --amount 100000 \
  --types '["address", "bytes", "bool"]' 0xfC9201f4116aE6b054722E10b98D904829b469c3 DrexsvCMH9WWjgnjVbx1iFf3YZcKadupFmxnZLfSyotd true

In this tutorial, you learned how to define a universal app contract that performs cross-chain token swaps. You deployed the Swap contract to a local development network and interacted with the contract by swapping tokens from a connected EVM chain. You also understood the mechanics of handling gas fees and token approvals in cross-chain swaps.

You can find the source code for the tutorial in the example contracts repository:

https://github.com/zeta-chain/example-contracts/tree/main/examples/swap (opens in a new tab)