Overview
The L1Unwrapper contract extends the OmniBridge WETHOmnibridgeRouter to provide automatic ETH/WETH conversion for cross-chain transfers. It serves as the mainnet entry and exit point for bridging assets while also handling TornadoPool account registrations.
Contract Location: contracts/bridge/L1Unwrapper.sol:20
Inheritance
contract L1Unwrapper is WETHOmnibridgeRouter
Inherits from OmniBridge’s WETHOmnibridgeRouter, which provides base bridging functionality for WETH tokens.
State Variables
Address that receives L1 execution fees. If set to zero address, fees go to tx.origin (the transaction relayer). Can be changed by the contract owner to implement custom fee sharing logic.
Constructor
constructor(
IOmnibridge _bridge,
IWETH _weth,
address _owner
) WETHOmnibridgeRouter(_bridge, _weth, _owner)
Address of the OmniBridge contract on mainnet
Address of the WETH contract on mainnet (0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2)
Address with administrative privileges to update configuration
Data Structures
Account
Represents a TornadoPool account registration:
struct Account {
address owner;
bytes publicKey;
}
Address that owns the TornadoPool account
Public key for the privacy pool account (used for encryption)
Core Functions
wrapAndRelayTokens
Wraps ETH to WETH and bridges it to the L2 receiver. Optionally registers a TornadoPool account.
function wrapAndRelayTokens(
address _receiver,
bytes memory _data,
Account memory _account
) public payable
Address on L2 (Gnosis Chain) that will receive the bridged WETH
Arbitrary data passed to the receiver contract on L2 (if receiver is a contract)
TornadoPool account to register. If _account.owner == msg.sender, the account is registered and a PublicKey event is emitted
Implementation
function wrapAndRelayTokens(
address _receiver,
bytes memory _data,
Account memory _account
) public payable {
WETH.deposit{ value: msg.value }();
bridge.relayTokensAndCall(address(WETH), _receiver, msg.value, _data);
if (_account.owner == msg.sender) {
_register(_account);
}
}
Wrap ETH to WETH
Deposits the entire msg.value to the WETH contract, converting ETH to WETH
Initiate Bridge Transfer
Calls relayTokensAndCall() on the OmniBridge to transfer WETH to L2 and execute callback
Register Account (Optional)
If the account owner matches the caller, registers the TornadoPool account and emits PublicKey event
Usage Example
// Bridge 1 ETH to L2 address and register account
Account memory account = Account({
owner: msg.sender,
publicKey: hex"0x..."
});
l1Unwrapper.wrapAndRelayTokens{value: 1 ether}(
l2ReceiverAddress,
"", // no additional data
account
);
onTokenBridged
Callback function invoked by the OmniBridge when tokens are bridged from L2 to L1. Unwraps WETH to ETH and distributes to recipient with fee handling.
function onTokenBridged(
address _token,
uint256 _value,
bytes memory _data
) external override
Address of the bridged token. Must be WETH or the transaction reverts
Amount of WETH bridged (in wei)
ABI-encoded data containing (address recipient, uint256 l1Fee). Must be exactly 64 bytes
Implementation
function onTokenBridged(
address _token,
uint256 _value,
bytes memory _data
) external override {
require(_token == address(WETH), "only WETH token");
require(msg.sender == address(bridge), "only from bridge address");
require(_data.length == 64, "incorrect data length");
WETH.withdraw(_value);
(address payable receipient, uint256 l1Fee) = abi.decode(_data, (address, uint256));
AddressHelper.safeSendValue(receipient, _value.sub(l1Fee));
if (l1Fee > 0) {
address payable l1FeeTo = l1FeeReceiver != payable(address(0)) ? l1FeeReceiver : payable(tx.origin);
AddressHelper.safeSendValue(l1FeeTo, l1Fee);
}
}
Validate Bridge Call
Ensures caller is the OmniBridge contract and token is WETH with correct data length
Unwrap WETH
Converts WETH back to native ETH
Decode Parameters
Extracts recipient address and L1 fee amount from the data payload
Send to Recipient
Transfers ETH to recipient (total amount minus L1 fee)
Process L1 Fee
If fee > 0, sends fee to either the configured l1FeeReceiver or the transaction relayer (tx.origin)
Security Note: This function can only be called by the OmniBridge contract. The data payload format is strictly validated to prevent malicious inputs.
Data Encoding
The _data parameter must be encoded as:
bytes memory data = abi.encode(
address(recipient), // 32 bytes
uint256(l1Fee) // 32 bytes
); // Total: 64 bytes
register
Registers a TornadoPool account and emits the public key.
function register(Account memory _account) public
Account to register. Must have _account.owner == msg.sender
function register(Account memory _account) public {
require(_account.owner == msg.sender, "only owner can be registered");
_register(_account);
}
Emits:
event PublicKey(address indexed owner, bytes key);
setL1FeeReceiver
Updates the L1 fee receiver address. Only callable by contract owner.
function setL1FeeReceiver(address payable _receiver) external onlyOwner
New L1 fee receiver address. Set to address(0) to send fees to tx.origin (relayer)
function setL1FeeReceiver(address payable _receiver) external onlyOwner {
l1FeeReceiver = _receiver;
}
Events
PublicKey
Emitted when a TornadoPool account is registered:
event PublicKey(address indexed owner, bytes key);
Address that owns the registered account
Public key associated with the account
L1 Fee Mechanism
The L1 fee system enables sustainable relayer economics:
Fee Distribution Logic
address payable l1FeeTo = l1FeeReceiver != payable(address(0))
? l1FeeReceiver
: payable(tx.origin);
Default Mode (l1FeeReceiver == address(0)):
- Fees go directly to
tx.origin (the relayer who submitted the transaction)
- Simple, direct compensation for L1 gas costs
Custom Receiver Mode (l1FeeReceiver != address(0)):
- Fees sent to a smart contract for advanced logic:
- Subsidize transactions based on block base fee
- Store surplus ETH for future subsidies
- Implement fee sharing between relayer and protocol
- Provide partial refunds to users
Fee Calculation Example
When bridging from L2 to L1:
// On L2, encode withdrawal with fee
uint256 withdrawAmount = 10 ether;
uint256 l1Fee = 0.01 ether; // ~$20-50 depending on gas
bytes memory data = abi.encode(
userAddress,
l1Fee
);
// After bridging:
// - User receives: 10 - 0.01 = 9.99 ETH
// - Relayer receives: 0.01 ETH
Integration Example
Bridging ETH from L1 to L2
// User wants to bridge 5 ETH to L2
address l2Receiver = 0x...; // L2 TornadoPool or user address
Account memory account = Account({
owner: msg.sender,
publicKey: generatePublicKey() // User's encryption key
});
l1Unwrapper.wrapAndRelayTokens{value: 5 ether}(
l2Receiver,
"",
account
);
Withdrawing from L2 to L1
// On L2, prepare withdrawal data
address recipient = 0x...; // L1 recipient address
uint256 l1Fee = 0.02 ether;
bytes memory data = abi.encode(recipient, l1Fee);
// Initiate bridge transfer (OmniBridge will call onTokenBridged on L1)
omnibridge.relayTokensAndCall(
address(weth),
address(l1Unwrapper),
withdrawalAmount,
data
);
Security Considerations
Critical ValidationsThe contract enforces strict validation in onTokenBridged():
- Only WETH token accepted (prevents malicious token bridging)
- Only callable by the OmniBridge contract (prevents unauthorized calls)
- Data length must be exactly 64 bytes (prevents malformed inputs)
- Uses
SafeMath for fee calculation (prevents overflow/underflow)
Potential Issues
- Bridge Trust: Users trust the OmniBridge validators
- Fee Receiver: Malicious fee receiver could revert and DoS withdrawals
- WETH Dependence: Contract assumes WETH is the canonical wrapper
Source Code
Full contract implementation:
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;
pragma abicoder v2;
import "omnibridge/contracts/helpers/WETHOmnibridgeRouter.sol";
import "@openzeppelin/contracts/math/SafeMath.sol";
/// @dev Extension for original WETHOmnibridgeRouter that stores TornadoPool account registrations.
contract L1Unwrapper is WETHOmnibridgeRouter {
using SafeMath for uint256;
// If this address sets to not zero it receives L1_fee.
// It can be changed by the multisig.
// And should implement fee sharing logic:
// - some part to tx.origin - based on block base fee and can be subsidized
// - store surplus of ETH for future subsidizions
address payable public l1FeeReceiver;
event PublicKey(address indexed owner, bytes key);
struct Account {
address owner;
bytes publicKey;
}
constructor(
IOmnibridge _bridge,
IWETH _weth,
address _owner
) WETHOmnibridgeRouter(_bridge, _weth, _owner) {}
function register(Account memory _account) public {
require(_account.owner == msg.sender, "only owner can be registered");
_register(_account);
}
function wrapAndRelayTokens(
address _receiver,
bytes memory _data,
Account memory _account
) public payable {
WETH.deposit{ value: msg.value }();
bridge.relayTokensAndCall(address(WETH), _receiver, msg.value, _data);
if (_account.owner == msg.sender) {
_register(_account);
}
}
function _register(Account memory _account) internal {
emit PublicKey(_account.owner, _account.publicKey);
}
function onTokenBridged(
address _token,
uint256 _value,
bytes memory _data
) external override {
require(_token == address(WETH), "only WETH token");
require(msg.sender == address(bridge), "only from bridge address");
require(_data.length == 64, "incorrect data length");
WETH.withdraw(_value);
(address payable receipient, uint256 l1Fee) = abi.decode(_data, (address, uint256));
AddressHelper.safeSendValue(receipient, _value.sub(l1Fee));
if (l1Fee > 0) {
address payable l1FeeTo = l1FeeReceiver != payable(address(0)) ? l1FeeReceiver : payable(tx.origin);
AddressHelper.safeSendValue(l1FeeTo, l1Fee);
}
}
function setL1FeeReceiver(address payable _receiver) external onlyOwner {
l1FeeReceiver = _receiver;
}
}