@zethub/bridge
Bridging

EVM to EVM

Full worked example moving USDC from Base Sepolia to Arbitrum Sepolia.

What happens

  1. If your USDC allowance for the source TokenMessengerV2 is short, sign an approve.
  2. Call depositForBurn on the source chain. USDC is burned and Circle emits the message.
  3. Poll Iris for the attestation.
  4. Call receiveMessage on the destination chain. USDC is minted to your recipient.

Steps 1, 2, and 4 return unsigned RawEvmTransaction objects. You sign and broadcast them yourself.

Setup

import {
  ZetHubBridge,
  Environment,
  NetworkId,
  AssetSymbol,
} from "@zethub/bridge";
import { createWalletClient, custom, http, createPublicClient } from "viem";
import { baseSepolia, arbitrumSepolia } from "viem/chains";

// Accounts
const senderAddress = "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d";
const recipientAddress = "0x9F70008A83912b19B3E64B58D5f4A08bBd7b3F0e";
const AMOUNT = "5.0";

// Wallet and public clients you already have in your app
const wallet = createWalletClient({
  transport: custom(window.ethereum),
  account: senderAddress,
});
const sourcePublic = createPublicClient({
  chain: baseSepolia,
  transport: http(),
});
const destPublic = createPublicClient({
  chain: arbitrumSepolia,
  transport: http(),
});

Discover the tokens

const sdk = new ZetHubBridge({ environment: Environment.TESTNET });

const chains = await sdk.chainDetailsMap();
const sourceToken = chains[NetworkId.BASE_SEPOLIA].tokens.find(
  (t) => t.symbol === AssetSymbol.USDC,
)!;
const destinationToken = chains[NetworkId.ARBITRUM_SEPOLIA].tokens.find(
  (t) => t.symbol === AssetSymbol.USDC,
)!;

Step 1: approve (only when allowance is short)

const enough = await sdk.bridge.checkAllowance({
  token: sourceToken,
  owner: senderAddress,
  amount: AMOUNT,
});

if (!enough) {
  const approveTx = await sdk.bridge.rawTxBuilder.approve({
    token: sourceToken,
    owner: senderAddress,
  });
  const approveHash = await wallet.sendTransaction({
    to: approveTx.to,
    data: approveTx.data,
    chain: baseSepolia,
  });
  await sourcePublic.waitForTransactionReceipt({ hash: approveHash });
}

Step 2: burn on the source

const burnTx = await sdk.bridge.rawTxBuilder.send({
  sourceToken,
  destinationToken,
  amount: AMOUNT,
  fromAccountAddress: senderAddress,
  toAccountAddress: recipientAddress,
});

const burnHash = await wallet.sendTransaction({
  to: burnTx.to,
  data: burnTx.data,
  chain: baseSepolia,
});
await sourcePublic.waitForTransactionReceipt({ hash: burnHash });

Step 3: wait for the attestation

const att = await sdk.attestation.waitFor(NetworkId.BASE_SEPOLIA, burnHash);

Step 4: mint on the destination

const mintTx = await sdk.bridge.rawTxBuilder.receive({
  destinationToken,
  toAccountAddress: recipientAddress,
  message: att.message,
  attestation: att.attestation,
});

const mintHash = await wallet.sendTransaction({
  to: mintTx.to,
  data: mintTx.data,
  chain: arbitrumSepolia,
});
await destPublic.waitForTransactionReceipt({ hash: mintHash });

console.log("Done:", { burnHash, mintHash });

Full script

import {
  ZetHubBridge,
  Environment,
  NetworkId,
  AssetSymbol,
} from "@zethub/bridge";
import { createWalletClient, custom, http, createPublicClient } from "viem";
import { baseSepolia, arbitrumSepolia } from "viem/chains";

async function bridge() {
  const senderAddress = "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d";
  const recipientAddress = "0x9F70008A83912b19B3E64B58D5f4A08bBd7b3F0e";
  const AMOUNT = "5.0";

  const wallet = createWalletClient({
    transport: custom(window.ethereum),
    account: senderAddress,
  });
  const sourcePublic = createPublicClient({ chain: baseSepolia, transport: http() });
  const destPublic = createPublicClient({ chain: arbitrumSepolia, transport: http() });

  const sdk = new ZetHubBridge({ environment: Environment.TESTNET });
  const chains = await sdk.chainDetailsMap();
  const sourceToken = chains[NetworkId.BASE_SEPOLIA].tokens.find(
    (t) => t.symbol === AssetSymbol.USDC,
  )!;
  const destinationToken = chains[NetworkId.ARBITRUM_SEPOLIA].tokens.find(
    (t) => t.symbol === AssetSymbol.USDC,
  )!;

  if (!(await sdk.bridge.checkAllowance({
    token: sourceToken,
    owner: senderAddress,
    amount: AMOUNT,
  }))) {
    const approveTx = await sdk.bridge.rawTxBuilder.approve({
      token: sourceToken,
      owner: senderAddress,
    });
    const h = await wallet.sendTransaction({
      to: approveTx.to,
      data: approveTx.data,
      chain: baseSepolia,
    });
    await sourcePublic.waitForTransactionReceipt({ hash: h });
  }

  const burnTx = await sdk.bridge.rawTxBuilder.send({
    sourceToken,
    destinationToken,
    amount: AMOUNT,
    fromAccountAddress: senderAddress,
    toAccountAddress: recipientAddress,
  });
  const burnHash = await wallet.sendTransaction({
    to: burnTx.to,
    data: burnTx.data,
    chain: baseSepolia,
  });
  await sourcePublic.waitForTransactionReceipt({ hash: burnHash });

  const att = await sdk.attestation.waitFor(NetworkId.BASE_SEPOLIA, burnHash);

  const mintTx = await sdk.bridge.rawTxBuilder.receive({
    destinationToken,
    toAccountAddress: recipientAddress,
    message: att.message,
    attestation: att.attestation,
  });
  const mintHash = await wallet.sendTransaction({
    to: mintTx.to,
    data: mintTx.data,
    chain: arbitrumSepolia,
  });
  await destPublic.waitForTransactionReceipt({ hash: mintHash });

  return { burnHash, mintHash };
}

Notes

  • The recipient is an EVM address on the destination chain.
  • Your wallet must be able to switch chains between the burn and the mint. Every mainstream browser wallet handles this.
  • All EVM chains share the same TokenMessengerV2 and MessageTransmitterV2 addresses. See Contracts.

On this page