feat(bridge): IPC ERC20 cross-chain bridge — Filecoin Calibration ↔ Ethereum Sepolia#1551
feat(bridge): IPC ERC20 cross-chain bridge — Filecoin Calibration ↔ Ethereum Sepolia#1551
Conversation
- 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.
| pnpm exec hardhat deploy-bridge-lock \ | ||
| --network calibration \ | ||
| --gateway "${FILECOIN_IPC_GATEWAY}" \ | ||
| --dest-root "${IPC_SUBNET_ROOT}" \ |
There was a problem hiding this comment.
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)
| symbol, | ||
| address(this) | ||
| ); | ||
| wrappedToken = address(new ERC1967Proxy(implAddr, initData)); |
There was a problem hiding this comment.
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.
| IPC_FEE="${IPC_FEE:-10000000000000000}" | ||
| mkdir -p "${DEPLOYMENTS_DIR}" | ||
|
|
||
| export PATH="${HOME}/.nvm/versions/node/v22.22.1/bin:${HOME}/.local/share/pnpm:${PATH}" |
There was a problem hiding this comment.
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)
|
|
||
| // Recipient must not be the zero address (Address::default is the null address) | ||
| if event.recipient == Address::new_id(0) { | ||
| return Err(ValidationError::InvalidRecipient); |
There was a problem hiding this comment.
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)
| CallMsg memory call = abi.decode(message, (CallMsg)); | ||
| (, , , bytes32 tid) = abi.decode(call.params, (address, address, uint256, bytes32)); | ||
| return tid; | ||
| } |
There was a problem hiding this comment.
_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.
| params: RelayLockEventParams, | ||
| ) -> Result<RelayLockEventReturn, ActorError> { | ||
| // Any caller may submit (the subnet infrastructure drives this). | ||
| rt.validate_immediate_caller_accept_any()?; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 8 total unresolved issues (including 6 from previous reviews).
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
|
|
||
| emit XnetMessageCommitted(committed); | ||
| return committed; | ||
| } |
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
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.


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)TokensLockedevent and sends IPC cross-message to BridgeMint on Ethereum SepoliaAccessControlUpgradeable(ADMIN + PAUSER roles),SafeERC20contracts/script/DeployBridgeLock.s.solWS2 — Ethereum-side contracts (
contracts/contracts/bridge/BridgeMint.sol,WrappedToken.sol)onlyGatewaycallers accepted (IpcExchange enforcement)WrappedToken: UUPS ERC20 withMINTER_ROLEgated to BridgeMintdeployAndRegisterAsset()for permissioned multi-asset onboardingcontracts/script/DeployBridgeMint.s.solWS3 — IPC WASM actor (
fendermint/actors/bridge-relay/)fendermint/actors/patterns (wasm_trampoline!,actor_dispatch!,frc42_dispatch)processed_transfers: Cid)bridge-relay/relayedorbridge-relay/rejected)SYSTEM_ACTOR_ADDRgated):UpdateValidationRules,UpdateAddressesDeploy scripts (
contracts/script/)DeployBridgeLock.s.sol— UUPS proxy deploy for Filecoin CalibrationDeployBridgeMint.s.sol— UUPS proxy + WrappedToken impl deploy for Ethereum SepoliaSDK scaffolding (
sdk/bridge/)Still in progress (will be added to this branch)
BridgeClientSDK withlockTokens,waitForCompletion, event subscriptionSecurity properties verified in tests
test_mint_rejectsDirectCallerNotGateway,test_mint_rejectsWrongOriginAddress,test_mint_rejectsWrongOriginSubnet)Testnets
/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) andBridgeMint/WrappedToken(origin-validated IPC receive + wrapped token minting) with replay protection, pausing, and UUPS upgrade controls.Introduces an IPC subnet WASM
bridge-relayactor that validates and relaysTokensLockedevents 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 fulldeploy-all.sh+smoke-test.shflow, 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.