diff --git a/contracts/modules/Asks/Gasless/ETH/AsksGaslessEth.sol b/contracts/modules/Asks/Gasless/ETH/AsksGaslessEth.sol new file mode 100644 index 00000000..27a82fd9 --- /dev/null +++ b/contracts/modules/Asks/Gasless/ETH/AsksGaslessEth.sol @@ -0,0 +1,310 @@ +// 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)"); + 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 = + 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)) + ) + ); + + 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"); + + // 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, _approvalSig.deadline, _approvalSig.v, _approvalSig.r, _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..49166825 --- /dev/null +++ b/contracts/modules/Asks/Gasless/ETH/IAsksGaslessEth.sol @@ -0,0 +1,76 @@ +// 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 + } + + /// @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 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; + + /// @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..83b9cd96 --- /dev/null +++ b/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.integration.t.sol @@ -0,0 +1,189 @@ +// 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 getSignedModuleApproval() 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 getSignedAsk() + 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)"); + 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)))) + ); + } + + /// /// + /// 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 + }); + + IAsksGaslessEth.ModuleApprovalSig memory sig = getSignedModuleApproval(); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); + + vm.prank(address(buyer)); + asks.fillAsk{value: 1 ether}(ask, sig, 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..a2ff293c --- /dev/null +++ b/contracts/test/modules/Asks/Gasless/ETH/AsksGaslessEth.t.sol @@ -0,0 +1,412 @@ +// 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_GetAskHash() public { + bytes32 sigHash = keccak256("SignedAsk(address tokenContract,uint256 tokenId,uint256 expiry,uint256 nonce, uint256 price)"); + + emit log_bytes32(sigHash); + } + + 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)"), + 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 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, + 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)"); + 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)))) + ); + } + + function getInvalidAsk() + 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)"); + 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), 0, 0, 0, 1 ether)))) + ); + } + + /// /// + /// FILL ASK /// + /// /// + + 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 + }); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); + + vm.prank(address(buyer)); + asks.fillAsk{value: 1 ether}(ask, v, r, s); + + require(token.ownerOf(1) == address(buyer)); + } + + function test_FillAskWithModuleApprovalSig() public { + require(!ZMM.isModuleApproved(seller, address(asks))); + + IAsksGaslessEth.GaslessAsk memory ask = IAsksGaslessEth.GaslessAsk({ + seller: seller, + tokenContract: address(token), + tokenId: 1, + expiry: 0, + nonce: 0, + price: 1 ether + }); + + IAsksGaslessEth.ModuleApprovalSig memory sig = getSignedModuleApproval(); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); + + vm.prank(address(buyer)); + asks.fillAsk{value: 1 ether}(ask, sig, 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 + }); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); + + 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_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, + tokenContract: address(token), + tokenId: 1, + expiry: 0, + nonce: 0, + price: 1 ether + }); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); + + 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 + }); + + vm.prank(seller); + asks.cancelAsk(ask); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); + + 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 + }); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); + + 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 + }); + + 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 + }); + + 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 + }); + + (uint8 v, bytes32 r, bytes32 s) = getSignedAsk(); + + 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 + }); + + (uint8 v, bytes32 r, bytes32 s) = getInvalidAsk(); + + bool valid = asks.validateAskSig(ask, v, r, s); + + require(!valid); + } +}