diff --git a/contracts/src/marketplace/ExecutionManager.sol b/contracts/src/marketplace/ExecutionManager.sol index e3e132df..7b4266de 100644 --- a/contracts/src/marketplace/ExecutionManager.sol +++ b/contracts/src/marketplace/ExecutionManager.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.17; +pragma solidity ^0.8.17; // Libraries import {OrderStructs} from "./libraries/OrderStructs.sol"; diff --git a/contracts/src/marketplace/LooksRareProtocol.sol b/contracts/src/marketplace/LooksRareProtocol.sol index 59382581..c52f67c8 100644 --- a/contracts/src/marketplace/LooksRareProtocol.sol +++ b/contracts/src/marketplace/LooksRareProtocol.sol @@ -30,6 +30,7 @@ import { // Direct dependencies import {TransferSelectorNFT} from "./TransferSelectorNFT.sol"; import {BatchOrderTypehashRegistry} from "./BatchOrderTypehashRegistry.sol"; +import {StrategyHypercertFractionOffer} from "./executionStrategies/StrategyHypercertFractionOffer.sol"; // Constants import {MAX_CALLDATA_PROOF_LENGTH, ONE_HUNDRED_PERCENT_IN_BP} from "./constants/NumericConstants.sol"; @@ -442,14 +443,28 @@ contract LooksRareProtocol is _transferToAskRecipientAndCreatorIfAny(recipients, feeAmounts, makerAsk.currency, sender); // Maker action goes second - _transferNFT( - makerAsk.collection, - makerAsk.collectionType, - signer, - takerBid.recipient == address(0) ? sender : takerBid.recipient, - itemIds, - amounts - ); + if ( + strategyInfo[makerAsk.strategyId].selector + == StrategyHypercertFractionOffer.executeHypercertFractionStrategyWithTakerBid.selector + ) { + _splitNFT( + makerAsk.collection, + makerAsk.collectionType, + signer, + takerBid.recipient == address(0) ? sender : takerBid.recipient, + itemIds, + amounts + ); + } else { + _transferNFT( + makerAsk.collection, + makerAsk.collectionType, + signer, + takerBid.recipient == address(0) ? sender : takerBid.recipient, + itemIds, + amounts + ); + } emit TakerBid( NonceInvalidationParameters({ diff --git a/contracts/src/marketplace/TransferManager.sol b/contracts/src/marketplace/TransferManager.sol index 876a084c..052d4d65 100644 --- a/contracts/src/marketplace/TransferManager.sol +++ b/contracts/src/marketplace/TransferManager.sol @@ -6,9 +6,13 @@ import {OwnableTwoSteps} from "@looksrare/contracts-libs/contracts/OwnableTwoSte import {LowLevelERC721Transfer} from "@looksrare/contracts-libs/contracts/lowLevelCallers/LowLevelERC721Transfer.sol"; import {LowLevelERC1155Transfer} from "@looksrare/contracts-libs/contracts/lowLevelCallers/LowLevelERC1155Transfer.sol"; +// Hypercert low-level callers +import {LowLevelHypercertCaller} from "./libraries/LowLevelHypercertCaller.sol"; + // Interfaces and errors import {ITransferManager} from "./interfaces/ITransferManager.sol"; import {AmountInvalid, LengthsInvalid} from "./errors/SharedErrors.sol"; +import {IHypercertToken} from "../protocol/interfaces/IHypercertToken.sol"; // Libraries import {OrderStructs} from "./libraries/OrderStructs.sol"; @@ -28,8 +32,13 @@ import {CollectionType} from "./enums/CollectionType.sol"; * to verify if the recipient is a contract as it requires verifying the receiver interface is valid. * @author LooksRare protocol team (👀,💎) */ -// TODO Needs to be updated to split a fraction and transfer the new fraction to the bidder -contract TransferManager is ITransferManager, LowLevelERC721Transfer, LowLevelERC1155Transfer, OwnableTwoSteps { +contract TransferManager is + ITransferManager, + LowLevelERC721Transfer, + LowLevelERC1155Transfer, + LowLevelHypercertCaller, + OwnableTwoSteps +{ /** * @notice This returns whether the user has approved the operator address. * The first address is the user and the second address is the operator (e.g. LooksRareProtocol). @@ -166,6 +175,57 @@ contract TransferManager is ITransferManager, LowLevelERC721Transfer, LowLevelER } } + /** + * @notice This function transfers items for a single Hypercert. + * @param collection Collection address + * @param from Sender address + * @param to Recipient address + * @param itemIds Array of itemIds + * @param amounts Array of amounts + * @dev It does not allow batch transferring if from = msg.sender since native function should be used. + */ + function splitItemsHypercert( + address collection, + address from, + address to, + uint256[] calldata itemIds, + uint256[] calldata amounts + ) external { + IHypercertToken hypercert = IHypercertToken(collection); + uint256 length = itemIds.length; + + if (length == 0 || amounts.length != length) { + revert LengthsInvalid(); + } + + _isOperatorValidForTransfer(from, msg.sender); + + if (length == 1) { + if (amounts[0] == 0) { + revert AmountInvalid(); + } + uint256[] memory newAmounts = new uint256[](2); + newAmounts[0] = hypercert.unitsOf(itemIds[0]) - amounts[0]; + newAmounts[1] = amounts[0]; + _executeHypercertSplitFraction(collection, from, to, itemIds[0], newAmounts); + } else { + for (uint256 i; i < length;) { + if (amounts[i] == 0) { + revert AmountInvalid(); + } + + uint256[] memory newAmounts = new uint256[](2); + newAmounts[0] = hypercert.unitsOf(itemIds[0]) - amounts[0]; + newAmounts[1] = amounts[0]; + _executeHypercertSplitFraction(collection, from, to, itemIds[0], newAmounts); + + unchecked { + ++i; + } + } + } + } + /** * @notice This function transfers items for a single Hyperboard. * @param collection Collection address diff --git a/contracts/src/marketplace/TransferSelectorNFT.sol b/contracts/src/marketplace/TransferSelectorNFT.sol index d0a40b9c..b15e0ada 100644 --- a/contracts/src/marketplace/TransferSelectorNFT.sol +++ b/contracts/src/marketplace/TransferSelectorNFT.sol @@ -15,7 +15,7 @@ import {CollectionType} from "./enums/CollectionType.sol"; /** * @title TransferSelectorNFT * @notice This contract handles the logic for transferring non-fungible items. - * @author LooksRare protocol team (👀,💎) + * @author LooksRare protocol team (👀,💎); bitbeckers; */ contract TransferSelectorNFT is ExecutionManager, PackableReentrancyGuard { error UnsupportedCollectionType(); @@ -64,4 +64,28 @@ contract TransferSelectorNFT is ExecutionManager, PackableReentrancyGuard { revert UnsupportedCollectionType(); } } + + /** + * @notice This function is internal and used to transfer non-fungible tokens. + * @param collection Collection address + * @param collectionType Collection type (e.g. 0 = ERC721, 1 = ERC1155) + * @param sender Sender address + * @param recipient Recipient address + * @param itemIds Array of itemIds + * @param amounts Array of amounts + */ + function _splitNFT( + address collection, + CollectionType collectionType, + address sender, + address recipient, + uint256[] memory itemIds, + uint256[] memory amounts + ) internal { + if (collectionType == CollectionType.Hypercert) { + transferManager.splitItemsHypercert(collection, sender, recipient, itemIds, amounts); + } else if (collectionType == CollectionType.Hyperboard) { + revert UnsupportedCollectionType(); + } + } } diff --git a/contracts/src/marketplace/executionStrategies/StrategyCollectionOffer.sol b/contracts/src/marketplace/executionStrategies/StrategyCollectionOffer.sol index 19a61aee..5c20a703 100644 --- a/contracts/src/marketplace/executionStrategies/StrategyCollectionOffer.sol +++ b/contracts/src/marketplace/executionStrategies/StrategyCollectionOffer.sol @@ -21,7 +21,9 @@ import {BaseStrategy, IStrategy} from "./BaseStrategy.sol"; * @notice This contract offers execution strategies for users to create maker bid offers for items in a collection. * There are two available functions: * 1. executeCollectionStrategyWithTakerAsk --> it applies to all itemIds in a collection - * 2. executeCollectionStrategyWithTakerAskWithProof --> it allows adding merkle proof criteria. + * 2. executeCollectionStrategyWithTakerAskWithProof --> it allows adding merkle proof criteria for tokenIds. + * 2. executeCollectionStrategyWithTakerAskWithAllowlist --> it allows adding merkle proof criteria for + * accounts. * @notice The bidder can only bid on 1 item id at a time. * 1. If ERC721, the amount must be 1. * 2. If ERC1155, the amount can be greater than 1. diff --git a/contracts/src/marketplace/executionStrategies/StrategyDutchAuction.sol b/contracts/src/marketplace/executionStrategies/StrategyDutchAuction.sol index 3c4fcf27..6dfbfd1f 100644 --- a/contracts/src/marketplace/executionStrategies/StrategyDutchAuction.sol +++ b/contracts/src/marketplace/executionStrategies/StrategyDutchAuction.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.17; +pragma solidity ^0.8.17; // Libraries import {OrderStructs} from "../libraries/OrderStructs.sol"; diff --git a/contracts/src/marketplace/executionStrategies/StrategyHypercertFractionOffer.sol b/contracts/src/marketplace/executionStrategies/StrategyHypercertFractionOffer.sol new file mode 100644 index 00000000..358cfce0 --- /dev/null +++ b/contracts/src/marketplace/executionStrategies/StrategyHypercertFractionOffer.sol @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +// Interface +import {IHypercertToken} from "../../protocol/interfaces/IHypercertToken.sol"; + +// Libraries +import {OrderStructs} from "../libraries/OrderStructs.sol"; + +// OpenZeppelin's library for verifying Merkle proofs +import {MerkleProofMemory} from "../libraries/OpenZeppelin/MerkleProofMemory.sol"; + +// Enums +import {QuoteType} from "../enums/QuoteType.sol"; + +// Shared errors +import {OrderInvalid, FunctionSelectorInvalid, MerkleProofInvalid, QuoteTypeInvalid} from "../errors/SharedErrors.sol"; + +// Base strategy contracts +import {BaseStrategy, IStrategy} from "./BaseStrategy.sol"; + +import "forge-std/console2.sol"; + +/** + * @title StrategyHypercertFractionOffer + * @notice This contract offers a single execution strategy for users to bid on + * a specific amount of units in an hypercerts that's for sale. + * Example: + * Alice has 100 units of a hypercert (id: 42) for sale at a minimum price of 0.001 ETH/unit. + * Bob wants to buy 10 units. + * Bob can create a bid order with the following parameters: + * - unitAmount: 10 + * - acceptedTokenAmount: 1000000000000000 (0.001 ETH in wei) + * - acceptedTokenAddress: 0x0000000000000000000000000000000000000000 + * This strategy will validate the available units and the price. + * @notice This contract offers execution strategies for users to create maker bid offers for items in a collection. + * There are three available functions: + * 1. executeCollectionStrategyWithTakerAsk --> it applies to all itemIds in a collection + * 2. executeCollectionStrategyWithTakerAskWithProof --> it allows adding merkle proof criteria for tokenIds. + * 2. executeCollectionStrategyWithTakerAskWithAllowlist --> it allows adding merkle proof criteria for + * accounts. + * @notice The bidder can only bid on 1 item id at a time. + * 1. If ERC721, the amount must be 1. + * 2. If ERC1155, the amount can be greater than 1. + * @dev Use cases can include trait-based offers or rarity score offers. + * @author LooksRare protocol team (👀,💎) + */ +// TODO This allows for a buyer to declare a set of items they're willing to buy in a merkle tree +contract StrategyHypercertFractionOffer is BaseStrategy { + /** + * @notice This function validates the order under the context of the chosen strategy and + * returns the fulfillable items/amounts/price/nonce invalidation status. + * This strategy executes a collection offer against a taker ask order without the need of merkle proofs. + * @param takerBid Taker ask struct (taker ask-specific parameters for the execution) + * @param makerAsk Maker bid struct (maker bid-specific parameters for the execution) + */ + function executeHypercertFractionStrategyWithTakerBid( + OrderStructs.Taker calldata takerBid, + OrderStructs.Maker calldata makerAsk + ) + external + view + returns (uint256 price, uint256[] memory itemIds, uint256[] calldata amounts, bool isNonceInvalidated) + { + amounts = makerAsk.amounts; + itemIds = makerAsk.itemIds; + + uint256 tokenBalance = IHypercertToken(makerAsk.collection).unitsOf(itemIds[0]); + (,, uint256 unitAmount, uint256 acceptedTokenAmount, address acceptedTokenAddress) = + abi.decode(takerBid.additionalParameters, (uint256, bytes32[], uint256, uint256, address)); + + (, uint256 minUnitAmount, uint256 maxUnitAmount) = + abi.decode(makerAsk.additionalParameters, (bytes32, uint256, uint256)); + + // A collection order can only be executable for 1 itemId but quantity to fill can vary + if ( + makerAsk.amounts.length != 1 || makerAsk.itemIds.length != 1 || acceptedTokenAddress != makerAsk.currency + || minUnitAmount > maxUnitAmount || unitAmount < minUnitAmount || makerAsk.price > acceptedTokenAmount + || makerAsk.price == 0 || tokenBalance < amounts[0] + ) { + revert OrderInvalid(); + } + + price = acceptedTokenAmount * unitAmount; + + isNonceInvalidated = true; + } + + /** + * @notice This function validates the order under the context of the chosen strategy + * and returns the fulfillable items/amounts/price/nonce invalidation status. + * This strategy executes a collection offer against a taker ask order with the need of a merkle proof. + * @param takerBid Taker ask struct (taker ask-specific parameters for the execution) + * @param makerAsk Maker bid struct (maker bid-specific parameters for the execution) + * @dev The transaction reverts if the maker does not include a merkle root in the additionalParameters. + */ + function executeHypercertFractionStrategyWithTakerBidWithProof( + OrderStructs.Taker calldata takerBid, + OrderStructs.Maker calldata makerAsk + ) + external + pure + returns (uint256 price, uint256[] memory itemIds, uint256[] calldata amounts, bool isNonceInvalidated) + { + price = makerAsk.price; + amounts = makerAsk.amounts; + + // A collection order can only be executable for 1 itemId but the actual quantity to fill can vary + if (amounts.length != 1) { + revert OrderInvalid(); + } + + (uint256 offeredItemId, bytes32[] memory proof) = + abi.decode(takerBid.additionalParameters, (uint256, bytes32[])); + itemIds = new uint256[](1); + itemIds[0] = offeredItemId; + isNonceInvalidated = true; + + bytes32 root = abi.decode(makerAsk.additionalParameters, (bytes32)); + bytes32 node = keccak256(abi.encodePacked(offeredItemId)); + + // Verify the merkle root for the given merkle proof + if (!MerkleProofMemory.verify(proof, root, node)) { + revert MerkleProofInvalid(); + } + } + + /** + * @notice This function validates the order under the context of the chosen strategy + * and returns the fulfillable items/amounts/price/nonce invalidation status. + * This strategy executes a collection offer against a taker ask order with the need of a merkle proof + * that the address is allowed to fullfil the ask. + * @param takerBid Taker ask struct (taker ask-specific parameters for the execution) + * @param makerAsk Maker bid struct (maker bid-specific parameters for the execution) + * @dev The transaction reverts if the maker does not include a merkle root in the additionalParameters. + */ + function executeHypercertFractionStrategyWithTakerBidWithAllowlist( + OrderStructs.Taker calldata takerBid, + OrderStructs.Maker calldata makerAsk + ) + external + pure + returns (uint256 price, uint256[] memory itemIds, uint256[] calldata amounts, bool isNonceInvalidated) + { + price = makerAsk.price; + amounts = makerAsk.amounts; + + // A collection order can only be executable for 1 itemId but the actual quantity to fill can vary + if (amounts.length != 1) { + revert OrderInvalid(); + } + + (uint256 offeredItemId, bytes32[] memory proof) = + abi.decode(takerBid.additionalParameters, (uint256, bytes32[])); + itemIds = new uint256[](1); + itemIds[0] = offeredItemId; + isNonceInvalidated = true; + + bytes32 root = abi.decode(makerAsk.additionalParameters, (bytes32)); + bytes32 node = keccak256(abi.encodePacked(takerBid.recipient)); + + // Verify the merkle root for the given merkle proof + if (!MerkleProofMemory.verify(proof, root, node)) { + revert MerkleProofInvalid(); + } + } + + /** + * @inheritdoc IStrategy + */ + function isMakerOrderValid(OrderStructs.Maker calldata makerAsk, bytes4 functionSelector) + external + view + override + returns (bool isValid, bytes4 errorSelector) + { + if ( + functionSelector + != StrategyHypercertFractionOffer.executeHypercertFractionStrategyWithTakerBidWithProof.selector + && functionSelector != StrategyHypercertFractionOffer.executeHypercertFractionStrategyWithTakerBid.selector + && functionSelector + != StrategyHypercertFractionOffer.executeHypercertFractionStrategyWithTakerBidWithAllowlist.selector + ) { + return (isValid, FunctionSelectorInvalid.selector); + } + + if (makerAsk.quoteType != QuoteType.Ask) { + return (isValid, QuoteTypeInvalid.selector); + } + + if (makerAsk.amounts.length != 1) { + return (isValid, OrderInvalid.selector); + } + + (, uint256 minUnitAmount, uint256 maxUnitAmount) = + abi.decode(makerAsk.additionalParameters, (bytes32, uint256, uint256)); + + // A collection order can only be executable for 1 itemId but quantity to fill can vary + if (minUnitAmount > maxUnitAmount || makerAsk.price == 0 || maxUnitAmount == 0) { + revert OrderInvalid(); + } + + _validateAmountNoRevert(makerAsk.amounts[0], makerAsk.collectionType); + + // If no root is provided or invalid length, it should be invalid. + // @dev It does not mean the merkle root is valid against a specific itemId that exists in the collection. + if ( + ( + functionSelector + == StrategyHypercertFractionOffer.executeHypercertFractionStrategyWithTakerBidWithProof.selector + || functionSelector + == StrategyHypercertFractionOffer.executeHypercertFractionStrategyWithTakerBidWithAllowlist.selector + ) && makerAsk.additionalParameters.length != 32 + ) { + return (isValid, OrderInvalid.selector); + } + + isValid = true; + } +} diff --git a/contracts/src/marketplace/executionStrategies/StrategyItemIdsRange.sol b/contracts/src/marketplace/executionStrategies/StrategyItemIdsRange.sol index 96470011..fc9b386f 100644 --- a/contracts/src/marketplace/executionStrategies/StrategyItemIdsRange.sol +++ b/contracts/src/marketplace/executionStrategies/StrategyItemIdsRange.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.17; +pragma solidity ^0.8.17; // Libraries import {OrderStructs} from "../libraries/OrderStructs.sol"; diff --git a/contracts/src/marketplace/helpers/OrderValidatorV2A.sol b/contracts/src/marketplace/helpers/OrderValidatorV2A.sol index e8ecf572..ceb4ef38 100644 --- a/contracts/src/marketplace/helpers/OrderValidatorV2A.sol +++ b/contracts/src/marketplace/helpers/OrderValidatorV2A.sol @@ -48,7 +48,6 @@ import {QuoteType} from "../enums/QuoteType.sol"; * @dev This version does not handle strategies with partial fills. * @author LooksRare protocol team (👀,💎) */ -//TODO this might need hypercerts specific changes like checking on amount of units in a fraction contract OrderValidatorV2A { using OrderStructs for OrderStructs.Maker; diff --git a/contracts/src/marketplace/libraries/LowLevelHypercertCaller.sol b/contracts/src/marketplace/libraries/LowLevelHypercertCaller.sol new file mode 100644 index 00000000..1bc52248 --- /dev/null +++ b/contracts/src/marketplace/libraries/LowLevelHypercertCaller.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +// Interfaces +import {IHypercertToken} from "../../protocol/interfaces/IHypercertToken.sol"; + +/** + * @title LowLevelHypercertCaller + * @notice This contract contains low-level calls to transfer ERC1155 tokens. + * @author LooksRare protocol team (👀,💎) + */ +contract LowLevelHypercertCaller { + error NotAContract(); + error HypercertSplitFractionError(); + + /** + * @notice Execute Hypercert splitFraction + * @param collection Address of the collection + * @param from Address of the sender + * @param to Address of the recipient + * @param tokenId tokenId to transfer + * @param amounts split distribution + */ + + function _executeHypercertSplitFraction( + address collection, + address from, + address to, + uint256 tokenId, + uint256[] memory amounts + ) internal { + if (collection.code.length == 0) { + revert NotAContract(); + } + + (bool status,) = collection.call(abi.encodeCall(IHypercertToken.splitFraction, (to, tokenId, amounts))); + + if (!status) { + revert HypercertSplitFractionError(); + } + } +} diff --git a/contracts/src/marketplace/libraries/OrderStructs.sol b/contracts/src/marketplace/libraries/OrderStructs.sol index 19b3a88d..5acd4a06 100644 --- a/contracts/src/marketplace/libraries/OrderStructs.sol +++ b/contracts/src/marketplace/libraries/OrderStructs.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.17; +pragma solidity ^0.8.17; // Enums import {CollectionType} from "../enums/CollectionType.sol"; diff --git a/contracts/tasks/deploy-marketplace.ts b/contracts/tasks/deploy-marketplace.ts index a1118508..a7bad58b 100644 --- a/contracts/tasks/deploy-marketplace.ts +++ b/contracts/tasks/deploy-marketplace.ts @@ -332,7 +332,7 @@ task("deploy-marketplace", "Deploy marketplace contracts and verify") strategyCollectionOfferTx.contractAddress, ]); - console.log("Adding strategy CollectionWithTakerAsk to exchange..."); + console.log("Adding strategy CollectionWithTakerAsk [strategyId 1] to exchange..."); const addStratTakerAskTx = await publicClient.waitForTransactionReceipt({ hash: addStratTakerAsk, }); @@ -343,6 +343,8 @@ task("deploy-marketplace", "Deploy marketplace contracts and verify") : "Failed to add strategy executeCollectionStrategyWithTakerAsk to exchange", ); + // Add executeCollectionStrategyWithTakerAskWithProof strategy to HypercertsExchange + const addStratTakerAskProof = await hypercertsExchangeInstance.write.addStrategy([ _standardProtocolFeeBP, _minTotalFeeBp, @@ -352,7 +354,7 @@ task("deploy-marketplace", "Deploy marketplace contracts and verify") strategyCollectionOfferTx.contractAddress, ]); - console.log("Adding strategy CollectionWithTakerAskWithProof to exchange..."); + console.log("Adding strategy CollectionWithTakerAskWithProof [strategyId 2] to exchange..."); const addStratTakerAskProofTx = await publicClient.waitForTransactionReceipt({ hash: addStratTakerAskProof, }); diff --git a/contracts/test/foundry/marketplace/executionStrategies/HypercertFractionOffers.t.sol b/contracts/test/foundry/marketplace/executionStrategies/HypercertFractionOffers.t.sol new file mode 100644 index 00000000..7b392be2 --- /dev/null +++ b/contracts/test/foundry/marketplace/executionStrategies/HypercertFractionOffers.t.sol @@ -0,0 +1,589 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +// Murky (third-party) library is used to compute Merkle trees in Solidity +import {Merkle} from "murky/Merkle.sol"; + +// Libraries +import {OrderStructs} from "@hypercerts/marketplace/libraries/OrderStructs.sol"; + +// Shared errors +import { + AmountInvalid, + OrderInvalid, + FunctionSelectorInvalid, + MerkleProofInvalid, + QuoteTypeInvalid +} from "@hypercerts/marketplace/errors/SharedErrors.sol"; +import {MAKER_ORDER_PERMANENTLY_INVALID_NON_STANDARD_SALE} from + "@hypercerts/marketplace/constants/ValidationCodeConstants.sol"; + +// Strategies +import {StrategyHypercertFractionOffer} from + "@hypercerts/marketplace/executionStrategies/StrategyHypercertFractionOffer.sol"; + +// Base test +import {ProtocolBase} from "../ProtocolBase.t.sol"; + +// Constants +import {ONE_HUNDRED_PERCENT_IN_BP} from "@hypercerts/marketplace/constants/NumericConstants.sol"; + +// Enums +import {CollectionType} from "@hypercerts/marketplace/enums/CollectionType.sol"; +import {QuoteType} from "@hypercerts/marketplace/enums/QuoteType.sol"; + +contract HypercertFractionOffersTest is ProtocolBase { + StrategyHypercertFractionOffer public strategyHypercertFractionOffer; + bytes4 public selectorNoProof = StrategyHypercertFractionOffer.executeHypercertFractionStrategyWithTakerBid.selector; + bytes4 public selectorWithProof = + StrategyHypercertFractionOffer.executeHypercertFractionStrategyWithTakerBidWithProof.selector; + bytes4 public selectorWithProofAllowlist = + StrategyHypercertFractionOffer.executeHypercertFractionStrategyWithTakerBidWithAllowlist.selector; + + uint256 private constant price = 1 ether; // Fixed price of sale + bytes32 private constant mockMerkleRoot = bytes32(keccak256("Mock")); // Mock merkle root + + function _createMakerAskAndTakerBidHypercert(uint256 numberOfItems) + private + returns (OrderStructs.Maker memory newMakerAsk, OrderStructs.Taker memory newTakerBid) + { + uint256[] memory itemIds = new uint256[](numberOfItems); + vm.startPrank(makerUser); + for (uint256 i; i < numberOfItems;) { + // Mint asset + mockHypercertMinter.mintClaim(makerUser, 10_000, "https://examle.com", FROM_CREATOR_ONLY); + itemIds[i] = ((i + 1) << 128) + 1; + unchecked { + ++i; + } + } + + vm.stopPrank(); + + uint256[] memory amounts = new uint256[](numberOfItems); + for (uint256 i; i < numberOfItems;) { + amounts[i] = 1; + unchecked { + ++i; + } + } + + newMakerAsk = _createSingleItemMakerOrder({ + quoteType: QuoteType.Ask, + globalNonce: 0, + subsetNonce: 0, + strategyId: 2, + collectionType: CollectionType.Hypercert, + orderNonce: 0, + collection: address(mockHypercertMinter), + currency: address(0), + signer: makerUser, + price: 10_000, + itemId: 0 + }); + + newMakerAsk.itemIds = itemIds; + newMakerAsk.amounts = amounts; + + uint256 minUnitAmount = 1; + uint256 maxUnitAmount = 100; + newMakerAsk.additionalParameters = abi.encode(mockMerkleRoot, minUnitAmount, maxUnitAmount); + + // Using startPrice as the maxPrice + uint256 offeredItemId = itemIds[0]; + bytes32[] memory proofs = new bytes32[](0); + uint256 unitAmount = amounts[0]; + uint256 acceptedTokenAmount = 0.001 ether; + address acceptedTokenAddress = address(0); + + newTakerBid = OrderStructs.Taker( + takerUser, abi.encode(offeredItemId, proofs, unitAmount, acceptedTokenAmount, acceptedTokenAddress) + ); + } + + function setUp() public { + _setUp(); + _setUpNewStrategies(); + } + + function _setUpNewStrategies() private asPrankedUser(_owner) { + strategyHypercertFractionOffer = new StrategyHypercertFractionOffer(); + _addStrategy(address(strategyHypercertFractionOffer), selectorNoProof, true); + _addStrategy(address(strategyHypercertFractionOffer), selectorWithProof, true); + _addStrategy(address(strategyHypercertFractionOffer), selectorWithProofAllowlist, true); + } + + function testNewStrategies() public { + _assertStrategyAttributes(address(strategyHypercertFractionOffer), selectorNoProof, true); + + ( + bool strategyIsActive, + uint16 strategyStandardProtocolFee, + uint16 strategyMinTotalFee, + uint16 strategyMaxProtocolFee, + bytes4 strategySelector, + bool strategyIsMakerBid, + address strategyImplementation + ) = looksRareProtocol.strategyInfo(2); + + assertTrue(strategyIsActive); + assertEq(strategyStandardProtocolFee, _standardProtocolFeeBp); + assertEq(strategyMinTotalFee, _minTotalFeeBp); + assertEq(strategyMaxProtocolFee, _maxProtocolFeeBp); + assertEq(strategySelector, selectorWithProof); + assertTrue(strategyIsMakerBid); + assertEq(strategyImplementation, address(strategyHypercertFractionOffer)); + } + + function testmakerAskAmountsLengthNotOne() public { + _setUpUsers(); + + (OrderStructs.Maker memory makerAsk, OrderStructs.Taker memory takerBid) = + _createMakerAskAndTakerBidHypercert(1); + + // Adjust strategy for collection order and sign order + // Change array to make it bigger than expected + uint256[] memory amounts = new uint256[](2); + amounts[0] = 1; + makerAsk.strategyId = 1; + makerAsk.amounts = amounts; + takerBid.additionalParameters = abi.encode(mockMerkleRoot, 1); + bytes memory signature = _signMakerOrder(makerAsk, makerUserPK); + + _assertOrderIsInvalid(makerAsk, false); + _assertMakerOrderReturnValidationCode(makerAsk, signature, MAKER_ORDER_PERMANENTLY_INVALID_NON_STANDARD_SALE); + + // vm.expectRevert(OrderInvalid.selector); + // looksRareProtocol.executeTakerBid(takerBid, makerAsk, signature, _EMPTY_MERKLE_TREE); + + // // With proof + // makerAsk.strategyId = 2; + // makerAsk.additionalParameters = abi.encode(mockMerkleRoot); + // signature = _signMakerOrder(makerAsk, makerUserPK); + + // _assertOrderIsInvalid(makerAsk, true); + // _assertMakerOrderReturnValidationCode(makerAsk, signature, + // MAKER_ORDER_PERMANENTLY_INVALID_NON_STANDARD_SALE); + + // vm.expectRevert(OrderInvalid.selector); + // looksRareProtocol.executeTakerBid(takerBid, makerAsk, signature, _EMPTY_MERKLE_TREE); + } + + function testZeroAmount() public { + _setUpUsers(); + + (OrderStructs.Maker memory makerBid, OrderStructs.Taker memory takerAsk) = + _createMakerAskAndTakerBidHypercert(1); + + uint256[] memory amounts = new uint256[](1); + amounts[0] = 0; + makerBid.amounts = amounts; + makerBid.strategyId = 1; + makerBid.additionalParameters = abi.encode(mockMerkleRoot); + takerAsk.additionalParameters = abi.encode(1, 1); + bytes memory signature = _signMakerOrder(makerBid, makerUserPK); + + _assertOrderIsInvalid(makerBid, false); + _assertMakerOrderReturnValidationCode(makerBid, signature, MAKER_ORDER_PERMANENTLY_INVALID_NON_STANDARD_SALE); + + vm.prank(takerUser); + vm.expectRevert(AmountInvalid.selector); + looksRareProtocol.executeTakerAsk(takerAsk, makerBid, signature, _EMPTY_MERKLE_TREE); + } + + /** + * A collection offer with merkle tree criteria + * + * COLLECTION TOKEN IDs + */ + function testTakerBidHypecertFractionOrderWithMerkleTree() public { + _setUpUsers(); + + OrderStructs.Maker memory makerBid = _createSingleItemMakerOrder({ + quoteType: QuoteType.Ask, + globalNonce: 0, + subsetNonce: 0, + strategyId: 1, + collectionType: CollectionType.Hypercert, + orderNonce: 0, + collection: address(mockHypercertMinterUUPS), + currency: address(weth), + signer: makerUser, + price: price, + itemId: 0 // Not used + }); + + uint256 itemIdInMerkleTree = 2; + (bytes32 merkleRoot, bytes32[] memory proof) = _mintNFTsToOwnerAndGetMerkleRootAndProof({ + owner: takerUser, + numberOfItemsInMerkleTree: 5, + itemIdInMerkleTree: itemIdInMerkleTree + }); + + makerBid.additionalParameters = abi.encode(merkleRoot); + + // Sign order + bytes memory signature = _signMakerOrder(makerBid, makerUserPK); + + // Prepare the taker ask + OrderStructs.Taker memory takerAsk = OrderStructs.Taker(takerUser, abi.encode(itemIdInMerkleTree, proof)); + + // Verify validity of maker bid order + _assertOrderIsValid(makerBid, true); + _assertValidMakerOrder(makerBid, signature); + + // Execute taker ask transaction + vm.prank(takerUser); + looksRareProtocol.executeTakerAsk(takerAsk, makerBid, signature, _EMPTY_MERKLE_TREE); + + _assertSuccessfulTakerAsk(makerBid, itemIdInMerkleTree); + } + + function testTakerAskCannotExecuteWithInvalidProof(uint256 itemIdSold) public { + vm.assume(itemIdSold > 5); + _setUpUsers(); + + OrderStructs.Maker memory makerBid = _createSingleItemMakerOrder({ + quoteType: QuoteType.Bid, + globalNonce: 0, + subsetNonce: 0, + strategyId: 2, + collectionType: CollectionType.ERC721, + orderNonce: 0, + collection: address(mockERC721), + currency: address(weth), + signer: makerUser, + price: price, + itemId: 0 // Not used + }); + + (bytes32 merkleRoot, bytes32[] memory proof) = _mintNFTsToOwnerAndGetMerkleRootAndProof({ + owner: takerUser, + numberOfItemsInMerkleTree: 5, + // Doesn't matter what itemIdInMerkleTree is as we are are going to tamper with the proof + itemIdInMerkleTree: 4 + }); + makerBid.additionalParameters = abi.encode(merkleRoot); + + // Sign order + bytes memory signature = _signMakerOrder(makerBid, makerUserPK); + + // Prepare the taker ask + proof[0] = bytes32(0); // Tamper with the proof + OrderStructs.Taker memory takerAsk = OrderStructs.Taker(takerUser, abi.encode(itemIdSold, proof)); + + // Verify validity of maker bid order + _assertOrderIsValid(makerBid, true); + _assertValidMakerOrder(makerBid, signature); + + vm.prank(takerUser); + vm.expectRevert(MerkleProofInvalid.selector); + looksRareProtocol.executeTakerAsk(takerAsk, makerBid, signature, _EMPTY_MERKLE_TREE); + } + + /** + * TAKER ALLOWLIST + */ + function testTakerAskCollectionOrderWithMerkleTreeERC721AccountAllowlist() public { + _setUpUsers(); + + OrderStructs.Maker memory makerBid = _createSingleItemMakerOrder({ + quoteType: QuoteType.Bid, + globalNonce: 0, + subsetNonce: 0, + strategyId: 3, + collectionType: CollectionType.ERC721, + orderNonce: 0, + collection: address(mockERC721), + currency: address(weth), + signer: makerUser, + price: price, + itemId: 0 // Not used + }); + + address accountInMerkleTree = takerUser; + uint256 tokenIdInMerkleTree = 2; + (bytes32 merkleRoot, bytes32[] memory proof) = _mintNFTsToOwnerAndGetMerkleRootAndProofAccountAllowlist({ + owner: takerUser, + numberOfAccountsInMerkleTree: 5, + accountInMerkleTree: accountInMerkleTree + }); + + makerBid.additionalParameters = abi.encode(merkleRoot); + + // Sign order + bytes memory signature = _signMakerOrder(makerBid, makerUserPK); + + // Prepare the taker ask + OrderStructs.Taker memory takerAsk = OrderStructs.Taker(takerUser, abi.encode(tokenIdInMerkleTree, proof)); + + // Verify validity of maker bid order + _assertOrderIsValid(makerBid, true); + _assertValidMakerOrder(makerBid, signature); + + // Execute taker ask transaction + vm.prank(takerUser); + looksRareProtocol.executeTakerAsk(takerAsk, makerBid, signature, _EMPTY_MERKLE_TREE); + + _assertSuccessfulTakerAsk(makerBid, tokenIdInMerkleTree); + } + + function testTakerAskCannotExecuteWithInvalidProofAccountAllowlist(uint256 itemIdSold) public { + vm.assume(itemIdSold > 5); + _setUpUsers(); + + OrderStructs.Maker memory makerBid = _createSingleItemMakerOrder({ + quoteType: QuoteType.Bid, + globalNonce: 0, + subsetNonce: 0, + strategyId: 3, + collectionType: CollectionType.ERC721, + orderNonce: 0, + collection: address(mockERC721), + currency: address(weth), + signer: makerUser, + price: price, + itemId: 0 // Not used + }); + + address accountInMerkleTree = takerUser; + uint256 tokenIdInMerkleTree = 2; + (bytes32 merkleRoot, bytes32[] memory proof) = _mintNFTsToOwnerAndGetMerkleRootAndProofAccountAllowlist({ + owner: takerUser, + numberOfAccountsInMerkleTree: 5, + // Doesn't matter what itemIdInMerkleTree is as we are are going to tamper with the proof + accountInMerkleTree: accountInMerkleTree + }); + makerBid.additionalParameters = abi.encode(merkleRoot); + + // Sign order + bytes memory signature = _signMakerOrder(makerBid, makerUserPK); + + // Prepare the taker ask + proof[0] = bytes32(0); // Tamper with the proof + OrderStructs.Taker memory takerAsk = OrderStructs.Taker(takerUser, abi.encode(tokenIdInMerkleTree, proof)); + + // Verify validity of maker bid order + _assertOrderIsValid(makerBid, true); + _assertValidMakerOrder(makerBid, signature); + + vm.prank(takerUser); + vm.expectRevert(MerkleProofInvalid.selector); + looksRareProtocol.executeTakerAsk(takerAsk, makerBid, signature, _EMPTY_MERKLE_TREE); + } + + function testInvalidAmounts() public { + _setUpUsers(); + + OrderStructs.Maker memory makerBid = _createSingleItemMakerOrder({ + quoteType: QuoteType.Bid, + globalNonce: 0, + subsetNonce: 0, + strategyId: 1, + collectionType: CollectionType.ERC721, + orderNonce: 0, + collection: address(mockERC721), + currency: address(weth), + signer: makerUser, + price: price, + itemId: 0 + }); + + // Prepare the taker ask + OrderStructs.Taker memory takerAsk = OrderStructs.Taker(takerUser, abi.encode(5)); + + // 1. Amount is 0 (without merkle proof) + makerBid.amounts[0] = 0; + bytes memory signature = _signMakerOrder(makerBid, makerUserPK); + _assertOrderIsInvalid(makerBid, false); + _assertMakerOrderReturnValidationCode(makerBid, signature, MAKER_ORDER_PERMANENTLY_INVALID_NON_STANDARD_SALE); + + vm.prank(takerUser); + vm.expectRevert(AmountInvalid.selector); + looksRareProtocol.executeTakerAsk(takerAsk, makerBid, signature, _EMPTY_MERKLE_TREE); + + // 2. Amount is too high for ERC721 (without merkle proof) + makerBid.amounts[0] = 2; + signature = _signMakerOrder(makerBid, makerUserPK); + _assertOrderIsInvalid(makerBid, false); + _assertMakerOrderReturnValidationCode(makerBid, signature, MAKER_ORDER_PERMANENTLY_INVALID_NON_STANDARD_SALE); + + vm.prank(takerUser); + vm.expectRevert(AmountInvalid.selector); + looksRareProtocol.executeTakerAsk(takerAsk, makerBid, signature, _EMPTY_MERKLE_TREE); + + // 3. Amount is 0 (with merkle proof) + makerBid.strategyId = 2; + uint256 itemIdInMerkleTree = 5; + (bytes32 merkleRoot, bytes32[] memory proof) = _mintNFTsToOwnerAndGetMerkleRootAndProof({ + owner: takerUser, + numberOfItemsInMerkleTree: 6, + itemIdInMerkleTree: itemIdInMerkleTree + }); + + makerBid.additionalParameters = abi.encode(merkleRoot); + makerBid.amounts[0] = 0; + signature = _signMakerOrder(makerBid, makerUserPK); + + takerAsk.additionalParameters = abi.encode(itemIdInMerkleTree, proof); + + _assertOrderIsInvalid(makerBid, true); + _assertMakerOrderReturnValidationCode(makerBid, signature, MAKER_ORDER_PERMANENTLY_INVALID_NON_STANDARD_SALE); + + vm.prank(takerUser); + vm.expectRevert(AmountInvalid.selector); + looksRareProtocol.executeTakerAsk(takerAsk, makerBid, signature, _EMPTY_MERKLE_TREE); + + // 4. Amount is too high for ERC721 (with merkle proof) + makerBid.amounts[0] = 2; + signature = _signMakerOrder(makerBid, makerUserPK); + _assertOrderIsInvalid(makerBid, true); + _assertMakerOrderReturnValidationCode(makerBid, signature, MAKER_ORDER_PERMANENTLY_INVALID_NON_STANDARD_SALE); + + vm.prank(takerUser); + vm.expectRevert(AmountInvalid.selector); + looksRareProtocol.executeTakerAsk(takerAsk, makerBid, signature, _EMPTY_MERKLE_TREE); + } + + function testMerkleRootLengthIsNot32() public { + OrderStructs.Maker memory makerBid = _createSingleItemMakerOrder({ + quoteType: QuoteType.Bid, + globalNonce: 0, + subsetNonce: 0, + strategyId: 2, + collectionType: CollectionType.ERC721, + orderNonce: 0, + collection: address(mockERC721), + currency: address(weth), + signer: makerUser, + price: price, + itemId: 0 + }); + + bytes memory signature = _signMakerOrder(makerBid, makerUserPK); + + _assertOrderIsInvalid(makerBid, true); + _assertMakerOrderReturnValidationCode(makerBid, signature, MAKER_ORDER_PERMANENTLY_INVALID_NON_STANDARD_SALE); + + vm.prank(takerUser); + vm.expectRevert(); // It should revert without data (since the root cannot be extracted since the + // additionalParameters length is 0) + looksRareProtocol.executeTakerAsk(_genericTakerOrder(), makerBid, signature, _EMPTY_MERKLE_TREE); + } + + function testInvalidSelector() public { + OrderStructs.Maker memory makerBid = _createSingleItemMakerOrder({ + quoteType: QuoteType.Bid, + globalNonce: 0, + subsetNonce: 0, + strategyId: 3, + collectionType: CollectionType.ERC721, + orderNonce: 0, + collection: address(mockERC721), + currency: address(weth), + signer: makerUser, + price: price, + itemId: 0 + }); + + (bool orderIsValid, bytes4 errorSelector) = + strategyHypercertFractionOffer.isMakerOrderValid(makerBid, bytes4(0)); + assertFalse(orderIsValid); + assertEq(errorSelector, FunctionSelectorInvalid.selector); + } + + function testWrongQuoteType() public { + OrderStructs.Maker memory makerAsk = _createSingleItemMakerOrder({ + quoteType: QuoteType.Ask, + globalNonce: 0, + subsetNonce: 0, + strategyId: 2, + collectionType: CollectionType.ERC721, + orderNonce: 0, + collection: address(mockERC721), + currency: address(weth), + signer: makerUser, + price: price, + itemId: 0 + }); + + (bool orderIsValid, bytes4 errorSelector) = + strategyHypercertFractionOffer.isMakerOrderValid(makerAsk, selectorNoProof); + + assertFalse(orderIsValid); + assertEq(errorSelector, QuoteTypeInvalid.selector); + } + + function _assertOrderIsValid(OrderStructs.Maker memory makerBid, bool withProof) private { + (bool orderIsValid, bytes4 errorSelector) = + strategyHypercertFractionOffer.isMakerOrderValid(makerBid, withProof ? selectorWithProof : selectorNoProof); + assertTrue(orderIsValid); + assertEq(errorSelector, _EMPTY_BYTES4); + } + + function _assertOrderIsInvalid(OrderStructs.Maker memory makerBid, bool withProof) private { + (bool orderIsValid, bytes4 errorSelector) = + strategyHypercertFractionOffer.isMakerOrderValid(makerBid, withProof ? selectorWithProof : selectorNoProof); + + assertFalse(orderIsValid); + assertEq(errorSelector, OrderInvalid.selector); + } + + function _mintNFTsToOwnerAndGetMerkleRootAndProof( + address owner, + uint256 numberOfItemsInMerkleTree, + uint256 itemIdInMerkleTree + ) private returns (bytes32 merkleRoot, bytes32[] memory proof) { + require(itemIdInMerkleTree < numberOfItemsInMerkleTree, "Invalid itemIdInMerkleTree"); + + // Initialize Merkle Tree + Merkle m = new Merkle(); + + bytes32[] memory merkleTreeIds = new bytes32[](numberOfItemsInMerkleTree); + for (uint256 i; i < numberOfItemsInMerkleTree; i++) { + mockERC721.mint(owner, i); + merkleTreeIds[i] = keccak256(abi.encodePacked(i)); + } + + // Compute merkle root + merkleRoot = m.getRoot(merkleTreeIds); + proof = m.getProof(merkleTreeIds, itemIdInMerkleTree); + + assertTrue(m.verifyProof(merkleRoot, proof, merkleTreeIds[itemIdInMerkleTree])); + } + + function _mintNFTsToOwnerAndGetMerkleRootAndProofAccountAllowlist( + address owner, + uint256 numberOfAccountsInMerkleTree, + address accountInMerkleTree + ) private returns (bytes32 merkleRoot, bytes32[] memory proof) { + // Initialize Merkle Tree + Merkle m = new Merkle(); + + bytes32[] memory merkleTreeAccounts = new bytes32[](numberOfAccountsInMerkleTree); + for (uint256 i; i < numberOfAccountsInMerkleTree; i++) { + mockERC721.mint(owner, i); + merkleTreeAccounts[i] = keccak256(abi.encodePacked(accountInMerkleTree)); + } + + // Compute merkle root + merkleRoot = m.getRoot(merkleTreeAccounts); + proof = m.getProof(merkleTreeAccounts, 2); + + assertTrue(m.verifyProof(merkleRoot, proof, merkleTreeAccounts[0])); + } + + function _assertSuccessfulTakerAsk(OrderStructs.Maker memory makerBid, uint256 tokenId) private { + // Taker user has received the asset + assertEq(mockERC721.ownerOf(tokenId), makerUser); + // Maker bid user pays the whole price + assertEq(weth.balanceOf(makerUser), _initialWETHBalanceUser - price); + // Taker ask user receives 99.5% of the whole price (0.5% protocol) + assertEq( + weth.balanceOf(takerUser), + _initialWETHBalanceUser + (price * _sellerProceedBpWithStandardProtocolFeeBp) / ONE_HUNDRED_PERCENT_IN_BP + ); + // Verify the nonce is marked as executed + assertEq(looksRareProtocol.userOrderNonce(makerUser, makerBid.orderNonce), MAGIC_VALUE_ORDER_NONCE_EXECUTED); + } +}