From 5bf9764cc339905197dcb9bb08512f140c4180fd Mon Sep 17 00:00:00 2001 From: tbtstl Date: Sat, 2 Apr 2022 23:08:48 -0400 Subject: [PATCH 1/5] [feat] unoptimized gasless asks --- contracts/ZoraModuleManager.sol | 7 + .../Asks/Core/ETH/GaslessAsksCoreEth.sol | 202 ++++++++++++++++++ .../Asks/Core/ETH/IGaslessAsksCoreEth.sol | 46 ++++ 3 files changed, 255 insertions(+) create mode 100644 contracts/modules/Asks/Core/ETH/GaslessAsksCoreEth.sol create mode 100644 contracts/modules/Asks/Core/ETH/IGaslessAsksCoreEth.sol diff --git a/contracts/ZoraModuleManager.sol b/contracts/ZoraModuleManager.sol index 85bc2e4b..c5640011 100644 --- a/contracts/ZoraModuleManager.sol +++ b/contracts/ZoraModuleManager.sol @@ -3,6 +3,13 @@ pragma solidity 0.8.10; import {ZoraProtocolFeeSettings} from "./auxiliary/ZoraProtocolFeeSettings/ZoraProtocolFeeSettings.sol"; +struct ModuleApprovalSig { + uint8 v; // The 129th byte and chain ID of the signature + bytes32 r; // The first 64 bytes of the signature + bytes32 s; // Bytes 64-128 of the signature + uint256 deadline; // The deadline at which point the approval expires +} + /// @title ZoraModuleManager /// @author tbtstl /// @notice This contract allows users to approve registered modules on ZORA V3 diff --git a/contracts/modules/Asks/Core/ETH/GaslessAsksCoreEth.sol b/contracts/modules/Asks/Core/ETH/GaslessAsksCoreEth.sol new file mode 100644 index 00000000..7d2ffd51 --- /dev/null +++ b/contracts/modules/Asks/Core/ETH/GaslessAsksCoreEth.sol @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {ReentrancyGuard} from "@rari-capital/solmate/src/utils/ReentrancyGuard.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +import {ZoraModuleManager} from "../../../../ZoraModuleManager.sol"; +import {FeePayoutSupportV1} from "../../../../common/FeePayoutSupport/FeePayoutSupportV1.sol"; +import {ModuleNamingSupportV1} from "../../../../common/ModuleNamingSupport/ModuleNamingSupportV1.sol"; +import {IGaslessAsksCoreEth} from "./IGaslessAsksCoreEth.sol"; +import {AsksCoreEth} from "./AsksCoreEth.sol"; + +/// @title Gasless Asks Core ETH +/// @author tbtstl +/// @notice Extension to minimal ETH asks module, providing off-chain order support +contract GaslessAsksCoreEth is IGaslessAsksCoreEth, ReentrancyGuard, FeePayoutSupportV1, ModuleNamingSupportV1 { + /// /// + /// IMMUTABLES /// + /// /// + + ZoraModuleManager private immutable zmm; + AsksCoreEth private immutable asksModule; + + /// @notice The EIP-712 domain separator + bytes32 private immutable EIP_712_DOMAIN_SEPARATOR = + keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes("ZORA:GaslessAsksCoreEth")), + keccak256(bytes("1")), + _chainID(), + address(this) + ) + ); + + /// @notice The EIP-712 type for a signed ask order + /// @dev keccak256("SignedAsk(address tokenAddress,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 amount,uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)") + bytes32 private constant SIGNED_ASK_TYPEHASH = 0x324d0f7b7aa4e0f218259028fc60b98a32657c974f8cb44eb3ceadbec042ddc4; + + /// /// + /// ASK STORAGE /// + /// /// + + /// @notice The spent (canceled or executed) asks + /// @dev ERC-721 token contract => ERC-721 token id => Ask signer => spent boolean + mapping(address => mapping(uint256 => mapping(address => bool))) public spentAsks; + + /// @param _zmm The ZORA Module Manager address + /// @param _asksModule The ZORA Asks Core ETH Module address + /// @param _royaltyEngine The Manifold Royalty Engine address + /// @param _protocolFeeSettings The ZORA Protocol Fee Settings address + /// @param _weth The WETH token address + // TODO we don't even need this to be a "module" since no transfer helpers are used here + constructor( + address _zmm, + address _asksModule, + address _royaltyEngine, + address _protocolFeeSettings, + address _weth + ) + FeePayoutSupportV1(_royaltyEngine, _protocolFeeSettings, _weth, ZoraModuleManager(_zmm).registrar()) + ModuleNamingSupportV1("Gasless Asks Core ETH") + { + zmm = ZoraModuleManager(_zmm); + asksModule = AsksCoreEth(_asksModule); + } + + /// @notice Implements EIP-165 for standard interface detection + /// @dev `0x01ffc9a7` is the IERC165 interface id + /// @param _interfaceId The identifier of a given interface + /// @return If the given interface is supported + function supportsInterface(bytes4 _interfaceId) external pure returns (bool) { + return _interfaceId == type(IGaslessAsksCoreEth).interfaceId || _interfaceId == 0x01ffc9a7; + } + + /// @notice Executes a signed order on the Asks module + /// @param _ask The signed ask parameters to execute + /// @param _v The 129th byte and chain ID of the signature + /// @param _r The first 64 bytes of the signature + /// @param _s Bytes 64-128 of the signature + function executeAsk( + IGaslessAsksCoreEth.GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external payable nonReentrant { + require(_ask.expiry == 0 || _ask.expiry >= block.timestamp, "EXPIRED_ASK"); + address recoveredAddress = _recoverAddress(_ask, _v, _r, _s); + require(recoveredAddress != address(0) && recoveredAddress == _ask.from, "INVALID_SIG"); + require(!spentAsks[_ask.tokenAddress][_ask.tokenId][_ask.from], "SPENT_ASK"); + + if (!zmm.isModuleApproved(_ask.from, address(asksModule))) { + zmm.setApprovalForModuleBySig( + address(asksModule), + _ask.from, + true, + _ask.approvalSig.deadline, + _ask.approvalSig.v, + _ask.approvalSig.r, + _ask.approvalSig.s + ); + } + + asksModule.createAsk(_ask.tokenAddress, _ask.tokenId, _ask.amount); + asksModule.fillAsk{value: msg.value}(_ask.tokenAddress, _ask.tokenId); + spentAsks[_ask.tokenAddress][_ask.tokenId][_ask.from] = true; + + IERC721(_ask.tokenAddress).transferFrom(address(this), msg.sender, _ask.tokenId); + } + + /// @notice Creates an on-chain order on the Asks module + /// @param _ask The signed ask parameters to store + /// @param _v The 129th byte and chain ID of the signature + /// @param _r The first 64 bytes of the signature + /// @param _s Bytes 64-128 of the signature + function storeAsk( + IGaslessAsksCoreEth.GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external nonReentrant { + require(_ask.expiry == 0 || _ask.expiry >= block.timestamp, "EXPIRED_ASK"); + address recoveredAddress = _recoverAddress(_ask, _v, _r, _s); + require(recoveredAddress != address(0) && recoveredAddress == _ask.from, "INVALID_SIG"); + + asksModule.createAsk(_ask.tokenAddress, _ask.tokenId, _ask.amount); + } + + /// @notice Broadcasts an on-chain order to indexers + /// @dev Intentionally a no-op, this can be picked up via EVM traces :) + /// @param _ask The signed ask parameters to broadcast + /// @param _v The 129th byte and chain ID of the signature + /// @param _r The first 64 bytes of the signature + /// @param _s Bytes 64-128 of the signature + function broadcastAsk( + IGaslessAsksCoreEth.GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external { + // noop :) + } + + /// @notice Invalidates an off-chain order + /// @param _ask The signed ask parameters to invalidate + function cancelAsk(IGaslessAsksCoreEth.GaslessAsk calldata _ask) external nonReentrant { + require(msg.sender == _ask.from, "ONLY_SIGNER"); + + spentAsks[_ask.tokenAddress][_ask.tokenId][msg.sender] = true; + } + + /// @notice Validates an on-chain order + /// @param _ask The signed ask parameters to validate + /// @param _v The 129th byte and chain ID of the signature + /// @param _r The first 64 bytes of the signature + /// @param _s Bytes 64-128 of the signature + function validateAskSig( + IGaslessAsksCoreEth.GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external view returns (bool) { + return _recoverAddress(_ask, _v, _r, _s) == _ask.from; + } + + function _recoverAddress( + IGaslessAsksCoreEth.GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) private view returns (address) { + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + EIP_712_DOMAIN_SEPARATOR, + keccak256( + abi.encode( + SIGNED_ASK_TYPEHASH, + _ask.tokenAddress, + _ask.tokenId, + _ask.expiry, + _ask.nonce, + _ask.amount, + _ask.approvalSig.v, + _ask.approvalSig.r, + _ask.approvalSig.s, + _ask.approvalSig.deadline + ) + ) + ) + ); + + return ecrecover(digest, _v, _r, _s); + } + + /// @notice The EIP-155 chain id + function _chainID() private view returns (uint256 id) { + assembly { + id := chainid() + } + } +} diff --git a/contracts/modules/Asks/Core/ETH/IGaslessAsksCoreEth.sol b/contracts/modules/Asks/Core/ETH/IGaslessAsksCoreEth.sol new file mode 100644 index 00000000..33ffd38f --- /dev/null +++ b/contracts/modules/Asks/Core/ETH/IGaslessAsksCoreEth.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {ModuleApprovalSig} from "../../../../ZoraModuleManager.sol"; + +interface IGaslessAsksCoreEth { + struct GaslessAsk { + address from; // The address of the seller + address tokenAddress; // The address of the NFT being sold + uint256 tokenId; // The ID of the NFT being sold + uint256 expiry; // The Unix timestamp that this order expires at + uint256 nonce; // Nonce to represent this order (for cancellations) + uint256 amount; // The amount of ETH to sell the NFT for + ModuleApprovalSig approvalSig; // The user's approval to use this module (optional, empty if already set) + } + + function executeAsk( + GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external payable; + + function storeAsk( + GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external; + + function broadcastAsk( + GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external; + + function cancelAsk(GaslessAsk calldata _ask) external; + + function validateAskSig( + GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external view returns (bool); +} From fd0dc9522d23ed370cd4121a2c739addfa2ecddf Mon Sep 17 00:00:00 2001 From: Rohan Kulkarni Date: Sun, 3 Apr 2022 02:53:38 -0400 Subject: [PATCH 2/5] refactor: move ModuleApprovalSig to IGaslessAsksCoreEth --- contracts/ZoraModuleManager.sol | 7 ------- contracts/modules/Asks/Core/ETH/IGaslessAsksCoreEth.sol | 9 +++++++-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/contracts/ZoraModuleManager.sol b/contracts/ZoraModuleManager.sol index c5640011..85bc2e4b 100644 --- a/contracts/ZoraModuleManager.sol +++ b/contracts/ZoraModuleManager.sol @@ -3,13 +3,6 @@ pragma solidity 0.8.10; import {ZoraProtocolFeeSettings} from "./auxiliary/ZoraProtocolFeeSettings/ZoraProtocolFeeSettings.sol"; -struct ModuleApprovalSig { - uint8 v; // The 129th byte and chain ID of the signature - bytes32 r; // The first 64 bytes of the signature - bytes32 s; // Bytes 64-128 of the signature - uint256 deadline; // The deadline at which point the approval expires -} - /// @title ZoraModuleManager /// @author tbtstl /// @notice This contract allows users to approve registered modules on ZORA V3 diff --git a/contracts/modules/Asks/Core/ETH/IGaslessAsksCoreEth.sol b/contracts/modules/Asks/Core/ETH/IGaslessAsksCoreEth.sol index 33ffd38f..508d6328 100644 --- a/contracts/modules/Asks/Core/ETH/IGaslessAsksCoreEth.sol +++ b/contracts/modules/Asks/Core/ETH/IGaslessAsksCoreEth.sol @@ -1,9 +1,14 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.10; -import {ModuleApprovalSig} from "../../../../ZoraModuleManager.sol"; - interface IGaslessAsksCoreEth { + struct ModuleApprovalSig { + uint8 v; // The 129th byte and chain ID of the signature + bytes32 r; // The first 64 bytes of the signature + bytes32 s; // Bytes 64-128 of the signature + uint256 deadline; // The deadline at which point the approval expires + } + struct GaslessAsk { address from; // The address of the seller address tokenAddress; // The address of the NFT being sold From d5825b742bbe115ae4be379dd9fc96a2698b9afa Mon Sep 17 00:00:00 2001 From: Rohan Kulkarni Date: Wed, 6 Apr 2022 02:56:29 -0400 Subject: [PATCH 3/5] refactor: restructure as independent module --- .../Asks/Core/ETH/GaslessAsksCoreEth.sol | 202 --------- .../Asks/Core/ETH/IGaslessAsksCoreEth.sol | 51 --- .../Asks/Gasless/ETH/AsksGaslessEth.sol | 268 ++++++++++++ .../Asks/Gasless/ETH/IAsksGaslessEth.sol | 63 +++ .../ETH/AsksGaslessEth.integration.t.sol | 196 +++++++++ .../Asks/Gasless/ETH/AsksGaslessEth.t.sol | 387 ++++++++++++++++++ 6 files changed, 914 insertions(+), 253 deletions(-) delete mode 100644 contracts/modules/Asks/Core/ETH/GaslessAsksCoreEth.sol delete mode 100644 contracts/modules/Asks/Core/ETH/IGaslessAsksCoreEth.sol create mode 100644 contracts/modules/Asks/Gasless/ETH/AsksGaslessEth.sol create mode 100644 contracts/modules/Asks/Gasless/ETH/IAsksGaslessEth.sol create mode 100644 contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.integration.t.sol create mode 100644 contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.t.sol diff --git a/contracts/modules/Asks/Core/ETH/GaslessAsksCoreEth.sol b/contracts/modules/Asks/Core/ETH/GaslessAsksCoreEth.sol deleted file mode 100644 index 7d2ffd51..00000000 --- a/contracts/modules/Asks/Core/ETH/GaslessAsksCoreEth.sol +++ /dev/null @@ -1,202 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.10; - -import {ReentrancyGuard} from "@rari-capital/solmate/src/utils/ReentrancyGuard.sol"; -import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; - -import {ZoraModuleManager} from "../../../../ZoraModuleManager.sol"; -import {FeePayoutSupportV1} from "../../../../common/FeePayoutSupport/FeePayoutSupportV1.sol"; -import {ModuleNamingSupportV1} from "../../../../common/ModuleNamingSupport/ModuleNamingSupportV1.sol"; -import {IGaslessAsksCoreEth} from "./IGaslessAsksCoreEth.sol"; -import {AsksCoreEth} from "./AsksCoreEth.sol"; - -/// @title Gasless Asks Core ETH -/// @author tbtstl -/// @notice Extension to minimal ETH asks module, providing off-chain order support -contract GaslessAsksCoreEth is IGaslessAsksCoreEth, ReentrancyGuard, FeePayoutSupportV1, ModuleNamingSupportV1 { - /// /// - /// IMMUTABLES /// - /// /// - - ZoraModuleManager private immutable zmm; - AsksCoreEth private immutable asksModule; - - /// @notice The EIP-712 domain separator - bytes32 private immutable EIP_712_DOMAIN_SEPARATOR = - keccak256( - abi.encode( - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - keccak256(bytes("ZORA:GaslessAsksCoreEth")), - keccak256(bytes("1")), - _chainID(), - address(this) - ) - ); - - /// @notice The EIP-712 type for a signed ask order - /// @dev keccak256("SignedAsk(address tokenAddress,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 amount,uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)") - bytes32 private constant SIGNED_ASK_TYPEHASH = 0x324d0f7b7aa4e0f218259028fc60b98a32657c974f8cb44eb3ceadbec042ddc4; - - /// /// - /// ASK STORAGE /// - /// /// - - /// @notice The spent (canceled or executed) asks - /// @dev ERC-721 token contract => ERC-721 token id => Ask signer => spent boolean - mapping(address => mapping(uint256 => mapping(address => bool))) public spentAsks; - - /// @param _zmm The ZORA Module Manager address - /// @param _asksModule The ZORA Asks Core ETH Module address - /// @param _royaltyEngine The Manifold Royalty Engine address - /// @param _protocolFeeSettings The ZORA Protocol Fee Settings address - /// @param _weth The WETH token address - // TODO we don't even need this to be a "module" since no transfer helpers are used here - constructor( - address _zmm, - address _asksModule, - address _royaltyEngine, - address _protocolFeeSettings, - address _weth - ) - FeePayoutSupportV1(_royaltyEngine, _protocolFeeSettings, _weth, ZoraModuleManager(_zmm).registrar()) - ModuleNamingSupportV1("Gasless Asks Core ETH") - { - zmm = ZoraModuleManager(_zmm); - asksModule = AsksCoreEth(_asksModule); - } - - /// @notice Implements EIP-165 for standard interface detection - /// @dev `0x01ffc9a7` is the IERC165 interface id - /// @param _interfaceId The identifier of a given interface - /// @return If the given interface is supported - function supportsInterface(bytes4 _interfaceId) external pure returns (bool) { - return _interfaceId == type(IGaslessAsksCoreEth).interfaceId || _interfaceId == 0x01ffc9a7; - } - - /// @notice Executes a signed order on the Asks module - /// @param _ask The signed ask parameters to execute - /// @param _v The 129th byte and chain ID of the signature - /// @param _r The first 64 bytes of the signature - /// @param _s Bytes 64-128 of the signature - function executeAsk( - IGaslessAsksCoreEth.GaslessAsk calldata _ask, - uint8 _v, - bytes32 _r, - bytes32 _s - ) external payable nonReentrant { - require(_ask.expiry == 0 || _ask.expiry >= block.timestamp, "EXPIRED_ASK"); - address recoveredAddress = _recoverAddress(_ask, _v, _r, _s); - require(recoveredAddress != address(0) && recoveredAddress == _ask.from, "INVALID_SIG"); - require(!spentAsks[_ask.tokenAddress][_ask.tokenId][_ask.from], "SPENT_ASK"); - - if (!zmm.isModuleApproved(_ask.from, address(asksModule))) { - zmm.setApprovalForModuleBySig( - address(asksModule), - _ask.from, - true, - _ask.approvalSig.deadline, - _ask.approvalSig.v, - _ask.approvalSig.r, - _ask.approvalSig.s - ); - } - - asksModule.createAsk(_ask.tokenAddress, _ask.tokenId, _ask.amount); - asksModule.fillAsk{value: msg.value}(_ask.tokenAddress, _ask.tokenId); - spentAsks[_ask.tokenAddress][_ask.tokenId][_ask.from] = true; - - IERC721(_ask.tokenAddress).transferFrom(address(this), msg.sender, _ask.tokenId); - } - - /// @notice Creates an on-chain order on the Asks module - /// @param _ask The signed ask parameters to store - /// @param _v The 129th byte and chain ID of the signature - /// @param _r The first 64 bytes of the signature - /// @param _s Bytes 64-128 of the signature - function storeAsk( - IGaslessAsksCoreEth.GaslessAsk calldata _ask, - uint8 _v, - bytes32 _r, - bytes32 _s - ) external nonReentrant { - require(_ask.expiry == 0 || _ask.expiry >= block.timestamp, "EXPIRED_ASK"); - address recoveredAddress = _recoverAddress(_ask, _v, _r, _s); - require(recoveredAddress != address(0) && recoveredAddress == _ask.from, "INVALID_SIG"); - - asksModule.createAsk(_ask.tokenAddress, _ask.tokenId, _ask.amount); - } - - /// @notice Broadcasts an on-chain order to indexers - /// @dev Intentionally a no-op, this can be picked up via EVM traces :) - /// @param _ask The signed ask parameters to broadcast - /// @param _v The 129th byte and chain ID of the signature - /// @param _r The first 64 bytes of the signature - /// @param _s Bytes 64-128 of the signature - function broadcastAsk( - IGaslessAsksCoreEth.GaslessAsk calldata _ask, - uint8 _v, - bytes32 _r, - bytes32 _s - ) external { - // noop :) - } - - /// @notice Invalidates an off-chain order - /// @param _ask The signed ask parameters to invalidate - function cancelAsk(IGaslessAsksCoreEth.GaslessAsk calldata _ask) external nonReentrant { - require(msg.sender == _ask.from, "ONLY_SIGNER"); - - spentAsks[_ask.tokenAddress][_ask.tokenId][msg.sender] = true; - } - - /// @notice Validates an on-chain order - /// @param _ask The signed ask parameters to validate - /// @param _v The 129th byte and chain ID of the signature - /// @param _r The first 64 bytes of the signature - /// @param _s Bytes 64-128 of the signature - function validateAskSig( - IGaslessAsksCoreEth.GaslessAsk calldata _ask, - uint8 _v, - bytes32 _r, - bytes32 _s - ) external view returns (bool) { - return _recoverAddress(_ask, _v, _r, _s) == _ask.from; - } - - function _recoverAddress( - IGaslessAsksCoreEth.GaslessAsk calldata _ask, - uint8 _v, - bytes32 _r, - bytes32 _s - ) private view returns (address) { - bytes32 digest = keccak256( - abi.encodePacked( - "\x19\x01", - EIP_712_DOMAIN_SEPARATOR, - keccak256( - abi.encode( - SIGNED_ASK_TYPEHASH, - _ask.tokenAddress, - _ask.tokenId, - _ask.expiry, - _ask.nonce, - _ask.amount, - _ask.approvalSig.v, - _ask.approvalSig.r, - _ask.approvalSig.s, - _ask.approvalSig.deadline - ) - ) - ) - ); - - return ecrecover(digest, _v, _r, _s); - } - - /// @notice The EIP-155 chain id - function _chainID() private view returns (uint256 id) { - assembly { - id := chainid() - } - } -} diff --git a/contracts/modules/Asks/Core/ETH/IGaslessAsksCoreEth.sol b/contracts/modules/Asks/Core/ETH/IGaslessAsksCoreEth.sol deleted file mode 100644 index 508d6328..00000000 --- a/contracts/modules/Asks/Core/ETH/IGaslessAsksCoreEth.sol +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.10; - -interface IGaslessAsksCoreEth { - struct ModuleApprovalSig { - uint8 v; // The 129th byte and chain ID of the signature - bytes32 r; // The first 64 bytes of the signature - bytes32 s; // Bytes 64-128 of the signature - uint256 deadline; // The deadline at which point the approval expires - } - - struct GaslessAsk { - address from; // The address of the seller - address tokenAddress; // The address of the NFT being sold - uint256 tokenId; // The ID of the NFT being sold - uint256 expiry; // The Unix timestamp that this order expires at - uint256 nonce; // Nonce to represent this order (for cancellations) - uint256 amount; // The amount of ETH to sell the NFT for - ModuleApprovalSig approvalSig; // The user's approval to use this module (optional, empty if already set) - } - - function executeAsk( - GaslessAsk calldata _ask, - uint8 _v, - bytes32 _r, - bytes32 _s - ) external payable; - - function storeAsk( - GaslessAsk calldata _ask, - uint8 _v, - bytes32 _r, - bytes32 _s - ) external; - - function broadcastAsk( - GaslessAsk calldata _ask, - uint8 _v, - bytes32 _r, - bytes32 _s - ) external; - - function cancelAsk(GaslessAsk calldata _ask) external; - - function validateAskSig( - GaslessAsk calldata _ask, - uint8 _v, - bytes32 _r, - bytes32 _s - ) external view returns (bool); -} diff --git a/contracts/modules/Asks/Gasless/ETH/AsksGaslessEth.sol b/contracts/modules/Asks/Gasless/ETH/AsksGaslessEth.sol new file mode 100644 index 00000000..1aa64554 --- /dev/null +++ b/contracts/modules/Asks/Gasless/ETH/AsksGaslessEth.sol @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {ReentrancyGuard} from "@rari-capital/solmate/src/utils/ReentrancyGuard.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +import {ZoraModuleManager} from "../../../../ZoraModuleManager.sol"; +import {ERC721TransferHelper} from "../../../../transferHelpers/ERC721TransferHelper.sol"; +import {FeePayoutSupportV1} from "../../../../common/FeePayoutSupport/FeePayoutSupportV1.sol"; +import {ModuleNamingSupportV1} from "../../../../common/ModuleNamingSupport/ModuleNamingSupportV1.sol"; +import {IAsksGaslessEth} from "./IAsksGaslessEth.sol"; + +/// @title Asks Gasless ETH +/// @author tbtstl & kulkarohan +/// @notice Module for gasless ETH asks for ERC-721 tokens, providing off-chain order support +contract AsksGaslessEth is ReentrancyGuard, FeePayoutSupportV1, ModuleNamingSupportV1 { + /// /// + /// MODULE SETUP /// + /// /// + + /// @notice The ZORA ERC-721 Transfer Helper + ERC721TransferHelper public immutable erc721TransferHelper; + + /// @notice The ZORA Module Manager + ZoraModuleManager public immutable ZMM; + + /// @param _zmm The ZORA Module Manager + /// @param _erc721TransferHelper The ZORA ERC-721 Transfer Helper address + /// @param _royaltyEngine The Manifold Royalty Engine address + /// @param _protocolFeeSettings The ZORA Protocol Fee Settings address + /// @param _weth The WETH token address + constructor( + address _zmm, + address _erc721TransferHelper, + address _royaltyEngine, + address _protocolFeeSettings, + address _weth + ) + FeePayoutSupportV1(_royaltyEngine, _protocolFeeSettings, _weth, ERC721TransferHelper(_erc721TransferHelper).ZMM().registrar()) + ModuleNamingSupportV1("Asks Gasless ETH") + { + ZMM = ZoraModuleManager(_zmm); + erc721TransferHelper = ERC721TransferHelper(_erc721TransferHelper); + } + + /// /// + /// EIP-165 /// + /// /// + + /// @notice Implements EIP-165 for standard interface detection + /// @dev `0x01ffc9a7` is the IERC165 interface id + /// @param _interfaceId The identifier of a given interface + /// @return If the given interface is supported + function supportsInterface(bytes4 _interfaceId) external pure returns (bool) { + return _interfaceId == type(IAsksGaslessEth).interfaceId || _interfaceId == 0x01ffc9a7; + } + + /// /// + /// EIP-712 /// + /// /// + + /// @notice The EIP-712 type for a signed ask order + /// @dev keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price,uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)"); + bytes32 private constant SIGNED_ASK_TYPEHASH = 0xde0428517acbd93d05cf529384fe8d583dfcab25db4370d93bcece3b3bc85629; + + /// @notice The EIP-712 domain separator + bytes32 private immutable EIP_712_DOMAIN_SEPARATOR = + keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes("ZORA:AsksGaslessEth")), + keccak256(bytes("1")), + _chainID(), + address(this) + ) + ); + + /// @notice The EIP-155 chain id + function _chainID() private view returns (uint256 id) { + assembly { + id := chainid() + } + } + + /// @notice Recovers the signer of the ask + /// @param _ask The signed gasless ask + /// @param _v The 129th byte and chain ID of the signature + /// @param _r The first 64 bytes of the signature + /// @param _s Bytes 64-128 of the signature + function _recoverAddress( + IAsksGaslessEth.GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) private view returns (address) { + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + EIP_712_DOMAIN_SEPARATOR, + keccak256( + abi.encode( + SIGNED_ASK_TYPEHASH, + _ask.tokenContract, + _ask.tokenId, + _ask.expiry, + _ask.nonce, + _ask.price, + _ask.approvalSig.v, + _ask.approvalSig.r, + _ask.approvalSig.s, + _ask.approvalSig.deadline + ) + ) + ) + ); + + return ecrecover(digest, _v, _r, _s); + } + + /// /// + /// ASK STORAGE /// + /// /// + + /// @notice The number of filled or canceled asks for a given token + /// @dev ERC-721 address => ERC-721 id + mapping(address => mapping(uint256 => uint256)) public nonce; + + /// /// + /// FILL ASK /// + /// /// + + /// @notice Emitted when a signed ask is filled + /// @param ask The metadata of the ask + /// @param buyer The address of the buyer + event AskFilled(IAsksGaslessEth.GaslessAsk ask, address buyer); + + /// @notice Fills the given signed ask for an NFT + /// @param _ask The signed ask to fill + /// @param _v The 129th byte and chain ID of the signature + /// @param _r The first 64 bytes of the signature + /// @param _s Bytes 64-128 of the signature + function fillAsk( + IAsksGaslessEth.GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external payable nonReentrant { + // Ensure the ask has not expired + require(_ask.expiry == 0 || _ask.expiry >= block.timestamp, "EXPIRED_ASK"); + + // Recover the signer address + address recoveredAddress = _recoverAddress(_ask, _v, _r, _s); + + // Cache the seller address + address seller = _ask.seller; + + // Ensure the recovered signer matches the seller + require(recoveredAddress == seller, "INVALID_SIG"); + + // Cache the token contract + address tokenContract = _ask.tokenContract; + + // Cache the token id + uint256 tokenId = _ask.tokenId; + + // Ensure the ask nonce matches the token nonce + require(_ask.nonce == nonce[tokenContract][tokenId], "INVALID_ASK"); + + // Ensure the attached ETH matches the price + require(msg.value == _ask.price, "MUST_MATCH_PRICE"); + + // If the seller has not approved this module in the ZORA Module Manager, + if (!ZMM.isModuleApproved(seller, address(this))) { + // Approve the module on behalf of the seller + ZMM.setApprovalForModuleBySig( + address(this), + seller, + true, + _ask.approvalSig.deadline, + _ask.approvalSig.v, + _ask.approvalSig.r, + _ask.approvalSig.s + ); + } + + // Payout associated token royalties, if any + (uint256 remainingProfit, ) = _handleRoyaltyPayout(tokenContract, tokenId, _ask.price, address(0), 300000); + + // Payout the module fee, if configured + remainingProfit = _handleProtocolFeePayout(remainingProfit, address(0)); + + // Transfer the remaining profit to the seller + _handleOutgoingTransfer(seller, remainingProfit, address(0), 50000); + + // Transfer the NFT to the buyer + // Reverts if the seller did not approve the ERC721TransferHelper or no longer owns the token + erc721TransferHelper.transferFrom(tokenContract, seller, msg.sender, tokenId); + + emit AskFilled(_ask, msg.sender); + + // Increment the nonce for the associated token + // Cannot realistically overflow + unchecked { + ++nonce[tokenContract][tokenId]; + } + } + + /// /// + /// CANCEL ASK /// + /// /// + + /// @notice Emitted when an ask is canceled + /// @param ask The metadata of the ask + event AskCanceled(IAsksGaslessEth.GaslessAsk ask); + + /// @notice Invalidates an off-chain order + /// @param _ask The signed ask parameters to invalidate + function cancelAsk(IAsksGaslessEth.GaslessAsk calldata _ask) external nonReentrant { + // Ensure the caller is the seller + require(msg.sender == _ask.seller, "ONLY_SIGNER"); + + // Increment the nonce for the associated token + // Cannot realistically overflow + unchecked { + ++nonce[_ask.tokenContract][_ask.tokenId]; + } + + emit AskCanceled(_ask); + } + + /// /// + /// BROADCAST ASK /// + /// /// + + /// @notice Broadcasts an order on-chain to indexers + /// @dev Intentionally a no-op, this can be picked up via EVM traces :) + /// @param _ask The signed ask parameters to broadcast + /// @param _v The 129th byte and chain ID of the signature + /// @param _r The first 64 bytes of the signature + /// @param _s Bytes 64-128 of the signature + function broadcastAsk( + IAsksGaslessEth.GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external { + // noop :) + } + + /// /// + /// VALIDATE ASK /// + /// /// + + /// @notice Checks if a given signature matches the signer of given ask + /// @param _ask The signed ask parameters to validate + /// @param _v The 129th byte and chain ID of the signature + /// @param _r The first 64 bytes of the signature + /// @param _s Bytes 64-128 of the signature + /// @return If the given signature matches the ask signature + function validateAskSig( + IAsksGaslessEth.GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external view returns (bool) { + return _recoverAddress(_ask, _v, _r, _s) == _ask.seller; + } +} diff --git a/contracts/modules/Asks/Gasless/ETH/IAsksGaslessEth.sol b/contracts/modules/Asks/Gasless/ETH/IAsksGaslessEth.sol new file mode 100644 index 00000000..66bac6e8 --- /dev/null +++ b/contracts/modules/Asks/Gasless/ETH/IAsksGaslessEth.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +interface IAsksGaslessEth { + struct ModuleApprovalSig { + uint8 v; // The 129th byte and chain ID of the signature + bytes32 r; // The first 64 bytes of the signature + bytes32 s; // Bytes 64-128 of the signature + uint256 deadline; // The deadline at which point the approval expires + } + + struct GaslessAsk { + address seller; // The address of the seller + address tokenContract; // The address of the NFT being sold + uint256 tokenId; // The ID of the NFT being sold + uint256 expiry; // The Unix timestamp that this order expires at + uint256 nonce; // The ID to represent this order (for cancellations) + uint256 price; // The amount of ETH to sell the NFT for + ModuleApprovalSig approvalSig; // The user's approval to use this module (optional, empty if already set) + } + + /// @notice Fills the given signed ask for an NFT + /// @param _ask The signed ask to fill + /// @param _v The 129th byte and chain ID of the signature + /// @param _r The first 64 bytes of the signature + /// @param _s Bytes 64-128 of the signature + function fillAsk( + IAsksGaslessEth.GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external payable; + + /// @notice Invalidates an off-chain order + /// @param _ask The signed ask parameters to invalidate + function cancelAsk(IAsksGaslessEth.GaslessAsk calldata _ask) external; + + /// @notice Broadcasts an order on-chain to indexers + /// @dev Intentionally a no-op, this can be picked up via EVM traces :) + /// @param _ask The signed ask parameters to broadcast + /// @param _v The 129th byte and chain ID of the signature + /// @param _r The first 64 bytes of the signature + /// @param _s Bytes 64-128 of the signature + function broadcastAsk( + IAsksGaslessEth.GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external; + + /// @notice Checks if a given signature matches the signer of given ask + /// @param _ask The signed ask parameters to validate + /// @param _v The 129th byte and chain ID of the signature + /// @param _r The first 64 bytes of the signature + /// @param _s Bytes 64-128 of the signature + /// @return If the given signature matches the ask signature + function validateAskSig( + IAsksGaslessEth.GaslessAsk calldata _ask, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external view returns (bool); +} diff --git a/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.integration.t.sol b/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.integration.t.sol new file mode 100644 index 00000000..88bfbf18 --- /dev/null +++ b/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.integration.t.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {DSTest} from "ds-test/test.sol"; + +import {IAsksGaslessEth, AsksGaslessEth} from "../../../../../modules/Asks/Gasless/ETH/AsksGaslessEth.sol"; +import {Zorb} from "../../../../utils/users/Zorb.sol"; +import {ZoraRegistrar} from "../../../../utils/users/ZoraRegistrar.sol"; +import {ZoraModuleManager} from "../../../../../ZoraModuleManager.sol"; +import {ZoraProtocolFeeSettings} from "../../../../../auxiliary/ZoraProtocolFeeSettings/ZoraProtocolFeeSettings.sol"; +import {ERC20TransferHelper} from "../../../../../transferHelpers/ERC20TransferHelper.sol"; +import {ERC721TransferHelper} from "../../../../../transferHelpers/ERC721TransferHelper.sol"; +import {RoyaltyEngine} from "../../../../utils/modules/RoyaltyEngine.sol"; +import {TestERC721} from "../../../../utils/tokens/TestERC721.sol"; +import {WETH} from "../../../../utils/tokens/WETH.sol"; +import {VM} from "../../../../utils/VM.sol"; + +/// @title Asks Gasless ETH +/// @notice Integration Tests for Asks Gasless ETH +contract AsksGaslessEthIntegrationTest is DSTest { + VM internal vm; + + ZoraRegistrar internal registrar; + ZoraProtocolFeeSettings internal ZPFS; + ZoraModuleManager internal ZMM; + ERC20TransferHelper internal erc20TransferHelper; + ERC721TransferHelper internal erc721TransferHelper; + RoyaltyEngine internal royaltyEngine; + + AsksGaslessEth internal asks; + WETH internal weth; + TestERC721 internal token; + + uint256 internal privateKey = 0xABCDEF; + address internal seller; + + Zorb internal buyer; + Zorb internal royaltyRecipient; + Zorb internal protocolFeeRecipient; + + function setUp() public { + // Cheatcodes + vm = VM(HEVM_ADDRESS); + + // Deploy V3 + registrar = new ZoraRegistrar(); + ZPFS = new ZoraProtocolFeeSettings(); + ZMM = new ZoraModuleManager(address(registrar), address(ZPFS)); + erc20TransferHelper = new ERC20TransferHelper(address(ZMM)); + erc721TransferHelper = new ERC721TransferHelper(address(ZMM)); + + // Init V3 + registrar.init(ZMM); + ZPFS.init(address(ZMM), address(0)); + + // Create users + seller = vm.addr(privateKey); + buyer = new Zorb(address(ZMM)); + royaltyRecipient = new Zorb(address(ZMM)); + protocolFeeRecipient = new Zorb(address(ZMM)); + + // Deploy mocks + royaltyEngine = new RoyaltyEngine(address(royaltyRecipient)); + token = new TestERC721(); + weth = new WETH(); + + // Deploy Asks Gasless ETH + asks = new AsksGaslessEth(address(ZMM), address(erc721TransferHelper), address(royaltyEngine), address(ZPFS), address(weth)); + registrar.registerModule(address(asks)); + + // Set module fee + vm.prank(address(registrar)); + ZPFS.setFeeParams(address(asks), address(protocolFeeRecipient), 1); + + // Set buyer balance + vm.deal(address(buyer), 100 ether); + + // Mint seller token + token.mint(seller, 1); + + // Seller approve ERC721TransferHelper + vm.prank(seller); + token.setApprovalForAll(address(erc721TransferHelper), true); + } + + /// /// + /// UTILS /// + /// /// + + function getModuleApprovalSig() public returns (IAsksGaslessEth.ModuleApprovalSig memory) { + bytes32 ZMM_DOMAIN_SEPARATOR = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes("ZORA")), + keccak256(bytes("3")), + 99, + address(ZMM) + ) + ); + + // keccak256("SignedApproval(address module,address user,bool approved,uint256 deadline,uint256 nonce)") + bytes32 SIGNED_APPROVAL = 0x8413132cc7aa5bd2ce1a1b142a3f09e2baeda86addf4f9a5dacd4679f56e7cec; + + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + privateKey, + keccak256(abi.encodePacked("\x19\x01", ZMM_DOMAIN_SEPARATOR, keccak256(abi.encode(SIGNED_APPROVAL, address(asks), seller, true, 0, 0)))) + ); + + IAsksGaslessEth.ModuleApprovalSig memory sig = IAsksGaslessEth.ModuleApprovalSig({v: v, r: r, s: s, deadline: 0}); + + return sig; + } + + function getSignedAskSig() + public + returns ( + uint8 v, + bytes32 r, + bytes32 s + ) + { + bytes32 ASKS_DOMAIN_SEPARATOR = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes("ZORA:AsksGaslessEth")), + keccak256(bytes("1")), + 99, + address(asks) + ) + ); + + // keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price,uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)"); + bytes32 ASK_APPROVAL = 0xde0428517acbd93d05cf529384fe8d583dfcab25db4370d93bcece3b3bc85629; + + IAsksGaslessEth.ModuleApprovalSig memory sig = getModuleApprovalSig(); + + (v, r, s) = vm.sign( + privateKey, + keccak256( + abi.encodePacked( + "\x19\x01", + ASKS_DOMAIN_SEPARATOR, + keccak256(abi.encode(ASK_APPROVAL, address(token), 1, 0, 0, 1 ether, sig.v, sig.r, sig.s, 0)) + ) + ) + ); + } + + /// /// + /// ETH INTEGRATION /// + /// /// + + function runETH() public { + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ + seller: seller, + tokenContract: address(token), + tokenId: 1, + expiry: 0, + nonce: 0, + price: 1 ether, + approvalSig: getModuleApprovalSig() + }); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + + vm.prank(address(buyer)); + asks.fillAsk{value: 1 ether}(ask, v, r, s); + } + + function test_ETHIntegration() public { + uint256 beforeBuyerBalance = address(buyer).balance; + uint256 beforeSellerBalance = address(seller).balance; + uint256 beforeRoyaltyRecipientBalance = address(royaltyRecipient).balance; + uint256 beforeProtocolFeeRecipientBalance = address(protocolFeeRecipient).balance; + address beforeTokenOwner = token.ownerOf(1); + + runETH(); + + uint256 afterBuyerBalance = address(buyer).balance; + uint256 afterSellerBalance = address(seller).balance; + uint256 afterRoyaltyRecipientBalance = address(royaltyRecipient).balance; + uint256 afterProtocolFeeRecipientBalance = address(protocolFeeRecipient).balance; + address afterTokenOwner = token.ownerOf(1); + + // 1 ETH withdrawn from buyer + require((beforeBuyerBalance - afterBuyerBalance) == 1 ether); + // 0.05 ETH creator royalty + require((afterRoyaltyRecipientBalance - beforeRoyaltyRecipientBalance) == 0.05 ether); + // 1 bps protocol fee (Remaining 0.95 ETH * 0.01% protocol fee = 0.000095 ETH) + require((afterProtocolFeeRecipientBalance - beforeProtocolFeeRecipientBalance) == 0.000095 ether); + // Remaining 0.949905 ETH paid to seller + require((afterSellerBalance - beforeSellerBalance) == 0.949905 ether); + // NFT transferred to buyer + require(beforeTokenOwner == address(seller) && afterTokenOwner == address(buyer)); + } +} diff --git a/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.t.sol b/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.t.sol new file mode 100644 index 00000000..21a4d94e --- /dev/null +++ b/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.t.sol @@ -0,0 +1,387 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {DSTest} from "ds-test/test.sol"; + +import {IAsksGaslessEth, AsksGaslessEth} from "../../../../../modules/Asks/Gasless/ETH/AsksGaslessEth.sol"; +import {Zorb} from "../../../../utils/users/Zorb.sol"; +import {ZoraRegistrar} from "../../../../utils/users/ZoraRegistrar.sol"; +import {ZoraModuleManager} from "../../../../../ZoraModuleManager.sol"; +import {ZoraProtocolFeeSettings} from "../../../../../auxiliary/ZoraProtocolFeeSettings/ZoraProtocolFeeSettings.sol"; +import {ERC20TransferHelper} from "../../../../../transferHelpers/ERC20TransferHelper.sol"; +import {ERC721TransferHelper} from "../../../../../transferHelpers/ERC721TransferHelper.sol"; +import {RoyaltyEngine} from "../../../../utils/modules/RoyaltyEngine.sol"; +import {TestERC721} from "../../../../utils/tokens/TestERC721.sol"; +import {WETH} from "../../../../utils/tokens/WETH.sol"; +import {VM} from "../../../../utils/VM.sol"; + +/// @title Asks Gasless ETH +/// @notice Unit Tests for Asks Gasless ETH +contract AsksGaslessEthTest is DSTest { + VM internal vm; + + ZoraRegistrar internal registrar; + ZoraProtocolFeeSettings internal ZPFS; + ZoraModuleManager internal ZMM; + ERC20TransferHelper internal erc20TransferHelper; + ERC721TransferHelper internal erc721TransferHelper; + RoyaltyEngine internal royaltyEngine; + + AsksGaslessEth internal asks; + WETH internal weth; + TestERC721 internal token; + + uint256 internal privateKey = 0xABCDEF; + address internal seller; + + Zorb internal buyer; + Zorb internal royaltyRecipient; + + function setUp() public { + // Cheatcodes + vm = VM(HEVM_ADDRESS); + + // Deploy V3 + registrar = new ZoraRegistrar(); + ZPFS = new ZoraProtocolFeeSettings(); + ZMM = new ZoraModuleManager(address(registrar), address(ZPFS)); + erc20TransferHelper = new ERC20TransferHelper(address(ZMM)); + erc721TransferHelper = new ERC721TransferHelper(address(ZMM)); + + // Init V3 + registrar.init(ZMM); + ZPFS.init(address(ZMM), address(0)); + + // Create users + seller = vm.addr(privateKey); + buyer = new Zorb(address(ZMM)); + royaltyRecipient = new Zorb(address(ZMM)); + + // Deploy mocks + royaltyEngine = new RoyaltyEngine(address(royaltyRecipient)); + token = new TestERC721(); + weth = new WETH(); + + // Deploy Asks Gasless ETH + asks = new AsksGaslessEth(address(ZMM), address(erc721TransferHelper), address(royaltyEngine), address(ZPFS), address(weth)); + registrar.registerModule(address(asks)); + + // Set buyer balance + vm.deal(address(buyer), 100 ether); + + // Mint seller token + token.mint(seller, 1); + + // Seller approve ERC721TransferHelper + vm.prank(seller); + token.setApprovalForAll(address(erc721TransferHelper), true); + } + + /// /// + /// UTILS /// + /// /// + + function test_GetHash() public { + bytes32 sigHash = keccak256( + "SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price,uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)" + ); + + emit log_bytes32(sigHash); + } + + function getModuleApprovalSig() public returns (IAsksGaslessEth.ModuleApprovalSig memory) { + bytes32 ZMM_DOMAIN_SEPARATOR = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes("ZORA")), + keccak256(bytes("3")), + 99, + address(ZMM) + ) + ); + + // keccak256("SignedApproval(address module,address user,bool approved,uint256 deadline,uint256 nonce)") + bytes32 SIGNED_APPROVAL = 0x8413132cc7aa5bd2ce1a1b142a3f09e2baeda86addf4f9a5dacd4679f56e7cec; + + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + privateKey, + keccak256(abi.encodePacked("\x19\x01", ZMM_DOMAIN_SEPARATOR, keccak256(abi.encode(SIGNED_APPROVAL, address(asks), seller, true, 0, 0)))) + ); + + IAsksGaslessEth.ModuleApprovalSig memory sig = IAsksGaslessEth.ModuleApprovalSig({v: v, r: r, s: s, deadline: 0}); + + return sig; + } + + function getSignedAskSig() + public + returns ( + uint8 v, + bytes32 r, + bytes32 s + ) + { + bytes32 ASKS_DOMAIN_SEPARATOR = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes("ZORA:AsksGaslessEth")), + keccak256(bytes("1")), + 99, + address(asks) + ) + ); + + // keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price,uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)"); + bytes32 ASK_APPROVAL = 0xde0428517acbd93d05cf529384fe8d583dfcab25db4370d93bcece3b3bc85629; + + IAsksGaslessEth.ModuleApprovalSig memory sig = getModuleApprovalSig(); + + (v, r, s) = vm.sign( + privateKey, + keccak256( + abi.encodePacked( + "\x19\x01", + ASKS_DOMAIN_SEPARATOR, + keccak256(abi.encode(ASK_APPROVAL, address(token), 1, 0, 0, 1 ether, sig.v, sig.r, sig.s, 0)) + ) + ) + ); + } + + function getSignedAskSigWithoutModuleApproval() + public + returns ( + uint8 v, + bytes32 r, + bytes32 s + ) + { + bytes32 ASKS_DOMAIN_SEPARATOR = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes("ZORA:AsksGaslessEth")), + keccak256(bytes("1")), + 99, + address(asks) + ) + ); + + // keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price,uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)"); + bytes32 ASK_APPROVAL = 0xde0428517acbd93d05cf529384fe8d583dfcab25db4370d93bcece3b3bc85629; + + IAsksGaslessEth.ModuleApprovalSig memory sig = IAsksGaslessEth.ModuleApprovalSig({v: 0, r: 0, s: 0, deadline: 0}); + + (v, r, s) = vm.sign( + privateKey, + keccak256( + abi.encodePacked( + "\x19\x01", + ASKS_DOMAIN_SEPARATOR, + keccak256(abi.encode(ASK_APPROVAL, address(token), 1, 0, 0, 1 ether, sig.v, sig.r, sig.s, 0)) + ) + ) + ); + } + + /// /// + /// FILL ASK /// + /// /// + + function test_FillAskWithModuleApprovalSig() public { + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ + seller: seller, + tokenContract: address(token), + tokenId: 1, + expiry: 0, + nonce: 0, + price: 1 ether, + approvalSig: getModuleApprovalSig() + }); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + + vm.prank(address(buyer)); + asks.fillAsk{value: 1 ether}(ask, v, r, s); + + require(token.ownerOf(1) == address(buyer)); + } + + function test_FillAskWithEmptyApprovalSig() public { + vm.prank(seller); + ZMM.setApprovalForModule(address(asks), true); + + IAsksGaslessEth.ModuleApprovalSig memory sig = IAsksGaslessEth.ModuleApprovalSig({v: 0, r: 0, s: 0, deadline: 0}); + + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ + seller: seller, + tokenContract: address(token), + tokenId: 1, + expiry: 0, + nonce: 0, + price: 1 ether, + approvalSig: sig + }); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAskSigWithoutModuleApproval(); + + vm.prank(address(buyer)); + asks.fillAsk{value: 1 ether}(ask, v, r, s); + + require(token.ownerOf(1) == address(buyer)); + } + + function testRevert_ExpiredAsk() public { + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ + seller: seller, + tokenContract: address(token), + tokenId: 1, + expiry: 1 days, + nonce: 0, + price: 1 ether, + approvalSig: getModuleApprovalSig() + }); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + + vm.warp(1 days + 1 minutes); + + vm.prank(address(buyer)); + vm.expectRevert("EXPIRED_ASK"); + asks.fillAsk{value: 1 ether}(ask, v, r, s); + } + + function testRevert_InvalidSig() public { + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ + seller: seller, + tokenContract: address(token), + tokenId: 1, + expiry: 0, + nonce: 0, + price: 1 ether, + approvalSig: getModuleApprovalSig() + }); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + + vm.prank(address(buyer)); + vm.expectRevert("INVALID_SIG"); + asks.fillAsk{value: 1 ether}(ask, v - 1, r, s); + } + + function testRevert_InvalidAsk() public { + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ + seller: seller, + tokenContract: address(token), + tokenId: 1, + expiry: 0, + nonce: 0, + price: 1 ether, + approvalSig: getModuleApprovalSig() + }); + + vm.prank(seller); + asks.cancelAsk(ask); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + + vm.prank(address(buyer)); + vm.expectRevert("INVALID_ASK"); + asks.fillAsk{value: 1 ether}(ask, v, r, s); + } + + function testRevert_MatchPrice() public { + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ + seller: seller, + tokenContract: address(token), + tokenId: 1, + expiry: 0, + nonce: 0, + price: 1 ether, + approvalSig: getModuleApprovalSig() + }); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + + vm.prank(address(buyer)); + vm.expectRevert("MUST_MATCH_PRICE"); + asks.fillAsk{value: 0.9 ether}(ask, v, r, s); + } + + /// /// + /// CANCEL ASK /// + /// /// + + function test_CancelAsk() public { + require(asks.nonce(address(token), 1) == 0); + + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ + seller: seller, + tokenContract: address(token), + tokenId: 1, + expiry: 0, + nonce: 0, + price: 1 ether, + approvalSig: getModuleApprovalSig() + }); + + vm.prank(seller); + asks.cancelAsk(ask); + + require(asks.nonce(address(token), 1) == 1); + } + + function testRevert_OnlySeller() public { + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ + seller: seller, + tokenContract: address(token), + tokenId: 1, + expiry: 0, + nonce: 0, + price: 1 ether, + approvalSig: getModuleApprovalSig() + }); + + vm.expectRevert("ONLY_SIGNER"); + asks.cancelAsk(ask); + } + + /// /// + /// VALIDATE ASK /// + /// /// + + function test_ValidateAsk() public { + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ + seller: seller, + tokenContract: address(token), + tokenId: 1, + expiry: 0, + nonce: 0, + price: 1 ether, + approvalSig: getModuleApprovalSig() + }); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + + bool valid = asks.validateAskSig(ask, v, r, s); + + require(valid); + } + + function testRevert_InvalidSigner() public { + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ + seller: seller, + tokenContract: address(token), + tokenId: 1, + expiry: 0, + nonce: 0, + price: 1 ether, + approvalSig: getModuleApprovalSig() + }); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAskSigWithoutModuleApproval(); + + bool valid = asks.validateAskSig(ask, v, r, s); + + require(!valid); + } +} From eb4796da52c47d7f06c97c8b8a9283a7e8edcd20 Mon Sep 17 00:00:00 2001 From: Rohan Kulkarni Date: Wed, 6 Apr 2022 16:25:29 -0400 Subject: [PATCH 4/5] refactor: decouple ask & module approval --- .../Asks/Gasless/ETH/AsksGaslessEth.sol | 92 ++++++++--- .../Asks/Gasless/ETH/IAsksGaslessEth.sol | 1 - .../ETH/AsksGaslessEth.integration.t.sol | 27 ++-- .../Asks/Gasless/ETH/AsksGaslessEth.t.sol | 153 ++++++++++-------- 4 files changed, 166 insertions(+), 107 deletions(-) diff --git a/contracts/modules/Asks/Gasless/ETH/AsksGaslessEth.sol b/contracts/modules/Asks/Gasless/ETH/AsksGaslessEth.sol index 1aa64554..27a82fd9 100644 --- a/contracts/modules/Asks/Gasless/ETH/AsksGaslessEth.sol +++ b/contracts/modules/Asks/Gasless/ETH/AsksGaslessEth.sol @@ -60,8 +60,12 @@ contract AsksGaslessEth is ReentrancyGuard, FeePayoutSupportV1, ModuleNamingSupp /// /// /// @notice The EIP-712 type for a signed ask order - /// @dev keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price,uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)"); - bytes32 private constant SIGNED_ASK_TYPEHASH = 0xde0428517acbd93d05cf529384fe8d583dfcab25db4370d93bcece3b3bc85629; + /// @dev keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price)"); + bytes32 private constant SIGNED_ASK_TYPEHASH = 0xf788c01ac4e7f192187030902df708ad915c1962e5a989fba9ee65a61f396fb4; + + /// @notice The EIP-712 type for a signed module approval + /// @dev keccak256("SignedModuleApproval(uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)"); + bytes32 private constant SIGNED_MODULE_APPROVAL_TYPEHASH = 0xe85f51623d2a2c6a227a03b74ae96521390f212006fafcabd7bf959916eec097; /// @notice The EIP-712 domain separator bytes32 private immutable EIP_712_DOMAIN_SEPARATOR = @@ -97,20 +101,7 @@ contract AsksGaslessEth is ReentrancyGuard, FeePayoutSupportV1, ModuleNamingSupp abi.encodePacked( "\x19\x01", EIP_712_DOMAIN_SEPARATOR, - keccak256( - abi.encode( - SIGNED_ASK_TYPEHASH, - _ask.tokenContract, - _ask.tokenId, - _ask.expiry, - _ask.nonce, - _ask.price, - _ask.approvalSig.v, - _ask.approvalSig.r, - _ask.approvalSig.s, - _ask.approvalSig.deadline - ) - ) + keccak256(abi.encode(SIGNED_ASK_TYPEHASH, _ask.tokenContract, _ask.tokenId, _ask.expiry, _ask.nonce, _ask.price)) ) ); @@ -169,18 +160,69 @@ contract AsksGaslessEth is ReentrancyGuard, FeePayoutSupportV1, ModuleNamingSupp // Ensure the attached ETH matches the price require(msg.value == _ask.price, "MUST_MATCH_PRICE"); + // Payout associated token royalties, if any + (uint256 remainingProfit, ) = _handleRoyaltyPayout(tokenContract, tokenId, _ask.price, address(0), 300000); + + // Payout the module fee, if configured + remainingProfit = _handleProtocolFeePayout(remainingProfit, address(0)); + + // Transfer the remaining profit to the seller + _handleOutgoingTransfer(seller, remainingProfit, address(0), 50000); + + // Transfer the NFT to the buyer + // Reverts if the seller did not approve the ERC721TransferHelper or no longer owns the token + erc721TransferHelper.transferFrom(tokenContract, seller, msg.sender, tokenId); + + emit AskFilled(_ask, msg.sender); + + // Increment the nonce for the associated token + // Cannot realistically overflow + unchecked { + ++nonce[tokenContract][tokenId]; + } + } + + /// @notice Fills the given signed ask for an NFT with a signed module approval + /// @param _ask The signed ask to fill + /// @param _approvalSig The signed module approval + /// @param _v The 129th byte and chain ID of the signature + /// @param _r The first 64 bytes of the signature + /// @param _s Bytes 64-128 of the signature + function fillAsk( + IAsksGaslessEth.GaslessAsk calldata _ask, + IAsksGaslessEth.ModuleApprovalSig calldata _approvalSig, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external payable nonReentrant { + // Ensure the ask has not expired + require(_ask.expiry == 0 || _ask.expiry >= block.timestamp, "EXPIRED_ASK"); + + // Recover the signer address + address recoveredAddress = _recoverAddress(_ask, _v, _r, _s); + + // Cache the seller address + address seller = _ask.seller; + + // Ensure the recovered signer matches the seller + require(recoveredAddress == seller, "INVALID_SIG"); + + // Cache the token contract + address tokenContract = _ask.tokenContract; + + // Cache the token id + uint256 tokenId = _ask.tokenId; + + // Ensure the ask nonce matches the token nonce + require(_ask.nonce == nonce[tokenContract][tokenId], "INVALID_ASK"); + + // Ensure the attached ETH matches the price + require(msg.value == _ask.price, "MUST_MATCH_PRICE"); + // If the seller has not approved this module in the ZORA Module Manager, if (!ZMM.isModuleApproved(seller, address(this))) { // Approve the module on behalf of the seller - ZMM.setApprovalForModuleBySig( - address(this), - seller, - true, - _ask.approvalSig.deadline, - _ask.approvalSig.v, - _ask.approvalSig.r, - _ask.approvalSig.s - ); + ZMM.setApprovalForModuleBySig(address(this), seller, true, _approvalSig.deadline, _approvalSig.v, _approvalSig.r, _approvalSig.s); } // Payout associated token royalties, if any diff --git a/contracts/modules/Asks/Gasless/ETH/IAsksGaslessEth.sol b/contracts/modules/Asks/Gasless/ETH/IAsksGaslessEth.sol index 66bac6e8..28cc114c 100644 --- a/contracts/modules/Asks/Gasless/ETH/IAsksGaslessEth.sol +++ b/contracts/modules/Asks/Gasless/ETH/IAsksGaslessEth.sol @@ -16,7 +16,6 @@ interface IAsksGaslessEth { uint256 expiry; // The Unix timestamp that this order expires at uint256 nonce; // The ID to represent this order (for cancellations) uint256 price; // The amount of ETH to sell the NFT for - ModuleApprovalSig approvalSig; // The user's approval to use this module (optional, empty if already set) } /// @notice Fills the given signed ask for an NFT diff --git a/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.integration.t.sol b/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.integration.t.sol index 88bfbf18..83b9cd96 100644 --- a/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.integration.t.sol +++ b/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.integration.t.sol @@ -87,7 +87,7 @@ contract AsksGaslessEthIntegrationTest is DSTest { /// UTILS /// /// /// - function getModuleApprovalSig() public returns (IAsksGaslessEth.ModuleApprovalSig memory) { + function getSignedModuleApproval() public returns (IAsksGaslessEth.ModuleApprovalSig memory) { bytes32 ZMM_DOMAIN_SEPARATOR = keccak256( abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), @@ -111,7 +111,7 @@ contract AsksGaslessEthIntegrationTest is DSTest { return sig; } - function getSignedAskSig() + function getSignedAsk() public returns ( uint8 v, @@ -129,20 +129,12 @@ contract AsksGaslessEthIntegrationTest is DSTest { ) ); - // keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price,uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)"); - bytes32 ASK_APPROVAL = 0xde0428517acbd93d05cf529384fe8d583dfcab25db4370d93bcece3b3bc85629; - - IAsksGaslessEth.ModuleApprovalSig memory sig = getModuleApprovalSig(); + // keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price)"); + bytes32 ASK_APPROVAL = 0xf788c01ac4e7f192187030902df708ad915c1962e5a989fba9ee65a61f396fb4; (v, r, s) = vm.sign( privateKey, - keccak256( - abi.encodePacked( - "\x19\x01", - ASKS_DOMAIN_SEPARATOR, - keccak256(abi.encode(ASK_APPROVAL, address(token), 1, 0, 0, 1 ether, sig.v, sig.r, sig.s, 0)) - ) - ) + keccak256(abi.encodePacked("\x19\x01", ASKS_DOMAIN_SEPARATOR, keccak256(abi.encode(ASK_APPROVAL, address(token), 1, 0, 0, 1 ether)))) ); } @@ -157,14 +149,15 @@ contract AsksGaslessEthIntegrationTest is DSTest { tokenId: 1, expiry: 0, nonce: 0, - price: 1 ether, - approvalSig: getModuleApprovalSig() + price: 1 ether }); - (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + IAsksGaslessEth.ModuleApprovalSig memory sig = getSignedModuleApproval(); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); vm.prank(address(buyer)); - asks.fillAsk{value: 1 ether}(ask, v, r, s); + asks.fillAsk{value: 1 ether}(ask, sig, v, r, s); } function test_ETHIntegration() public { diff --git a/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.t.sol b/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.t.sol index 21a4d94e..a2ff293c 100644 --- a/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.t.sol +++ b/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.t.sol @@ -81,15 +81,19 @@ contract AsksGaslessEthTest is DSTest { /// UTILS /// /// /// - function test_GetHash() public { - bytes32 sigHash = keccak256( - "SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price,uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)" - ); + function test_GetAskHash() public { + bytes32 sigHash = keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price)"); emit log_bytes32(sigHash); } - function getModuleApprovalSig() public returns (IAsksGaslessEth.ModuleApprovalSig memory) { + function test_GetModApprovalHash() public { + bytes32 sigHash = keccak256("SignedModuleApproval(uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)"); + + emit log_bytes32(sigHash); + } + + function getSignedModuleApproval() public returns (IAsksGaslessEth.ModuleApprovalSig memory) { bytes32 ZMM_DOMAIN_SEPARATOR = keccak256( abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), @@ -113,7 +117,31 @@ contract AsksGaslessEthTest is DSTest { return sig; } - function getSignedAskSig() + function getInvalidModuleApproval() public returns (IAsksGaslessEth.ModuleApprovalSig memory) { + bytes32 ZMM_DOMAIN_SEPARATOR = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes("ZORA")), + keccak256(bytes("3")), + 99, + address(ZMM) + ) + ); + + // keccak256("SignedApproval(address module,address user,bool approved,uint256 deadline,uint256 nonce)") + bytes32 SIGNED_APPROVAL = 0x8413132cc7aa5bd2ce1a1b142a3f09e2baeda86addf4f9a5dacd4679f56e7cec; + + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + privateKey, + keccak256(abi.encodePacked("\x19\x01", ZMM_DOMAIN_SEPARATOR, keccak256(abi.encode(SIGNED_APPROVAL, address(asks), seller, true, 0, 0)))) + ); + + IAsksGaslessEth.ModuleApprovalSig memory sig = IAsksGaslessEth.ModuleApprovalSig({v: v, r: r, s: s, deadline: 1 hours}); + + return sig; + } + + function getSignedAsk() public returns ( uint8 v, @@ -131,24 +159,16 @@ contract AsksGaslessEthTest is DSTest { ) ); - // keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price,uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)"); - bytes32 ASK_APPROVAL = 0xde0428517acbd93d05cf529384fe8d583dfcab25db4370d93bcece3b3bc85629; - - IAsksGaslessEth.ModuleApprovalSig memory sig = getModuleApprovalSig(); + // keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price)"); + bytes32 ASK_APPROVAL = 0xf788c01ac4e7f192187030902df708ad915c1962e5a989fba9ee65a61f396fb4; (v, r, s) = vm.sign( privateKey, - keccak256( - abi.encodePacked( - "\x19\x01", - ASKS_DOMAIN_SEPARATOR, - keccak256(abi.encode(ASK_APPROVAL, address(token), 1, 0, 0, 1 ether, sig.v, sig.r, sig.s, 0)) - ) - ) + keccak256(abi.encodePacked("\x19\x01", ASKS_DOMAIN_SEPARATOR, keccak256(abi.encode(ASK_APPROVAL, address(token), 1, 0, 0, 1 ether)))) ); } - function getSignedAskSigWithoutModuleApproval() + function getInvalidAsk() public returns ( uint8 v, @@ -166,20 +186,12 @@ contract AsksGaslessEthTest is DSTest { ) ); - // keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price,uint8 _v,bytes32 _r,bytes32 _s,uint256 deadline)"); - bytes32 ASK_APPROVAL = 0xde0428517acbd93d05cf529384fe8d583dfcab25db4370d93bcece3b3bc85629; - - IAsksGaslessEth.ModuleApprovalSig memory sig = IAsksGaslessEth.ModuleApprovalSig({v: 0, r: 0, s: 0, deadline: 0}); + // keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price)"); + bytes32 ASK_APPROVAL = 0xf788c01ac4e7f192187030902df708ad915c1962e5a989fba9ee65a61f396fb4; (v, r, s) = vm.sign( privateKey, - keccak256( - abi.encodePacked( - "\x19\x01", - ASKS_DOMAIN_SEPARATOR, - keccak256(abi.encode(ASK_APPROVAL, address(token), 1, 0, 0, 1 ether, sig.v, sig.r, sig.s, 0)) - ) - ) + keccak256(abi.encodePacked("\x19\x01", ASKS_DOMAIN_SEPARATOR, keccak256(abi.encode(ASK_APPROVAL, address(token), 0, 0, 0, 1 ether)))) ); } @@ -187,18 +199,20 @@ contract AsksGaslessEthTest is DSTest { /// FILL ASK /// /// /// - function test_FillAskWithModuleApprovalSig() public { + function test_FillAsk() public { + vm.prank(seller); + ZMM.setApprovalForModule(address(asks), true); + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ seller: seller, tokenContract: address(token), tokenId: 1, expiry: 0, nonce: 0, - price: 1 ether, - approvalSig: getModuleApprovalSig() + price: 1 ether }); - (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); vm.prank(address(buyer)); asks.fillAsk{value: 1 ether}(ask, v, r, s); @@ -206,11 +220,8 @@ contract AsksGaslessEthTest is DSTest { require(token.ownerOf(1) == address(buyer)); } - function test_FillAskWithEmptyApprovalSig() public { - vm.prank(seller); - ZMM.setApprovalForModule(address(asks), true); - - IAsksGaslessEth.ModuleApprovalSig memory sig = IAsksGaslessEth.ModuleApprovalSig({v: 0, r: 0, s: 0, deadline: 0}); + function test_FillAskWithModuleApprovalSig() public { + require(!ZMM.isModuleApproved(seller, address(asks))); IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ seller: seller, @@ -218,14 +229,15 @@ contract AsksGaslessEthTest is DSTest { tokenId: 1, expiry: 0, nonce: 0, - price: 1 ether, - approvalSig: sig + price: 1 ether }); - (uint8 v, bytes32 r, bytes32 s) = getSignedAskSigWithoutModuleApproval(); + IAsksGaslessEth.ModuleApprovalSig memory sig = getSignedModuleApproval(); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); vm.prank(address(buyer)); - asks.fillAsk{value: 1 ether}(ask, v, r, s); + asks.fillAsk{value: 1 ether}(ask, sig, v, r, s); require(token.ownerOf(1) == address(buyer)); } @@ -237,11 +249,10 @@ contract AsksGaslessEthTest is DSTest { tokenId: 1, expiry: 1 days, nonce: 0, - price: 1 ether, - approvalSig: getModuleApprovalSig() + price: 1 ether }); - (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); vm.warp(1 days + 1 minutes); @@ -250,6 +261,27 @@ contract AsksGaslessEthTest is DSTest { asks.fillAsk{value: 1 ether}(ask, v, r, s); } + function testRevert_ExpiredModuleApproval() public { + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ + seller: seller, + tokenContract: address(token), + tokenId: 1, + expiry: 0, + nonce: 0, + price: 1 ether + }); + + IAsksGaslessEth.ModuleApprovalSig memory sig = getInvalidModuleApproval(); + + vm.warp(2 hours); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); + + vm.prank(address(buyer)); + vm.expectRevert("ZMM::setApprovalForModuleBySig deadline expired"); + asks.fillAsk{value: 1 ether}(ask, sig, v, r, s); + } + function testRevert_InvalidSig() public { IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ seller: seller, @@ -257,11 +289,10 @@ contract AsksGaslessEthTest is DSTest { tokenId: 1, expiry: 0, nonce: 0, - price: 1 ether, - approvalSig: getModuleApprovalSig() + price: 1 ether }); - (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); vm.prank(address(buyer)); vm.expectRevert("INVALID_SIG"); @@ -275,14 +306,13 @@ contract AsksGaslessEthTest is DSTest { tokenId: 1, expiry: 0, nonce: 0, - price: 1 ether, - approvalSig: getModuleApprovalSig() + price: 1 ether }); vm.prank(seller); asks.cancelAsk(ask); - (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); vm.prank(address(buyer)); vm.expectRevert("INVALID_ASK"); @@ -296,11 +326,10 @@ contract AsksGaslessEthTest is DSTest { tokenId: 1, expiry: 0, nonce: 0, - price: 1 ether, - approvalSig: getModuleApprovalSig() + price: 1 ether }); - (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); vm.prank(address(buyer)); vm.expectRevert("MUST_MATCH_PRICE"); @@ -320,8 +349,7 @@ contract AsksGaslessEthTest is DSTest { tokenId: 1, expiry: 0, nonce: 0, - price: 1 ether, - approvalSig: getModuleApprovalSig() + price: 1 ether }); vm.prank(seller); @@ -337,8 +365,7 @@ contract AsksGaslessEthTest is DSTest { tokenId: 1, expiry: 0, nonce: 0, - price: 1 ether, - approvalSig: getModuleApprovalSig() + price: 1 ether }); vm.expectRevert("ONLY_SIGNER"); @@ -356,11 +383,10 @@ contract AsksGaslessEthTest is DSTest { tokenId: 1, expiry: 0, nonce: 0, - price: 1 ether, - approvalSig: getModuleApprovalSig() + price: 1 ether }); - (uint8 v, bytes32 r, bytes32 s) = getSignedAskSig(); + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); bool valid = asks.validateAskSig(ask, v, r, s); @@ -374,11 +400,10 @@ contract AsksGaslessEthTest is DSTest { tokenId: 1, expiry: 0, nonce: 0, - price: 1 ether, - approvalSig: getModuleApprovalSig() + price: 1 ether }); - (uint8 v, bytes32 r, bytes32 s) = getSignedAskSigWithoutModuleApproval(); + (uint8 v, bytes32 r, bytes32 s) = getInvalidAsk(); bool valid = asks.validateAskSig(ask, v, r, s); From 23f29cd8dfcb240a136d67944f738a333fb26a8b Mon Sep 17 00:00:00 2001 From: Rohan Kulkarni Date: Mon, 11 Apr 2022 16:46:28 -0400 Subject: [PATCH 5/5] chore: update interface --- .../modules/Asks/Gasless/ETH/IAsksGaslessEth.sol | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/contracts/modules/Asks/Gasless/ETH/IAsksGaslessEth.sol b/contracts/modules/Asks/Gasless/ETH/IAsksGaslessEth.sol index 28cc114c..49166825 100644 --- a/contracts/modules/Asks/Gasless/ETH/IAsksGaslessEth.sol +++ b/contracts/modules/Asks/Gasless/ETH/IAsksGaslessEth.sol @@ -30,6 +30,20 @@ interface IAsksGaslessEth { bytes32 _s ) external payable; + /// @notice Fills the given signed ask for an NFT with a signed module approval + /// @param _ask The signed ask to fill + /// @param _approvalSig The signed module approval + /// @param _v The 129th byte and chain ID of the signature + /// @param _r The first 64 bytes of the signature + /// @param _s Bytes 64-128 of the signature + function fillAsk( + IAsksGaslessEth.GaslessAsk calldata _ask, + IAsksGaslessEth.ModuleApprovalSig calldata _approvalSig, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external payable; + /// @notice Invalidates an off-chain order /// @param _ask The signed ask parameters to invalidate function cancelAsk(IAsksGaslessEth.GaslessAsk calldata _ask) external;