@zethub/bridge
Bridging

EVM to Stellar

Full worked example moving USDC from Base Sepolia to a Stellar testnet account.

What happens

  1. If your USDC allowance for the source TokenMessengerV2 is short, sign an approve.
  2. Call depositForBurnWithHook on the source chain. The mint recipient in the message is Circle's Stellar CctpForwarder, and the hook carries your real recipient.
  3. Poll Iris for the attestation.
  4. Call CctpForwarder.mint_and_forward on Stellar. The forwarder mints USDC to itself and forwards it to your recipient from the hook payload.

Steps 1 and 2 return RawEvmTransaction. Step 4 returns RawSorobanTransaction.

Pre flight: trustline

Every Stellar destination must hold a USDC trustline before it can receive. Check the recipient first so your app can prompt them if the trustline is missing.

const canReceive = await sdk.bridge.hasTrustline({
  token: destinationToken,
  address: recipientG,
});
if (!canReceive) {
  throw new Error("Recipient must establish a USDC trustline before bridging");
}

Setup

import {
  ZetHubBridge,
  Environment,
  NetworkId,
  AssetSymbol,
} from "@zethub/bridge";
import { createWalletClient, custom, http, createPublicClient } from "viem";
import { baseSepolia } from "viem/chains";
import {
  TransactionBuilder,
  rpc,
} from "@stellar/stellar-sdk";
import { signTransaction } from "@stellar/freighter-api";

const senderEvm = "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d";
const recipientG =
  "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
const AMOUNT = "5.0";

const wallet = createWalletClient({
  transport: custom(window.ethereum),
  account: senderEvm,
});
const sourcePublic = createPublicClient({
  chain: baseSepolia,
  transport: http(),
});
const soroban = new rpc.Server("https://soroban-testnet.stellar.org");

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.STELLAR_TESTNET].tokens.find(
  (t) => t.symbol === AssetSymbol.USDC,
)!;

Step 1: approve on the source

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

Step 2: burn with hook

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

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

Behind the scenes the SDK encodes the forwarder hook payload, sets the mint recipient to Circle's Stellar forwarder, and calls depositForBurnWithHook.

Step 3: wait for the attestation

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

Step 4: mint and forward on Stellar

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

// Sign with your Stellar wallet
const signedXdr = await signTransaction(mintTx.xdr, {
  networkPassphrase: mintTx.networkPassphrase,
});
const signedTx = TransactionBuilder.fromXDR(signedXdr, mintTx.networkPassphrase);

// Broadcast to Soroban
const sent = await soroban.sendTransaction(signedTx);
if (sent.status !== "PENDING") {
  throw new Error("Mint submission failed: " + JSON.stringify(sent));
}
console.log("Done:", { burnHash, mintHash: sent.hash });

Full script

import {
  ZetHubBridge,
  Environment,
  NetworkId,
  AssetSymbol,
} from "@zethub/bridge";
import { createWalletClient, custom, http, createPublicClient } from "viem";
import { baseSepolia } from "viem/chains";
import { TransactionBuilder, rpc } from "@stellar/stellar-sdk";
import { signTransaction } from "@stellar/freighter-api";

async function bridge() {
  const senderEvm = "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d";
  const recipientG =
    "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
  const AMOUNT = "5.0";

  const wallet = createWalletClient({
    transport: custom(window.ethereum),
    account: senderEvm,
  });
  const sourcePublic = createPublicClient({ chain: baseSepolia, transport: http() });
  const soroban = new rpc.Server("https://soroban-testnet.stellar.org");

  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.STELLAR_TESTNET].tokens.find(
    (t) => t.symbol === AssetSymbol.USDC,
  )!;

  if (!(await sdk.bridge.hasTrustline({ token: destinationToken, address: recipientG }))) {
    throw new Error("Recipient must establish a USDC trustline before bridging");
  }

  if (!(await sdk.bridge.checkAllowance({
    token: sourceToken,
    owner: senderEvm,
    amount: AMOUNT,
  }))) {
    const approveTx = await sdk.bridge.rawTxBuilder.approve({
      token: sourceToken,
      owner: senderEvm,
    });
    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: senderEvm,
    toAccountAddress: recipientG,
  });
  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: recipientG,
    message: att.message,
    attestation: att.attestation,
  });
  const signedXdr = await signTransaction(mintTx.xdr, {
    networkPassphrase: mintTx.networkPassphrase,
  });
  const signedTx = TransactionBuilder.fromXDR(signedXdr, mintTx.networkPassphrase);
  const sent = await soroban.sendTransaction(signedTx);
  if (sent.status !== "PENDING") throw new Error(JSON.stringify(sent));

  return { burnHash, mintHash: sent.hash };
}

Notes

  • The recipient is a Stellar account id starting with G.
  • The forwarder address is baked into the Stellar network config. See Contracts.
  • If the recipient does not have a USDC trustline the mint call reverts. Always run hasTrustline before the burn.

On this page