Bridging
Stellar to EVM
Full worked example moving USDC from a Stellar testnet account to Arbitrum Sepolia.
What happens
- Stellar's Soroban
transfer_fromhas no auth bypass, so an explicitapproveon the USDC Stellar Asset Contract is required before the burn. - Call
deposit_for_burnon the StellarTokenMessenger. USDC is burned and Circle emits the message. - Poll Iris for the attestation.
- Call
receiveMessageon 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 spendon the source chain. - The
deposit_for_burncall takescaller: Addressas its first argument. The SDK sets this tofromAccountAddressfor you. - Set a memo with
memo: { type: MemoType.TEXT, value: "..." }onsendif you need one on the burn transaction.