Minimal unidirectional lock-and-mint ERC20 bridge from Base Sepolia to Arbitrum One Sepolia.
A user approves and locks an ERC20 token (using
TKNX in this
demo) into the BridgeLocker contract on chain A, which emits a
BridgeLocker__TokensLocked event.
The relayer service polls chain A for these events and, once a lock is sufficiently confirmed (using 5 confirmations for demo purposes), signs an EIP-712 attestation over a Payload that encodes a nonce (to prevent replay attacks), token, amount, recipient, and both chain IDs. The signed attestation is then retrievable by the frontend via the relayer's REST API.
On the frontend, after waiting for the required number of confirmations, the client fetches the attestation and submits it to the BridgeMinter contract on chain B, which verifies the EIP-712 signature, deploys a wrapped/bridged token contract (using bTKNX in this demo) via BridgedTokenFactory if one does not exist yet, and mints the equivalent amount to the recipient.
A rate-limited Faucet is included in the demo so that anyone can test the app.
- Retrying from failed steps during bridging
- Better error messages from common transaction errors
- Automatically resume recent pending transfers
- Wrapped token deployment is done via
CREATE2, which allows predicting the bridged token address of any token - Relayer has no persistence but if it gets polled with an unknown attestation nonce, it will query it on-chain and compute the attestation (fallback mode)
- Pausing capabilities on the contracts allowing for emergency bridge pause
- Original token metadata (name, symbol, decimals) included in the signed payload so BridgeMinter can deploy the wrapped token on first use without any prior setup on chain B
Extra features already supported by the protocol (contracts and relayer service) but cut from the UI to keep it minimal and in scope:
- Supports custom recipient
mintis permissionless so any user can pay for it
- Supports any ERC20 to be bridged
- Rejects tokens with non-standard transfers, e.g. fee-on-transfer tokens, as the locker must receive the exact transfer amount to ensure 1:1 backing on the destination chain
- Creation of the bridged token contract happens automatically during the first transfer
- Single trusted relayer: simplest to build and operate, the tradeoff is it's a fully trusted intermediary. A compromised relayer key can both censor transfers and forge attestations, minting unbacked wrapped tokens on chain B.
- One-directional only (lock + mint, no burn + release): halves the contract surface and eliminates a reverse attestation flow. Tradeoff: tokens locked on chain A cannot be returned.
- In-memory relayer state: no DB to set up, fast to build. Tradeoff: starts tracking from the block
at startup, the fallback
lookupAndSignhandles older nonces by scanning from block 0, but that's an unbounded RPC call that can hit rate limits under load.
Primarily, integrate a database in the relayer service, listening to lock and mint events,
generating and storing the attestation/signature as locks come in. Progress would be tracked via a
lastCheckedBlock to ensure the relayer wouldn't miss any events due to downtime. That would
improve the DApp in several aspects:
- Currently the relayer relies on querying the blockchain if it doesn't have an attestation in memory, which could rate limit the RPC when polled frequently, causing a DoS. The new method would solve it since all attestation requests would result in a read from a database and not a single RPC call.
- It would make it trivial to add a list of past transactions for the user, allowing them to resume any past transfer (as opposed to the current limitation of having to check only pending transfers from the past few minutes).
The bridge contracts having pausing functionality with a separate pauser would allow for a watcher service that would monitor the bridge state and if it detected any anomalies, such as minting amounts without having them locked (e.g. via compromised relayer), emergency pause the contracts, which would then be investigated and resumed manually by the owner.
Bridged token deployment could be made cheaper by using Clones.
This project has a monorepo format, consisting of a Bun workspace and a Foundry project. It is recommended to run all commands from the root directory.
It has the following structure:
.
├── broadcast - Transaction logs from the contract deployments
├── contracts - Contracts source code
├── client - The client [Bun package]
├── server - The relayer service [Bun package]
└── shared - Shared code between client and server [Bun package]
And the following tech stack:
- Contracts: Foundry
- Client: Vite React app, using wagmi and viem for contracts/wallet integration and TailwindCSS and Shadcn for the UI
- Server: Bun HTTP server, using viem for on-chain interactions
To get started, first install Bun (recommended version 1.3.14) and Foundry (recommended version
1.7.1).
Important
In order to run the DApp with the existing deployments, you will need the relayer private key which is not included in this repository (see server/.env.example).
For that reason, it is recommended to create new deployments, updating all of the addresses accordingly in the deploy scripts, in the shared package and in the server .env file.
Install dependencies:
bun installCreate the .env files from the examples (Foundry env lives in the root directory) and tweak values
as needed:
cp .env.example .env
cp client/.env.example client/.env
cp server/.env.example server/.envStart the relayer:
bun run server:startAnd finally start the client:
bun run client:devContracts are fully unit tested. To build:
forge buildTo run tests:
forge testTo see the test coverage:
forge coverageTo deploy, refer to Deployment procedure.
- EOAs
- Owner:
0xbeef8A79FF64C0B70e42a2D2daa7Cf8D414db444 - Relayer:
0x9E5B0792bcE79caa545BFD7f93ef82B9BA3730fC
- Owner:
- Contracts
- Base Sepolia
- Faucet:
0xc570f5577B509A287ea78f3cF0e8E18e87A1e00D - BridgeLocker:
0x89E39Cb6449A3853Ed78c1715081635B2559E4Ba - TokenX (TKNX):
0xCFA134AC648A11904A35c4B22C95952c2C6574D6
- Faucet:
- Arbitrum Sepolia
- BridgedTokenFactory:
0xc570f5577B509A287ea78f3cF0e8E18e87A1e00D - BridgeMinter:
0xCFA134AC648A11904A35c4B22C95952c2C6574D6 - Bridged TokenX (bTKNX):
0x64110ef605f0353c4aABb2803EEF09408Dca2595
- BridgedTokenFactory:
- Base Sepolia
Run the deploy scripts in order (updating the required variables inside them):
forge script contracts/script/01_DeployFaucet.s.sol:DeployFaucet \
--rpc-url base_sepolia \
--account beef-dev \
--sender 0xbeef8A79FF64C0B70e42a2D2daa7Cf8D414db444 \
--broadcast
# == Logs ==
# Faucet deployed at: 0xc570f5577B509A287ea78f3cF0e8E18e87A1e00Dforge script contracts/script/02_DeployMockERC20.s.sol:DeployMockERC20 \
--rpc-url base_sepolia \
--account beef-dev \
--sender 0xbeef8A79FF64C0B70e42a2D2daa7Cf8D414db444 \
--broadcast
# == Logs ==
# TokenX deployed at: 0xCFA134AC648A11904A35c4B22C95952c2C6574D6
# TokenX wired to faucet.forge script contracts/script/03_DeployBridgeLocker.s.sol:DeployBridgeLocker \
--rpc-url base_sepolia \
--account beef-dev \
--sender 0xbeef8A79FF64C0B70e42a2D2daa7Cf8D414db444 \
--broadcast
# == Logs ==
# BridgeLocker deployed at: 0x89E39Cb6449A3853Ed78c1715081635B2559E4Baforge script contracts/script/04_DeployBridgedTokenFactory.s.sol:DeployBridgedTokenFactory \
--rpc-url arbitrum_one_sepolia \
--account beef-dev \
--sender 0xbeef8A79FF64C0B70e42a2D2daa7Cf8D414db444 \
--broadcast
# == Logs ==
# BridgedTokenFactory deployed at: 0xc570f5577B509A287ea78f3cF0e8E18e87A1e00Dforge script contracts/script/05_DeployBridgeMinter.s.sol:DeployBridgeMinter \
--rpc-url arbitrum_one_sepolia \
--account beef-dev \
--sender 0xbeef8A79FF64C0B70e42a2D2daa7Cf8D414db444 \
--broadcast
# == Logs ==
# BridgeMinter deployed at: 0xCFA134AC648A11904A35c4B22C95952c2C6574D6
# BridgedTokenFactory wired to minter.