BuildBear Tutorial: Cross-Chain Bridge

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.
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.
High-level sequence

The user approves and calls
lockAndQuoteon the source bridge.The bridge pulls the source token, reads WETH/USD from the data feed, computes the destination amount, and emits
BridgeRequested.The relayer observes that event on the source chain and calls
releaseon the destination bridge with the mapped token, amount, and nonce.The destination bridge checks the nonce, transfers tokens to the recipient, and emits
TransferReleased.The destination side is pre-funded, and the relayer must be running for events to be processed.
Contract internals

Lock-and-QuoteFunctionalityTake custody of the source token via
safeTransferFrom.Read WETH/USD from the data feed and normalize for math.
Handle decimals and calculate the destination amount.
Emit
BridgeRequested.Increment
nonce.
ReleaseFunctionalityEnforce admin-only access.
Ensure the external nonce is unused and mark it processed.
Transfer tokens out to the recipient.
Emit
TransferReleased.
Directory and Project Structure

Create Sandboxes and Install Plugins
- Create two Mainnet Sandboxes:
Ethereum Mainnet
Polygon Mainnet
DD-BB-BLOG to receive 1 month of free BuildBear Premium. Activate your premium access from here and start enjoying the benefits immediately.- 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):
0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419Polygon 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


Environment Variables & Wallet Setup
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
deployerto the Bridge- Requires
deployerto hold more than1000WETH and25000USDT.
- Requires
Fund the receiver's wallet with some
WETHto 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,
releasewould fail due to insufficient balance.
Creating Contracts & Scripts
Bridge.sol Contract
Roles and Storage
admin: deployer; the only permitted caller torelease.nonce: increments per request; included inBridgeRequested.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 to1e8decimals for stable math.Pulls
srcAmountin viasafeTransferFrom.Gets WETH/USD price once & computes
dstAmountEmits
BridgeRequested(...)and incrementsnonce.
Assets Release Post-Bridging
Triggered by the relayer with
onlyAdminrestriction and check for unusedprocessedNonces[externalChainNonce], and marks it used.Transfers
amountoftokentoto.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
NetworkConfigstruct that carries:deployerKey(private key pulled from env),wethToken,usdtToken,wethUsdFeed,deployer(EOA that will pre-fund the bridge).
Chooses
activeNetworkConfigbased onblock.chainid:1for the Ethereum Sandbox,137for 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
HelperConfigto 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 becomesadmin.Pre-funding from
deployerto the deployed bridge:Requires
deployerto hold more than1000e18WETH and25_000e6USDT.Transfers a fixed seed amount to the bridge (
1000e18WETH and25_000e6USDT).Lower or remove the
requirethresholds 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,
releasewould 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.chainidfrombroadcast/DeployBridge.s.sol/<chainId>/run-latest.json.Loads
wethTokenandusdtTokenfromHelperConfig.Uses
RECEIVER_WALLETandRECEIVER_PRIVATE_KEYto 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 callreleaseon 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
HelperConfigand 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?
Deploy the bridge on both Sandboxes. Each bridge has its own admin and token/feed wiring.
Pre-fund both bridges with the destination tokens you expect to release.
Start the relayer. It subscribes to
BridgeRequestedon both chains, maps tokens for the opposite chain, and callsreleaseas the admin there.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: watchBridgeRequestedon the source chain and callreleaseon 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 usingPRIVATE_KEY.Flow: poll logs forBridgeRequested, parse{to, dstToken, dstAmount, nonce}, mapdstTokenviaTOKEN_MAP, then sendrelease(mappedDstToken, to, dstAmount, nonce)on the destination bridge.Safety: destination bridge enforces one-time processing withprocessedNonces[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 callreleaseon 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:
installinstalls Foundry dependencies, Node packages, and builds contracts.deploy-mainnet-sourcifydeploys to the ETH Sandbox and verifies via the Sourcify plugin endpoint.deploy-pol-sourcifydeploys to the Polygon Sandbox and verifies via the corresponding Sourcify plugin endpoint.interact-mainnet-bridgeruns your interaction script against the ETH Sandbox to bridge assets.interact-pol-bridgeruns 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
releasetransaction on the bridge contract that transfers assets from the bridge to thereceiver.
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.






