Skip to main content

Command Palette

Search for a command to run...

BuildBear Tutorial: Cross-Chain Bridge

Updated
20 min read
BuildBear Tutorial:  Cross-Chain Bridge
Ϗ

♂️ he/him 🎇 C̶̣̑h̵͖͋â̷̟ö̵̪́t̸͉̑i̴̪͝c̸͙͂ ̸͖̍G̵̠̈́ö̸̳́o̴̳̕d̶̨̐ 📦 Cargo Cultist 🔄 Word Rotator 💞 Polyamorist

This is post was written by @0xJustUzair

BuildBear is a specialized platform for DApp development and testing. Developers can create a personalized private testnet sandbox for various EVM and EVM-compatible blockchain networks.

Key Features

  • Access a private faucet for unlimited minting of native and ERC-20 tokens.

  • Experience lightning-fast transactions, completing in under three seconds.

  • Debug transactions effortlessly within your sandbox using the built-in explorer and transaction tracer.

  • Leverage real-time testing and debugging tools, along with many other advanced features.

Sign in to the BuildBear Dashboard

Sign in using your GitHub, Google, or a Web3 wallet.

Once you're logged in, you'll see a page similar to the image below.

Create a Sandbox

Discover how to set up a sandbox on BuildBear for seamless DApp development and testing. Customize your environment, fork from supported blockchains, and access essential tools for debugging and verification.

Create Your BuildBear Sandbox

Click Create a Sandbox to start configuring your test blockchain.

Forking Options

This feature enables you to create a sandbox, forked from the state of any supported blockchain, at any block number, with a randomly generated chain ID.

You can also create a test blockchain without forking from an existing chain.

Advanced Options

When creating a Sandbox, you can configure additional options to customize your environment:

  • Deploy Large Contracts: Enable contracts larger than the default 24KB limit.

  • Unlocked Accounts: Access unlocked accounts for easier script execution.

  • Mnemonic: Generate a unique mnemonic phrase for your testnet.

  • Override Gas Estimation: Detect and override gas estimation failures.

  • Impersonation Transactions: Allow transactions to be sent from any address for testing purposes.

  • Snapshots: Take snapshots of the blockchain state for easy rollbacks.

  • Block Mining & Timestamp Manipulation: Manually mine blocks and adjust timestamps.

Configure these options based on your development needs to optimize testing and debugging.

Your RPC Is Ready for Use

After clicking Create, select Go to Dashboard to access your newly created sandbox.

On the Dashboard, you’ll find essential details and tools to manage your test environment efficiently.

Each sandbox ships with a funded mnemonic. To extract the first account’s key:

cast wallet address PRIVATE_KEY

Use the wallet address to obtain funds from your sandbox faucet.

Faucet

The BuildBear Faucet provides an easy way to acquire native and ERC-20 tokens for your testnet sandbox. Whether you’re testing transactions or deploying smart contracts, you'll need tokens for gas fees. Use the faucet to mint both native tokens and ERC-20 tokens to fund your development efforts.

Start by selecting the type of token you need (native or ERC-20), and you’ll receive the required tokens instantly. The faucet ensures a seamless experience for developers testing on BuildBear’s platform.

Get ERC20 Tokens

The BuildBear Faucet allows you to request testnet tokens for your blockchain projects, enabling smooth testing and development.

Add ERC-20 Tokens

Click Faucet in the left navigation pane.

Use the faucet to mint ERC-20 tokens for your testing needs.

The faucet provides a list of supported ERC-20 tokens. You can select the token you wish to mint from the list.

Get Native Tokens

The BuildBear Faucet allows you to request testnet tokens for your blockchain projects, enabling smooth testing and development.

Add Native Tokens or ERC-20 Tokens

Click Faucet in the left Navigation Pane.

To perform transactions on any sandbox, you’ll need native tokens for gas fees. Use the faucet to acquire them.

Plugins Installation

Install the required plugins from the BuildBear Plugin Marketplace.

Contracts Verification API

Verify your smart contracts and fetch verified contract artifacts via BuildBear’s unified verification endpoints.

Supported Verification Types

Foundry

  • Etherscan

  • Sourcify

Hardhat

  • Etherscan

  • Sourcify

Refer to the following doc to learn to Verify Contracts on BuildBear Sandboxes.

Docs - Contracts Verification API - BuildBear Labs

BuildBear Explorer

BuildBear Explorer provides an intuitive and powerful interface for monitoring blockchain transactions. Whether you’re debugging smart contracts or tracking transaction history, the explorer offers real-time insights and seamless navigation.

Key Features

  • Transaction Monitoring – View pending and confirmed transactions across multiple explorers (BuildBear, BlockScout, and Ethernal).

  • Detailed Transaction Insights – Access transaction details, including hash, block number, sender/receiver addresses, and gas usage.

  • Advanced Debugging Tools – Utilize Sentio Tracer, Sentio Debugger, and Simbolik Debugger for in-depth contract execution analysis.

  • External Explorer Support – Open transactions in third-party explorers directly from BuildBear.

Monitoring Transactions on BuildBear Explorer

Click Explorer in the left Navigation Pane.

A list of transactions will be displayed. You can filter the transactions by All, Pending, and Confirmed.

You can also expand the Explorer menu to select a default explorer from BuildBear, BlockScout, or Ethernal.

Click on a transaction to view its details. The transaction details page includes information such as the transaction hash, block number, sender and receiver addresses, etc.

Transaction Debugging

The Transaction Details page provides essential debugging tools, including Sentio Tracer, Sentio Debugger, and Simbolik Debugger, to analyze transaction execution.

At the top, next to View all Transactions, you’ll find a drop-down menu to open the current transaction’s details in an external explorer of your choice.


Building a Cross-Chain Bridge with Node Relayer on BuildBear

We will build a minimal, event-driven bridge that works end-to-end across two BuildBear Mainnet Sandboxes (Ethereum and Polygon).
Instead of mocks, we will attach real Chainlink ETH/USD feeds via the BuildBear Data Feeds plugin, verify contracts with Sourcify, and debug transactions with Sentio, all in BuildBear's Sandboxes.

What We Will Learn

  • Setting up two BuildBear Mainnet Sandboxes (ETH and Polygon)

  • Installing the Data Feeds plugin and attaching the WETH/USD feed on both Sandboxes

  • Preparing environment variables and Foundry configs

  • Deploying the Bridge with Foundry and verifying via Sourcify

  • Running a bi-directional relayer to detect and release assets on the destination chain

  • Interacting with the bridge (WETH ⇄ USDT) and inspecting results

  • End-to-End transaction debugging with Sentio and BuildBear explorer

Project Repository

You can either use the repository provided by us or create your own Foundry Project. Here, we will use the repository that we have provided.

GitHub - BuildBearLabs/tutorial-buildbear-datafeed-bridge: Build a minimal event-driven bridge (WETH ⇄ USDT) using Foundry and Node relayer, fully built in BuildBear Mainnet Sandboxes with the Chainlink DataFeed Plugin.

How it Works

The following snapshots provide a quick, visual understanding of the system before diving into the code. Add your rendered-mermaid images where indicated.

  1. High-level sequence

    1. The user approves and calls lockAndQuote on the source bridge.

    2. The bridge pulls the source token, reads WETH/USD from the data feed, computes the destination amount, and emits BridgeRequested.

    3. The relayer observes that event on the source chain and calls release on the destination bridge with the mapped token, amount, and nonce.

    4. The destination bridge checks the nonce, transfers tokens to the recipient, and emits TransferReleased.

    5. The destination side is pre-funded, and the relayer must be running for events to be processed.

  2. Contract internals

    1. Lock-and-Quote Functionality

      1. Take custody of the source token via safeTransferFrom.

      2. Read WETH/USD from the data feed and normalize for math.

      3. Handle decimals and calculate the destination amount.

      4. Emit BridgeRequested.

      5. Increment nonce.

    2. Release Functionality

      1. Enforce admin-only access.

      2. Ensure the external nonce is unused and mark it processed.

      3. Transfer tokens out to the recipient.

      4. Emit TransferReleased.

Directory and Project Structure

Create Sandboxes and Install Plugins

  1. Create two Mainnet Sandboxes:
  • Ethereum Mainnet

  • Polygon Mainnet

💡
Use the code DD-BB-BLOG to receive 1 month of free BuildBear Premium. Activate your premium access from here and start enjoying the benefits immediately.
  1. Install the following plugins on both Ethereum and Polygon Mainnet Sandboxes:
  • Installing and configuring the Chainlink Data Feed Plugin.

    • Install the "Chainlink Data Feed" Plugin from the Plugin Marketplace.

    • Search and Subscribe to the "ETH/USD" feed.

    • Once you have installed the Data-Feeds on both the Sandboxes, verify that your feed addresses match the addresses below:

      • Ethereum Mainnet (ETH/USD Feed): 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419

      • Polygon Mainnet (ETH/USD Feed): 0xF9680D99D6C9589e2a93a78A04A279e509205945

You can also visit Chainlink Data Feed Dashboard to see your subscribed feeds, and add/remove feeds.

Also, install Sentio, Simbolik & Sourcify Plugins from BuildBear Plugin Marketplace.

Simbolik relies on Sourcify-verified source code to debug contracts. It currently works best for unoptimized contracts as there can be limited debugging information and inconsistencies, with optimized builds. Support for optimized contracts is improving with Solidity’s debugging tooling.

Before running any transactions:

  • Go to your Sandbox's Plugins tab

  • Look for Sourcify, Simbolik, and Sentio plugins**.**

  • Click Install Plugin

Create a basic Foundry Project, and copy .env.example to .env and fill in values or use the env example below. We have some values pre-filled to save you some time sleuthing for addresses. These mirror Mainnet token addresses and price feed addresses that BuildBear exposes inside the Sandboxes after the plugins are installed and configured.

# RPC endpoints (your BuildBear Sandbox RPC URLs) 
ETH_MAINNET_SANDBOX=<YOUR_ETH_MAINNET_SANDBOX_URL> 
POL_MAINNET_SANDBOX=<YOUR_POLYGON_MAINNET_SANDBOX_URL> 

# Deployer/admin key & address 
PRIVATE_KEY= WALLET_ADDRESS= 

# Chainlink WETH/USD feeds (mainnet addresses) 
MAINNET_FEED_WETH_USD=0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419
POLYGON_FEED_WETH_USD=0xF9680D99D6C9589e2a93a78A04A279e509205945 

# Token addresses (mainnet addresses; mirrored in BuildBear Sandboxes) 
ETH_WETH_TOKEN=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 
ETH_USDT_TOKEN=0xdAC17F958D2ee523a2206206994597C13D831ec7 
POL_WETH_TOKEN=0x7ceb23fd6bc0add59e62ac25578270cff1b9f619 
POL_USDT_TOKEN=0xc2132D05D31c914a87C6611C10748AEb04B58e8F 

# EOA used to perform lockAndQuote 
RECEIVER_WALLET= 
RECEIVER_PRIVATE_KEY=

foundry.toml

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
# required to dynamically read and interact with latest bridge addresses
fs_permissions = [{ access = "read", path = "./broadcast/DeployBridge.s.sol/" }]
remappings = ['@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts']
[rpc_endpoints]
eth_mainnet_sandbox = "${ETH_MAINNET_SANDBOX}"
pol_mainnet_sandbox = "${POL_MAINNET_SANDBOX}"

We also need some npm packages for the relayer to work, refer to the package.json file or put the contents below in it:

{
  "scripts": {
    "start": "npm run start-relayer",
    "start-relayer": "tsx ./relayer/index.ts"
  },
  "dependencies": {
    "dotenv": "^16.3.1",
    "ethers": "^6.13.5",
    "permissionless": "^0.2.47",
    "viem": "^2.30.0"
  },
  "devDependencies": {
    "@types/node": "^20.11.10",
    "tsx": "^3.13.0"
  }
}

Need a new wallet keypair? Create one with cast

cast wallet new

Funding your Deployer & Receiver Wallet

Once you have configured your wallets, you will need to fund your wallets with enough funds to:

  • Cover deployment and interaction gas costs.

  • Fund the bridges while deployment from deployer to the Bridge

    • Requires deployer to hold more than 1000 WETH and 25000 USDT.
  • Fund the receiver's wallet with some WETH to allow interaction with Bridge.sol and bridge the assets.

Why do we need to pre-fund wallet-addresses?

The relayer's call to release funds depends on the destination bridge already holding the mapped token. Without initial liquidity, release would fail due to insufficient balance.


Creating Contracts & Scripts

Bridge.sol Contract

Roles and Storage

  • admin: deployer; the only permitted caller to release.

  • nonce: increments per request; included in BridgeRequested.

  • processedNonces: marks opposite-chain nonces as used to prevent re-release.

  • wethToken, usdtToken: ERC-20s handled on this chain.

  • wethUsdFeed: Chainlink WETH/USD aggregator.

Price & Quotes

  • _getWethPrice() reads Chainlink and normalizes the result to 1e8 decimals for stable math.

  • Pulls srcAmount in via safeTransferFrom.

  • Gets WETH/USD price once & computes dstAmount

  • Emits BridgeRequested(...) and increments nonce.

Assets Release Post-Bridging

  • Triggered by the relayer with onlyAdmin restriction and check for unused processedNonces[externalChainNonce], and marks it used.

  • Transfers amount of token to to.

  • Emits TransferReleased(...).

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

interface AggregatorV3Interface {
    function decimals() external view returns (uint8);
    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80);
}

contract Bridge {
    using SafeERC20 for IERC20;

    address public admin;
    uint256 public nonce;
    mapping(uint256 => bool) public processedNonces;

    address public wethToken;
    address public usdtToken;
    address public wethUsdFeed;

    event BridgeRequested( // still present, but will be filled by relayer mapping
        address indexed from,
        address indexed to,
        address srcToken,
        address dstToken,
        uint256 srcAmount,
        uint256 dstAmount,
        uint256 nonce,
        uint256 date
    );

    event TransferReleased(address indexed to, address token, uint256 amount, uint256 externalChainNonce, uint256 date);

    modifier onlyAdmin() {
        require(msg.sender == admin, "Not admin");
        _;
    }

    constructor(address _weth, address _usdt, address _wethUsdFeed) {
        admin = msg.sender;
        wethToken = _weth;
        usdtToken = _usdt;
        wethUsdFeed = _wethUsdFeed;
    }

    function _getWethPrice() internal view returns (uint256) {
        AggregatorV3Interface feed = AggregatorV3Interface(wethUsdFeed);
        (, int256 answer,,,) = feed.latestRoundData();
        require(answer > 0, "invalid answer");

        uint8 dec = feed.decimals();
        uint256 price = uint256(answer);

        if (dec > 8) price = price / (10 ** (dec - 8));
        else if (dec < 8) price = price * (10 ** (8 - dec));

        return price; // 1e8
    }

    function lockAndQuote(address srcFromToken, address srcToToken, uint256 srcAmount, address to) external {
        IERC20(srcFromToken).safeTransferFrom(msg.sender, address(this), srcAmount);

        uint256 dstAmount;

        uint256 wethPrice = _getWethPrice(); // 1e8

        if (srcFromToken == wethToken && srcToToken == usdtToken) {
            // WETH → USDT
            dstAmount = (srcAmount * wethPrice) / 1e8 / 1e12; // adjust 18→6 decimals
        } else if (srcFromToken == usdtToken && srcToToken == wethToken) {
            // USDT → WETH
            dstAmount = (srcAmount * 1e12 * 1e8) / wethPrice; // adjust 6→18 decimals
        } else {
            revert("unsupported token pair");
        }

        emit BridgeRequested(msg.sender, to, srcFromToken, srcToToken, srcAmount, dstAmount, nonce, block.timestamp);
        nonce++;
    }

    function release(address token, address to, uint256 amount, uint256 externalChainNonce) external onlyAdmin {
        require(!processedNonces[externalChainNonce], "already processed");
        processedNonces[externalChainNonce] = true;

        IERC20(token).safeTransfer(to, amount);

        emit TransferReleased(to, token, amount, externalChainNonce, block.timestamp);
    }
}

interface IERC20Metadata is IERC20 {
    function decimals() external returns (uint8 decimals);
}

Foundry Scripts & Their Usage

HelperConfig.s.sol

  • Defines a NetworkConfig struct that carries:

    • deployerKey (private key pulled from env),

    • wethToken, usdtToken,

    • wethUsdFeed,

    • deployer (EOA that will pre-fund the bridge).

  • Chooses activeNetworkConfig based on block.chainid:

    • 1 for the Ethereum Sandbox,

    • 137 for the Polygon Sandbox.

  • All addresses and keys are read, vm.env* so you configure once in .env.

When to reference it:

  • Deployment and interaction scripts are used HelperConfig to get chain-specific parameters without branching logic scattered across scripts.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Script} from "forge-std/Script.sol";

contract HelperConfig is Script {
    struct NetworkConfig {
        uint256 deployerKey;
        address wethToken;
        address usdtToken;
        address wethUsdFeed;
        address deployer;
    }

    NetworkConfig public activeNetworkConfig;

    constructor() {
        if (block.chainid == 1) {
            activeNetworkConfig = getEthMainnetConfig();
        } else if (block.chainid == 137) {
            activeNetworkConfig = getPolygonMainnetConfig();
        } else {
            revert("Unsupported network");
        }
    }

    function getEthMainnetConfig() public view returns (NetworkConfig memory) {
        return NetworkConfig({
            deployerKey: vm.envUint("PRIVATE_KEY"),
            wethToken: vm.envAddress("ETH_WETH_TOKEN"), // BuildBear faucet weth token
            usdtToken: vm.envAddress("ETH_USDT_TOKEN"), // BuildBear faucet USDT token
            wethUsdFeed: vm.envAddress("MAINNET_FEED_WETH_USD"),
            deployer: vm.envAddress("WALLET_ADDRESS")
        });
    }

    function getPolygonMainnetConfig() public view returns (NetworkConfig memory) {
        return NetworkConfig({
            deployerKey: vm.envUint("PRIVATE_KEY"),
            wethToken: vm.envAddress("POL_WETH_TOKEN"), // BuildBear faucet weth token
            usdtToken: vm.envAddress("POL_USDT_TOKEN"), // BuildBear faucet USDT token
            wethUsdFeed: vm.envAddress("POLYGON_FEED_WETH_USD"),
            deployer: vm.envAddress("WALLET_ADDRESS")
        });
    }
}

DeployBridge.s.sol

  • Starts a broadcast with deployerKey.

  • Deploys Bridge(wethToken, usdtToken, wethUsdFeed). The deployer becomes admin.

  • Pre-funding from deployer to the deployed bridge:

    • Requires deployer to hold more than 1000e18 WETH and 25_000e6 USDT.

    • Transfers a fixed seed amount to the bridge (1000e18 WETH and 25_000e6 USDT).

      • Lower or remove the require thresholds to demo with smaller balances.

      • Add more tokens or feeds by extending constructor arguments and storage.

Why the pre-funding check exists?

The relayer's call to release funds depends on the destination bridge already holding the mapped token. Without initial liquidity, release would fail due to insufficient balance.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Script} from "forge-std/Script.sol";
import {console2} from "forge-std/console2.sol";

import {HelperConfig} from "script/HelperConfig.s.sol";
import {Bridge, IERC20, SafeERC20} from "src/Bridge.sol";

contract DeployBridge is Script {
    using SafeERC20 for IERC20;

    function run() external {
        (uint256 deployerKey, address wethToken, address usdtToken, address wethUsdFeed, address deployer) =
            new HelperConfig().activeNetworkConfig();

        vm.startBroadcast(deployerKey);

        // Deploy bridge
        Bridge bridge = new Bridge(wethToken, usdtToken, wethUsdFeed);

        // Optionally pre-fund bridge with WETH + USDT liquidity from deployer
        uint256 wethBal = IERC20(wethToken).balanceOf(deployer);
        uint256 usdtBal = IERC20(usdtToken).balanceOf(deployer);
        require(wethBal > 1000e18, "Fund your account with atleast 1000 WETH tokens");
        require(usdtBal > 25000e6, "Fund your account with atleast 25000 USDT tokens");

        if (wethBal > 0) {
            // IERC20(wethToken).approve(address(bridge), 1000e18);
            IERC20(wethToken).safeTransfer(address(bridge), 1000e18);
        }
        if (usdtBal > 0) {
            // IERC20(usdtToken).approve(address(bridge), 25000e6);
            IERC20(usdtToken).safeTransfer(address(bridge), 25000e6);
        }

        vm.stopBroadcast();

        console2.log("Bridge deployed at:", address(bridge));
        console2.log("WETH/USD feed:", wethUsdFeed);
    }
}

InteractBridge.s.sol

  • Reads the deployed bridge address for the current block.chainid from broadcast/DeployBridge.s.sol/<chainId>/run-latest.json.

  • Loads wethToken and usdtToken from HelperConfig.

  • Uses RECEIVER_WALLET and RECEIVER_PRIVATE_KEY to send the tx.

  • Approves the source token, then calls lockAndQuote(wethToken, usdtToken, 1e18, receiver) to perform WETH → USDT on the current chain.

  • Emits BridgeRequested, which the relayer observes to call release on the opposite chain.

  • For USDT → WETH, swap the token params from before and use, 6-decimals (e.g., 1000e6).

  • Works on chain 1 and 137 since addresses come from HelperConfig and broadcast files at runtime.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Script} from "forge-std/Script.sol";
import {console2} from "forge-std/console2.sol";
import {Bridge, IERC20, SafeERC20} from "src/Bridge.sol";
import {HelperConfig} from "script/HelperConfig.s.sol";

contract InteractBridge is Script {
    using SafeERC20 for IERC20;

    function run() external {
        // Load JSON from broadcast folder (network-specific)
        string memory path = string.concat(
            vm.projectRoot(), "/broadcast/DeployBridge.s.sol/", vm.toString(block.chainid), "/run-latest.json"
        );
        string memory json = vm.readFile(path);

        // Parse contract address from broadcast
        address bridgeAddr = abi.decode(vm.parseJson(json, ".transactions[0].contractAddress"), (address));
        console2.log("Bridge found at:", bridgeAddr);

        Bridge bridge = Bridge(bridgeAddr);

        // Load network config
        (, address wethToken, address usdtToken,,) = new HelperConfig().activeNetworkConfig();

        // Wallet for interaction
        address receiver = vm.envAddress("RECEIVER_WALLET");
        uint256 receiverKey = vm.envUint("RECEIVER_PRIVATE_KEY");
        console2.log("EOA Interacting with Bridge:", receiver);

        // Interact: approve WETH and call lockAndQuote
        vm.startBroadcast(receiverKey);

        uint256 amount = 1e18; // 1 WETH
        IERC20(wethToken).approve(bridgeAddr, amount);

        bridge.lockAndQuote(wethToken, usdtToken, amount, receiver);

        vm.stopBroadcast();

        console2.log("lockAndQuote called with", amount, "WETH -> expecting USDT");
    }
}

How do these scripts work together?

  1. Deploy the bridge on both Sandboxes. Each bridge has its own admin and token/feed wiring.

  2. Pre-fund both bridges with the destination tokens you expect to release.

  3. Start the relayer. It subscribes to BridgeRequested on both chains, maps tokens for the opposite chain, and calls release as the admin there.

  4. Run the interaction script on the source chain. It locks tokens and emits the event. The relayer fulfills on the destination chain.

This separation keeps on-chain logic minimal and pushes cross-chain coordination into the relayer, which is appropriate for an educational, event-driven demo.

Relayer

  • Purpose: watch BridgeRequested on the source chain and call release on the destination chain as the bridge admin.

  • Setup: loads bridge addresses from Foundry broadcasts, creates JSON-RPC providers, and builds a signer on the destination chain using PRIVATE_KEY.

  • Flow: poll logs for BridgeRequested, parse {to, dstToken, dstAmount, nonce}, map dstToken via TOKEN_MAP, then send release(mappedDstToken, to, dstAmount, nonce) on the destination bridge.

  • Safety: destination bridge enforces one-time processing with processedNonces[nonce] to prevent replay.

  • Requirements: destination bridge must hold sufficient token liquidity; the signer must be the bridge admin.

  • Scope: the shown script is one-directional (ETH to POL). To support both directions, add a second poller for Polygon and call release on Ethereum with an ETH-side admin signer.

  • Limitation: processes only events observed while running; no backfill of past events.

import fs from "fs";
import path from "path";
import { ethers } from "ethers";
import BridgeAbi from "../out/Bridge.sol/Bridge.json";
import "dotenv/config";

function getLatestDeployment(chainId: number) {
  const filePath = path.join(
    __dirname,
    `../broadcast/DeployBridge.s.sol/${chainId}/run-latest.json`
  );
  const raw = fs.readFileSync(filePath, "utf-8");
  const json = JSON.parse(raw);
  const tx = json.transactions.find((t: any) => t.transactionType === "CREATE");
  if (!tx) throw new Error("No CREATE transaction in broadcast file");
  return tx.contractAddress;
}

const ETH_RPC = process.env.ETH_MAINNET_SANDBOX!;
const POL_RPC = process.env.POL_MAINNET_SANDBOX!;
const PK = process.env.PRIVATE_KEY!;

// cross-chain token mapping
const TOKEN_MAP: Record<string, string> = {
  [process.env.ETH_USDT_TOKEN!]: process.env.POL_USDT_TOKEN!,
  [process.env.ETH_WETH_TOKEN!]: process.env.POL_WETH_TOKEN!,
  [process.env.POL_USDT_TOKEN!]: process.env.ETH_USDT_TOKEN!,
  [process.env.POL_WETH_TOKEN!]: process.env.ETH_WETH_TOKEN!,
};

async function main() {
  const ethProvider = new ethers.JsonRpcProvider(ETH_RPC);
  const polProvider = new ethers.JsonRpcProvider(POL_RPC);
  const wallet = new ethers.Wallet(PK, polProvider);

  const ethBridgeAddr = getLatestDeployment(1);
  const polBridgeAddr = getLatestDeployment(137);

  console.log("ETH Bridge:", ethBridgeAddr);
  console.log("POL Bridge:", polBridgeAddr);

  const ethBridge = new ethers.Contract(
    ethBridgeAddr,
    BridgeAbi.abi,
    ethProvider
  );
  const polBridge = new ethers.Contract(polBridgeAddr, BridgeAbi.abi, wallet);

  const bridgeRequestedTopic = ethers.id(
    "BridgeRequested(address,address,address,address,uint256,uint256,uint256,uint256)"
  );

  let lastProcessed = await ethProvider.getBlockNumber();
  console.log("Relayer started. Watching new events...");

  setInterval(async () => {
    try {
      const latestBlock = await ethProvider.getBlockNumber();
      if (latestBlock <= lastProcessed) return;

      const logs = await ethProvider.getLogs({
        fromBlock: lastProcessed + 1,
        toBlock: latestBlock,
        address: ethBridgeAddr,
        topics: [bridgeRequestedTopic],
      });

      for (const log of logs) {
        const parsed = ethBridge.interface.parseLog(log);
        let { from, to, srcToken, dstToken, srcAmount, dstAmount, nonce } =
          parsed.args;

        console.log("Detected BridgeRequested:");
        console.log({
          from,
          to,
          srcToken,
          dstToken,
          srcAmount,
          dstAmount,
          nonce,
        });

        // map token for destination chain
        const mappedDstToken = TOKEN_MAP[dstToken] || dstToken;

        try {
          const tx = await polBridge.release(
            mappedDstToken,
            to,
            dstAmount,
            nonce
          );
          console.log("Release tx sent:", tx.hash);
          await tx.wait();
          console.log("Release confirmed.");
        } catch (err) {
          console.error("Release failed:", err);
        }
      }

      lastProcessed = latestBlock;
    } catch (err) {
      console.error("Polling error:", err);
    }
  }, 10_000);
}

main().catch(err => {
  console.error(err);
  process.exit(1);
});

Configuring Makefile

Now with the project set up, we can configure commands in a Makefile to deploy, verify and interact with bridges on different sandboxes.

Put this Makefile at the project root. It installs dependencies, deploys with Sourcify verification on both Sandboxes, and provides interaction targets. It basically takes away the overhead of writing commands again and again, working with complex commands and replacing them with simpler ones.

Remember to replace the placeholders in verifier-urls below with your actual BuildBear Sandbox ID

.PHONY: make-deploy install deploy-mainnet-sourcify deploy-pol-sourcify interact-mainnet-bridge interact-pol-bridge

install:
    forge install && npm i && forge build


deploy-mainnet-sourcify:
    forge script script/DeployBridge.s.sol \
     --rpc-url eth_mainnet_sandbox \
     --verifier sourcify \
     --verify \
     --verifier-url https://rpc.buildbear.io/verify/sourcify/server/<eth-mainnet-Sandbox-id> \
     --broadcast \


deploy-pol-sourcify:
    forge script script/DeployBridge.s.sol \
     --rpc-url pol_mainnet_sandbox \
     --verifier sourcify \
     --verify \
     --verifier-url https://rpc.buildbear.io/verify/sourcify/server/<polygon-mainnet-Sandbox-id>  \
     --broadcast \


interact-mainnet-bridge:
    forge script script/InteractBridge.s.sol \
     --rpc-url eth_mainnet_sandbox \
     --broadcast \


interact-pol-bridge:
    forge script script/InteractBridge.s.sol \
     --rpc-url eth_mainnet_sandbox \
     --broadcast \

How it is set up:

  • install installs Foundry dependencies, Node packages, and builds contracts.

  • deploy-mainnet-sourcify deploys to the ETH Sandbox and verifies via the Sourcify plugin endpoint.

  • deploy-pol-sourcify deploys to the Polygon Sandbox and verifies via the corresponding Sourcify plugin endpoint.

  • interact-mainnet-bridge runs your interaction script against the ETH Sandbox to bridge assets.

  • interact-pol-bridge runs your interaction script against the Polygon Sandbox to bridge assets.


Install and Build

make install

This installs Foundry dependencies into lib, installs Node dependencies for the relayer, and compiles the contracts.


Deploy and Verify

Deploy both bridges:

make deploy-mainnet-sourcify deploy-pol-sourcify

Your Foundry broadcast files will include the deployed addresses. Sourcify verification will be available in the Sandbox explorer.

Pre-funding note from your deploy script:

Ensure the deployer wallet has sufficient WETH and USDT in both Sandboxes for the seed transfers. Adjust or remove the require checks inside the script if you want smaller demo amounts.


Start the Relayer

Remember to install the dependencies as mentioned before, and run the relayer:

npm start

This relayer processes only those events that occur while it is running. Backfill isn't supported, by the script for the purpose of simplicity and ease of implementation


Interact and Bridge the Assets

ETH to Polygon Bridging:

Remember to start the relayer before you call the Interact-Bridge Script on either of the Sandboxes.

While the relayer is running, in another terminal, execute an interaction with the bridge

# This will emit a bridge request from ETH ---> Polygon for WETH to USDT
make interact-mainnet-bridge

Script Broadcast

Tx on BuildBear Explorer

Your interaction script reads the last deployment address from Foundry broadcasts, approves the source token, and calls lockAndQuote. The relayer observes the BridgeRequested event on the source chain and calls release on the destination bridge.

  • In the output below, the relayer captured the BridgeRequested(...) event and released the asset on the Polygon Mainnet Sandbox

  • Asset release tx on Polygon Mainnet Sandbox


Transaction Debugging with the Sentio Plugin

This section is optional and shows how to inspect an end-to-end transfer using the explorer and Sentio. For the Sentio Debugger to work, you will need to install the Sentio Plugin from BuildBear Plugin Marketplace

Source: Bridging Tx

Open the source Sandbox explorer.

Find the tx hash for the interaction with lockAndQuote, on the bridge contract.

Click on View Trace on Sentio.

Fund Flow

A visual map of token movement for the source transaction. Use it to confirm transferFrom into the bridge and any intermediate ERC20 flows.

Call Trace

A step-by-step execution trace. Helpful to see the call into lockAndQuote, the oracle read, and the event emission order.

Call Graph

Graph view of calls across contracts during the source transaction. Useful for understanding the sequence and flow of internal calls.

Events Tab

Structured list of emitted events. Verify BridgeRequested fields such as srcToken, dstToken, dstAmount, and nonce.

State Tab

Storage and variable snapshots at key points. Useful for checking nonce increments and any flags written during the call.


Destination: Release Tx

  • Open the destination Sandbox Explorer.

  • Find the relayer release transaction on the bridge contract that transfers assets from the bridge to the receiver.

  • Click on View Trace on Sentio.

Fund Flow

Shows token movement out of the bridge to the receiver. Use it to confirm the final ERC20 transfer with the expected amount.

Call Trace

Execution path for the release call by the relayer. Check the nonce processing and the order of effects before the transfer.

Call Graph

Graph view of the destination call sequence during release. Helps verify checks, effects, interactions, ordering, and the final token transfer.

Events Tab

Verify TransferReleased with the same external nonce used on the source chain, along with token and amount fields.

State Tab

Inspect processedNonces and any relevant balances after the transfer to ensure idempotence and correct accounting.

That’s it. You now have a minimal, event-driven cross-chain bridge running end-to-end in BuildBear Mainnet Sandboxes. You can extend this demo by adding multi-sig relayers, accounting, fees, slippage controls, and stronger verification models.


You have now set up a minimal, event-driven bridge in BuildBear that uses BuildBear’s Data Feeds Plugin for price quoting, verifies via Sourcify Plugin, and can be traced and debugged in Sentio Plugin, all in BuildBear's Sandbox environment.

More from this blog

D

Developer DAO Blog | Web3 Tutorials

200 posts

The Developer DAO blog is an entirely Member-driven publication. We publish technical tutorials to help readers learn how to become a web3 developer, Member stories and the occasional opinion piece.