diff --git a/contracts/LooksRareExchange.sol b/contracts/LooksRareExchange.sol index 4c37321..462e44b 100644 --- a/contracts/LooksRareExchange.sol +++ b/contracts/LooksRareExchange.sol @@ -5,6 +5,8 @@ pragma solidity ^0.8.0; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Context} from "@openzeppelin/contracts/utils/Context.sol"; +import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol"; // LooksRare interfaces import {ICurrencyManager} from "./interfaces/ICurrencyManager.sol"; @@ -57,7 +59,7 @@ LOOKSRARELOOKSRARELOOKSRLOOKSRARELOOKSRo' 'oLOOKSRARELOOKSRLOOKSRARELOOKS LOOKSRARELOOKSRARELOOKSRLOOKSRARELOOKSRARE,. .,dRELOOKSRARELOOKSRLOOKSRARELOOKSRARELOOKSR LOOKSRARELOOKSRARELOOKSRLOOKSRARELOOKSRARELOOKSRARELOOKSRARELOOKSRLOOKSRARELOOKSRARELOOKSR */ -contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { +contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable, ERC2771Context { using SafeERC20 for IERC20; using OrderTypes for OrderTypes.MakerOrder; @@ -74,7 +76,10 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { ITransferSelectorNFT public transferSelectorNFT; mapping(address => uint256) public userMinOrderNonce; - mapping(address => mapping(uint256 => bool)) private _isUserOrderNonceExecutedOrCancelled; + // maker => nonce => amount + // it is an executed amount + // amount == type(uint256).max means that an offer was cancelled or fully executed + mapping(address => mapping(uint256 => uint256)) private _isUserOrderNonceExecutedOrCancelled; event CancelAllOrders(address indexed user, uint256 newMinNonce); event CancelMultipleOrders(address indexed user, uint256[] orderNonces); @@ -131,8 +136,9 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { address _executionManager, address _royaltyFeeManager, address _WETH, - address _protocolFeeRecipient - ) { + address _protocolFeeRecipient, + address _trustedForwarder + ) ERC2771Context(_trustedForwarder) { // Calculate the domain separator DOMAIN_SEPARATOR = keccak256( abi.encode( @@ -156,11 +162,12 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { * @param minNonce minimum user nonce */ function cancelAllOrdersForSender(uint256 minNonce) external { - require(minNonce > userMinOrderNonce[msg.sender], "Cancel: Order nonce lower than current"); - require(minNonce < userMinOrderNonce[msg.sender] + 500000, "Cancel: Cannot cancel more orders"); - userMinOrderNonce[msg.sender] = minNonce; + address msgSender = _msgSender(); + require(minNonce > userMinOrderNonce[msgSender], "Cancel: Order nonce lower than current"); + require(minNonce < userMinOrderNonce[msgSender] + 500000, "Cancel: Cannot cancel more orders"); + userMinOrderNonce[msgSender] = minNonce; - emit CancelAllOrders(msg.sender, minNonce); + emit CancelAllOrders(msgSender, minNonce); } /** @@ -168,14 +175,16 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { * @param orderNonces array of order nonces */ function cancelMultipleMakerOrders(uint256[] calldata orderNonces) external { + address msgSender = _msgSender(); + require(orderNonces.length > 0, "Cancel: Cannot be empty"); for (uint256 i = 0; i < orderNonces.length; i++) { - require(orderNonces[i] >= userMinOrderNonce[msg.sender], "Cancel: Order nonce lower than current"); - _isUserOrderNonceExecutedOrCancelled[msg.sender][orderNonces[i]] = true; + require(orderNonces[i] >= userMinOrderNonce[msgSender], "Cancel: Order nonce lower than current"); + _isUserOrderNonceExecutedOrCancelled[msgSender][orderNonces[i]] = type(uint256).max; } - emit CancelMultipleOrders(msg.sender, orderNonces); + emit CancelMultipleOrders(msgSender, orderNonces); } /** @@ -189,11 +198,11 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { ) external payable override nonReentrant { require((makerAsk.isOrderAsk) && (!takerBid.isOrderAsk), "Order: Wrong sides"); require(makerAsk.currency == WETH, "Order: Currency must be WETH"); - require(msg.sender == takerBid.taker, "Order: Taker must be the sender"); + require(_msgSender() == takerBid.taker, "Order: Taker must be the sender"); // If not enough ETH to cover the price, use WETH if (takerBid.price > msg.value) { - IERC20(WETH).safeTransferFrom(msg.sender, address(this), (takerBid.price - msg.value)); + IERC20(WETH).safeTransferFrom(takerBid.taker, address(this), (takerBid.price - msg.value)); } else { require(takerBid.price == msg.value, "Order: Msg.value too high"); } @@ -209,10 +218,17 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { (bool isExecutionValid, uint256 tokenId, uint256 amount) = IExecutionStrategy(makerAsk.strategy) .canExecuteTakerBid(takerBid, makerAsk); - require(isExecutionValid, "Strategy: Execution invalid"); + unchecked { + require(isExecutionValid, "Strategy: Execution invalid"); + uint256 newUsedAmount = _isUserOrderNonceExecutedOrCancelled[makerAsk.signer][makerAsk.nonce] + amount; + require(newUsedAmount >= amount, "Strategy: Execution invalid"); + require(makerAsk.amount >= newUsedAmount, "Strategy: Execution invalid"); - // Update maker ask order status to true (prevents replay) - _isUserOrderNonceExecutedOrCancelled[makerAsk.signer][makerAsk.nonce] = true; + // Update maker ask order status to true (prevents replay) + _isUserOrderNonceExecutedOrCancelled[makerAsk.signer][makerAsk.nonce] = makerAsk.amount == newUsedAmount + ? type(uint256).max + : newUsedAmount; + } // Execution part 1/2 _transferFeesAndFundsWithWETH( @@ -252,7 +268,7 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { nonReentrant { require((makerAsk.isOrderAsk) && (!takerBid.isOrderAsk), "Order: Wrong sides"); - require(msg.sender == takerBid.taker, "Order: Taker must be the sender"); + require(_msgSender() == takerBid.taker, "Order: Taker must be the sender"); // Check the maker ask order bytes32 askHash = makerAsk.hash(); @@ -263,8 +279,16 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { require(isExecutionValid, "Strategy: Execution invalid"); - // Update maker ask order status to true (prevents replay) - _isUserOrderNonceExecutedOrCancelled[makerAsk.signer][makerAsk.nonce] = true; + unchecked { + uint256 newUsedAmount = _isUserOrderNonceExecutedOrCancelled[makerAsk.signer][makerAsk.nonce] + amount; + require(newUsedAmount >= amount, "Strategy: Execution invalid"); + require(makerAsk.amount >= newUsedAmount, "Strategy: Execution invalid"); + + // Update maker ask order status to true (prevents replay) + _isUserOrderNonceExecutedOrCancelled[makerAsk.signer][makerAsk.nonce] = makerAsk.amount == newUsedAmount + ? type(uint256).max + : newUsedAmount; + } // Execution part 1/2 _transferFeesAndFunds( @@ -272,7 +296,7 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { makerAsk.collection, tokenId, makerAsk.currency, - msg.sender, + takerBid.taker, makerAsk.signer, takerBid.price, makerAsk.minPercentageToAsk @@ -306,7 +330,7 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { nonReentrant { require((!makerBid.isOrderAsk) && (takerAsk.isOrderAsk), "Order: Wrong sides"); - require(msg.sender == takerAsk.taker, "Order: Taker must be the sender"); + require(_msgSender() == takerAsk.taker, "Order: Taker must be the sender"); // Check the maker bid order bytes32 bidHash = makerBid.hash(); @@ -317,11 +341,19 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { require(isExecutionValid, "Strategy: Execution invalid"); - // Update maker bid order status to true (prevents replay) - _isUserOrderNonceExecutedOrCancelled[makerBid.signer][makerBid.nonce] = true; + unchecked { + uint256 newUsedAmount = _isUserOrderNonceExecutedOrCancelled[makerBid.signer][makerBid.nonce] + amount; + require(newUsedAmount >= amount, "Strategy: Excessive amount"); // overflow check + require(makerBid.amount >= newUsedAmount, "Strategy: Excessive amount"); + + // Update maker bid order status to true (prevents replay) + _isUserOrderNonceExecutedOrCancelled[makerBid.signer][makerBid.nonce] = makerBid.amount == newUsedAmount + ? type(uint256).max + : newUsedAmount; + } // Execution part 1/2 - _transferNonFungibleToken(makerBid.collection, msg.sender, makerBid.signer, tokenId, amount); + _transferNonFungibleToken(makerBid.collection, takerAsk.taker, makerBid.signer, tokenId, amount); // Execution part 2/2 _transferFeesAndFunds( @@ -405,6 +437,16 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { * @param orderNonce nonce of the order */ function isUserOrderNonceExecutedOrCancelled(address user, uint256 orderNonce) external view returns (bool) { + return _isUserOrderNonceExecutedOrCancelled[user][orderNonce] == type(uint256).max; + } + + /** + * @notice Check an executed/used amount of an order. + * type(uint256).max means that the order was cancelled or fully used. + * @param user address of user + * @param orderNonce nonce of the order + */ + function executedAmount(address user, uint256 orderNonce) external view returns (uint256) { return _isUserOrderNonceExecutedOrCancelled[user][orderNonce]; } @@ -562,7 +604,7 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { function _validateOrder(OrderTypes.MakerOrder calldata makerOrder, bytes32 orderHash) internal view { // Verify whether order nonce has expired require( - (!_isUserOrderNonceExecutedOrCancelled[makerOrder.signer][makerOrder.nonce]) && + (_isUserOrderNonceExecutedOrCancelled[makerOrder.signer][makerOrder.nonce] < type(uint256).max) && (makerOrder.nonce >= userMinOrderNonce[makerOrder.signer]), "Order: Matching order expired" ); @@ -575,14 +617,7 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { // Verify the validity of the signature require( - SignatureChecker.verify( - orderHash, - makerOrder.signer, - makerOrder.v, - makerOrder.r, - makerOrder.s, - DOMAIN_SEPARATOR - ), + SignatureChecker.verify(orderHash, makerOrder.signer, makerOrder.signature, DOMAIN_SEPARATOR), "Signature: Invalid" ); @@ -592,4 +627,12 @@ contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { // Verify whether strategy can be executed require(executionManager.isStrategyWhitelisted(makerOrder.strategy), "Strategy: Not whitelisted"); } + + function _msgData() internal view virtual override(Context, ERC2771Context) returns (bytes calldata) { + return ERC2771Context._msgData(); + } + + function _msgSender() internal view virtual override(Context, ERC2771Context) returns (address sender) { + return ERC2771Context._msgSender(); + } } diff --git a/contracts/executionStrategies/StrategyPartialSaleForFixedPrice.sol b/contracts/executionStrategies/StrategyPartialSaleForFixedPrice.sol new file mode 100644 index 0000000..fbe9d81 --- /dev/null +++ b/contracts/executionStrategies/StrategyPartialSaleForFixedPrice.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {OrderTypes} from "../libraries/OrderTypes.sol"; +import {IExecutionStrategy} from "../interfaces/IExecutionStrategy.sol"; + +/** + * @title StrategyPartialSaleForFixedPrice + * @notice Strategy that executes an order at a fixed price that + * can be taken either by a bid or an ask. + * Partial means that a taker can buy/sell a partial amount. + */ +contract StrategyPartialSaleForFixedPrice is IExecutionStrategy { + uint256 public immutable PROTOCOL_FEE; + + /** + * @notice Constructor + * @param _protocolFee protocol fee (200 --> 2%, 400 --> 4%) + */ + constructor(uint256 _protocolFee) { + PROTOCOL_FEE = _protocolFee; + } + + /** + * @notice Check whether a taker ask order can be executed against a maker bid + * @param takerAsk taker ask order + * @param makerBid maker bid order + * @return (whether strategy can be executed, tokenId to execute, amount of tokens to execute) + */ + function canExecuteTakerAsk(OrderTypes.TakerOrder calldata takerAsk, OrderTypes.MakerOrder calldata makerBid) + external + view + override + returns ( + bool, + uint256, + uint256 + ) + { + uint256 takerAskAmount = abi.decode(takerAsk.params, (uint256)); + return ( + (_verifyPrice(takerAsk.price, takerAskAmount, makerBid.price, makerBid.amount) && + (makerBid.tokenId == takerAsk.tokenId) && + (makerBid.startTime <= block.timestamp) && + (makerBid.endTime >= block.timestamp) && + (takerAskAmount > 0) && + (makerBid.amount >= takerAskAmount)), + makerBid.tokenId, + takerAskAmount + ); + } + + /** + * @notice Check whether a taker bid order can be executed against a maker ask + * @param takerBid taker bid order + * @param makerAsk maker ask order + * @return (whether strategy can be executed, tokenId to execute, amount of tokens to execute) + */ + function canExecuteTakerBid(OrderTypes.TakerOrder calldata takerBid, OrderTypes.MakerOrder calldata makerAsk) + external + view + override + returns ( + bool, + uint256, + uint256 + ) + { + uint256 takerBidAmount = abi.decode(takerBid.params, (uint256)); + return ( + (_verifyPrice(makerAsk.price, makerAsk.amount, takerBid.price, takerBidAmount) && + (makerAsk.tokenId == takerBid.tokenId) && + (makerAsk.startTime <= block.timestamp) && + (makerAsk.endTime >= block.timestamp) && + (takerBidAmount > 0) && + (makerAsk.amount >= takerBidAmount)), + makerAsk.tokenId, + takerBidAmount + ); + } + + /** + * @dev checks that askPrice/askAmount <= bidPrice/bidAmount + * which is askPrice * bidAmount <= bidPrice * askAmount + * note that these are uint256 * uint256 = uint512 + * the function supports uint256 overflows and extreme values + * a lot of low level arithmetics + */ + function _verifyPrice( + uint256 askPrice, + uint256 askAmount, + uint256 bidPrice, + uint256 bidAmount + ) private pure returns (bool result) { + // none operation oferflows + unchecked { + // low 128 bits + uint256 left = (askPrice & (2**128 - 1)) * (bidAmount & (2**128 - 1)); + uint256 leftHigh = left >> 128; + left &= 2**128 - 1; + uint256 right = (bidPrice & (2**128 - 1)) * (askAmount & (2**128 - 1)); + uint256 rightHigh = right >> 128; + right &= 2**128 - 1; + result = left <= right; + + // middle 128 bits + left = leftHigh + (askPrice >> 128) * (bidAmount & (2**128 - 1)); + leftHigh = left >> 128; + left &= 2**128 - 1; + left += (askPrice & (2**128 - 1)) * (bidAmount >> 128); + leftHigh += left >> 128; + left &= 2**128 - 1; + right = rightHigh + (bidPrice >> 128) * (askAmount & (2**128 - 1)); + rightHigh = right >> 128; + right &= 2**128 - 1; + right += (bidPrice & (2**128 - 1)) * (askAmount >> 128); + rightHigh += right >> 128; + right &= 2**128 - 1; + result = left < right || (left == right && result); + + // high 256 bits + left = leftHigh + (askPrice >> 128) * (bidAmount >> 128); + right = rightHigh + (bidPrice >> 128) * (askAmount >> 128); + result = left < right || (left == right && result); + } + } + + /** + * @notice Return protocol fee for this strategy + * @return protocol fee + */ + function viewProtocolFee() external view override returns (uint256) { + return PROTOCOL_FEE; + } +} diff --git a/contracts/libraries/OrderTypes.sol b/contracts/libraries/OrderTypes.sol index 943bfec..079bea3 100644 --- a/contracts/libraries/OrderTypes.sol +++ b/contracts/libraries/OrderTypes.sol @@ -23,14 +23,12 @@ library OrderTypes { uint256 endTime; // endTime in timestamp uint256 minPercentageToAsk; // slippage protection (9000 --> 90% of the final price must return to ask) bytes params; // additional parameters - uint8 v; // v: parameter (27 or 28) - bytes32 r; // r: parameter - bytes32 s; // s: parameter + bytes signature; // the signature of the above } struct TakerOrder { bool isOrderAsk; // true --> ask / false --> bid - address taker; // msg.sender + address taker; // _msgSender() uint256 price; // final price for the purchase uint256 tokenId; uint256 minPercentageToAsk; // // slippage protection (9000 --> 90% of the final price must return to ask) diff --git a/contracts/libraries/SignatureChecker.sol b/contracts/libraries/SignatureChecker.sol index d711315..d9e2691 100644 --- a/contracts/libraries/SignatureChecker.sol +++ b/contracts/libraries/SignatureChecker.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.0; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; /** @@ -42,18 +43,14 @@ library SignatureChecker { * @notice Returns whether the signer matches the signed message * @param hash the hash containing the signed mesage * @param signer the signer address to confirm message validity - * @param v parameter (27 or 28) - * @param r parameter - * @param s parameter - * @param domainSeparator paramer to prevent signature being executed in other chains and environments + * @param signature eip712 or eip1271 + * @param domainSeparator parameter to prevent signature being executed in other chains and environments * @return true --> if valid // false --> if invalid */ function verify( bytes32 hash, address signer, - uint8 v, - bytes32 r, - bytes32 s, + bytes memory signature, bytes32 domainSeparator ) internal view returns (bool) { // \x19\x01 is the standardized encoding prefix @@ -61,9 +58,9 @@ library SignatureChecker { bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, hash)); if (Address.isContract(signer)) { // 0x1626ba7e is the interfaceId for signature contracts (see IERC1271) - return IERC1271(signer).isValidSignature(digest, abi.encodePacked(r, s, v)) == 0x1626ba7e; + return IERC1271(signer).isValidSignature(digest, signature) == 0x1626ba7e; } else { - return recover(digest, v, r, s) == signer; + return ECDSA.recover(digest, signature) == signer; } } } diff --git a/contracts/test/CheckMinimalForwarder.sol b/contracts/test/CheckMinimalForwarder.sol new file mode 100644 index 0000000..ff094f5 --- /dev/null +++ b/contracts/test/CheckMinimalForwarder.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/metatx/MinimalForwarder.sol"; + +contract CheckMinimalForwarder is MinimalForwarder { + function checkExecute(ForwardRequest calldata req, bytes calldata signature) public payable { + (bool success, bytes memory result) = execute(req, signature); + if (!success) { + // If call reverts + // If there is return data, the call reverted without a reason or a custom error. + if (result.length == 0) revert("CheckMinimalForwarder: the call failed without an error message"); + assembly { + // We use Yul's revert() to bubble up errors from the target contract. + revert(add(32, result), mload(result)) + } + } + } +} diff --git a/contracts/test/DutchAuction.t.sol b/contracts/test/DutchAuction.t.sol index 1d76de7..2353bf9 100644 --- a/contracts/test/DutchAuction.t.sol +++ b/contracts/test/DutchAuction.t.sol @@ -16,9 +16,7 @@ abstract contract TestParameters { uint256 internal _TOKEN_ID = 1; uint256 internal _MIN_PERCENTAGE_TO_ASK = 8500; bytes internal _TAKER_PARAMS; - uint8 internal _V = 27; - bytes32 internal _R; - bytes32 internal _S; + bytes internal _SIGNATURE; // Dutch Auction constructor parameters uint256 internal _PROTOCOL_FEE = 200; @@ -70,9 +68,7 @@ contract StrategyDutchAuctionTest is TestHelpers, TestParameters { endTime, _MIN_PERCENTAGE_TO_ASK, makerParams, - _V, - _R, - _S + _SIGNATURE ); (bool canExecute, , ) = strategyDutchAuction.canExecuteTakerBid(takerBidOrder, makerAskOrder); diff --git a/contracts/test/utils/MockNSSignerContract.sol b/contracts/test/utils/MockNSSignerContract.sol new file mode 100644 index 0000000..8b3326b --- /dev/null +++ b/contracts/test/utils/MockNSSignerContract.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @dev Mock NS Signer Contract, NS stands for no signature, +/// it means that a signature in isValidSignature() is ignored +contract MockNSSignerContract is IERC1271, ERC721Holder, Ownable { + mapping(bytes32 => bool) public hashes; + + // bytes4(keccak256("isValidSignature(bytes32,bytes)") + bytes4 internal constant MAGICVALUE = 0x1626ba7e; + + function approveHash(bytes32 hash, bool approved) external onlyOwner { + hashes[hash] = approved; + } + + /** + * @notice Approve ERC20 + */ + function approveERC20ToBeSpent(address token, address target) external onlyOwner { + IERC20(token).approve(target, type(uint256).max); + } + + /** + * @notice Approve all ERC721 tokens + */ + function approveERC721NFT(address collection, address target) external onlyOwner { + IERC721(collection).setApprovalForAll(target, true); + } + + /** + * @notice Withdraw ERC20 balance + */ + function withdrawERC20(address token) external onlyOwner { + IERC20(token).transfer(msg.sender, IERC20(token).balanceOf(address(this))); + } + + /** + * @notice Withdraw ERC721 tokenId + */ + function withdrawERC721NFT(address collection, uint256 tokenId) external onlyOwner { + IERC721(collection).transferFrom(address(this), msg.sender, tokenId); + } + + /** + * @notice Verifies that the signer is the owner of the signing contract. + */ + function isValidSignature( + bytes32 hash, + bytes memory /*signature*/ + ) external view override returns (bytes4) { + return hashes[hash] ? MAGICVALUE : bytes4(0xffffffff); + } +} diff --git a/test/helpers/eip2771.ts b/test/helpers/eip2771.ts new file mode 100644 index 0000000..f1fd82c --- /dev/null +++ b/test/helpers/eip2771.ts @@ -0,0 +1,84 @@ +import { BigNumber, utils, Wallet, BytesLike } from "ethers"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +/* eslint-disable node/no-extraneous-import */ +import { TypedDataDomain } from "@ethersproject/abstract-signer"; +/* eslint-disable node/no-extraneous-import */ +import { Signature } from "@ethersproject/bytes"; +/* eslint-disable node/no-extraneous-import */ +import { _TypedDataEncoder } from "@ethersproject/hash"; +import { findPrivateKey } from "./hardhat-keys"; + +const { defaultAbiCoder, keccak256, solidityPack } = utils; + +export interface ForwardRequest { + from: string; // signer address + to: string; // LooksRareExchange address by default + value: BigNumber; // eth transfer + gas: BigNumber; // max gas for an internal call + nonce: BigNumber; // forwarder's signer nonce + data: BytesLike; // internal call data +} + +/** + * Generate a signature used to generate v, r, s parameters + * @param signer signer + * @param types solidity types of the value param + * @param values params to be sent to the Solidity function + * @param verifyingContract verifying contract address ("LooksRareExchange") + * @returns splitted signature + * @see https://docs.ethers.io/v5/api/signer/#Signer-signTypedData + */ +const signTypedData = async ( + signer: SignerWithAddress, + types: string[], + values: (string | boolean | BigNumber)[], + verifyingContract: string +): Promise => { + const domain: TypedDataDomain = { + name: "MinimalForwarder", + version: "0.0.1", + chainId: "31337", // HRE + verifyingContract: verifyingContract, + }; + + const domainSeparator = _TypedDataEncoder.hashDomain(domain); + + // https://docs.ethers.io/v5/api/utils/abi/coder/#AbiCoder--methods + const hash = keccak256(defaultAbiCoder.encode(types, values)); + + // Compute the digest + const digest = keccak256( + solidityPack(["bytes1", "bytes1", "bytes32", "bytes32"], ["0x19", "0x01", domainSeparator, hash]) + ); + + const adjustedSigner = new Wallet(findPrivateKey(signer.address)); + return { ...adjustedSigner._signingKey().signDigest(digest) }; +}; + +/** + * Create a signature for a maker order + * @param signer signer for the order + * @param verifyingContract verifying contract address + * @param order see MakerOrder definition + * @returns splitted signature + */ +export const signForwardRequest = async ( + signer: SignerWithAddress, + verifyingContract: string, + req: ForwardRequest +): Promise => { + const types = ["bytes32", "address", "address", "uint256", "uint256", "uint256", "bytes32"]; + + const values = [ + "0xdd8f4b70b0f4393e889bd39128a30628a78b61816a9eb8199759e7a349657e48", // keccak256(ForwarderRequest) + req.from, + req.to, + req.value, + req.gas, + req.nonce, + keccak256(req.data), + ]; + + const sig = await signTypedData(signer, types, values, verifyingContract); + return utils.joinSignature(sig); +}; diff --git a/test/helpers/order-helper.ts b/test/helpers/order-helper.ts index 27a5227..94daaf7 100644 --- a/test/helpers/order-helper.ts +++ b/test/helpers/order-helper.ts @@ -1,4 +1,5 @@ import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { utils } from "ethers"; import { MakerOrder, MakerOrderWithSignature, TakerOrder } from "./order-types"; import { signMakerOrder } from "./signature-helper"; @@ -45,9 +46,7 @@ export async function createMakerOrder({ // Extend makerOrder with proper signature const makerOrderExtended: MakerOrderWithSignature = { ...makerOrder, - r: signedOrder.r, - s: signedOrder.s, - v: signedOrder.v, + signature: utils.joinSignature(signedOrder), }; return makerOrderExtended; diff --git a/test/helpers/order-types.ts b/test/helpers/order-types.ts index b666266..4a59a84 100644 --- a/test/helpers/order-types.ts +++ b/test/helpers/order-types.ts @@ -1,4 +1,4 @@ -import { BigNumber, BigNumberish, BytesLike } from "ethers"; +import { BigNumber, BytesLike } from "ethers"; export interface MakerOrder { isOrderAsk: boolean; // true if ask, false if bid @@ -17,9 +17,7 @@ export interface MakerOrder { } export interface MakerOrderWithSignature extends MakerOrder { - r: BytesLike; // r: parameter - s: BytesLike; // s: parameter - v: BigNumberish; // v: parameter (27 or 28) + signature: BytesLike; // eip712 or eip1271 signature } export interface TakerOrder { diff --git a/test/helpers/signature-helper.ts b/test/helpers/signature-helper.ts index 9f8efc1..72e7da6 100644 --- a/test/helpers/signature-helper.ts +++ b/test/helpers/signature-helper.ts @@ -100,6 +100,21 @@ export const computeOrderHash = (order: MakerOrder): string => { return keccak256(defaultAbiCoder.encode(types, values)); }; +/** + * Compute order digest for a maker order, EIP712 structure + * @param order MakerOrder + * @returns digest + */ +export const computeOrderDigest = (verifyingContract: string, order: MakerOrder): string => { + const hash = computeOrderHash(order); + const domainSeparator = computeDomainSeparator(verifyingContract); + // Compute the digest + const digest = keccak256( + solidityPack(["bytes1", "bytes1", "bytes32", "bytes32"], ["0x19", "0x01", domainSeparator, hash]) + ); + return digest; +}; + /** * Create a signature for a maker order * @param signer signer for the order diff --git a/test/looksRareExchange.test.ts b/test/looksRareExchange.test.ts index 0030afe..9320d6f 100644 --- a/test/looksRareExchange.test.ts +++ b/test/looksRareExchange.test.ts @@ -6,11 +6,12 @@ import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; import { increaseTo } from "./helpers/block-traveller"; import { MakerOrderWithSignature, TakerOrder } from "./helpers/order-types"; import { createMakerOrder, createTakerOrder } from "./helpers/order-helper"; -import { computeDomainSeparator, computeOrderHash } from "./helpers/signature-helper"; +import { computeDomainSeparator, computeOrderHash, computeOrderDigest } from "./helpers/signature-helper"; +import { signForwardRequest, ForwardRequest } from "./helpers/eip2771"; import { setUp } from "./test-setup"; import { tokenSetUp } from "./token-set-up"; -const { defaultAbiCoder, parseEther } = utils; +const { defaultAbiCoder, parseEther, arrayify } = utils; describe("LooksRare Exchange", () => { // Mock contracts @@ -19,6 +20,7 @@ describe("LooksRare Exchange", () => { let mockERC721WithRoyalty: Contract; let mockERC1155: Contract; let weth: Contract; + let minimalForwarder: Contract; // Exchange contracts let transferSelectorNFT: Contract; @@ -74,6 +76,8 @@ describe("LooksRare Exchange", () => { royaltyFeeRegistry, royaltyFeeManager, royaltyFeeSetter, + , + minimalForwarder, ] = await setUp(admin, feeRecipient, royaltyCollector, standardProtocolFee, royaltyFeeLimit); await tokenSetUp( @@ -532,6 +536,420 @@ describe("LooksRare Exchange", () => { await mockSignerContract.connect(userSigningThroughContract).withdrawERC20(weth.address); assert.deepEqual(await weth.balanceOf(mockSignerContract.address), constants.Zero); }); + + it("ERC1271/Contract No Signature - MakerBid order is matched by TakerAsk order", async () => { + const userSigningThroughContract = accounts[1]; + const takerAskUser = accounts[2]; + + const MockNSSignerContract = await ethers.getContractFactory("MockNSSignerContract"); + const mockNSSignerContract = await MockNSSignerContract.connect(userSigningThroughContract).deploy(); + await mockNSSignerContract.deployed(); + + await weth.connect(userSigningThroughContract).transfer(mockNSSignerContract.address, parseEther("1")); + await mockNSSignerContract + .connect(userSigningThroughContract) + .approveERC20ToBeSpent(weth.address, looksRareExchange.address); + + const makerBidOrder = await createMakerOrder({ + isOrderAsk: false, + signer: mockNSSignerContract.address, + collection: mockERC721.address, + tokenId: constants.One, + price: parseEther("1"), + amount: constants.One, + strategy: strategyStandardSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: userSigningThroughContract, + verifyingContract: looksRareExchange.address, + }); + // no signature, approve hash instead + makerBidOrder.signature = "0x"; + const orderDigest = computeOrderDigest(looksRareExchange.address, makerBidOrder); + await mockNSSignerContract.connect(userSigningThroughContract).approveHash(orderDigest, true); + + const takerAskOrder = createTakerOrder({ + isOrderAsk: true, + taker: takerAskUser.address, + tokenId: makerBidOrder.tokenId, + price: makerBidOrder.price, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + }); + + const tx = await looksRareExchange.connect(takerAskUser).matchBidWithTakerAsk(takerAskOrder, makerBidOrder); + await expect(tx) + .to.emit(looksRareExchange, "TakerAsk") + .withArgs( + computeOrderHash(makerBidOrder), + makerBidOrder.nonce, + takerAskUser.address, + mockNSSignerContract.address, + strategyStandardSaleForFixedPrice.address, + makerBidOrder.currency, + makerBidOrder.collection, + takerAskOrder.tokenId, + makerBidOrder.amount, + makerBidOrder.price + ); + + // Verify funds/tokens were transferred + assert.equal(await mockERC721.ownerOf("1"), mockNSSignerContract.address); + assert.isTrue( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(mockNSSignerContract.address, makerBidOrder.nonce) + ); + + // Withdraw it back + await mockNSSignerContract.connect(userSigningThroughContract).withdrawERC721NFT(mockERC721.address, "1"); + assert.equal(await mockERC721.ownerOf("1"), userSigningThroughContract.address); + }); + + it("ERC1271/Contract No Signature - MakerAsk order is matched by TakerBid order", async () => { + const userSigningThroughContract = accounts[1]; + const takerBidUser = accounts[2]; + const MockNSSignerContract = await ethers.getContractFactory("MockNSSignerContract"); + const mockNSSignerContract = await MockNSSignerContract.connect(userSigningThroughContract).deploy(); + await mockNSSignerContract.deployed(); + + await mockERC721 + .connect(userSigningThroughContract) + .transferFrom(userSigningThroughContract.address, mockNSSignerContract.address, "0"); + + await mockNSSignerContract + .connect(userSigningThroughContract) + .approveERC721NFT(mockERC721.address, transferManagerERC721.address); + + const makerAskOrder = await createMakerOrder({ + isOrderAsk: true, + signer: mockNSSignerContract.address, + collection: mockERC721.address, + tokenId: constants.Zero, + price: parseEther("1"), + amount: constants.One, + strategy: strategyStandardSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: userSigningThroughContract, + verifyingContract: looksRareExchange.address, + }); + // no signature, approve hash instead + makerAskOrder.signature = "0x"; + const orderDigest = computeOrderDigest(looksRareExchange.address, makerAskOrder); + await mockNSSignerContract.connect(userSigningThroughContract).approveHash(orderDigest, true); + + const takerBidOrder = createTakerOrder({ + isOrderAsk: false, + taker: takerBidUser.address, + tokenId: makerAskOrder.tokenId, + price: makerAskOrder.price, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + }); + + const tx = await looksRareExchange.connect(takerBidUser).matchAskWithTakerBid(takerBidOrder, makerAskOrder); + await expect(tx) + .to.emit(looksRareExchange, "TakerBid") + .withArgs( + computeOrderHash(makerAskOrder), + makerAskOrder.nonce, + takerBidUser.address, + mockNSSignerContract.address, + strategyStandardSaleForFixedPrice.address, + makerAskOrder.currency, + makerAskOrder.collection, + takerBidOrder.tokenId, + makerAskOrder.amount, + makerAskOrder.price + ); + + // Verify funds/tokens were transferred + assert.equal(await mockERC721.ownerOf("1"), takerBidUser.address); + assert.deepEqual( + await weth.balanceOf(mockNSSignerContract.address), + takerBidOrder.price.mul("9800").div("10000") + ); + + assert.isTrue( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(mockNSSignerContract.address, makerAskOrder.nonce) + ); + + // Withdraw WETH back + await mockNSSignerContract.connect(userSigningThroughContract).withdrawERC20(weth.address); + assert.deepEqual(await weth.balanceOf(mockNSSignerContract.address), constants.Zero); + }); + + it("ERC1271/Contract No Signature - fails if an order is not approved", async () => { + const userSigningThroughContract = accounts[1]; + const takerAskUser = accounts[2]; + + const MockNSSignerContract = await ethers.getContractFactory("MockNSSignerContract"); + const mockNSSignerContract = await MockNSSignerContract.connect(userSigningThroughContract).deploy(); + await mockNSSignerContract.deployed(); + + await weth.connect(userSigningThroughContract).transfer(mockNSSignerContract.address, parseEther("1")); + await mockNSSignerContract + .connect(userSigningThroughContract) + .approveERC20ToBeSpent(weth.address, looksRareExchange.address); + + const makerBidOrder = await createMakerOrder({ + isOrderAsk: false, + signer: mockNSSignerContract.address, + collection: mockERC721.address, + tokenId: constants.One, + price: parseEther("1"), + amount: constants.One, + strategy: strategyStandardSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: userSigningThroughContract, + verifyingContract: looksRareExchange.address, + }); + + const takerAskOrder = createTakerOrder({ + isOrderAsk: true, + taker: takerAskUser.address, + tokenId: makerBidOrder.tokenId, + price: makerBidOrder.price, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + }); + + await expect( + looksRareExchange.connect(takerAskUser).matchBidWithTakerAsk(takerAskOrder, makerBidOrder) + ).to.be.revertedWith("Signature: Invalid"); + }); + + it("EIP2771/ERC721/ETH only - MakerAsk order is matched by TakerBid order", async () => { + const forwardExecutor = accounts[0]; + const makerAskUser = accounts[1]; + const takerBidUser = accounts[2]; + + const makerAskOrder: MakerOrderWithSignature = await createMakerOrder({ + isOrderAsk: true, + signer: makerAskUser.address, + collection: mockERC721.address, + price: parseEther("3"), + tokenId: constants.Zero, + amount: constants.One, + strategy: strategyStandardSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerAskUser, + verifyingContract: looksRareExchange.address, + }); + + const takerBidOrder = createTakerOrder({ + isOrderAsk: false, + taker: takerBidUser.address, + price: parseEther("3"), + tokenId: constants.Zero, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + }); + + // a way to encode tx data + const intTxData = ( + await looksRareExchange.populateTransaction.matchAskWithTakerBidUsingETHAndWETH(takerBidOrder, makerAskOrder, { + value: takerBidOrder.price, + }) + ).data; + const forwardRequest: ForwardRequest = { + from: takerBidUser.address, + to: looksRareExchange.address, + value: takerBidOrder.price, + gas: BigNumber.from(300000), + nonce: constants.Zero, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + data: arrayify(intTxData!), + }; + const forwardRequestSig = await signForwardRequest(takerBidUser, minimalForwarder.address, forwardRequest); + + const tx = await minimalForwarder.connect(forwardExecutor).checkExecute(forwardRequest, forwardRequestSig, { + value: takerBidOrder.price, + }); + + await expect(tx) + .to.emit(looksRareExchange, "TakerBid") + .withArgs( + computeOrderHash(makerAskOrder), + makerAskOrder.nonce, + takerBidUser.address, + makerAskUser.address, + strategyStandardSaleForFixedPrice.address, + makerAskOrder.currency, + makerAskOrder.collection, + makerAskOrder.tokenId, + makerAskOrder.amount, + takerBidOrder.price + ); + + assert.equal(await mockERC721.ownerOf("0"), takerBidUser.address); + + assert.isTrue( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerAskUser.address, makerAskOrder.nonce) + ); + + // Orders that have been executed cannot be matched again + await expect( + looksRareExchange.connect(takerBidUser).matchAskWithTakerBidUsingETHAndWETH(takerBidOrder, makerAskOrder, { + value: takerBidOrder.price, + }) + ).to.be.revertedWith("Order: Matching order expired"); + }); + + it("EIP2771/ERC721/WETH only - MakerBid order is matched by TakerAsk order", async () => { + const forwardExecutor = accounts[0]; + const makerBidUser = accounts[2]; + const takerAskUser = accounts[1]; + + const makerBidOrder = await createMakerOrder({ + isOrderAsk: false, + signer: makerBidUser.address, + collection: mockERC721.address, + tokenId: constants.Zero, + price: parseEther("3"), + amount: constants.One, + strategy: strategyStandardSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerBidUser, + verifyingContract: looksRareExchange.address, + }); + + const takerAskOrder = createTakerOrder({ + isOrderAsk: true, + taker: takerAskUser.address, + tokenId: constants.Zero, + price: makerBidOrder.price, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + }); + + // a way to encode tx data + const intTxData = (await looksRareExchange.populateTransaction.matchBidWithTakerAsk(takerAskOrder, makerBidOrder)) + .data; + const forwardRequest: ForwardRequest = { + from: takerAskUser.address, + to: looksRareExchange.address, + value: constants.Zero, + gas: BigNumber.from(300000), + nonce: constants.Zero, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + data: arrayify(intTxData!), + }; + const forwardRequestSig = await signForwardRequest(takerAskUser, minimalForwarder.address, forwardRequest); + + const tx = await minimalForwarder.connect(forwardExecutor).checkExecute(forwardRequest, forwardRequestSig); + + await expect(tx) + .to.emit(looksRareExchange, "TakerAsk") + .withArgs( + computeOrderHash(makerBidOrder), + makerBidOrder.nonce, + takerAskUser.address, + makerBidUser.address, + strategyStandardSaleForFixedPrice.address, + makerBidOrder.currency, + makerBidOrder.collection, + takerAskOrder.tokenId, + makerBidOrder.amount, + makerBidOrder.price + ); + + assert.equal(await mockERC721.ownerOf("0"), makerBidUser.address); + assert.isTrue( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerBidUser.address, makerBidOrder.nonce) + ); + }); + + it("EIP2771/ERC721/WETH only - TakerBid order is matched by MakerAsk order", async () => { + const forwardExecutor = accounts[0]; + const makerAskUser = accounts[1]; + const takerBidUser = accounts[2]; + + const makerAskOrder = await createMakerOrder({ + isOrderAsk: true, + signer: makerAskUser.address, + collection: mockERC721WithRoyalty.address, + price: parseEther("3"), + tokenId: constants.Zero, + amount: constants.One, + strategy: strategyStandardSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerAskUser, + verifyingContract: looksRareExchange.address, + }); + + const takerBidOrder: TakerOrder = { + isOrderAsk: false, + taker: takerBidUser.address, + price: makerAskOrder.price, + tokenId: makerAskOrder.tokenId, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + }; + + // a way to encode tx data + const intTxData = (await looksRareExchange.populateTransaction.matchAskWithTakerBid(takerBidOrder, makerAskOrder)) + .data; + const forwardRequest: ForwardRequest = { + from: takerBidUser.address, + to: looksRareExchange.address, + value: constants.Zero, + gas: BigNumber.from(300000), + nonce: constants.Zero, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + data: arrayify(intTxData!), + }; + const forwardRequestSig = await signForwardRequest(takerBidUser, minimalForwarder.address, forwardRequest); + + const tx = await minimalForwarder.connect(forwardExecutor).checkExecute(forwardRequest, forwardRequestSig); + + await expect(tx) + .to.emit(looksRareExchange, "TakerBid") + .withArgs( + computeOrderHash(makerAskOrder), + makerAskOrder.nonce, + takerBidUser.address, + makerAskUser.address, + strategyStandardSaleForFixedPrice.address, + makerAskOrder.currency, + makerAskOrder.collection, + takerBidOrder.tokenId, + makerAskOrder.amount, + makerAskOrder.price + ); + + assert.equal(await mockERC721WithRoyalty.ownerOf("0"), takerBidUser.address); + assert.isTrue( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerAskUser.address, makerAskOrder.nonce) + ); + }); }); describe("#3 - Royalty fee system", async () => { @@ -1320,6 +1738,64 @@ describe("LooksRare Exchange", () => { ).to.be.revertedWith("Order: Matching order expired"); }); + it("EIP2771/Cancel Multiple orders", async () => { + const forwardExecutor = accounts[0]; + const makerAskUser = accounts[1]; + const takerBidUser = accounts[2]; + + const makerAskOrder = await createMakerOrder({ + isOrderAsk: true, + signer: makerAskUser.address, + collection: mockERC721.address, + tokenId: constants.Zero, + price: parseEther("3"), + amount: constants.One, + strategy: strategyStandardSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerAskUser, + verifyingContract: looksRareExchange.address, + }); + + const takerBidOrder = createTakerOrder({ + isOrderAsk: false, + taker: takerBidUser.address, + tokenId: makerAskOrder.tokenId, + price: makerAskOrder.price, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + }); + + // a way to encode tx data + const intTxData = (await looksRareExchange.populateTransaction.cancelMultipleMakerOrders([makerAskOrder.nonce])) + .data; + const forwardRequest: ForwardRequest = { + from: makerAskUser.address, + to: looksRareExchange.address, + value: constants.Zero, + gas: BigNumber.from(300000), + nonce: constants.Zero, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + data: arrayify(intTxData!), + }; + const forwardRequestSig = await signForwardRequest(makerAskUser, minimalForwarder.address, forwardRequest); + + const tx = await minimalForwarder.connect(forwardExecutor).checkExecute(forwardRequest, forwardRequestSig); + + // Event params are not tested because of array issue with BN + await expect(tx).to.emit(looksRareExchange, "CancelMultipleOrders"); + + await expect( + looksRareExchange.connect(takerBidUser).matchAskWithTakerBidUsingETHAndWETH(takerBidOrder, makerAskOrder, { + value: takerBidOrder.price, + }) + ).to.be.revertedWith("Order: Matching order expired"); + }); + it("Cancel - Cannot match if on a different checkpoint than current on-chain signer's checkpoint", async () => { const makerAskUser = accounts[1]; const takerBidUser = accounts[3]; @@ -1361,6 +1837,62 @@ describe("LooksRare Exchange", () => { ).to.be.revertedWith("Order: Matching order expired"); }); + it("EIP2771/Cancel All orders", async () => { + const forwardExecutor = accounts[0]; + const makerAskUser = accounts[1]; + const takerBidUser = accounts[3]; + + const makerAskOrder = await createMakerOrder({ + isOrderAsk: true, + signer: makerAskUser.address, + collection: mockERC721.address, + price: parseEther("3"), + tokenId: constants.Zero, + amount: constants.One, + strategy: strategyStandardSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerAskUser, + verifyingContract: looksRareExchange.address, + }); + + const takerBidOrder = createTakerOrder({ + isOrderAsk: false, + taker: takerBidUser.address, + tokenId: makerAskOrder.tokenId, + price: makerAskOrder.price, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + }); + + // a way to encode tx data + const intTxData = (await looksRareExchange.populateTransaction.cancelAllOrdersForSender("1")).data; + const forwardRequest: ForwardRequest = { + from: makerAskUser.address, + to: looksRareExchange.address, + value: constants.Zero, + gas: BigNumber.from(300000), + nonce: constants.Zero, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + data: arrayify(intTxData!), + }; + const forwardRequestSig = await signForwardRequest(makerAskUser, minimalForwarder.address, forwardRequest); + + const tx = await minimalForwarder.connect(forwardExecutor).checkExecute(forwardRequest, forwardRequestSig); + + await expect(tx).to.emit(looksRareExchange, "CancelAllOrders").withArgs(makerAskUser.address, "1"); + + await expect( + looksRareExchange.connect(takerBidUser).matchAskWithTakerBidUsingETHAndWETH(takerBidOrder, makerAskOrder, { + value: takerBidOrder.price, + }) + ).to.be.revertedWith("Order: Matching order expired"); + }); + it("Order - Cannot match if msg.value is too high", async () => { const makerAskUser = accounts[1]; const takerBidUser = accounts[3]; @@ -2018,7 +2550,8 @@ describe("LooksRare Exchange", () => { verifyingContract: looksRareExchange.address, }); - makerAskOrder.v = 29; + // signature.v = 29; + makerAskOrder.signature = makerAskOrder.signature.toString().substring(0, 130) + "1d"; const takerBidOrder: TakerOrder = { isOrderAsk: false, @@ -2031,7 +2564,7 @@ describe("LooksRare Exchange", () => { await expect( looksRareExchange.connect(takerBidUser).matchAskWithTakerBid(takerBidOrder, makerAskOrder) - ).to.be.revertedWith("Signature: Invalid v parameter"); + ).to.be.revertedWith("ECDSA: invalid signature 'v' value"); }); it("SignatureChecker - Cannot match if invalid s parameter", async () => { @@ -2057,7 +2590,11 @@ describe("LooksRare Exchange", () => { }); // The s value is picked randomly to make the condition be rejected - makerAskOrder.s = "0x9ca0e65dda4b504989e1db8fc30095f24489ee7226465e9545c32fc7853fe985"; + // signature.s = 0x9ca0e65dda4b504989e1db8fc30095f24489ee7226465e9545c32fc7853fe985; + makerAskOrder.signature = + makerAskOrder.signature.toString().substring(0, 66) + + "9ca0e65dda4b504989e1db8fc30095f24489ee7226465e9545c32fc7853fe985" + + makerAskOrder.signature.toString().substring(130, 132); const takerBidOrder: TakerOrder = { isOrderAsk: false, @@ -2070,7 +2607,7 @@ describe("LooksRare Exchange", () => { await expect( looksRareExchange.connect(takerBidUser).matchAskWithTakerBid(takerBidOrder, makerAskOrder) - ).to.be.revertedWith("Signature: Invalid s parameter"); + ).to.be.revertedWith("ECDSA: invalid signature 's' value"); }); it("Order - Cannot cancel if no order", async () => { @@ -2325,14 +2862,14 @@ describe("LooksRare Exchange", () => { it("ExecutionManager - View functions work as expected", async () => { const numberStrategies = await executionManager.viewCountWhitelistedStrategies(); - assert.equal(numberStrategies.toString(), "5"); + assert.equal(numberStrategies.toString(), "6"); let tx = await executionManager.viewWhitelistedStrategies("0", "2"); assert.equal(tx[0].length, 2); assert.deepEqual(BigNumber.from(tx[1].toString()), constants.Two); tx = await executionManager.viewWhitelistedStrategies("2", "100"); - assert.equal(tx[0].length, 3); + assert.equal(tx[0].length, 4); assert.deepEqual(BigNumber.from(tx[1].toString()), BigNumber.from(numberStrategies.toString())); }); }); diff --git a/test/strategyAnyItemFromCollectionForFixedPrice.test.ts b/test/strategyAnyItemFromCollectionForFixedPrice.test.ts index 32277d8..274dfe2 100644 --- a/test/strategyAnyItemFromCollectionForFixedPrice.test.ts +++ b/test/strategyAnyItemFromCollectionForFixedPrice.test.ts @@ -64,6 +64,7 @@ describe("Strategy - AnyItemFromCollectionForFixedPrice ('Collection orders')", , , , + , ] = await setUp(admin, feeRecipient, royaltyCollector, standardProtocolFee, royaltyFeeLimit); await tokenSetUp( diff --git a/test/strategyAnyItemInASetForAFixedPrice.test.ts b/test/strategyAnyItemInASetForAFixedPrice.test.ts index 1c6de90..22c45a9 100644 --- a/test/strategyAnyItemInASetForAFixedPrice.test.ts +++ b/test/strategyAnyItemInASetForAFixedPrice.test.ts @@ -67,6 +67,7 @@ describe("Strategy - AnyItemInASetForFixedPrice ('Trait orders')", () => { , , , + , ] = await setUp(admin, feeRecipient, royaltyCollector, standardProtocolFee, royaltyFeeLimit); await tokenSetUp( diff --git a/test/strategyDutchAuction.test.ts b/test/strategyDutchAuction.test.ts index d837bbf..4bfbfe1 100644 --- a/test/strategyDutchAuction.test.ts +++ b/test/strategyDutchAuction.test.ts @@ -65,6 +65,7 @@ describe("Strategy - Dutch Auction", () => { , , , + , ] = await setUp(admin, feeRecipient, royaltyCollector, standardProtocolFee, royaltyFeeLimit); await tokenSetUp( diff --git a/test/strategyPartialSaleForFixedPrice.test.ts b/test/strategyPartialSaleForFixedPrice.test.ts new file mode 100644 index 0000000..9fedfd7 --- /dev/null +++ b/test/strategyPartialSaleForFixedPrice.test.ts @@ -0,0 +1,728 @@ +import { assert, expect } from "chai"; +import { BigNumber, constants, Contract, utils } from "ethers"; +import { ethers } from "hardhat"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; + +import { MakerOrderWithSignature } from "./helpers/order-types"; +import { createMakerOrder, createTakerOrder } from "./helpers/order-helper"; +import { computeDomainSeparator, computeOrderHash } from "./helpers/signature-helper"; +import { setUp } from "./test-setup"; +import { tokenSetUp } from "./token-set-up"; + +const { defaultAbiCoder, parseEther } = utils; + +describe("Strategy - PartialSaleForFixedPrice", () => { + // Mock contracts + let mockERC721: Contract; + let mockERC721WithRoyalty: Contract; + let mockERC1155: Contract; + let weth: Contract; + + // Exchange contracts + let transferManagerERC721: Contract; + let transferManagerERC1155: Contract; + let looksRareExchange: Contract; + + // Strategy contracts (used for this test file) + let strategyPartialSaleForFixedPrice: Contract; + + // Other global variables + let standardProtocolFee: BigNumber; + let royaltyFeeLimit: BigNumber; + let accounts: SignerWithAddress[]; + let admin: SignerWithAddress; + let feeRecipient: SignerWithAddress; + let royaltyCollector: SignerWithAddress; + let startTimeOrder: BigNumber; + let endTimeOrder: BigNumber; + + beforeEach(async () => { + accounts = await ethers.getSigners(); + admin = accounts[0]; + feeRecipient = accounts[19]; + royaltyCollector = accounts[15]; + standardProtocolFee = BigNumber.from("200"); + royaltyFeeLimit = BigNumber.from("9500"); // 95% + [ + weth, + mockERC721, + mockERC1155, + , + mockERC721WithRoyalty, + , + , + , + transferManagerERC721, + transferManagerERC1155, + , + looksRareExchange, + , + , + , + , + , + , + , + , + strategyPartialSaleForFixedPrice, + ] = await setUp(admin, feeRecipient, royaltyCollector, standardProtocolFee, royaltyFeeLimit); + + await tokenSetUp( + accounts.slice(1, 10), + weth, + mockERC721, + mockERC721WithRoyalty, + mockERC1155, + looksRareExchange, + transferManagerERC721, + transferManagerERC1155 + ); + + // Verify the domain separator is properly computed + assert.equal(await looksRareExchange.DOMAIN_SEPARATOR(), computeDomainSeparator(looksRareExchange.address)); + + // Set up defaults startTime/endTime (for orders) + startTimeOrder = BigNumber.from((await ethers.provider.getBlock(await ethers.provider.getBlockNumber())).timestamp); + endTimeOrder = startTimeOrder.add(BigNumber.from("1000")); + }); + + describe("#1 - Regular sales", async () => { + it("Standard Order/ERC721/ETH only - MakerAsk order is matched by TakerBid order", async () => { + const makerAskUser = accounts[1]; + const takerBidUser = accounts[2]; + + const makerAskOrder: MakerOrderWithSignature = await createMakerOrder({ + isOrderAsk: true, + signer: makerAskUser.address, + collection: mockERC721.address, + price: parseEther("3"), + tokenId: constants.Zero, + amount: constants.One, + strategy: strategyPartialSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerAskUser, + verifyingContract: looksRareExchange.address, + }); + + const takerBidOrder = createTakerOrder({ + isOrderAsk: false, + taker: takerBidUser.address, + price: parseEther("3"), + tokenId: constants.Zero, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode(["uint256"], [constants.One]), + }); + + const tx = await looksRareExchange + .connect(takerBidUser) + .matchAskWithTakerBidUsingETHAndWETH(takerBidOrder, makerAskOrder, { + value: takerBidOrder.price, + }); + + await expect(tx) + .to.emit(looksRareExchange, "TakerBid") + .withArgs( + computeOrderHash(makerAskOrder), + makerAskOrder.nonce, + takerBidUser.address, + makerAskUser.address, + strategyPartialSaleForFixedPrice.address, + makerAskOrder.currency, + makerAskOrder.collection, + makerAskOrder.tokenId, + makerAskOrder.amount, + takerBidOrder.price + ); + + assert.equal(await mockERC721.ownerOf("0"), takerBidUser.address); + + assert.isTrue( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerAskUser.address, makerAskOrder.nonce) + ); + + // Orders that have been executed cannot be matched again + await expect( + looksRareExchange.connect(takerBidUser).matchAskWithTakerBidUsingETHAndWETH(takerBidOrder, makerAskOrder, { + value: takerBidOrder.price, + }) + ).to.be.revertedWith("Order: Matching order expired"); + }); + + it("Standard Order/ERC721/(ETH + WETH) - MakerAsk order is matched by TakerBid order", async () => { + const makerAskUser = accounts[1]; + const takerBidUser = accounts[2]; + + const makerAskOrder: MakerOrderWithSignature = await createMakerOrder({ + isOrderAsk: true, + signer: makerAskUser.address, + collection: mockERC721.address, + tokenId: constants.Zero, + price: parseEther("3"), + amount: constants.One, + strategy: strategyPartialSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerAskUser, + verifyingContract: looksRareExchange.address, + }); + + const takerBidOrder = createTakerOrder({ + isOrderAsk: false, + taker: takerBidUser.address, + tokenId: constants.Zero, + price: parseEther("3"), + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode(["uint256"], [constants.One]), + }); + + // Order is worth 3 ETH; taker user splits it as 2 ETH + 1 WETH + const expectedBalanceInWETH = BigNumber.from((await weth.balanceOf(takerBidUser.address)).toString()).sub( + BigNumber.from(parseEther("1")) + ); + + const tx = await looksRareExchange + .connect(takerBidUser) + .matchAskWithTakerBidUsingETHAndWETH(takerBidOrder, makerAskOrder, { + value: parseEther("2"), + }); + + await expect(tx) + .to.emit(looksRareExchange, "TakerBid") + .withArgs( + computeOrderHash(makerAskOrder), + makerAskOrder.nonce, + takerBidUser.address, + makerAskUser.address, + strategyPartialSaleForFixedPrice.address, + makerAskOrder.currency, + makerAskOrder.collection, + makerAskOrder.tokenId, + makerAskOrder.amount, + takerBidOrder.price + ); + + assert.equal(await mockERC721.ownerOf("0"), takerBidUser.address); + assert.isTrue( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerAskUser.address, makerAskOrder.nonce) + ); + + // Check balance of WETH is same as expected + assert.deepEqual(expectedBalanceInWETH, await weth.balanceOf(takerBidUser.address)); + }); + + it("Standard Order/ERC1155/ETH only - MakerAsk order is matched by TakerBid order", async () => { + const makerAskUser = accounts[1]; + const takerBidUser = accounts[2]; + + const makerAskOrder: MakerOrderWithSignature = await createMakerOrder({ + isOrderAsk: true, + signer: makerAskUser.address, + collection: mockERC1155.address, + tokenId: constants.One, + price: parseEther("3"), + amount: constants.Two, + strategy: strategyPartialSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerAskUser, + verifyingContract: looksRareExchange.address, + }); + + const takerBidOrder = createTakerOrder({ + isOrderAsk: false, + taker: takerBidUser.address, + tokenId: constants.One, + price: parseEther("3"), + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode(["uint256"], [constants.Two]), + }); + + const tx = await looksRareExchange + .connect(takerBidUser) + .matchAskWithTakerBidUsingETHAndWETH(takerBidOrder, makerAskOrder, { + value: takerBidOrder.price, + }); + + await expect(tx) + .to.emit(looksRareExchange, "TakerBid") + .withArgs( + computeOrderHash(makerAskOrder), + makerAskOrder.nonce, + takerBidUser.address, + makerAskUser.address, + strategyPartialSaleForFixedPrice.address, + makerAskOrder.currency, + makerAskOrder.collection, + makerAskOrder.tokenId, + makerAskOrder.amount, + takerBidOrder.price + ); + + assert.isTrue( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerAskUser.address, makerAskOrder.nonce) + ); + + // User 2 had minted 2 tokenId=1 so he has 4 + assert.equal((await mockERC1155.balanceOf(takerBidUser.address, "1")).toString(), "4"); + }); + + it("Standard Order/ERC721/WETH only - MakerBid order is matched by TakerAsk order", async () => { + const makerBidUser = accounts[2]; + const takerAskUser = accounts[1]; + + const makerBidOrder = await createMakerOrder({ + isOrderAsk: false, + signer: makerBidUser.address, + collection: mockERC721.address, + tokenId: constants.Zero, + price: parseEther("3"), + amount: constants.One, + strategy: strategyPartialSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerBidUser, + verifyingContract: looksRareExchange.address, + }); + + const takerAskOrder = createTakerOrder({ + isOrderAsk: true, + taker: takerAskUser.address, + tokenId: constants.Zero, + price: makerBidOrder.price, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode(["uint256"], [constants.One]), + }); + + const tx = await looksRareExchange.connect(takerAskUser).matchBidWithTakerAsk(takerAskOrder, makerBidOrder); + await expect(tx) + .to.emit(looksRareExchange, "TakerAsk") + .withArgs( + computeOrderHash(makerBidOrder), + makerBidOrder.nonce, + takerAskUser.address, + makerBidUser.address, + strategyPartialSaleForFixedPrice.address, + makerBidOrder.currency, + makerBidOrder.collection, + takerAskOrder.tokenId, + makerBidOrder.amount, + makerBidOrder.price + ); + + assert.equal(await mockERC721.ownerOf("0"), makerBidUser.address); + assert.isTrue( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerBidUser.address, makerBidOrder.nonce) + ); + }); + + it("Standard Order/ERC1155/WETH only - MakerBid order is matched by TakerAsk order", async () => { + const makerBidUser = accounts[1]; + const takerAskUser = accounts[2]; + + const makerBidOrder = await createMakerOrder({ + isOrderAsk: false, + signer: makerBidUser.address, + collection: mockERC1155.address, + tokenId: BigNumber.from("3"), + price: parseEther("3"), + amount: constants.Two, + strategy: strategyPartialSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerBidUser, + verifyingContract: looksRareExchange.address, + }); + + const takerAskOrder = createTakerOrder({ + isOrderAsk: true, + taker: takerAskUser.address, + tokenId: BigNumber.from("3"), + price: makerBidOrder.price, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode(["uint256"], [constants.Two]), + }); + + const tx = await looksRareExchange.connect(takerAskUser).matchBidWithTakerAsk(takerAskOrder, makerBidOrder); + await expect(tx) + .to.emit(looksRareExchange, "TakerAsk") + .withArgs( + computeOrderHash(makerBidOrder), + makerBidOrder.nonce, + takerAskUser.address, + makerBidUser.address, + strategyPartialSaleForFixedPrice.address, + makerBidOrder.currency, + makerBidOrder.collection, + takerAskOrder.tokenId, + makerBidOrder.amount, + makerBidOrder.price + ); + + assert.isTrue( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerBidUser.address, makerBidOrder.nonce) + ); + }); + }); + + describe("#2 - Partial sales", async () => { + it("Split Order/ERC1155/WETH only - MakerBid order is matched by two TakerAsk orders", async () => { + const makerBidUser = accounts[1]; + const takerAskUser = accounts[2]; + + const makerBidOrder = await createMakerOrder({ + isOrderAsk: false, + signer: makerBidUser.address, + collection: mockERC1155.address, + tokenId: BigNumber.from("3"), + price: parseEther("3"), + amount: constants.Two, + strategy: strategyPartialSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerBidUser, + verifyingContract: looksRareExchange.address, + }); + + const takerAskOrder = createTakerOrder({ + isOrderAsk: true, + taker: takerAskUser.address, + tokenId: BigNumber.from("3"), + price: makerBidOrder.price.div(2), + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode(["uint256"], [constants.One]), + }); + + const tx1 = await looksRareExchange.connect(takerAskUser).matchBidWithTakerAsk(takerAskOrder, makerBidOrder); + await expect(tx1) + .to.emit(looksRareExchange, "TakerAsk") + .withArgs( + computeOrderHash(makerBidOrder), + makerBidOrder.nonce, + takerAskUser.address, + makerBidUser.address, + strategyPartialSaleForFixedPrice.address, + makerBidOrder.currency, + makerBidOrder.collection, + takerAskOrder.tokenId, + constants.One, + takerAskOrder.price + ); + + assert.isFalse( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerBidUser.address, makerBidOrder.nonce) + ); + + const tx2 = await looksRareExchange.connect(takerAskUser).matchBidWithTakerAsk(takerAskOrder, makerBidOrder); + await expect(tx2) + .to.emit(looksRareExchange, "TakerAsk") + .withArgs( + computeOrderHash(makerBidOrder), + makerBidOrder.nonce, + takerAskUser.address, + makerBidUser.address, + strategyPartialSaleForFixedPrice.address, + makerBidOrder.currency, + makerBidOrder.collection, + takerAskOrder.tokenId, + constants.One, + takerAskOrder.price + ); + + assert.isTrue( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerBidUser.address, makerBidOrder.nonce) + ); + }); + + it("Split Order/ERC1155/WETH only - MakerBid order is not matched by excessive TakerAsk order", async () => { + const makerBidUser = accounts[1]; + const takerAskUser = accounts[2]; + + const makerBidOrder = await createMakerOrder({ + isOrderAsk: false, + signer: makerBidUser.address, + collection: mockERC1155.address, + tokenId: BigNumber.from("3"), + price: parseEther("3"), + amount: constants.Two, + strategy: strategyPartialSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerBidUser, + verifyingContract: looksRareExchange.address, + }); + + const takerAskOrder = createTakerOrder({ + isOrderAsk: true, + taker: takerAskUser.address, + tokenId: BigNumber.from("3"), + price: makerBidOrder.price.div(2), + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode(["uint256"], [constants.One]), + }); + + const tx1 = await looksRareExchange.connect(takerAskUser).matchBidWithTakerAsk(takerAskOrder, makerBidOrder); + await expect(tx1) + .to.emit(looksRareExchange, "TakerAsk") + .withArgs( + computeOrderHash(makerBidOrder), + makerBidOrder.nonce, + takerAskUser.address, + makerBidUser.address, + strategyPartialSaleForFixedPrice.address, + makerBidOrder.currency, + makerBidOrder.collection, + takerAskOrder.tokenId, + constants.One, + takerAskOrder.price + ); + + assert.isFalse( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerBidUser.address, makerBidOrder.nonce) + ); + + takerAskOrder.params = defaultAbiCoder.encode(["uint256"], [constants.Two]); + await expect( + looksRareExchange.connect(takerAskUser).matchBidWithTakerAsk(takerAskOrder, makerBidOrder) + ).to.be.revertedWith("Strategy: Excessive amount"); + }); + + it("Split Order/ERC1155/WETH only - MakerBid order can be cancelled", async () => { + const makerBidUser = accounts[1]; + const takerAskUser = accounts[2]; + + const makerBidOrder = await createMakerOrder({ + isOrderAsk: false, + signer: makerBidUser.address, + collection: mockERC1155.address, + tokenId: BigNumber.from("3"), + price: parseEther("3"), + amount: constants.Two, + strategy: strategyPartialSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerBidUser, + verifyingContract: looksRareExchange.address, + }); + + const takerAskOrder = createTakerOrder({ + isOrderAsk: true, + taker: takerAskUser.address, + tokenId: BigNumber.from("3"), + price: makerBidOrder.price.div(2), + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode(["uint256"], [constants.One]), + }); + + const tx1 = await looksRareExchange.connect(takerAskUser).matchBidWithTakerAsk(takerAskOrder, makerBidOrder); + await expect(tx1) + .to.emit(looksRareExchange, "TakerAsk") + .withArgs( + computeOrderHash(makerBidOrder), + makerBidOrder.nonce, + takerAskUser.address, + makerBidUser.address, + strategyPartialSaleForFixedPrice.address, + makerBidOrder.currency, + makerBidOrder.collection, + takerAskOrder.tokenId, + constants.One, + takerAskOrder.price + ); + + assert.isFalse( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerBidUser.address, makerBidOrder.nonce) + ); + + await looksRareExchange.connect(makerBidUser).cancelMultipleMakerOrders([makerBidOrder.nonce]); + + assert.isTrue( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerBidUser.address, makerBidOrder.nonce) + ); + }); + + it("Split Order/ERC1155/WETH only - MakerBid order is not matched by overpriced TakerAsk order", async () => { + const makerBidUser = accounts[1]; + const takerAskUser = accounts[2]; + + const makerBidOrder = await createMakerOrder({ + isOrderAsk: false, + signer: makerBidUser.address, + collection: mockERC1155.address, + tokenId: BigNumber.from("3"), + price: parseEther("3"), + amount: constants.Two, + strategy: strategyPartialSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerBidUser, + verifyingContract: looksRareExchange.address, + }); + + const takerAskOrder = createTakerOrder({ + isOrderAsk: true, + taker: takerAskUser.address, + tokenId: BigNumber.from("3"), + price: makerBidOrder.price.div(2).add(1), + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode(["uint256"], [constants.One]), + }); + + await expect( + looksRareExchange.connect(takerAskUser).matchBidWithTakerAsk(takerAskOrder, makerBidOrder) + ).to.be.revertedWith("Strategy: Execution invalid"); + }); + + it("Split Order/ERC1155/WETH only - MakerAsk order is matched by two TakerBid orders", async () => { + const makerAskUser = accounts[1]; + const takerBidUser = accounts[2]; + + const makerAskOrder: MakerOrderWithSignature = await createMakerOrder({ + isOrderAsk: true, + signer: makerAskUser.address, + collection: mockERC1155.address, + tokenId: constants.One, + price: parseEther("3"), + amount: constants.Two, + strategy: strategyPartialSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerAskUser, + verifyingContract: looksRareExchange.address, + }); + + const takerBidOrder = createTakerOrder({ + isOrderAsk: false, + taker: takerBidUser.address, + tokenId: constants.One, + price: parseEther("3").div(2), + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode(["uint256"], [constants.One]), + }); + + const tx1 = await looksRareExchange.connect(takerBidUser).matchAskWithTakerBid(takerBidOrder, makerAskOrder); + + await expect(tx1) + .to.emit(looksRareExchange, "TakerBid") + .withArgs( + computeOrderHash(makerAskOrder), + makerAskOrder.nonce, + takerBidUser.address, + makerAskUser.address, + strategyPartialSaleForFixedPrice.address, + makerAskOrder.currency, + makerAskOrder.collection, + makerAskOrder.tokenId, + constants.One, + takerBidOrder.price + ); + + assert.isFalse( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerAskUser.address, makerAskOrder.nonce) + ); + + // User 2 had minted 2 tokenId=1 so he has 3 + assert.equal((await mockERC1155.balanceOf(takerBidUser.address, "1")).toString(), "3"); + + const tx2 = await looksRareExchange.connect(takerBidUser).matchAskWithTakerBid(takerBidOrder, makerAskOrder); + + await expect(tx2) + .to.emit(looksRareExchange, "TakerBid") + .withArgs( + computeOrderHash(makerAskOrder), + makerAskOrder.nonce, + takerBidUser.address, + makerAskUser.address, + strategyPartialSaleForFixedPrice.address, + makerAskOrder.currency, + makerAskOrder.collection, + makerAskOrder.tokenId, + constants.One, + takerBidOrder.price + ); + + assert.isTrue( + await looksRareExchange.isUserOrderNonceExecutedOrCancelled(makerAskUser.address, makerAskOrder.nonce) + ); + + // User 2 had minted 2 tokenId=1 so he has 4 now + assert.equal((await mockERC1155.balanceOf(takerBidUser.address, "1")).toString(), "4"); + }); + + it("Split Order/ERC1155/WETH only - MakerAsk order is not matched by underpriced TakerBid order", async () => { + const makerAskUser = accounts[1]; + const takerBidUser = accounts[2]; + + const makerAskOrder: MakerOrderWithSignature = await createMakerOrder({ + isOrderAsk: true, + signer: makerAskUser.address, + collection: mockERC1155.address, + tokenId: constants.One, + price: parseEther("3"), + amount: constants.Two, + strategy: strategyPartialSaleForFixedPrice.address, + currency: weth.address, + nonce: constants.Zero, + startTime: startTimeOrder, + endTime: endTimeOrder, + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode([], []), + signerUser: makerAskUser, + verifyingContract: looksRareExchange.address, + }); + + const takerBidOrder = createTakerOrder({ + isOrderAsk: false, + taker: takerBidUser.address, + tokenId: constants.One, + price: parseEther("3").div(2).sub(1), + minPercentageToAsk: constants.Zero, + params: defaultAbiCoder.encode(["uint256"], [constants.One]), + }); + + await expect( + looksRareExchange.connect(takerBidUser).matchAskWithTakerBid(takerBidOrder, makerAskOrder) + ).to.revertedWith("Strategy: Execution invalid"); + }); + }); +}); diff --git a/test/strategyPrivateSale.test.ts b/test/strategyPrivateSale.test.ts index d0186c0..fa6481b 100644 --- a/test/strategyPrivateSale.test.ts +++ b/test/strategyPrivateSale.test.ts @@ -64,6 +64,7 @@ describe("Strategy - PrivateSale", () => { , , , + , ] = await setUp(admin, feeRecipient, royaltyCollector, standardProtocolFee, royaltyFeeLimit); await tokenSetUp( diff --git a/test/test-setup.ts b/test/test-setup.ts index 0b002a9..2927642 100644 --- a/test/test-setup.ts +++ b/test/test-setup.ts @@ -9,7 +9,7 @@ export async function setUp( standardProtocolFee: BigNumber, royaltyFeeLimit: BigNumber ): Promise { - /** 1. Deploy WETH, Mock ERC721, Mock ERC1155, Mock USDT, MockERC721WithRoyalty + /** 1. Deploy WETH, Mock ERC721, Mock ERC1155, Mock USDT, MockERC721WithRoyalty, MinimalForwarder */ const WETH = await ethers.getContractFactory("WETH"); const weth = await WETH.deploy(); @@ -30,6 +30,9 @@ export async function setUp( "200" // 2% royalty fee ); await mockERC721WithRoyalty.deployed(); + const MinimalForwarder = await ethers.getContractFactory("CheckMinimalForwarder"); + const minimalForwarder = await MinimalForwarder.deploy(); + await minimalForwarder.deployed(); /** 2. Deploy ExecutionManager contract and add WETH to whitelisted currencies */ @@ -66,9 +69,13 @@ export async function setUp( const StrategyStandardSaleForFixedPrice = await ethers.getContractFactory("StrategyStandardSaleForFixedPrice"); const strategyStandardSaleForFixedPrice = await StrategyStandardSaleForFixedPrice.deploy(standardProtocolFee); await strategyStandardSaleForFixedPrice.deployed(); + const StrategyPartialSaleForFixedPrice = await ethers.getContractFactory("StrategyPartialSaleForFixedPrice"); + const strategyPartialSaleForFixedPrice = await StrategyPartialSaleForFixedPrice.deploy(standardProtocolFee); + await strategyPartialSaleForFixedPrice.deployed(); // Whitelist these five strategies await executionManager.connect(admin).addStrategy(strategyStandardSaleForFixedPrice.address); + await executionManager.connect(admin).addStrategy(strategyPartialSaleForFixedPrice.address); await executionManager.connect(admin).addStrategy(strategyAnyItemFromCollectionForFixedPrice.address); await executionManager.connect(admin).addStrategy(strategyAnyItemInASetForFixedPrice.address); await executionManager.connect(admin).addStrategy(strategyDutchAuction.address); @@ -96,7 +103,8 @@ export async function setUp( executionManager.address, royaltyFeeManager.address, weth.address, - feeRecipient.address + feeRecipient.address, + minimalForwarder.address ); await looksRareExchange.deployed(); @@ -144,5 +152,7 @@ export async function setUp( royaltyFeeRegistry, royaltyFeeManager, royaltyFeeSetter, + strategyPartialSaleForFixedPrice, + minimalForwarder, ]; }