DEV Community

Cover image for How to Build a Cross-Chain Token Bridge
Sumana
Sumana

Posted on

2 2 2

How to Build a Cross-Chain Token Bridge

๐Ÿงญ BNB Smart Chain Testnet โ†” Polygon Amoy

Introduction

Cross-chain bridges are essential infrastructure in the evolving blockchain ecosystem. They enable tokens and data to move across independent networks โ€” allowing true interoperability in the Web3 world.

In this blog, Iโ€™ll break down how to build a cross-chain token bridge between the BNB Smart Chain Testnet and Polygon Amoy Testnet. The project uses Solidity smart contracts, a WebSocket-based event listener, a transaction queue with nonce tracking, and a frontend for users to interact with the bridge.


โš™๏ธ Smart Contracts: Token Locking & Releasing

The bridge uses two smart contracts โ€” one on each chain โ€” to manage token custody and emit events during transfers.

Key Features

  • Token Locking: Locks tokens on the source chain when a transfer is initiated.
  • Token Releasing: Releases tokens on the destination chain after verification.
  • Nonce Verification: Prevents replay attacks by tracking unique nonces per transaction.
  • Token Whitelisting: Only pre-approved tokens can be bridged.
  • Amount Limits: Defines minimum and maximum bridge amounts.
  • Access Control: Only the bridge owner can execute the release function.

๐Ÿ” Code Snippet


function bridge(IERC20 _tokenAddress, uint256 _amount) public {
        // Validate token and amount
        if (!whitelistedTokens[_tokenAddress]) revert BridgeContract__Token_Not_Whitelisted();
        if (_amount > maxAmounts[_tokenAddress] && maxAmounts[_tokenAddress] > 0) 
            revert BridgeContract__Amount_Too_Large();
        if (_amount < minAmounts[_tokenAddress] && minAmounts[_tokenAddress] > 0) 
            revert BridgeContract__Amount_Too_Small();

        // Check allowance
        if (_tokenAddress.allowance(_msgSender(), address(this)) < _amount) 
            revert BridgeContract__Insufficient_Allowance();

        // Transfer tokens from sender to bridge
        bool success = _tokenAddress.transferFrom(_msgSender(), address(this), _amount);
        if (!success) revert BridgeContract__Transaction_Failed();

        // Emit bridge event
        emit Bridge(_tokenAddress, _amount, _msgSender());
    }

Enter fullscreen mode Exit fullscreen mode

(https://github.com/sumana10/Bridge-Contract/blob/main/contract/src/BridgeContract.sol)


๐Ÿ“ก WebSocket Listener: Real-Time Blockchain Monitoring

Instead of polling blocks, the system uses WebSocket connections to listen for TokenLocked events in real time from both chains.

Key Features

  • Dual Network Monitoring: Monitors both BNB and Polygon chains concurrently.
  • Event-Based Architecture: Processes transactions as soon as they're emitted.
  • Block Tracking: Records the last processed block to prevent missing events after reconnection.
  • Historical Event Replay: On restart, reprocesses missed events from the last saved block.

๐Ÿ” Code Snippet


  const bridgeListener = (tokenAddress, amount, sender, event) => {
      console.log(`Bridge event detected on ${network}: Token ${tokenAddress} Amount ${amount}`);

      const txhash = event.log.transactionHash.toLowerCase();

      console.log(`Adding job to queue for new event: ${txhash}`);

      bridgeQueue.add({
        txhash,
        tokenAddress: tokenAddress.toString(),
        amount: amount.toString(),
        sender: sender.toString(),
        network,
      }).catch(error => {
        console.error(`Error adding job to queue:`, error);
      });

      prisma.networkStatus.update({
        where: { network },
        data: { lastProcessedBlock: event.log.blockNumber },
      }).catch(error => {
        console.error(`Error updating last processed block:`, error);
      });
    };

Enter fullscreen mode Exit fullscreen mode

(https://github.com/sumana10/Bridge-Contract/blob/main/indexer/src/index.ts)


๐Ÿงต Bridge Queue: Ordered Transaction Processing

The queue service ensures that events are processed sequentially, securely, and only once.

Key Features

  • Nonce Management: Prevents duplicate or replayed transactions.
  • Sequential Execution: Guarantees events are processed in order.
  • Transaction Validation: Verifies if a transaction has already been handled.
  • Gas Optimization: Dynamically adjusts gas based on current network conditions.

๐Ÿ” Code Snippet


bridgeQueue.process(async (job) => {
  const { txhash, tokenAddress, amount, sender, network } = job.data;
  console.log(`Processing job for txhash ${txhash} on ${network}`);

  try {
    console.log(`Checking if transaction ${txhash} exists in database`);
    let transaction = await prisma.transactionData.findUnique({
      where: { txHash: txhash },
    });

    if (!transaction) {
      console.log(`Transaction ${txhash} not found, creating new record`);

      console.log(`Getting nonce for ${network}`);
      const nonceRecord = await prisma.nonce.upsert({
        where: { network },
        update: { nonce: { increment: 1 } },
        create: { network, nonce: 1 },
      });

      console.log(`Using nonce ${nonceRecord.nonce} for ${network}`);

      transaction = await prisma.transactionData.create({
        data: {
          txHash: txhash,
          tokenAddress,
          amount,
          sender,
          network,
          isDone: false,
          nonce: nonceRecord.nonce,
        },
      });

      console.log(`Created transaction record for ${txhash}`);
    } else {
      console.log(`Transaction ${txhash} already exists in database`);
    }

    if (transaction.isDone) {
      console.log(`Transaction ${txhash} already processed, skipping`);
      return { success: true, message: "Transaction already processed" };
    }

    console.log(`Executing transfer for transaction ${txhash}`);
    await transferToken(network === "BNB", amount, sender, transaction.nonce);

    console.log(`Transfer completed, updating transaction status for ${txhash}`);
    await prisma.transactionData.update({
      where: { txHash: txhash },
      data: { isDone: true },
    });

    console.log(`Transaction ${txhash} marked as done`);
    return { success: true };
  } catch (error) {
    console.error(`Error processing job for txhash ${txhash}:`, error);
    throw error;
  }
});

Enter fullscreen mode Exit fullscreen mode

๐Ÿ—ƒ๏ธ PostgreSQL Database: State Persistence

The bridge uses PostgreSQL to store event history, transaction data, and the current state of each chain.

Key Tables

  • TransactionData: Stores hash, amount, token, sender, chain, and nonce.
  • NetworkStatus: Tracks the last processed block per chain.
  • Nonce: Ensures nonce uniqueness per network.

๐Ÿ–ผ๏ธ Frontend: Bridge UI for Users

The frontend allows users to connect their wallets, select a token, approve it, and initiate cross-chain transfers.

Key Features

  • Network Detection: Auto-detects the connected chain.
  • Token Approval Flow: Prompts users to approve tokens if allowance is insufficient.
  • Transaction Submission: Initiates the lock transaction on the source chain.
  • Real-Time Feedback: Shows transaction progress and final status.

Bridge Architecture

๐Ÿ”’ Security Features

Security is baked into every part of the system:

  • Replay Protection via nonce verification
  • Whitelisted Tokens Only to prevent malicious tokens
  • Minimum/Maximum Amount Limits
  • Owner-Only Release Logic
  • Reliable Event Tracking across node restarts
  • Gas Adjustment based on network conditions

๐Ÿ“ฝ๏ธ Demo and Repository

Youโ€™ll find setup instructions, deployment scripts, and detailed documentation in the repo.


๐Ÿง  Conclusion

This cross-chain bridge project demonstrates a clean, modular approach to transferring tokens between EVM-compatible blockchains. By combining smart contracts, event-driven services, a transaction queue, and a PostgreSQL database, the system achieves reliable, end-to-end cross-chain communication.

You can extend this project to support additional chains, add NFT bridging, or even decentralize the bridge using a validator network or oracle integration.

Top comments (0)