0xJosee.
HomeAboutProjectsBlogContact
0xJosee.

Full-stack developer specializing in Solana DeFi, blockchain engineering, and Web3 application development.

Navigation

HomeAboutProjectsBlogContact

Connect

© 2026 0xJosee. All rights reserved.
DevelopmentJuly 1, 20257 min read

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.

WormholeCross-ChainTypeScriptSolanaEVMArchitecture

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 signers

The 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.

On this page

  • Setting Up the Wormhole SDK
  • The Async Generator Problem
  • Normalizing EVM vs Solana Transactions
  • Three Phases of a Bridge
  • Generating Claim Transactions
  • The Calldata Regeneration Problem
  • Dual Lock Coordination
  • Separating Quote from Execution
Share:

Previous

Volatility-Based LP Ranges: Fisher Transform for Concentrated Liquidity

Related Posts

Trading Bots
Building Trading Bots in TypeScript: Lessons from Production

Hard-won lessons from building and running automated trading bots on Solana. Covers architecture patterns, error handling, and the operational concerns nobody talks about.

April 20, 2025
TypeScriptTradingSolana
DeFi & Solana
Getting Started with Solana DeFi: A Developer's Perspective

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.

April 1, 2025
SolanaDeFiTypeScript
Trading Bots
When NOT to Rebalance: Regime Detection and EV-Based LP Decisions

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.

June 15, 2025
SolanaMeteoraDrift