diff --git a/.gas-snapshot b/.gas-snapshot index d01040e..053a379 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,3 +1,7 @@ +OrderOriginPermit2Test:test_fillPermit2() (gas: 225289) +OrderOriginPermit2Test:test_fillPermit2_multi() (gas: 1019134) +OrderOriginPermit2Test:test_initiatePermit2() (gas: 235752) +OrderOriginPermit2Test:test_initiatePermit2_multi() (gas: 989274) OrdersTest:test_fill_ERC20() (gas: 70537) OrdersTest:test_fill_ETH() (gas: 68498) OrdersTest:test_fill_both() (gas: 166773) @@ -12,6 +16,8 @@ OrdersTest:test_orderExpired() (gas: 28106) OrdersTest:test_sweepERC20() (gas: 60491) OrdersTest:test_sweepETH() (gas: 82186) OrdersTest:test_underflowETH() (gas: 63690) +PassagePermit2Test:test_disallowedEnterPermit2() (gas: 699630) +PassagePermit2Test:test_enterTokenPermit2() (gas: 145449) PassageTest:test_configureEnter() (gas: 125771) PassageTest:test_disallowedEnter() (gas: 56619) PassageTest:test_enter() (gas: 25519) @@ -23,6 +29,7 @@ PassageTest:test_onlyTokenAdmin() (gas: 16881) PassageTest:test_receive() (gas: 21383) PassageTest:test_setUp() (gas: 17011) PassageTest:test_withdraw() (gas: 59188) +RollupPassagePermit2Test:test_exitTokenPermit2() (gas: 129402) RollupPassageTest:test_exit() (gas: 22403) RollupPassageTest:test_exitToken() (gas: 50232) RollupPassageTest:test_fallback() (gas: 19949) diff --git a/test/Helpers.t.sol b/test/Helpers.t.sol index c040458..ef5e73d 100644 --- a/test/Helpers.t.sol +++ b/test/Helpers.t.sol @@ -1,10 +1,13 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.24; -import {Test, console2} from "forge-std/Test.sol"; import {Zenith} from "../src/Zenith.sol"; +import {UsesPermit2} from "../src/permit2/UsesPermit2.sol"; + +import {Test, console2} from "forge-std/Test.sol"; import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; import {ERC20Burnable} from "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import {ISignatureTransfer} from "permit2/src/interfaces/ISignatureTransfer.sol"; contract TestERC20 is ERC20Burnable { constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {} @@ -14,6 +17,176 @@ contract TestERC20 is ERC20Burnable { } } +contract Permit2Helpers is Test { + address permit2Contract; + + /// @notice the address signing the Permit messages and its pk + uint256 ownerKey = 123; + address owner = vm.addr(ownerKey); + + // permit consts + UsesPermit2.Witness witness; + + bytes32 private immutable _CACHED_DOMAIN_SEPARATOR; + uint256 private immutable _CACHED_CHAIN_ID; + + bytes32 private constant _HASHED_NAME = keccak256("Permit2"); + bytes32 private constant _TYPE_HASH = + keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"); + + string public constant _PERMIT_TRANSFER_FROM_WITNESS_TYPEHASH_STUB = + "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,"; + + bytes32 public constant _TOKEN_PERMISSIONS_TYPEHASH = keccak256("TokenPermissions(address token,uint256 amount)"); + + string public constant _PERMIT_BATCH_WITNESS_TRANSFER_FROM_TYPEHASH_STUB = + "PermitBatchWitnessTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline,"; + + // Cache the domain separator as an immutable value, but also store the chain id that it + // corresponds to, in order to invalidate the cached domain separator if the chain id changes. + constructor() { + _CACHED_CHAIN_ID = block.chainid; + _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME); + } + + function _setUpPermit2(address token, uint256 amount) internal { + vm.label(owner, "owner"); + + // setup permit2 contract + permit2Contract = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + vm.label(address(permit2Contract), "permit2"); + + // approve permit2 + vm.prank(owner); + TestERC20(token).approve(address(permit2Contract), amount * 10000); + } + + /// @notice given a Permit and a Witness, produce a signature from the `owner` + function signPermit( + uint256 signingKey, + address spender, + ISignatureTransfer.PermitTransferFrom memory permit, + UsesPermit2.Witness memory _witness + ) internal view returns (bytes memory signature) { + bytes32 permit2Hash = hashWithWitness(spender, permit, _witness.witnessHash, _witness.witnessTypeString); + bytes32 signHash = _hashTypedData(permit2Hash); + uint8 v; + bytes32 r; + bytes32 s; + (v, r, s) = vm.sign(signingKey, signHash); + signature = abi.encodePacked(r, s, v); + } + + // this function is private on permit2 contracts but need to port it here for test functionality + function hashWithWitness( + address spender, + ISignatureTransfer.PermitTransferFrom memory _permit, + bytes32 _witness, + string memory witnessTypeString + ) internal pure returns (bytes32) { + bytes32 typeHash = keccak256(abi.encodePacked(_PERMIT_TRANSFER_FROM_WITNESS_TYPEHASH_STUB, witnessTypeString)); + + bytes32 tokenPermissionsHash = _hashTokenPermissions(_permit.permitted); + return keccak256(abi.encode(typeHash, tokenPermissionsHash, spender, _permit.nonce, _permit.deadline, _witness)); + } + + /// @notice given a Permit and a Witness, produce a signature from the `owner` + function signPermit( + uint256 signingKey, + address spender, + ISignatureTransfer.PermitBatchTransferFrom memory permit, + UsesPermit2.Witness memory _witness + ) internal view returns (bytes memory signature) { + bytes32 permit2Hash = hashWithWitness(spender, permit, _witness.witnessHash, _witness.witnessTypeString); + bytes32 signHash = _hashTypedData(permit2Hash); + uint8 v; + bytes32 r; + bytes32 s; + (v, r, s) = vm.sign(signingKey, signHash); + signature = abi.encodePacked(r, s, v); + } + + function hashWithWitness( + address spender, + ISignatureTransfer.PermitBatchTransferFrom memory permit, + bytes32 _witness, + string memory witnessTypeString + ) internal pure returns (bytes32) { + bytes32 typeHash = + keccak256(abi.encodePacked(_PERMIT_BATCH_WITNESS_TRANSFER_FROM_TYPEHASH_STUB, witnessTypeString)); + + uint256 numPermitted = permit.permitted.length; + bytes32[] memory tokenPermissionHashes = new bytes32[](numPermitted); + + for (uint256 i = 0; i < numPermitted; ++i) { + tokenPermissionHashes[i] = _hashTokenPermissions(permit.permitted[i]); + } + + return keccak256( + abi.encode( + typeHash, + keccak256(abi.encodePacked(tokenPermissionHashes)), + spender, + permit.nonce, + permit.deadline, + _witness + ) + ); + } + + // this function is private on permit2 contracts but need to port it here for test functionality + function _hashTokenPermissions(ISignatureTransfer.TokenPermissions memory _permitted) + private + pure + returns (bytes32) + { + return keccak256(abi.encode(_TOKEN_PERMISSIONS_TYPEHASH, _permitted)); + } + + /// @notice Returns the domain separator for the current chain. + /// @dev Uses cached version if chainid and address are unchanged from construction. + function DOMAIN_SEPARATOR() public view returns (bytes32) { + return block.chainid == _CACHED_CHAIN_ID + ? _CACHED_DOMAIN_SEPARATOR + : _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME); + } + + /// @notice Builds a domain separator using the current chainId and contract address. + function _buildDomainSeparator(bytes32 typeHash, bytes32 nameHash) private view returns (bytes32) { + return keccak256(abi.encode(typeHash, nameHash, block.chainid, permit2Contract)); + } + + /// @notice Creates an EIP-712 typed data hash + function _hashTypedData(bytes32 dataHash) internal view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), dataHash)); + } +} + +/// HACK to use abi.encodeWithSelector +interface ISinglePermit { + /// @notice stubbed `permitWitnessTransferFrom` - does not check signature, nonce, or deadline + function permitWitnessTransferFrom( + ISignatureTransfer.PermitTransferFrom memory permit, + ISignatureTransfer.SignatureTransferDetails calldata transferDetails, + address owner, + bytes32, /*witness*/ + string calldata, /*witnessTypeString*/ + bytes calldata /*signature*/ + ) external; +} + +/// HACK to use abi.encodeWithSelector +interface IBatchPermit { + function permitWitnessTransferFrom( + ISignatureTransfer.PermitBatchTransferFrom memory permit, + ISignatureTransfer.SignatureTransferDetails[] calldata transferDetails, + address owner, + bytes32, /*witness*/ + string calldata, /*witnessTypeString*/ + bytes calldata /*signature*/ + ) external; +} + contract HelpersTest is Test { Zenith public target; diff --git a/test/Permit2Orders.t.sol b/test/Permit2Orders.t.sol new file mode 100644 index 0000000..4d9d04e --- /dev/null +++ b/test/Permit2Orders.t.sol @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +// test contracts +import {RollupOrders} from "../src/Orders.sol"; +import {IOrders} from "../src/interfaces/IOrders.sol"; +import {UsesPermit2} from "../src/permit2/UsesPermit2.sol"; + +// Permit2 deps +import {ISignatureTransfer} from "permit2/src/interfaces/ISignatureTransfer.sol"; + +// other test utils +import {Permit2Helpers, IBatchPermit, TestERC20} from "./Helpers.t.sol"; +import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {Test, console2} from "forge-std/Test.sol"; + +contract Permit2BatchTest is Permit2Helpers { + // batch permit + UsesPermit2.Permit2Batch permit2; + ISignatureTransfer.SignatureTransferDetails[] transferDetails; + + function _setupBatchPermit(address token, uint256 amount) internal { + // create a batch permit with generic details + permit2.permit.permitted.push(ISignatureTransfer.TokenPermissions({token: token, amount: amount})); + permit2.permit.nonce = 0; + permit2.permit.deadline = block.timestamp; + permit2.owner = owner; + } +} + +contract OrderOriginPermit2Test is Permit2BatchTest { + RollupOrders public target; + + IOrders.Input[] public inputs; + IOrders.Output[] public outputs; + + mapping(address => bool) isToken; + + address token; + uint32 chainId = 3; + address recipient = address(0x123); + uint256 amount = 200; + uint256 deadline = block.timestamp; + + address tokenRecipient = address(0xdeadbeef); + + event Order(uint256 deadline, IOrders.Input[] inputs, IOrders.Output[] outputs); + + event Filled(IOrders.Output[] outputs); + + function setUp() public { + vm.createSelectFork("https://ethereum-rpc.publicnode.com"); + // deploy token + token = address(new TestERC20("hi", "HI")); + TestERC20(token).mint(owner, amount * 10000); + isToken[token] = true; + + // setup permit2 contract & permit details + _setUpPermit2(token, amount); + _setupBatchPermit(token, amount); + + // deploy Orders contract + target = new RollupOrders(address(permit2Contract)); + vm.label(address(target), "orders"); + + // setup Order Inputs/Outputs + IOrders.Input memory input = IOrders.Input(token, amount); + inputs.push(input); + + IOrders.Output memory output = IOrders.Output(token, amount, recipient, chainId); + outputs.push(output); + + // construct Orders witness + witness = target.outputWitness(outputs); + + // sign permit + witness + permit2.signature = signPermit(ownerKey, address(target), permit2.permit, witness); + } + + function test_initiatePermit2() public { + // construct transfer details + transferDetails.push(ISignatureTransfer.SignatureTransferDetails({to: tokenRecipient, requestedAmount: amount})); + + // expect Order event is initiated, ERC20 is transferred + vm.expectEmit(); + emit Order(permit2.permit.deadline, inputs, outputs); + vm.expectCall( + address(permit2Contract), + abi.encodeWithSelector( + IBatchPermit.permitWitnessTransferFrom.selector, + permit2.permit, + transferDetails, + owner, + witness.witnessHash, + witness.witnessTypeString, + permit2.signature + ) + ); + vm.expectCall(token, abi.encodeWithSelector(ERC20.transferFrom.selector, owner, tokenRecipient, amount)); + target.initiatePermit2(tokenRecipient, outputs, permit2); + } + + // input multiple ERC20s + function test_initiatePermit2_multi() public { + // construct transfer details + transferDetails.push(ISignatureTransfer.SignatureTransferDetails({to: tokenRecipient, requestedAmount: amount})); + + // setup second token + address token2 = address(new TestERC20("bye", "BYE")); + TestERC20(token2).mint(owner, amount * 10000); + vm.prank(owner); + TestERC20(token2).approve(address(permit2Contract), amount * 10000); + + // add second token input + inputs.push(IOrders.Input(token2, amount * 2)); + + // add TokenPermissions + permit2.permit.permitted.push(ISignatureTransfer.TokenPermissions({token: token2, amount: amount * 2})); + + // add TransferDetails + transferDetails.push( + ISignatureTransfer.SignatureTransferDetails({to: tokenRecipient, requestedAmount: amount * 2}) + ); + + // re-sign new permit + permit2.signature = signPermit(ownerKey, address(target), permit2.permit, witness); + + // expect Order event is emitted, ERC20 is transferred + vm.expectEmit(); + emit Order(permit2.permit.deadline, inputs, outputs); + vm.expectCall( + address(permit2Contract), + abi.encodeWithSelector( + IBatchPermit.permitWitnessTransferFrom.selector, + permit2.permit, + transferDetails, + owner, + witness.witnessHash, + witness.witnessTypeString, + permit2.signature + ) + ); + vm.expectCall(token, abi.encodeWithSelector(ERC20.transferFrom.selector, owner, tokenRecipient, amount)); + vm.expectCall(token2, abi.encodeWithSelector(ERC20.transferFrom.selector, owner, tokenRecipient, amount * 2)); + target.initiatePermit2(tokenRecipient, outputs, permit2); + } + + function test_fillPermit2() public { + // construct transfer details + transferDetails.push(ISignatureTransfer.SignatureTransferDetails({to: recipient, requestedAmount: amount})); + + vm.expectEmit(); + emit Filled(outputs); + vm.expectCall( + address(permit2Contract), + abi.encodeWithSelector( + IBatchPermit.permitWitnessTransferFrom.selector, + permit2.permit, + transferDetails, + owner, + witness.witnessHash, + witness.witnessTypeString, + permit2.signature + ) + ); + vm.expectCall(token, abi.encodeWithSelector(ERC20.transferFrom.selector, owner, recipient, amount)); + target.fillPermit2(outputs, permit2); + } + + function test_fillPermit2_multi() public { + // construct transfer details + transferDetails.push(ISignatureTransfer.SignatureTransferDetails({to: recipient, requestedAmount: amount})); + + // setup second token + address token2 = address(new TestERC20("bye", "BYE")); + TestERC20(token2).mint(owner, amount * 10000); + vm.prank(owner); + TestERC20(token2).approve(address(permit2Contract), amount * 10000); + + // add second token output + outputs.push(IOrders.Output(token2, amount * 2, recipient, chainId)); + + // add TokenPermissions + permit2.permit.permitted.push(ISignatureTransfer.TokenPermissions({token: token2, amount: amount * 2})); + + // add TransferDetails + transferDetails.push(ISignatureTransfer.SignatureTransferDetails({to: recipient, requestedAmount: amount * 2})); + + // re-sign new permit + witness = target.outputWitness(outputs); + permit2.signature = signPermit(ownerKey, address(target), permit2.permit, witness); + + // expect Filled event is emitted, ERC20 is transferred + vm.expectEmit(); + emit Filled(outputs); + vm.expectCall( + address(permit2Contract), + abi.encodeWithSelector( + IBatchPermit.permitWitnessTransferFrom.selector, + permit2.permit, + transferDetails, + owner, + witness.witnessHash, + witness.witnessTypeString, + permit2.signature + ) + ); + vm.expectCall(token, abi.encodeWithSelector(ERC20.transferFrom.selector, owner, recipient, amount)); + vm.expectCall(token2, abi.encodeWithSelector(ERC20.transferFrom.selector, owner, recipient, amount * 2)); + target.fillPermit2(outputs, permit2); + } +} diff --git a/test/Permit2Passage.t.sol b/test/Permit2Passage.t.sol new file mode 100644 index 0000000..6fd6df0 --- /dev/null +++ b/test/Permit2Passage.t.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +// test contracts +import {Passage, RollupPassage} from "../src/Passage.sol"; +import {UsesPermit2} from "../src/permit2/UsesPermit2.sol"; + +// Permit2 deps +import {ISignatureTransfer} from "permit2/src/interfaces/ISignatureTransfer.sol"; + +// other test utils +import {Permit2Helpers, ISinglePermit, TestERC20} from "./Helpers.t.sol"; +import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {ERC20Burnable} from "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import {Test, console2} from "forge-std/Test.sol"; + +contract SharedPermit2Test is Permit2Helpers { + // single permit + UsesPermit2.Permit2 permit2; + ISignatureTransfer.SignatureTransferDetails transferDetails; + + function _setupSinglePermit(address token, uint256 amount) internal { + // create a single permit with generic details + permit2.permit = ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: token, amount: amount}), + nonce: 0, + deadline: block.timestamp + }); + permit2.owner = owner; + } +} + +contract PassagePermit2Test is SharedPermit2Test { + Passage public target; + + // token consts + address token; + uint256 amount = 200; + uint256 chainId = 3; + address recipient = address(0x123); + + event EnterToken( + uint256 indexed rollupChainId, address indexed rollupRecipient, address indexed token, uint256 amount + ); + + function setUp() public { + vm.createSelectFork("https://ethereum-rpc.publicnode.com"); + + // deploy token + token = address(new TestERC20("hi", "HI")); + TestERC20(token).mint(owner, amount * 10000); + + // configure token for passage + address[] memory initialEnterTokens = new address[](2); + initialEnterTokens[0] = token; + + // setup permit2 contract & permit details + _setUpPermit2(token, amount); + _setupSinglePermit(token, amount); + + // deploy Passage + target = new Passage(block.chainid + 1, address(this), initialEnterTokens, address(permit2Contract)); + vm.label(address(target), "passage"); + + // construct Enter witness + witness = target.enterWitness(chainId, recipient); + + // sign permit + witness + permit2.signature = signPermit(ownerKey, address(target), permit2.permit, witness); + + // construct transfer details + transferDetails = ISignatureTransfer.SignatureTransferDetails({to: address(target), requestedAmount: amount}); + } + + function test_enterTokenPermit2() public { + vm.expectEmit(); + emit EnterToken(chainId, recipient, token, amount); + vm.expectCall( + address(permit2Contract), + abi.encodeWithSelector( + ISinglePermit.permitWitnessTransferFrom.selector, + permit2.permit, + transferDetails, + owner, + witness.witnessHash, + witness.witnessTypeString, + permit2.signature + ) + ); + vm.expectCall(token, abi.encodeWithSelector(ERC20.transferFrom.selector, owner, address(target), amount)); + target.enterTokenPermit2(chainId, recipient, permit2); + } + + function test_disallowedEnterPermit2() public { + // deploy new token & approve permit2 + address newToken = address(new TestERC20("bye", "BYE")); + TestERC20(newToken).mint(owner, amount * 10000); + vm.prank(owner); + TestERC20(newToken).approve(address(permit2Contract), amount * 10000); + + // edit permit token to new token + permit2.permit.permitted.token = newToken; + + // re-sign permit + witness + permit2.signature = signPermit(ownerKey, address(target), permit2.permit, witness); + + // expect revert DisallowedEnter + vm.expectRevert(abi.encodeWithSelector(Passage.DisallowedEnter.selector, newToken)); + target.enterTokenPermit2(chainId, recipient, permit2); + } +} + +contract RollupPassagePermit2Test is SharedPermit2Test { + RollupPassage public target; + + // token consts + address token; + uint256 amount = 200; + uint256 chainId = 3; + address recipient = address(0x123); + + event ExitToken(address indexed hostRecipient, address indexed token, uint256 amount); + + function setUp() public { + vm.createSelectFork("https://ethereum-rpc.publicnode.com"); + + // deploy token & approve permit2 + token = address(new TestERC20("hi", "HI")); + TestERC20(token).mint(owner, amount * 10000); + + // setup permit2 contract & permit details + _setUpPermit2(token, amount); + _setupSinglePermit(token, amount); + + // deploy Passage + target = new RollupPassage(address(permit2Contract)); + vm.label(address(target), "passage"); + + // construct Exit witness + witness = target.exitWitness(recipient); + + // sign permit + witness + permit2.signature = signPermit(ownerKey, address(target), permit2.permit, witness); + + // construct transfer details + transferDetails = ISignatureTransfer.SignatureTransferDetails({to: address(target), requestedAmount: amount}); + } + + function test_exitTokenPermit2() public { + vm.expectEmit(); + emit ExitToken(recipient, token, amount); + vm.expectCall( + address(permit2Contract), + abi.encodeWithSelector( + ISinglePermit.permitWitnessTransferFrom.selector, + permit2.permit, + transferDetails, + owner, + witness.witnessHash, + witness.witnessTypeString, + permit2.signature + ) + ); + vm.expectCall(token, abi.encodeWithSelector(ERC20.transferFrom.selector, owner, address(target), amount)); + vm.expectCall(token, abi.encodeWithSelector(ERC20Burnable.burn.selector, amount)); + target.exitTokenPermit2(recipient, permit2); + } +}