Skip to content

feat(bridge): IPC ERC20 cross-chain bridge — Filecoin Calibration ↔ Ethereum Sepolia#1551

Open
phutchins wants to merge 8 commits intomainfrom
feat/ipc-erc20-bridge
Open

feat(bridge): IPC ERC20 cross-chain bridge — Filecoin Calibration ↔ Ethereum Sepolia#1551
phutchins wants to merge 8 commits intomainfrom
feat/ipc-erc20-bridge

Conversation

@phutchins
Copy link
Copy Markdown
Contributor

@phutchins phutchins commented Mar 19, 2026

Summary

Implements a production-grade multi-asset ERC20 bridge using the IPC subnet as a trustless coordination layer between Filecoin Calibration and Ethereum Sepolia.

Architecture uses IPC native cross-messaging (IpcExchange/IpcEnvelope) — no standalone relay infrastructure needed.


What's included

WS1 — Filecoin-side contract (contracts/contracts/bridge/BridgeLock.sol)

  • Accepts arbitrary ERC20 deposits (multi-asset via token address in payload)
  • Emits TokensLocked event and sends IPC cross-message to BridgeMint on Ethereum Sepolia
  • UUPS upgradeable, AccessControlUpgradeable (ADMIN + PAUSER roles), SafeERC20
  • On-chain replay protection (transferId tracking)
  • Foundry test suite: 31 tests including fuzz (amount variation, unique transferId guarantee)
  • Deploy script: contracts/script/DeployBridgeLock.s.sol

WS2 — Ethereum-side contracts (contracts/contracts/bridge/BridgeMint.sol, WrappedToken.sol)

  • Receives IPC cross-messages; only onlyGateway callers accepted (IpcExchange enforcement)
  • Origin validation: subnet ID + FvmAddress equality check — spoofed origins rejected
  • Per-transferId replay protection (CEI pattern)
  • WrappedToken: UUPS ERC20 with MINTER_ROLE gated to BridgeMint
  • deployAndRegisterAsset() for permissioned multi-asset onboarding
  • Foundry test suite: 31 tests including fuzz + explicit spoofed-caller and wrong-origin rejection tests
  • Deploy script: contracts/script/DeployBridgeMint.s.sol

WS3 — IPC WASM actor (fendermint/actors/bridge-relay/)

  • Rust actor following existing fendermint/actors/ patterns (wasm_trampoline!, actor_dispatch!, frc42_dispatch)
  • HAMT-backed replay protection (persistent processed_transfers: Cid)
  • Configurable validation: min/max amount bounds, token allowlist, zero-recipient check
  • Never silent: every outcome emits an event (bridge-relay/relayed or bridge-relay/rejected)
  • Admin methods (SYSTEM_ACTOR_ADDR gated): UpdateValidationRules, UpdateAddresses
  • 25 pure unit tests (no FVM runtime required)

Deploy scripts (contracts/script/)

  • DeployBridgeLock.s.sol — UUPS proxy deploy for Filecoin Calibration
  • DeployBridgeMint.s.sol — UUPS proxy + WrappedToken impl deploy for Ethereum Sepolia

SDK scaffolding (sdk/bridge/)

  • TypeScript types and ABIs for BridgeLock, BridgeMint, WrappedToken
  • Full BridgeClient SDK in progress (WS4)

Still in progress (will be added to this branch)

  • WS4: Full TypeScript BridgeClient SDK with lockTokens, waitForCompletion, event subscription
  • WS5: Single-command E2E deployment scripts + smoke test
  • WS6: QA test report + security considerations document

Security properties verified in tests

  • ✅ Spoofed mint rejected (test_mint_rejectsDirectCallerNotGateway, test_mint_rejectsWrongOriginAddress, test_mint_rejectsWrongOriginSubnet)
  • ✅ Replay protection on both Solidity contracts and WASM actor
  • ✅ CEI pattern on all state-modifying paths
  • ✅ Access controls: ADMIN/PAUSER roles, MINTER_ROLE, SYSTEM_ACTOR gating

Testnets

  • Filecoin Calibration (chainId 314159)
  • Ethereum Sepolia (chainId 11155111)

/cc @phutchins


Note

High Risk
Introduces new cross-chain token-bridging contracts and an IPC relay actor with minting/locking authority, which are security- and fund-critical paths despite test coverage and access controls.

Overview
Adds an end-to-end IPC-based ERC20 bridge between Filecoin Calibration and Ethereum Sepolia, including BridgeLock (token locking + IPC dispatch) and BridgeMint/WrappedToken (origin-validated IPC receive + wrapped token minting) with replay protection, pausing, and UUPS upgrade controls.

Introduces an IPC subnet WASM bridge-relay actor that validates and relays TokensLocked events with persistent HAMT-backed replay protection and configurable validation rules.

Adds deployment/ops tooling: .env.example, Makefile.bridge, Foundry and Hardhat deploy tasks/scripts, a full deploy-all.sh + smoke-test.sh flow, plus new Foundry test suites, operator runbook, and QA/security documentation; updates workspace manifests to include the new actor crate.

Written by Cursor Bugbot for commit 55be578. This will update automatically on new commits. Configure here.

Bridge Bot and others added 4 commits March 19, 2026 14:47
- contracts/script/DeployBridgeLock.s.sol: UUPS proxy deploy for Filecoin Calibration
- contracts/script/DeployBridgeMint.s.sol: UUPS proxy deploy + WrappedToken impl for Ethereum Sepolia
- sdk/bridge/: TypeScript SDK scaffolding (types, ABIs for BridgeLock/BridgeMint/WrappedToken)
- Cargo.lock updated for bridge-relay crate

WS4 (full SDK) and WS5 (E2E scripts) in progress.
@phutchins phutchins requested a review from a team as a code owner March 19, 2026 15:06
pnpm exec hardhat deploy-bridge-lock \
--network calibration \
--gateway "${FILECOIN_IPC_GATEWAY}" \
--dest-root "${IPC_SUBNET_ROOT}" \
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deploy script uses wrong chain ID for destination

High Severity

deploy-all.sh passes IPC_SUBNET_ROOT (314159, Filecoin Calibration) as --dest-root for BridgeLock and set-bridge-destination, but the destination is Ethereum Sepolia which has chain ID 11155111. The test suite confirms this: BridgeLock.t.sol uses root: 11155111 for destSubnet, while BridgeMint.t.sol uses root: 314159 for srcSubnet. The Foundry deploy script documentation also says DEST_SUBNET_ROOT should be 11155111. Using the wrong root means IPC messages are routed to the wrong subnet, breaking the bridge entirely. ETHEREUM_CHAIN_ID is defined in .env.example but never referenced by the deploy script.

Additional Locations (2)
Fix in Cursor Fix in Web

symbol,
address(this)
);
wrappedToken = address(new ERC1967Proxy(implAddr, initData));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deployAndRegisterAsset locks WrappedToken governance to contract

Medium Severity

deployAndRegisterAsset initializes the WrappedToken with admin_ = address(this) (the BridgeMint contract), making BridgeMint the sole holder of DEFAULT_ADMIN_ROLE and MINTER_ROLE. Since BridgeMint has no methods to call grantRole, revokeRole, or upgradeToAndCall on the WrappedToken, the human admin cannot manage or upgrade it. This contradicts the test setup in BridgeMint.t.sol, which correctly grants the human admin the admin role and separately grants MINTER_ROLE to the bridge.

Fix in Cursor Fix in Web

IPC_FEE="${IPC_FEE:-10000000000000000}"
mkdir -p "${DEPLOYMENTS_DIR}"

export PATH="${HOME}/.nvm/versions/node/v22.22.1/bin:${HOME}/.local/share/pnpm:${PATH}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded Node.js version path in deploy script

Medium Severity

Both deploy-all.sh and smoke-test.sh hardcode a developer-specific Node.js path (${HOME}/.nvm/versions/node/v22.22.1/bin). This breaks for any other developer or CI environment not using that exact nvm version. These appear to be local development paths that were accidentally committed into shared deployment scripts.

Additional Locations (1)
Fix in Cursor Fix in Web


// Recipient must not be the zero address (Address::default is the null address)
if event.recipient == Address::new_id(0) {
return Err(ValidationError::InvalidRecipient);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zero-address check misses Ethereum-format delegated addresses

Medium Severity

The recipient zero-address check compares against Address::new_id(0) (FVM ID address f00), but bridge addresses are Ethereum-style 20-byte delegated addresses (f4 type). The Ethereum zero address 0x0000…0000 is a type-4 delegated address, which never equals Address::new_id(0). This makes the InvalidRecipient check and the constructor zero-address guards effectively dead code for real bridge addresses.

Additional Locations (1)
Fix in Cursor Fix in Web

CallMsg memory call = abi.decode(message, (CallMsg));
(, , , bytes32 tid) = abi.decode(call.params, (address, address, uint256, bytes32));
return tid;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_safeDecodeTransferId callable by any external account

Low Severity

_safeDecodeTransferId is declared external pure with no access control, yet its NatDoc states "Callable by this contract only." Any external account can call this function. While it's pure and harmless today, the misleading documentation and the underscore-prefix convention (implying internal use) create a false trust assumption about the contract's public surface area.

Fix in Cursor Fix in Web

params: RelayLockEventParams,
) -> Result<RelayLockEventReturn, ActorError> {
// Any caller may submit (the subnet infrastructure drives this).
rt.validate_immediate_caller_accept_any()?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relay actor accepts forged events from any caller

High Severity

relay_lock_event uses validate_immediate_caller_accept_any, meaning any account on the IPC subnet can submit fabricated TokensLockedEvent data. The actor stores bridge_lock_addr in state but never validates that the caller or event source matches it. A malicious actor could submit a forged lock event with a fresh transfer_id, pass validation rules, and cause the actor to emit a bridge-relay/relayed event — potentially triggering an unauthorized mint on the destination chain.

Fix in Cursor Fix in Web

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 8 total unresolved issues (including 6 from previous reviews).

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.


emit XnetMessageCommitted(committed);
return committed;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ETH permanently trapped in EthGatewayMessenger contract

Medium Severity

sendContractXnetMessage is payable and records msg.value in the committed envelope, but the ETH received stays in the contract with no way to recover it. There is no receive(), fallback(), or withdraw() function on EthGatewayMessenger. Any ETH sent by callers is permanently locked in the contract.

Fix in Cursor Fix in Web

// nonReentrant omitted: performIpcCall() (called below) is itself nonReentrant
// via IpcExchange's ReentrancyGuard, preventing gateway-level re-entry.
// ERC20 callback re-entry is prevented by the CEI pattern below:
// state is updated before the external token pull.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect reentrancy protection reasoning in lock function

Medium Severity

The lock() function omits nonReentrant with the justification that performIpcCall's nonReentrant prevents re-entry. This reasoning is flawed — a malicious ERC20 can re-enter lock() during safeTransferFrom (line 211), which executes before performIpcCall. Each re-entrant call gets a fresh nonce and succeeds independently, potentially allowing multiple IPC mint messages for fewer actual token transfers.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant