Bridging
EVM to Stellar
Full worked example moving USDC from Base Sepolia to a Stellar testnet account.
What happens
- If your USDC allowance for the source
TokenMessengerV2is short, sign anapprove. - Call
depositForBurnWithHookon the source chain. The mint recipient in the message is Circle's StellarCctpForwarder, and the hook carries your real recipient. - Poll Iris for the attestation.
- Call
CctpForwarder.mint_and_forwardon 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
hasTrustlinebefore the burn.