@zethub/bridge
Bridging

Stellar to EVM

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

What happens

  1. Stellar's Soroban transfer_from has no auth bypass, so an explicit approve on the USDC Stellar Asset Contract is required before the burn.
  2. Call deposit_for_burn on the Stellar TokenMessenger. USDC is burned and Circle emits the message.
  3. Poll Iris for the attestation.
  4. Call receiveMessage on the destination EVM chain. USDC is minted to your recipient.

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

Setup

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

const senderG =
  "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
const recipientEvm = "0x9F70008A83912b19B3E64B58D5f4A08bBd7b3F0e";
const AMOUNT = "5.0";

const soroban = new rpc.Server("https://soroban-testnet.stellar.org");

const wallet = createWalletClient({
  transport: custom(window.ethereum),
  account: recipientEvm, // whoever signs the mint on the destination
});
const destPublic = createPublicClient({
  chain: arbitrumSepolia,
  transport: http(),
});

async function submitSoroban(xdr: string, passphrase: string) {
  const signedXdr = await signTransaction(xdr, { networkPassphrase: passphrase });
  const signedTx = TransactionBuilder.fromXDR(signedXdr, passphrase);
  const sent = await soroban.sendTransaction(signedTx);
  if (sent.status !== "PENDING") throw new Error(JSON.stringify(sent));
  return sent.hash;
}

Discover the tokens

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

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

Step 1: approve on Stellar

The Stellar Asset Contract requires a real on chain allowance before transfer_from succeeds. There is no auth based bypass. This step is mandatory even if the amount is small.

const approveTx = await sdk.bridge.rawTxBuilder.approve({
  token: sourceToken,
  owner: senderG,
  amount: AMOUNT,
});
await submitSoroban(approveTx.xdr, approveTx.networkPassphrase);

approve on Stellar defaults to an expiration of about ten days from the current ledger, which is plenty for a normal bridge flow.

Step 2: burn on Stellar

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

const burnHash = await submitSoroban(burnTx.xdr, burnTx.networkPassphrase);

Step 3: wait for the attestation

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

Step 4: mint on the destination EVM

const mintTx = await sdk.bridge.rawTxBuilder.receive({
  destinationToken,
  toAccountAddress: recipientEvm,
  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 { arbitrumSepolia } from "viem/chains";
import { TransactionBuilder, rpc } from "@stellar/stellar-sdk";
import { signTransaction } from "@stellar/freighter-api";

async function bridge() {
  const senderG =
    "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
  const recipientEvm = "0x9F70008A83912b19B3E64B58D5f4A08bBd7b3F0e";
  const AMOUNT = "5.0";

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

  async function submitSoroban(xdr: string, passphrase: string) {
    const signedXdr = await signTransaction(xdr, { networkPassphrase: passphrase });
    const signedTx = TransactionBuilder.fromXDR(signedXdr, passphrase);
    const sent = await soroban.sendTransaction(signedTx);
    if (sent.status !== "PENDING") throw new Error(JSON.stringify(sent));
    return sent.hash;
  }

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

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

  const approveTx = await sdk.bridge.rawTxBuilder.approve({
    token: sourceToken,
    owner: senderG,
    amount: AMOUNT,
  });
  await submitSoroban(approveTx.xdr, approveTx.networkPassphrase);

  const burnTx = await sdk.bridge.rawTxBuilder.send({
    sourceToken,
    destinationToken,
    amount: AMOUNT,
    fromAccountAddress: senderG,
    toAccountAddress: recipientEvm,
  });
  const burnHash = await submitSoroban(burnTx.xdr, burnTx.networkPassphrase);

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

  const mintTx = await sdk.bridge.rawTxBuilder.receive({
    destinationToken,
    toAccountAddress: recipientEvm,
    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 Stellar approve step is mandatory. Trying to skip it produces not enough allowance to spend on the source chain.
  • The deposit_for_burn call takes caller: Address as its first argument. The SDK sets this to fromAccountAddress for you.
  • Set a memo with memo: { type: MemoType.TEXT, value: "..." } on send if you need one on the burn transaction.

On this page