Taming Wormhole: Async Generators for Cross-Chain Bridge Transactions
How I integrated Wormhole bridge transactions that yield an unknown number of sequential steps via async generators. Covers the three-phase bridge lifecycle, calldata regeneration, and dual-lock coordination.
When I was building a cross-chain order execution system, the bridge integrations were by far the hardest part. Most DEX aggregators have straightforward APIs: send parameters, get calldata back, submit the transaction. Bridge protocols are fundamentally different because they involve coordinating state across two independent blockchains with no shared transaction model. Among all the bridges I integrated -- Stargate, Hyperlane, Arbitrum's native bridge, Polygon Portal -- Wormhole was the one that required rethinking how the execution pipeline worked at its core.
The issue was not complexity in the abstract sense. The issue was that Wormhole's Token Bridge SDK uses an async generator pattern that does not fit neatly into the "request calldata, execute transaction" model that every other venue uses. The documentation at the time was sparse on the practical details, so I spent considerable time reading SDK source code and experimenting to figure out the right approach.
Setting Up the Wormhole SDK
Before getting into the generator pattern, the SDK setup itself has nuances worth covering. Wormhole supports multiple networks and platforms, and you need to import and register the correct platform modules for each chain you want to interact with.
import { wormhole, Wormhole, Network, Chain } from '@wormhole-foundation/sdk'
import EvmPlatform from '@wormhole-foundation/sdk/evm'
import SolanaPlatform from '@wormhole-foundation/sdk/solana'
// Initialize with platform support for the chains you need
const wh = await wormhole('Mainnet', [EvmPlatform, SolanaPlatform])
// Get a chain context -- this is how you interact with a specific chain
const ethChain = wh.getChain('Ethereum')
const solChain = wh.getChain('Solana')
// Get the Token Bridge protocol for a specific chain
const ethTokenBridge = await ethChain.getTokenBridge()
const solTokenBridge = await solChain.getTokenBridge()If you are also using NTT (Native Token Transfers), the setup is more involved because you need to configure route objects with per-token manager and transceiver addresses for each chain:
import { routes } from '@wormhole-foundation/sdk'
import { nttManualRoute } from '@wormhole-foundation/sdk-route-ntt'
// NTT tokens require per-chain configuration:
// manager address, transceiver address, optional quoter
const nttConfig = {
tokens: {
TOKEN_NAME: [
{
chain: 'Solana',
manager: 'NTTManager111...',
token: 'TokenMint111...',
transceiver: [{ address: 'Transceiver111...', type: 'wormhole' }],
},
{
chain: 'Ethereum',
manager: '0xManager...',
token: '0xToken...',
transceiver: [{ address: '0xTransceiver...', type: 'wormhole' }],
},
],
},
}
// Register NTT route with the resolver
const nttRoute = nttManualRoute(nttConfig)The Async Generator Problem
Most bridge SDKs return a transaction or a list of transactions. You know what you are getting: build it, sign it, submit it. Wormhole's transfer method instead returns an AsyncGenerator that yields transactions one at a time. You iterate over it with for await...of, and each iteration gives you one transaction to execute.
// Wormhole SDK: tokenBridge.transfer() returns AsyncGenerator
const sender = Wormhole.chainAddress('Ethereum', '0xYourWallet...')
const recipient = Wormhole.chainAddress('Solana', 'YourSolanaWallet...')
const token = Wormhole.tokenId('Ethereum', '0xTokenAddress...')
const amount = BigInt('1000000') // In token's smallest unit
const transfer = ethTokenBridge.transfer(
sender.address,
recipient,
token.address,
amount
)
// The critical part: iterate the generator to collect calldata
const callDatas: CallData[] = []
let stepCount = 0
for await (const txResult of transfer) {
stepCount++
// Step 1 is typically an ERC20 approval (if needed)
// Step 2 is the actual bridge call
// But you cannot assume the count -- it depends on approval state
callDatas.push({
to: txResult.transaction.to,
data: txResult.transaction.data,
value: txResult.transaction.value?.toString() ?? '0',
gasLimit: '300000',
type: stepCount === 1 && callDatas.length === 0 ? 'approve' : 'bridge',
})
}The problem is threefold. First, you do not know how many transactions the generator will yield until you finish iterating. An EVM transfer might yield two (token approval plus bridge call) or just one if the token is already approved. Second, each yielded transaction depends on the previous one having been executed successfully -- the generator internally tracks state. Third, the transaction shapes differ between EVM chains and Solana in ways that your execution layer needs to account for.
Normalizing EVM vs Solana Transactions
When the generator yields transactions for Solana, they come in a completely different format than EVM. You need a normalization layer that converts both into a common calldata structure. The key difference: EVM transactions have to, data, value fields and can be submitted immediately. Solana transactions need a recentBlockhash (valid for only ~60 seconds), a feePayer assignment, and may include additional signers that the SDK injects for certain operations.
interface NormalizedCallData {
chain: string
type: 'evm' | 'solana'
// EVM fields
to?: string
data?: string
value?: string
// Solana fields
serializedTransaction?: string
signers?: string[]
recentBlockhash?: string
}
// EVM: straightforward field extraction from the generator output
// Solana: must call getLatestBlockhash(), set feePayer, serialize with
// requireAllSignatures: false, and extract any additional signersThe Solana blockhash expiry is the single biggest gotcha. If your execution pipeline has any queuing between calldata generation and transaction submission, the blockhash will expire and the transaction silently fails. I solved this by generating Solana calldata as late as possible -- right before submission rather than during the collection phase.
Another subtle issue: the same token can have different decimal precision across chains (e.g., USDC is 6 decimals on both EVM and Solana, but some wrapped tokens differ). You need to resolve decimals per chain via Solana getMint() or EVM decimals() calls before computing transfer amounts.
Three Phases of a Bridge
A Wormhole bridge is not a single operation. It is a three-phase process that can take anywhere from seconds to minutes, with different failure modes at each phase.
Phase 1: Initiation. Execute all transactions yielded by the generator on the source chain. This locks the tokens and emits a Wormhole message that the guardian network will observe.
Phase 2: Attestation. Wait for the Wormhole guardian network to observe the message and produce a VAA (Verifiable Action Approval). This happens off-chain and typically takes 15-30 seconds but can take longer during congestion. You monitor progress by polling the Wormholescan API.
Phase 3: Completion. Some transfers complete automatically via relayer. Others require a manual redemption transaction on the destination chain. Your system must handle both.
type BridgePhase =
| 'initiated'
| 'attesting'
| 'ready_to_claim'
| 'completed'
| 'failed'
interface BridgeStatus {
phase: BridgePhase
sourceChain: string
sourceTxHash: string
destinationChain: string
destinationTxHash?: string
vaaAvailable: boolean
estimatedCompletionTime?: number
}
async function pollBridgeStatus(
sourceTxHash: string,
sourceChain: string
): Promise<BridgeStatus> {
// Wormholescan API provides operation status
const response = await fetch(
`https://api.wormholescan.io/api/v1/operations?txHash=${sourceTxHash}`
)
const data = await response.json()
if (!data.operations?.length) {
return {
phase: 'initiated',
vaaAvailable: false,
sourceChain,
sourceTxHash,
destinationChain: '',
}
}
const op = data.operations[0]
const hasVaa = op.vaa?.raw !== undefined
const hasDestTx = op.destinationTx?.txHash !== undefined
if (hasDestTx) {
return {
phase: 'completed',
vaaAvailable: true,
sourceChain,
sourceTxHash,
destinationChain: op.destinationTx.chainId,
destinationTxHash: op.destinationTx.txHash,
}
}
if (hasVaa) {
return {
phase: 'ready_to_claim',
vaaAvailable: true,
sourceChain,
sourceTxHash,
destinationChain: op.content?.standardizedProperties?.toChain ?? '',
}
}
return {
phase: 'attesting',
vaaAvailable: false,
sourceChain,
sourceTxHash,
destinationChain: '',
}
}The monitoring loop runs in parallel with normal order processing. A pending bridge does not block the worker from handling new orders. Instead, a separate cycle periodically checks all pending bridges and triggers claim generation when a transfer reaches ready_to_claim.
Generating Claim Transactions
When a bridge reaches the ready_to_claim phase, you need to generate and submit a redemption transaction on the destination chain. The process differs between EVM and Solana destinations.
For EVM destinations, you fetch the VAA and call the Token Bridge's completeTransfer method:
async function generateEvmClaimCallData(
destChain: Chain,
sourceTxHash: string,
wh: Wormhole<'Mainnet'>
): Promise<NormalizedCallData[]> {
const destTokenBridge = await wh.getChain(destChain).getTokenBridge()
// The SDK's redeem method also returns an async generator
const vaa = await fetchVaaFromWormholescan(sourceTxHash)
const redeem = destTokenBridge.redeem(recipientAddress, vaa)
const callDatas: NormalizedCallData[] = []
for await (const tx of redeem) {
callDatas.push(normalizeEvmTransaction(tx))
}
return callDatas
}For Solana destinations, you may need to create an Associated Token Account first if the recipient does not already have one for the bridged token:
async function generateSolanaClaimCallData(
sourceTxHash: string,
recipientPubkey: PublicKey,
tokenMint: PublicKey,
connection: Connection
): Promise<NormalizedCallData[]> {
const callDatas: NormalizedCallData[] = []
// Check if recipient has an ATA for this token
const ata = getAssociatedTokenAddressSync(tokenMint, recipientPubkey)
const ataInfo = await connection.getAccountInfo(ata)
if (!ataInfo) {
// Prepend ATA creation instruction
callDatas.push({
chain: 'solana',
type: 'solana',
serializedTransaction: buildCreateAtaTransaction(
recipientPubkey,
tokenMint
),
})
}
// Then add the redemption transaction
const redeemTx = await buildSolanaRedeemTransaction(
sourceTxHash,
recipientPubkey
)
callDatas.push(redeemTx)
return callDatas
}The Calldata Regeneration Problem
Cross-chain operations have a fundamental timing problem. Between when you generate calldata and when it actually executes, the world changes: gas prices shift, token approvals may expire, nonces increment from other transactions, and Solana blockhashes expire after about 60 seconds.
The execution system classifies every error into one of two retry strategies:
type RetryStrategy = 'same_calldata' | 'regenerate' | 'abort'
function classifyBridgeError(error: unknown): RetryStrategy {
const msg = String(error).toLowerCase()
// Network issues: safe to retry the same calldata
if (msg.includes('timeout') || msg.includes('econnrefused'))
return 'same_calldata'
if (msg.includes('429') || msg.includes('rate limit')) return 'same_calldata'
// State changed: need fresh calldata from scratch
if (msg.includes('nonce too low') || msg.includes('replacement underpriced'))
return 'regenerate'
if (msg.includes('blockhash not found') || msg.includes('expired'))
return 'regenerate'
if (msg.includes('insufficient funds') || msg.includes('exceeds balance'))
return 'regenerate'
// Unrecoverable
if (msg.includes('invalid token') || msg.includes('unsupported'))
return 'abort'
return 'regenerate' // Default to regenerate if uncertain
}For Wormhole specifically, a "regenerate" retry means re-running the async generator with fresh parameters. The generator's output may differ from the first run because on-chain state has changed. An approval that was needed on the first attempt might not be needed on the second if it persisted, or vice versa.
Dual Lock Coordination
The execution system processes orders across multiple chains in parallel, but certain operations on the same chain must be serialized to prevent nonce conflicts. For bridges, this requires two types of locks.
type LockScope = 'execution' | 'claim'
interface ChainLock {
chain: string
scope: LockScope
holder: string
acquiredAt: number
ttlMs: number
}
class LockManager {
// Execution locks: short TTL, prevent parallel txs on same chain
async acquireExecutionLock(
chain: string,
holderId: string
): Promise<ChainLock | null> {
// Check for conflicting claim locks first
const existingClaim = await this.getActiveLock(chain, 'claim')
if (existingClaim) return null // Cannot execute while claiming
return this.tryAcquire(chain, 'execution', holderId, 120_000) // 2 min TTL
}
// Claim locks: longer TTL, prevent claims from racing with executions
async acquireClaimLock(
chain: string,
holderId: string
): Promise<ChainLock | null> {
const existingExec = await this.getActiveLock(chain, 'execution')
if (existingExec) return null // Cannot claim while executing
return this.tryAcquire(chain, 'claim', holderId, 300_000) // 5 min TTL
}
}Execution locks are short-lived and prevent two workers from submitting transactions to the same chain simultaneously. Claim locks are longer and prevent bridge redemption from conflicting with new orders on the destination chain. Crucially, a claim lock on Solana does not block Ethereum execution -- the locks are per-chain but the system is multi-chain.
Separating Quote from Execution
One hard-won lesson: never create a full TokenTransfer object during the quote phase. Early versions of the integration tried to get an exact quote by constructing the actual transfer, which had side effects -- it could consume nonces, cache stale state, and create race conditions if multiple quotes were requested in parallel.
The solution is to keep the quote phase lightweight and defer the real transfer construction to execution time:
async function getQuote(request: BridgeQuoteRequest): Promise<BridgeQuote> {
// Lightweight: use route resolver for estimates, do NOT create TokenTransfer
const transferRequest = await routes.RouteTransferRequest.create(wh, {
source: Wormhole.tokenId(request.sourceChain, request.sourceToken),
destination: Wormhole.tokenId(request.destChain, request.destToken),
})
const resolver = wh.resolver([routes.TokenBridgeRoute])
const foundRoutes = await resolver.findRoutes(transferRequest)
if (foundRoutes.length === 0) {
return { available: false, reason: 'No route found' }
}
// Estimate without creating actual transfer objects
return {
available: true,
estimatedAmount: request.amount, // Token Bridge is 1:1 minus relayer fee
estimatedTimeSeconds: 60,
route: foundRoutes[0].type,
}
}
// Only during execution: create the real transfer via async generator
async function execute(order: BridgeOrder): Promise<CallData[]> {
const tokenBridge = await wh.getChain(order.sourceChain).getTokenBridge()
const transfer = tokenBridge.transfer(/* fresh params */)
// ... consume generator as shown above
}The system now processes orders across seven chains daily, handling the full spectrum of swap, bridge, and transfer operations. The Wormhole integration was the hardest piece, but the patterns it forced me to develop -- generator-based calldata collection, phase-aware monitoring, and dual-scope locking -- made every subsequent bridge integration significantly easier to build.
Related Posts
Hard-won lessons from building and running automated trading bots on Solana. Covers architecture patterns, error handling, and the operational concerns nobody talks about.
Practical lessons from building DeFi bots on Solana. Covers the account model, transaction patterns, real-time monitoring via WebSocket, and production pitfalls that documentation does not warn you about.
How market regime detection, expected value calculations, and delta-based hedging transformed a simple DLMM rebalancer into a bot that knows when to sit still. Covers ATR, ADX, EMA indicators, the math behind profitable patience, and LP delta hedging on Drift.