diff --git a/README.md b/README.md index 1bc07a3..e76c97d 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,7 @@ src/ ├── base │ ├── Nonce.sol // Utility contract to safely sequence multiple pending transactions │ └── ResourceManager.sol. // Utility contract for defining the `RESOURCE` token and its amount requirements +├── crosschain/ // Work-in-progress crosschain logic ├── feeAdapters │ ├── V3FeeAdapter.sol // Logic for Uniswap v3 fee-setting and collection │ └── V4FeeAdapter.sol // Work-in-progress logic for Uniswap v4 fee-setting and collection @@ -212,6 +213,7 @@ src/ test ├── TokenJar.t.sol +├── CrossChainFirepit.t.sol ├── Deployer.t.sol // Test Deployer configures the system properly ├── ExchangeReleaser.t.sol ├── Firepit.t.sol diff --git a/snapshots/V3FeeAdapterTest.json b/snapshots/V3FeeAdapterTest.json index ffe21f9..331ee7a 100644 --- a/snapshots/V3FeeAdapterTest.json +++ b/snapshots/V3FeeAdapterTest.json @@ -1,6 +1,6 @@ { - "batchTriggerFeeUpdate_allLeaves": "277996773", - "triggerFeeUpdate_0": "57728", - "triggerFeeUpdate_4500": "57704", - "triggerFeeUpdate_8999": "55136" + "batchTriggerFeeUpdate_allLeaves": "277997793", + "triggerFeeUpdate_0": "57680", + "triggerFeeUpdate_4500": "57656", + "triggerFeeUpdate_8999": "55112" } \ No newline at end of file diff --git a/src/crosschain/FirepitDestination.sol b/src/crosschain/FirepitDestination.sol new file mode 100644 index 0000000..c63203f --- /dev/null +++ b/src/crosschain/FirepitDestination.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.29; + +import {Owned} from "solmate/src/auth/Owned.sol"; +import {Currency} from "v4-core/types/Currency.sol"; +import {IL1CrossDomainMessenger} from "../interfaces/IL1CrossDomainMessenger.sol"; +import {TokenJar} from "../TokenJar.sol"; +import {Nonce} from "../base/Nonce.sol"; + +error UnauthorizedCall(); + +/// @notice a contract for receiving crosschain messages. Validates messages and releases assets +/// from the TokenJar +contract FirepitDestination is Nonce, Owned { + /// @notice the source contract that is allowed to originate messages to this contract i.e. + /// FirepitSource + /// @dev updatable by owner + address public allowableSource; + + /// @notice the local contract(s) that are allowed to call this contract, i.e. Message Relayers + /// @dev updatable by owner + mapping(address callers => bool allowed) public allowableCallers; + + TokenJar public immutable TOKEN_JAR; + uint256 public constant MINIMUM_RELEASE_GAS = 100_000; + + event FailedRelease(uint256 indexed _nonce, address indexed _claimer); + + constructor(address _owner, address _tokenJar) Owned(_owner) { + TOKEN_JAR = TokenJar(payable(_tokenJar)); + } + + modifier onlyAllowed() { + require( + allowableCallers[msg.sender] + && allowableSource == IL1CrossDomainMessenger(msg.sender).xDomainMessageSender(), + UnauthorizedCall() + ); + _; + } + + /// @notice Calls Token Jar to release assets to a destination + /// @dev only callable by the messenger via the authorized L1 source contract + function claimTo(uint256 _nonce, Currency[] calldata assets, address claimer) + external + onlyAllowed + handleNonce(_nonce) + { + if (gasleft() < MINIMUM_RELEASE_GAS) { + emit FailedRelease(_nonce, claimer); + return; + } + try TOKEN_JAR.release(assets, claimer) {} + catch { + emit FailedRelease(_nonce, claimer); + return; + } + } + + function setAllowableCallers(address callers, bool isAllowed) external onlyOwner { + allowableCallers[callers] = isAllowed; + } + + function setAllowableSource(address source) external onlyOwner { + allowableSource = source; + } +} diff --git a/src/crosschain/FirepitSource.sol b/src/crosschain/FirepitSource.sol new file mode 100644 index 0000000..9e0ecf2 --- /dev/null +++ b/src/crosschain/FirepitSource.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.29; + +import {Currency} from "v4-core/types/Currency.sol"; +import {ERC20} from "solmate/src/tokens/ERC20.sol"; +import {SafeTransferLib} from "solmate/src/utils/SafeTransferLib.sol"; +import {Nonce} from "../base/Nonce.sol"; +import {ResourceManager} from "../base/ResourceManager.sol"; + +abstract contract FirepitSource is ResourceManager, Nonce { + using SafeTransferLib for ERC20; + + uint256 public constant DEFAULT_BRIDGE_ID = 0; + + /// TODO: Move threshold to constructor. It should not default to 0. + constructor(address _owner, address _resource) + ResourceManager(_resource, 69_420, _owner, address(0xdead)) + {} + + function _sendReleaseMessage( + uint256 bridgeId, + uint256 destinationNonce, + Currency[] calldata assets, + address claimer, + bytes memory addtlData + ) internal virtual; + + /// @notice Torches the RESOURCE by sending it to the burn address and sends a cross-domain + /// message to release the assets + function release(uint256 _nonce, Currency[] calldata assets, address claimer, uint32 l2GasLimit) + external + handleNonce(_nonce) + { + RESOURCE.safeTransferFrom(msg.sender, RESOURCE_RECIPIENT, threshold); + + _sendReleaseMessage(DEFAULT_BRIDGE_ID, _nonce, assets, claimer, abi.encode(l2GasLimit)); + } +} diff --git a/src/crosschain/OPStackFirepitSource.sol b/src/crosschain/OPStackFirepitSource.sol new file mode 100644 index 0000000..335b66e --- /dev/null +++ b/src/crosschain/OPStackFirepitSource.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.29; + +import {Currency} from "v4-core/types/Currency.sol"; +import {IL1CrossDomainMessenger} from "../interfaces/IL1CrossDomainMessenger.sol"; +import {IFirepitDestination} from "../interfaces/IFirepitDestination.sol"; +import {FirepitSource} from "./FirepitSource.sol"; + +contract OPStackFirepitSource is FirepitSource { + IL1CrossDomainMessenger public immutable MESSENGER; + address public immutable L2_TARGET; + + constructor(address _resource, address _messenger, address _l2Target) + FirepitSource(msg.sender, _resource) + { + MESSENGER = IL1CrossDomainMessenger(_messenger); + L2_TARGET = _l2Target; + } + + function _sendReleaseMessage( + uint256, // bridgeId + uint256 destinationNonce, + Currency[] calldata assets, + address claimer, + bytes memory addtlData + ) internal override { + (uint32 l2GasLimit) = abi.decode(addtlData, (uint32)); + MESSENGER.sendMessage( + L2_TARGET, + abi.encodeCall(IFirepitDestination.claimTo, (destinationNonce, assets, claimer)), + l2GasLimit + ); + } +} diff --git a/src/interfaces/IFirepitDestination.sol b/src/interfaces/IFirepitDestination.sol new file mode 100644 index 0000000..bb9fd83 --- /dev/null +++ b/src/interfaces/IFirepitDestination.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.29; + +import {Currency} from "v4-core/types/Currency.sol"; + +interface IFirepitDestination { + function claimTo(uint256 _nonce, Currency[] calldata assets, address claimer) external; +} diff --git a/src/interfaces/IL1CrossDomainMessenger.sol b/src/interfaces/IL1CrossDomainMessenger.sol new file mode 100644 index 0000000..696f005 --- /dev/null +++ b/src/interfaces/IL1CrossDomainMessenger.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.29; + +interface IL1CrossDomainMessenger { + function sendMessage(address _target, bytes calldata _message, uint32 _minGasLimit) external; + function xDomainMessageSender() external view returns (address); +} diff --git a/test/CrossChainFirepit.t.sol b/test/CrossChainFirepit.t.sol new file mode 100644 index 0000000..a5566e3 --- /dev/null +++ b/test/CrossChainFirepit.t.sol @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.29; + +import {ProtocolFeesTestBase, FirepitDestination} from "./utils/ProtocolFeesTestBase.sol"; +import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol"; + +import {INonce} from "../src/interfaces/base/INonce.sol"; + +contract CrossChainFirepitTest is ProtocolFeesTestBase { + uint32 public constant L2_GAS_LIMIT = 1_000_000; + + function setUp() public override { + super.setUp(); + + vm.prank(owner); + tokenJar.setReleaser(address(firepitDestination)); + } + + function test_release_release_erc20() public { + assertEq(resource.balanceOf(alice), INITIAL_TOKEN_AMOUNT); + assertEq(resource.balanceOf(address(opStackFirepitSource)), 0); + assertEq(resource.balanceOf(address(0xdead)), 0); + + vm.startPrank(alice); + resource.approve(address(opStackFirepitSource), INITIAL_TOKEN_AMOUNT); + opStackFirepitSource.release( + opStackFirepitSource.nonce(), releaseMockToken, alice, L2_GAS_LIMIT + ); + vm.stopPrank(); + + assertEq(mockToken.balanceOf(alice), INITIAL_TOKEN_AMOUNT); + assertEq(mockToken.balanceOf(address(tokenJar)), 0); + assertEq(resource.balanceOf(alice), 0); + assertEq(resource.balanceOf(address(opStackFirepitSource)), 0); + assertEq(resource.balanceOf(address(0xdead)), opStackFirepitSource.threshold()); + } + + /// @dev release SUCCEEDS on reverting tokens + function test_release_release_revertingToken() public { + uint256 nonce = opStackFirepitSource.nonce(); + vm.startPrank(alice); + resource.approve(address(opStackFirepitSource), INITIAL_TOKEN_AMOUNT); + + vm.expectEmit(true, true, false, false); + emit FirepitDestination.FailedRelease(nonce, alice); + opStackFirepitSource.release(nonce, releaseMockReverting, alice, L2_GAS_LIMIT); + vm.stopPrank(); + + // resource still burned + assertEq(resource.balanceOf(alice), 0); + assertEq(resource.balanceOf(address(opStackFirepitSource)), 0); + assertEq(resource.balanceOf(address(0xdead)), opStackFirepitSource.threshold()); + + // alice did NOT receive the reverting token + assertEq(revertingToken.balanceOf(alice), 0); + } + + /// @dev release SUCCEEDS on *releasing* an insufficient balance + /// @dev note release FAILS on an insufficient balance of the RESOURCE token + function test_release_release_insufficientBalance() public { + Currency[] memory assets = new Currency[](1); + assets[0] = Currency.wrap(address(0xffdeadbeefc0ffeebabeff)); + + uint256 nonce = opStackFirepitSource.nonce(); + + vm.startPrank(alice); + resource.approve(address(opStackFirepitSource), INITIAL_TOKEN_AMOUNT); + vm.expectEmit(true, true, false, false); + emit FirepitDestination.FailedRelease(nonce, alice); + opStackFirepitSource.release(nonce, assets, alice, L2_GAS_LIMIT); + vm.stopPrank(); + + // resource still burned + assertEq(resource.balanceOf(alice), 0); + assertEq(resource.balanceOf(address(opStackFirepitSource)), 0); + assertEq(resource.balanceOf(address(0xdead)), opStackFirepitSource.threshold()); + } + + function test_release_release_native() public { + uint256 bobNativeBefore = CurrencyLibrary.ADDRESS_ZERO.balanceOf(bob); + uint256 tokenJarNativeBefore = CurrencyLibrary.ADDRESS_ZERO.balanceOf(address(tokenJar)); + + assertGt(tokenJarNativeBefore, 0); + assertEq(resource.balanceOf(alice), INITIAL_TOKEN_AMOUNT); + assertEq(resource.balanceOf(address(opStackFirepitSource)), 0); + assertEq(resource.balanceOf(address(0xdead)), 0); + + vm.startPrank(alice); + resource.approve(address(opStackFirepitSource), INITIAL_TOKEN_AMOUNT); + opStackFirepitSource.release(opStackFirepitSource.nonce(), releaseMockNative, bob, L2_GAS_LIMIT); + vm.stopPrank(); + + // resource burned + assertEq(resource.balanceOf(alice), 0); + assertEq(resource.balanceOf(address(opStackFirepitSource)), 0); + assertEq(resource.balanceOf(address(0xdead)), opStackFirepitSource.threshold()); + + // bob received native asset + assertEq(CurrencyLibrary.ADDRESS_ZERO.balanceOf(bob), bobNativeBefore + tokenJarNativeBefore); + assertEq(CurrencyLibrary.ADDRESS_ZERO.balanceOf(address(tokenJar)), 0); + } + + function test_release_release_OOGToken() public { + uint256 currentNonce = opStackFirepitSource.nonce(); + uint256 currentDestinationNonce = firepitDestination.nonce(); + + vm.startPrank(alice); + resource.approve(address(opStackFirepitSource), INITIAL_TOKEN_AMOUNT); + + vm.expectEmit(true, true, false, false); + emit FirepitDestination.FailedRelease(currentNonce, alice); + opStackFirepitSource.release(opStackFirepitSource.nonce(), releaseMockOOG, alice, L2_GAS_LIMIT); + vm.stopPrank(); + + // resource still burned + assertEq(resource.balanceOf(alice), 0); + assertEq(resource.balanceOf(address(opStackFirepitSource)), 0); + assertEq(resource.balanceOf(address(0xdead)), opStackFirepitSource.threshold()); + + // nonces should have been incremented + uint256 newNonce = opStackFirepitSource.nonce(); + uint256 newDestinationNonce = firepitDestination.nonce(); + assertEq(newNonce, currentNonce + 1); + assertEq(newDestinationNonce, currentDestinationNonce + 1); + } + + /// @dev insufficient balance of the RESOURCE token will lead to a revert + function test_fuzz_revert_release_insufficient_balance(uint256 amount, uint256 seed) public { + amount = bound(amount, 1, resource.balanceOf(alice)); + + // alice spends some of her resource and is below the threshold + vm.prank(alice); + bool success = resource.transfer(bob, amount); + assertTrue(success); + + // alice does not have the threshold amount + assertLt(resource.balanceOf(alice), opStackFirepitSource.threshold()); + + uint256 _nonce = opStackFirepitSource.nonce(); + + vm.startPrank(alice); + resource.approve(address(opStackFirepitSource), INITIAL_TOKEN_AMOUNT); + vm.expectRevert(); + opStackFirepitSource.release( + _nonce, fuzzReleaseAny[seed % fuzzReleaseAny.length], bob, L2_GAS_LIMIT + ); + vm.stopPrank(); + } + + function test_fuzz_revert_release_invalid_nonce(uint256 _nonce, uint256 seed) public { + vm.assume(_nonce != opStackFirepitSource.nonce()); + + vm.startPrank(alice); + resource.approve(address(opStackFirepitSource), INITIAL_TOKEN_AMOUNT); + vm.expectRevert(INonce.InvalidNonce.selector); + opStackFirepitSource.release( + _nonce, fuzzReleaseAny[seed % fuzzReleaseAny.length], bob, L2_GAS_LIMIT + ); + vm.stopPrank(); + } + + /// @dev test that two transactions with the same nonce, the second one should revert + function test_revert_release_frontrun() public { + uint256 _nonce = opStackFirepitSource.nonce(); + + vm.startPrank(alice); + resource.approve(address(opStackFirepitSource), INITIAL_TOKEN_AMOUNT); + opStackFirepitSource.release(_nonce, releaseMockBoth, alice, L2_GAS_LIMIT); + vm.stopPrank(); + + vm.startPrank(bob); + resource.approve(address(opStackFirepitSource), INITIAL_TOKEN_AMOUNT); + vm.expectRevert(INonce.InvalidNonce.selector); + opStackFirepitSource.release(_nonce, releaseMockBoth, bob, L2_GAS_LIMIT); + vm.stopPrank(); + } + + /// @dev test that insufficient gas DOES NOT revert + function test_fuzz_release_insufficient_gas(uint8 seed) public { + uint256 currentNonce = opStackFirepitSource.nonce(); + uint256 currentDestinationNonce = firepitDestination.nonce(); + + TestBalances memory aliceBalances = _testBalances(alice); + + vm.startPrank(alice); + resource.approve(address(opStackFirepitSource), INITIAL_TOKEN_AMOUNT); + vm.expectEmit(false, false, false, false, address(firepitDestination), 1); + emit FirepitDestination.FailedRelease(0, address(0)); + opStackFirepitSource.release{gas: 150_000}( + currentNonce, fuzzReleaseAny[seed % fuzzReleaseAny.length], alice, 150_000 + ); + vm.stopPrank(); + + // alice did not receive any assets + TestBalances memory aliceBalancesAfter = _testBalances(alice); + assertEq(aliceBalancesAfter.native, aliceBalances.native); + assertEq(aliceBalancesAfter.mockToken, aliceBalances.mockToken); + + // nonces should have been incremented + uint256 newNonce = opStackFirepitSource.nonce(); + uint256 newDestinationNonce = firepitDestination.nonce(); + assertEq(newNonce, currentNonce + 1); + assertEq(newDestinationNonce, currentDestinationNonce + 1); + } + + /// @dev releasing a revert token, OOG token, or revert bomb token are still successful + function test_fuzz_gas_release_malicious(uint32 gasUsed, uint32 revertLength) public { + vm.assume(150_000 < gasUsed); + try revertBombToken.setBigReason(revertLength) {} catch {} + + uint256 currentNonce = opStackFirepitSource.nonce(); + uint256 currentDestinationNonce = firepitDestination.nonce(); + + // the cross-chain message always succeeds + vm.startPrank(alice); + resource.approve(address(opStackFirepitSource), INITIAL_TOKEN_AMOUNT); + opStackFirepitSource.release{gas: gasUsed}(currentNonce, releaseMalicious, alice, gasUsed); + vm.stopPrank(); + + // nonces should have been incremented + uint256 newNonce = opStackFirepitSource.nonce(); + uint256 newDestinationNonce = firepitDestination.nonce(); + assertEq(newNonce, currentNonce + 1); + assertEq(newDestinationNonce, currentDestinationNonce + 1); + } +} diff --git a/test/mocks/MockCrossDomainMessenger.sol b/test/mocks/MockCrossDomainMessenger.sol new file mode 100644 index 0000000..79683c0 --- /dev/null +++ b/test/mocks/MockCrossDomainMessenger.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.29; + +import {IL1CrossDomainMessenger} from "../../src/interfaces/IL1CrossDomainMessenger.sol"; + +contract MockCrossDomainMessenger is IL1CrossDomainMessenger { + address public sender; + + function sendMessage(address _target, bytes memory _message, uint32 _gasLimit) external override { + sender = msg.sender; + + // simulate sending a message + (bool success,) = _target.call{gas: _gasLimit}(_message); + require(success, "Message send failed"); + } + + function xDomainMessageSender() external view override returns (address) { + return sender; + } +} diff --git a/test/utils/ProtocolFeesTestBase.sol b/test/utils/ProtocolFeesTestBase.sol index 7620eff..f85913d 100644 --- a/test/utils/ProtocolFeesTestBase.sol +++ b/test/utils/ProtocolFeesTestBase.sol @@ -11,6 +11,10 @@ import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol"; import {Firepit} from "../../src/releasers/Firepit.sol"; import {TokenJar, ITokenJar} from "../../src/TokenJar.sol"; import {IReleaser} from "../../src/interfaces/IReleaser.sol"; +import {OPStackFirepitSource} from "../../src/crosschain/OPStackFirepitSource.sol"; +import {FirepitDestination} from "../../src/crosschain/FirepitDestination.sol"; + +import {MockCrossDomainMessenger} from "../mocks/MockCrossDomainMessenger.sol"; contract ProtocolFeesTestBase is Test { address owner; @@ -25,6 +29,9 @@ contract ProtocolFeesTestBase is Test { ITokenJar tokenJar; IReleaser firepit; + OPStackFirepitSource opStackFirepitSource; + MockCrossDomainMessenger mockCrossDomainMessenger = new MockCrossDomainMessenger(); + FirepitDestination firepitDestination; uint256 public constant INITIAL_TOKEN_AMOUNT = 1000e18; uint256 public constant INITIAL_NATIVE_AMOUNT = 10 ether; @@ -66,8 +73,18 @@ contract ProtocolFeesTestBase is Test { firepit.setThresholdSetter(owner); + firepitDestination = new FirepitDestination(owner, address(tokenJar)); + // owner is set to the msg.sender + opStackFirepitSource = new OPStackFirepitSource( + address(resource), address(mockCrossDomainMessenger), address(firepitDestination) + ); + opStackFirepitSource.setThresholdSetter(owner); + opStackFirepitSource.setThreshold(INITIAL_TOKEN_AMOUNT); + revertingToken.setRevertFrom(address(tokenJar), true); + firepitDestination.setAllowableSource(address(opStackFirepitSource)); + firepitDestination.setAllowableCallers(address(mockCrossDomainMessenger), true); vm.stopPrank(); // Supply tokens to the TokenJar