Documentation

Build a Cross-Chain Bridge with BuildBear Mainnet Sandboxes & Data Feeds

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

Introduction

In this tutorial 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 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 provided by us.

Directory and Project Structure

Bridge.sol
HelperConfig.s.sol
DeployBridge.s.sol
InteractBridge.s.sol
index.ts
package.json
Makefile
foundry.toml
.env
.env.example

How it Works (Mermaid Diagrams)

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

  1. High-level sequence overview-diagram

    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 contract-internals-diagram

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

Step 1: Create Sandboxes and Install Plugins

  1. Create two Mainnet Sandboxes:
  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. Datafeed - Plugin Marketplace
    • Search and Subscribe to the "ETH/USD" feed. Datafeed - Plugin Marketplace
    • 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.

  • Installing the Sourcify Plugin Sourcify - Plugin Marketplace
  • Optionally, Install Sentio Plugin for Debugging and Tx Tracing Sentio - Plugin Marketplace

Step 2: Environment Variables & Wallet Setup

Copy .env.example to .env and fill in values. 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 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.


Step 3: Creating Contracts & Scripts

3.1 Bridge.sol Contract

Roles and Storage

  • admin: deployer; the only caller allowed 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 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);
}

3.2 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 with vm.env* so you configure once in .env.

When to reference it:

  • Deployment and interaction scripts use 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

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 these script 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.

3.3 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);
});

Step 4: Configuring Makefile

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.

Step 5: Install and Build

make install

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


Step 6: 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.

Verified Bridge

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.

Step 7: Start the Relayer

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

npm start

Relayer Started

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


Step 8: 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 a interaction with the bridge

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

Script Broadcast Eth to Pol Interaction Tx on BuildBear Explorer Eth to Pol Interaction

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, relayer captured the BridgeRequested(...) event and released the asset on Polygon Mainnet Sandbox relayer-event-capture
  • Asset release tx on Polygon Mainnet Sandbox release-assets

Step 9: Transaction Debugging with Sentio Plugin (Optional)

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. Eth to Pol Interaction

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. Fund Flow

Call Trace

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

Call Graph

Graph view of calls across contracts during the source transaction. Useful to understand the sequence and fan out of internal calls. Call Graph

Events Tab

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

State Tab

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

Destination: Release Tx

  • Open the destination Sandbox explorer.
  • Find the relayer release transaction on the bridge contract, that transfers assets from Bridge to the receiver. release-assets
  • 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. Fund Flow

Call Trace

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

Call Graph

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

Events Tab

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

State Tab

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

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.


Conclusion

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