diff --git a/src/router/IPermit2.sol b/src/router/IPermit2.sol new file mode 100644 index 0000000..5a0a44b --- /dev/null +++ b/src/router/IPermit2.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.20; + +interface IPermit2 { + struct PermitTransferFrom { + TokenPermissions permitted; + // a unique value for every token owner's signature to prevent signature replays + uint256 nonce; + // deadline on the permit signature + uint256 deadline; + } + + struct TokenPermissions { + // ERC20 token address + address token; + // the maximum amount that can be spent + uint256 amount; + } + + struct SignatureTransferDetails { + // recipient address + address to; + // spender requested amount + uint256 requestedAmount; + } + + function permitTransferFrom( + PermitTransferFrom memory permit, + SignatureTransferDetails calldata transferDetails, + address owner, + bytes calldata signature + ) external; + + function DOMAIN_SEPARATOR() external view returns (bytes32); +} diff --git a/src/router/Multicall.sol b/src/router/Multicall.sol new file mode 100644 index 0000000..9528007 --- /dev/null +++ b/src/router/Multicall.sol @@ -0,0 +1,34 @@ +// forked from https://github.com/Uniswap/v3-periphery/blob/main/contracts/base/Multicall.sol + +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.6; + +/// @title Multicall +/// @notice Enables calling multiple methods in a single call to the contract +abstract contract Multicall { + /// @notice Call multiple functions in the current contract and return the data from all of them if they all succeed + /// @dev The `msg.value` should not be trusted for any method callable from multicall. + /// @param data The encoded function data for each of the calls to make to this contract + /// @return results The results from each of the calls passed in via data + function multicall( + bytes[] calldata data + ) public payable returns (bytes[] memory results) { + results = new bytes[](data.length); + for (uint256 i = 0; i < data.length; i++) { + (bool success, bytes memory result) = address(this).delegatecall( + data[i] + ); + + if (!success) { + // Next 5 lines from https://ethereum.stackexchange.com/a/83577 + if (result.length < 68) revert(); + assembly { + result := add(result, 0x04) + } + revert(abi.decode(result, (string))); + } + + results[i] = result; + } + } +} diff --git a/src/router/PeripheryPayments.sol b/src/router/PeripheryPayments.sol new file mode 100644 index 0000000..14918ed --- /dev/null +++ b/src/router/PeripheryPayments.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + @title Periphery Payments + @notice Immutable state used by periphery contracts + Largely Forked from https://github.com/Uniswap/v3-periphery/blob/main/contracts/base/PeripheryPayments.sol + Changes: + * no interface + * no inheritdoc + * add immutable WETH9 in constructor instead of PeripheryImmutableState + * receive from any address + * Solmate interfaces and transfer lib + * casting + * add approve, wrapWETH9 and pullToken +*/ +abstract contract PeripheryPayments { + using SafeERC20 for *; + + IWETH9 public immutable WETH9; + + constructor(address _WETH9) { + WETH9 = IWETH9(_WETH9); + } + + receive() external payable {} + + function approve(ERC20 token, address to, uint256 amount) public payable { + token.forceApprove(to, amount); + } + + function unwrapWETH9( + uint256 amountMinimum, + address recipient + ) public payable { + uint256 balanceWETH9 = WETH9.balanceOf(address(this)); + require(balanceWETH9 >= amountMinimum, "Insufficient WETH9"); + + if (balanceWETH9 > 0) { + WETH9.withdraw(balanceWETH9); + safeTransferETH(recipient, balanceWETH9); + } + } + + function wrapWETH9() public payable { + if (address(this).balance > 0) + WETH9.deposit{value: address(this).balance}(); // wrap everything + } + + function pullToken( + ERC20 token, + uint256 amount, + address recipient + ) public payable { + token.safeTransferFrom(msg.sender, recipient, amount); + } + + function sweepToken( + ERC20 token, + uint256 amountMinimum, + address recipient + ) public payable { + uint256 balanceToken = token.balanceOf(address(this)); + require(balanceToken >= amountMinimum, "Insufficient token"); + + if (balanceToken > 0) { + token.safeTransfer(recipient, balanceToken); + } + } + + function refundETH() external payable { + if (address(this).balance > 0) + safeTransferETH(msg.sender, address(this).balance); + } + + // From https://github.com/transmissions11/solmate/blob/9f16db2144cc9a7e2ffc5588d4bf0b66784283bd/src/utils/SafeTransferLib.sol + function safeTransferETH(address to, uint256 amount) internal { + bool success; + + assembly { + // Transfer the ETH and store if it succeeded or not. + success := call(gas(), to, amount, 0, 0, 0, 0) + } + + require(success, "ETH_TRANSFER_FAILED"); + } +} + +abstract contract IWETH9 is ERC20 { + /// @notice Deposit ether to get wrapped ether + function deposit() external payable virtual; + + /// @notice Withdraw wrapped ether to get ether + function withdraw(uint256) external virtual; +} diff --git a/src/router/STBRouter.sol b/src/router/STBRouter.sol new file mode 100644 index 0000000..2746a1b --- /dev/null +++ b/src/router/STBRouter.sol @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.20; + +import {L1YearnEscrow} from "../L1YearnEscrow.sol"; +import {L1Deployer} from "../L1Deployer.sol"; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import {Multicall} from "./Multicall.sol"; +import {PeripheryPayments} from "./PeripheryPayments.sol"; + +import {IPermit2} from "./IPermit2.sol"; + +/// @notice Simple router for bridging to any L2's that use the LxLy Stake the Bridge implementation. +/// @dev Allows for multicall, native ETH and Permit2 bridging. +contract STBRouter is Multicall, PeripheryPayments { + using SafeERC20 for ERC20; + + /// @notice The canonical permit2 contract. + IPermit2 public immutable PERMIT2; + + /// @notice The L1 Deployer for Polygon's LxLy Escrows. + L1Deployer public immutable L1DEPLOYER; + + constructor( + address weth, + address _permit2, + address _l1Deployer + ) PeripheryPayments(weth) { + PERMIT2 = IPermit2(_permit2); + L1DEPLOYER = L1Deployer(_l1Deployer); + } + + /** + * @notice Name of the contract + */ + function name() external pure returns (string memory) { + return "Stake The Bridge Router"; + } + + /** + * @notice Bridge all of token to a L2. + * @dev Default to msg sender for receiver and the full balance as amount. + * @param _rollupID The Polygon Rollup ID to bridge to. + * @param _asset Token to bridge. + */ + function bridge(uint32 _rollupID, address _asset) external virtual { + _bridge( + _rollupID, + _asset, + ERC20(_asset).balanceOf(msg.sender), + msg.sender + ); + } + + /** + * @notice Bridge a token to a L2. + * @dev Defaults to msg.sender as the receiver. + * @param _rollupID The Polygon Rollup ID to bridge to. + * @param _asset Token to bridge. + * @param _amount The amount of `_asset` to bridge. + */ + function bridge( + uint32 _rollupID, + address _asset, + uint256 _amount + ) external virtual { + _bridge(_rollupID, _asset, _amount, msg.sender); + } + + /** + * @notice Bridge a token to a L2. + * @param _rollupID The Polygon Rollup ID to bridge to. + * @param _asset Token to bridge. + * @param _amount The amount of `_asset` to bridge. + * @param _receiver The address to receive the tokens on the L2. + */ + function bridge( + uint32 _rollupID, + address _asset, + uint256 _amount, + address _receiver + ) public virtual { + _bridge(_rollupID, _asset, _amount, _receiver); + } + + /** + * @notice Bridge a token to a L2 using Permit2. + * @dev Requires an off chain signature and the sender to have approved Permit2. + * @param _rollupID The Polygon Rollup ID to bridge to. + * @param _asset Token to bridge + * @param _amount The amount of `_asset` to bridge. + * @param _receiver The address to receive the tokens on the L2. + * @param _nonce The unique nonce for Permit2 to use. + * @param _deadline Timestamp the signature is good till. + * @param _signature Off chain Permit2 signature signed by msg.sender. + */ + function bridgePermit2( + uint32 _rollupID, + address _asset, + uint256 _amount, + address _receiver, + uint256 _nonce, + uint256 _deadline, + bytes calldata _signature + ) public virtual { + // Transfer from using Permit2 + PERMIT2.permitTransferFrom( + IPermit2.PermitTransferFrom({ + permitted: IPermit2.TokenPermissions({ + token: _asset, + amount: _amount + }), + nonce: _nonce, + deadline: _deadline + }), + IPermit2.SignatureTransferDetails({ + to: address(this), + requestedAmount: _amount + }), + msg.sender, + _signature + ); + + _bridgeToken(_rollupID, _asset, _amount, _receiver); + } + + /** + * @notice Bridge WETH to a L2 using native ETH. + * @dev The amount used will be the msg.value. + * @param _rollupID The Polygon Rollup ID to bridge to. + * @param _receiver The address to receive the tokens on the L2. + */ + function bridgeEth( + uint32 _rollupID, + address _receiver + ) public payable virtual { + wrapWETH9(); + uint256 _amount = WETH9.balanceOf(address(this)); + _bridgeToken(_rollupID, address(WETH9), _amount, _receiver); + } + + /** + * @dev Pulls the token from the caller and bridges. + * Requires that approval to this contract has been given. + * AND that approval has been granted for the escrow to pull + * `_asset` from this contract. + */ + function _bridge( + uint32 _rollupID, + address _asset, + uint256 _amount, + address _receiver + ) internal virtual { + pullToken(ERC20(_asset), _amount, address(this)); + _bridgeToken(_rollupID, _asset, _amount, _receiver); + } + + /** + * @dev Retrieves the escrow from the L1 Deployer and bridges to the L2. + * Will revert if no escrow has been deployed yet. + */ + function _bridgeToken( + uint32 _rollupID, + address _asset, + uint256 _amount, + address _receiver + ) internal virtual { + L1YearnEscrow escrow = L1YearnEscrow( + L1DEPLOYER.getEscrow(_rollupID, _asset) + ); + + escrow.bridgeToken(_receiver, _amount, true); + } +} diff --git a/test/Router.t.sol b/test/Router.t.sol new file mode 100644 index 0000000..17d53bb --- /dev/null +++ b/test/Router.t.sol @@ -0,0 +1,383 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity >=0.8.18; + +import {Setup, console, L1YearnEscrow, IPolygonZkEVMBridge, IVault, L1Deployer, ERC20} from "./utils/Setup.sol"; +import {IPolygonRollupManager, IPolygonRollupContract} from "../src/interfaces/Polygon/IPolygonRollupManager.sol"; + +import {STBRouter, IPermit2} from "../src/router/STBRouter.sol"; + +interface ISTBRouter { + function bridge(uint32 _rollupID, address _asset) external; +} + +contract RouterTest is Setup { + STBRouter public router; + + address public permit2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + + address public weth = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + L1YearnEscrow public mockEscrow; + + function setUp() public override { + super.setUp(); + + router = new STBRouter(weth, permit2, address(l1Deployer)); + + address rollupAdmin = address( + IPolygonRollupManager(polygonZkEVMBridge.polygonRollupManager()) + .rollupIDToRollupData(l2RollupID) + .rollupContract + .admin() + ); + + vm.prank(rollupAdmin); + l1Deployer.registerRollup(l2RollupID, czar, address(l2Deployer)); + + (address _l1Escrow, address _vault) = l1Deployer.newEscrow( + l2RollupID, + address(asset) + ); + mockEscrow = L1YearnEscrow(_l1Escrow); + vault = IVault(_vault); + } + + function test_bridge_withDefaults(uint256 _amount) public { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + + address counterPart = l1Deployer.getL2EscrowAddress( + l2RollupID, + address(asset) + ); + + airdrop(asset, user, _amount); + + router.approve(asset, address(mockEscrow), 2 ** 256 - 1); + + vm.prank(user); + asset.approve(address(router), _amount); + + bytes memory data = abi.encode(user, _amount); + uint256 depositCount = polygonZkEVMBridge.depositCount(); + vm.expectEmit(true, true, true, true, address(polygonZkEVMBridge)); + emit BridgeEvent( + 1, + l1RollupID, + address(mockEscrow), + l2RollupID, + counterPart, + 0, + data, + uint32(depositCount) + ); + vm.prank(user); + router.bridge(l2RollupID, address(asset)); + + assertEq(asset.balanceOf(user), 0); + assertEq(asset.balanceOf(address(vault)), _amount); + assertEq(vault.balanceOf(address(mockEscrow)), _amount); + } + + function test_bridge_withAmount(uint256 _amount) public { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + + address counterPart = l1Deployer.getL2EscrowAddress( + l2RollupID, + address(asset) + ); + + airdrop(asset, user, _amount); + + uint256 toBridge = _amount - 100; + + router.approve(asset, address(mockEscrow), 2 ** 256 - 1); + + vm.prank(user); + asset.approve(address(router), _amount); + + bytes memory data = abi.encode(user, toBridge); + uint256 depositCount = polygonZkEVMBridge.depositCount(); + vm.expectEmit(true, true, true, true, address(polygonZkEVMBridge)); + emit BridgeEvent( + 1, + l1RollupID, + address(mockEscrow), + l2RollupID, + counterPart, + 0, + data, + uint32(depositCount) + ); + vm.prank(user); + router.bridge(l2RollupID, address(asset), toBridge); + + assertEq(asset.balanceOf(user), _amount - toBridge); + assertEq(asset.balanceOf(address(vault)), toBridge); + assertEq(vault.balanceOf(address(mockEscrow)), toBridge); + } + + function test_bridge_customReceiver(uint256 _amount) public { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + + address counterPart = l1Deployer.getL2EscrowAddress( + l2RollupID, + address(asset) + ); + + airdrop(asset, user, _amount); + + uint256 toBridge = _amount - 100; + + router.approve(asset, address(mockEscrow), 2 ** 256 - 1); + + vm.prank(user); + asset.approve(address(router), _amount); + + bytes memory data = abi.encode(czar, toBridge); + uint256 depositCount = polygonZkEVMBridge.depositCount(); + vm.expectEmit(true, true, true, true, address(polygonZkEVMBridge)); + emit BridgeEvent( + 1, + l1RollupID, + address(mockEscrow), + l2RollupID, + counterPart, + 0, + data, + uint32(depositCount) + ); + vm.prank(user); + router.bridge(l2RollupID, address(asset), toBridge, czar); + + assertEq(asset.balanceOf(user), _amount - toBridge); + assertEq(asset.balanceOf(address(vault)), toBridge); + assertEq(vault.balanceOf(address(mockEscrow)), toBridge); + } + + function test_bridge_withMulticall(uint256 _amount) public { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + + address counterPart = l1Deployer.getL2EscrowAddress( + l2RollupID, + address(asset) + ); + + airdrop(asset, user, _amount); + + vm.prank(user); + asset.approve(address(router), _amount); + + bytes[] memory multiCallData = new bytes[](2); + + multiCallData[0] = abi.encodeWithSelector( + router.approve.selector, + asset, + address(mockEscrow), + 2 ** 256 - 1 + ); + multiCallData[1] = abi.encodeWithSelector( + ISTBRouter.bridge.selector, + l2RollupID, + address(asset) + ); + + bytes memory data = abi.encode(user, _amount); + uint256 depositCount = polygonZkEVMBridge.depositCount(); + vm.expectEmit(true, true, true, true, address(polygonZkEVMBridge)); + emit BridgeEvent( + 1, + l1RollupID, + address(mockEscrow), + l2RollupID, + counterPart, + 0, + data, + uint32(depositCount) + ); + vm.prank(user); + router.multicall(multiCallData); + + assertEq(asset.balanceOf(user), 0); + assertEq(asset.balanceOf(address(vault)), _amount); + assertEq(vault.balanceOf(address(mockEscrow)), _amount); + } + + function test_bridge_eth() public { + asset = ERC20(weth); + (address _l1Escrow, address _vault) = l1Deployer.newEscrow( + l2RollupID, + address(asset) + ); + mockEscrow = L1YearnEscrow(_l1Escrow); + vault = IVault(_vault); + + address counterPart = l1Deployer.getL2EscrowAddress( + l2RollupID, + address(asset) + ); + + router.approve(asset, address(mockEscrow), 2 ** 256 - 1); + + uint256 wethBalance = asset.balanceOf(user); + uint256 _amount = user.balance; + + bytes memory data = abi.encode(user, _amount); + uint256 depositCount = polygonZkEVMBridge.depositCount(); + vm.expectEmit(true, true, true, true, address(polygonZkEVMBridge)); + emit BridgeEvent( + 1, + l1RollupID, + address(mockEscrow), + l2RollupID, + counterPart, + 0, + data, + uint32(depositCount) + ); + vm.prank(user); + router.bridgeEth{value: _amount}(l2RollupID, user); + + assertEq(asset.balanceOf(user), wethBalance); + assertEq(asset.balanceOf(address(vault)), _amount); + assertEq(vault.balanceOf(address(mockEscrow)), _amount); + assertEq(user.balance, 0); + } + + function test_bridge_ethWithMulticall() public { + asset = ERC20(weth); + (address _l1Escrow, address _vault) = l1Deployer.newEscrow( + l2RollupID, + address(asset) + ); + mockEscrow = L1YearnEscrow(_l1Escrow); + vault = IVault(_vault); + + address counterPart = l1Deployer.getL2EscrowAddress( + l2RollupID, + address(asset) + ); + + uint256 wethBalance = asset.balanceOf(user); + uint256 _amount = user.balance; + + bytes[] memory multiCallData = new bytes[](3); + + multiCallData[0] = abi.encodeWithSelector( + router.approve.selector, + asset, + address(mockEscrow), + 2 ** 256 - 1 + ); + multiCallData[2] = abi.encodeWithSelector( + router.bridgeEth.selector, + l2RollupID, + user + ); + + bytes memory data = abi.encode(user, _amount); + uint256 depositCount = polygonZkEVMBridge.depositCount(); + vm.expectEmit(true, true, true, true, address(polygonZkEVMBridge)); + emit BridgeEvent( + 1, + l1RollupID, + address(mockEscrow), + l2RollupID, + counterPart, + 0, + data, + uint32(depositCount) + ); + vm.prank(user); + router.multicall{value: _amount}(multiCallData); + + assertEq(asset.balanceOf(user), wethBalance); + assertEq(asset.balanceOf(address(vault)), _amount); + assertEq(vault.balanceOf(address(mockEscrow)), _amount); + assertEq(user.balance, 0); + } + + bytes32 public constant _PERMIT_TRANSFER_FROM_TYPEHASH = + keccak256( + "PermitTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)" + ); + + bytes32 public constant _TOKEN_PERMISSIONS_TYPEHASH = + keccak256("TokenPermissions(address token,uint256 amount)"); + + function test_bridge_withPermit(uint256 _amount) public { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + + uint256 privateKey = 0xBEEF; + user = vm.addr(privateKey); + + address counterPart = l1Deployer.getL2EscrowAddress( + l2RollupID, + address(asset) + ); + + airdrop(asset, user, _amount); + + router.approve(asset, address(mockEscrow), 2 ** 256 - 1); + + vm.prank(user); + asset.approve(address(permit2), 2 ** 256 - 1); + + uint256 nonce = block.timestamp - 1; + uint256 deadline = block.timestamp; + + bytes32 tokenPermissions = keccak256( + abi.encode( + _TOKEN_PERMISSIONS_TYPEHASH, + IPermit2.TokenPermissions({ + token: address(asset), + amount: _amount + }) + ) + ); + + bytes32 msgHash = keccak256( + abi.encodePacked( + "\x19\x01", + IPermit2(permit2).DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + _PERMIT_TRANSFER_FROM_TYPEHASH, + tokenPermissions, + address(router), + nonce, + deadline + ) + ) + ) + ); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, msgHash); + bytes memory signature = bytes.concat(r, s, bytes1(v)); + + vm.prank(user); + router.bridgePermit2( + l2RollupID, + address(asset), + _amount, + user, + nonce, + deadline, + signature + ); + + assertEq(asset.balanceOf(user), 0); + assertEq(asset.balanceOf(address(vault)), _amount); + assertEq(vault.balanceOf(address(mockEscrow)), _amount); + } + + event BridgeEvent( + uint8 leafType, + uint32 originNetwork, + address originAddress, + uint32 destinationNetwork, + address destinationAddress, + uint256 amount, + bytes metadata, + uint32 depositCount + ); +}