diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cc5324bf..950620a7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 - name: Install dependencies - run: yarn install --frozen-lockfile + run: yarn install --frozen-lockfile --network-concurrency 2 - name: Run tests run: forge test -vvv hardhat-test: @@ -29,8 +29,9 @@ jobs: uses: actions/setup-node@v3 with: node-version: lts/* + cache: 'yarn' - name: Install dependencies - run: yarn install --frozen-lockfile + run: yarn install --frozen-lockfile --network-concurrency 2 - name: Run Tests run: yarn test lint: @@ -43,8 +44,9 @@ jobs: uses: actions/setup-node@v3 with: node-version: lts/* + cache: 'yarn' - name: Install dependencies - run: yarn install --frozen-lockfile + run: yarn install --frozen-lockfile --network-concurrency 2 - name: Run eslint run: yarn lint publish: @@ -58,8 +60,9 @@ jobs: with: node-version-file: ".nvmrc" registry-url: https://registry.npmjs.org/ + cache: 'yarn' - name: Install dependencies - run: yarn install --frozen-lockfile + run: yarn install --frozen-lockfile --network-concurrency 2 - name: Compile contracts run: yarn compile - name: Build dist files diff --git a/audits/202308-audit-information.md b/audits/202308-audit-information.md new file mode 100644 index 00000000..c976cce5 --- /dev/null +++ b/audits/202308-audit-information.md @@ -0,0 +1,39 @@ +# Immutable Seaport +Immutable Seaport is a minor fork and extension of OpenSea's [Seaport](https://github.com/ProjectOpenSea/seaport) NFT settlement contract. The intension of the extension is to ensure that royalties and other platform fees (protocol, marketplace) are enforceable. This has been achieved with the use of a custom zone contract that implements [SIP7](https://github.com/ProjectOpenSea/SIPs/blob/main/SIPS/sip-7.md). + +## Seaport Fork Details +OpenSea split their Seaport implementation into 3 repositories: +- [seaport-types](https://github.com/ProjectOpenSea/seaport-types): This repo contains the core Seaport structs, enums, and interfaces for use in other Seaport repos. +- [seaport-core](https://github.com/ProjectOpenSea/seaport-core): This repo contains the core Seaport smart contracts (with no reference contracts, helpers, or tests) and is meant to facilitate building on Seaport without needing to grapple with long compile times or other complications. +- [seaport](https://github.com/ProjectOpenSea/seaport): This repo contains reference implementation contracts and testing utilities + +### Seaport Types +No fork + +### Seaport Core +The function signatures in the `seaport-core` Consideration contract have [been updated](https://github.com/ProjectOpenSea/seaport-core/compare/main...immutable:seaport-core:1.5.0+im.1) to be `virtual` so that the `ImmutableSeaport` Consideration contract can add additional validation prior to calling the underlying `Consideration` implementation. + +### Seaport +Immutable use [the fork](https://github.com/ProjectOpenSea/seaport/compare/main...immutable:seaport:1.5.0+im.1) to strictly pin the version of the upstream `seaport` contract implementations, which are currently on version 1.5. The fork also exposed testing utilities so that they can be used from the Immutable Seaport repository. + +## Immutable Seaport Implementation +To ensure that the fees on an order meet the requirements of the Immutable ecosystem, all orders passed for consideration **must** be signed by an Immutable signer prior to fulfilment. This is achieved through the implementation of a SIP7 zone contract and by extending seaport consideration methods to ensure all fulfillments are validated by said zone. + +### Extension to Seaport Consideration +The extension applies to all methods that can be used for order fulfilment and is always fundamentally the same; the order / orders being passed for fulfilment must be either `PARTIAL_RESTRICTED` or `FULL_RESTRICTED` and they must reference an allowed zone. + +### Immutable Signed Zone +The [Immutable Signed Zone](https://github.com/immutable/immutable-seaport/blob/main/contracts/zones/ImmutableSignedZone.sol) is an implementation of the SIP-7 specification with sub-standards 3 and 4. + +The `zone` of an order is a mandatory (in the case of `Immutable Seaport`) secondary account attached to the order with two additional privileges: +- The zone may cancel orders where it is named as the zone by calling `cancel`. (Note that offerers can also cancel their own orders, either individually or for all orders signed with their current counter at once by calling `incrementCounter`). +- "Restricted" orders (as specified by the order type) can be executed by anyone but must be approved by the zone indicated by a call to `validateOrder` on the zone. + +The critical piece for the Immutable Seaport implementation is that all order executions make a call to `validateOrder` on the `Immutable Signed Zone`. If this check fails, the order will not be fulfilled. + +## Other Contracts +We use some other contracts from the seaport ecosystem. These are unmodified and just reference the OpenSea upstream. These include: +- ReadOnlyOrderValidator +- SeaportValidator +- SeaportValidatorHelper +- ConduitController diff --git a/contracts/trading/seaport/ImmutableSeaport.sol b/contracts/trading/seaport/ImmutableSeaport.sol new file mode 100644 index 00000000..05dcd27b --- /dev/null +++ b/contracts/trading/seaport/ImmutableSeaport.sol @@ -0,0 +1,681 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache-2 +pragma solidity 0.8.17; + +import { Consideration } from "seaport-core/src/lib/Consideration.sol"; +import { + AdvancedOrder, + BasicOrderParameters, + CriteriaResolver, + Execution, + Fulfillment, + FulfillmentComponent, + Order, + OrderComponents +} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import { OrderType } from "seaport-types/src/lib/ConsiderationEnums.sol"; +import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import { + ImmutableSeaportEvents +} from "./interfaces/ImmutableSeaportEvents.sol"; + +/** + * @title ImmutableSeaport + * @custom:version 1.5 + * @notice Seaport is a generalized native token/ERC20/ERC721/ERC1155 + * marketplace with lightweight methods for common routes as well as + * more flexible methods for composing advanced orders or groups of + * orders. Each order contains an arbitrary number of items that may be + * spent (the "offer") along with an arbitrary number of items that must + * be received back by the indicated recipients (the "consideration"). + */ +contract ImmutableSeaport is + Consideration, + Ownable2Step, + ImmutableSeaportEvents +{ + // Mapping to store valid ImmutableZones - this allows for multiple Zones + // to be active at the same time, and can be expired or added on demand. + mapping(address => bool) public allowedZones; + + error OrderNotRestricted(); + error InvalidZone(address zone); + + /** + * @notice Derive and set hashes, reference chainId, and associated domain + * separator during deployment. + * + * @param conduitController A contract that deploys conduits, or proxies + * that may optionally be used to transfer approved + * ERC20/721/1155 tokens. + */ + constructor( + address conduitController + ) Consideration(conduitController) Ownable2Step() {} + + /** + * @dev Set the validity of a zone for use during fulfillment. + */ + function setAllowedZone(address zone, bool allowed) external onlyOwner { + allowedZones[zone] = allowed; + emit AllowedZoneSet(zone, allowed); + } + + /** + * @dev Internal pure function to retrieve and return the name of this + * contract. + * + * @return The name of this contract. + */ + function _name() internal pure override returns (string memory) { + // Return the name of the contract. + return "ImmutableSeaport"; + } + + /** + * @dev Internal pure function to retrieve the name of this contract as a + * string that will be used to derive the name hash in the constructor. + * + * @return The name of this contract as a string. + */ + function _nameString() internal pure override returns (string memory) { + // Return the name of the contract. + return "ImmutableSeaport"; + } + + /** + * @dev Helper function to revert any fulfillment that has an invalid zone + */ + function _rejectIfZoneInvalid(address zone) internal view { + if (!allowedZones[zone]) { + revert InvalidZone(zone); + } + } + + /** + * @notice Fulfill an order offering an ERC20, ERC721, or ERC1155 item by + * supplying Ether (or other native tokens), ERC20 tokens, an ERC721 + * item, or an ERC1155 item as consideration. Six permutations are + * supported: Native token to ERC721, Native token to ERC1155, ERC20 + * to ERC721, ERC20 to ERC1155, ERC721 to ERC20, and ERC1155 to + * ERC20 (with native tokens supplied as msg.value). For an order to + * be eligible for fulfillment via this method, it must contain a + * single offer item (though that item may have a greater amount if + * the item is not an ERC721). An arbitrary number of "additional + * recipients" may also be supplied which will each receive native + * tokens or ERC20 items from the fulfiller as consideration. Refer + * to the documentation for a more comprehensive summary of how to + * utilize this method and what orders are compatible with it. + * + * @param parameters Additional information on the fulfilled order. Note + * that the offerer and the fulfiller must first approve + * this contract (or their chosen conduit if indicated) + * before any tokens can be transferred. Also note that + * contract recipients of ERC1155 consideration items must + * implement `onERC1155Received` to receive those items. + * + * @return fulfilled A boolean indicating whether the order has been + * successfully fulfilled. + */ + function fulfillBasicOrder( + BasicOrderParameters calldata parameters + ) public payable virtual override returns (bool fulfilled) { + // All restricted orders are captured using this method + if ( + uint(parameters.basicOrderType) % 4 != 2 && + uint(parameters.basicOrderType) % 4 != 3 + ) { + revert OrderNotRestricted(); + } + + _rejectIfZoneInvalid(parameters.zone); + + return super.fulfillBasicOrder(parameters); + } + + /** + * @notice Fulfill an order offering an ERC20, ERC721, or ERC1155 item by + * supplying Ether (or other native tokens), ERC20 tokens, an ERC721 + * item, or an ERC1155 item as consideration. Six permutations are + * supported: Native token to ERC721, Native token to ERC1155, ERC20 + * to ERC721, ERC20 to ERC1155, ERC721 to ERC20, and ERC1155 to + * ERC20 (with native tokens supplied as msg.value). For an order to + * be eligible for fulfillment via this method, it must contain a + * single offer item (though that item may have a greater amount if + * the item is not an ERC721). An arbitrary number of "additional + * recipients" may also be supplied which will each receive native + * tokens or ERC20 items from the fulfiller as consideration. Refer + * to the documentation for a more comprehensive summary of how to + * utilize this method and what orders are compatible with it. Note + * that this function costs less gas than `fulfillBasicOrder` due to + * the zero bytes in the function selector (0x00000000) which also + * results in earlier function dispatch. + * + * @param parameters Additional information on the fulfilled order. Note + * that the offerer and the fulfiller must first approve + * this contract (or their chosen conduit if indicated) + * before any tokens can be transferred. Also note that + * contract recipients of ERC1155 consideration items must + * implement `onERC1155Received` to receive those items. + * + * @return fulfilled A boolean indicating whether the order has been + * successfully fulfilled. + */ + function fulfillBasicOrder_efficient_6GL6yc( + BasicOrderParameters calldata parameters + ) public payable virtual override returns (bool fulfilled) { + // All restricted orders are captured using this method + if ( + uint(parameters.basicOrderType) % 4 != 2 && + uint(parameters.basicOrderType) % 4 != 3 + ) { + revert OrderNotRestricted(); + } + + _rejectIfZoneInvalid(parameters.zone); + + return super.fulfillBasicOrder_efficient_6GL6yc(parameters); + } + + /** + * @notice Fulfill an order with an arbitrary number of items for offer and + * consideration. Note that this function does not support + * criteria-based orders or partial filling of orders (though + * filling the remainder of a partially-filled order is supported). + * + * @custom:param order The order to fulfill. Note that both the + * offerer and the fulfiller must first approve + * this contract (or the corresponding conduit if + * indicated) to transfer any relevant tokens on + * their behalf and that contracts must implement + * `onERC1155Received` to receive ERC1155 tokens + * as consideration. + * @param fulfillerConduitKey A bytes32 value indicating what conduit, if + * any, to source the fulfiller's token approvals + * from. The zero hash signifies that no conduit + * should be used (and direct approvals set on + * this contract). + * + * @return fulfilled A boolean indicating whether the order has been + * successfully fulfilled. + */ + function fulfillOrder( + /** + * @custom:name order + */ + Order calldata order, + bytes32 fulfillerConduitKey + ) public payable virtual override returns (bool fulfilled) { + if ( + order.parameters.orderType != OrderType.FULL_RESTRICTED && + order.parameters.orderType != OrderType.PARTIAL_RESTRICTED + ) { + revert OrderNotRestricted(); + } + + _rejectIfZoneInvalid(order.parameters.zone); + + return super.fulfillOrder(order, fulfillerConduitKey); + } + + /** + * @notice Fill an order, fully or partially, with an arbitrary number of + * items for offer and consideration alongside criteria resolvers + * containing specific token identifiers and associated proofs. + * + * @custom:param advancedOrder The order to fulfill along with the + * fraction of the order to attempt to fill. + * Note that both the offerer and the + * fulfiller must first approve this + * contract (or their conduit if indicated + * by the order) to transfer any relevant + * tokens on their behalf and that contracts + * must implement `onERC1155Received` to + * receive ERC1155 tokens as consideration. + * Also note that all offer and + * consideration components must have no + * remainder after multiplication of the + * respective amount with the supplied + * fraction for the partial fill to be + * considered valid. + * @custom:param criteriaResolvers An array where each element contains a + * reference to a specific offer or + * consideration, a token identifier, and a + * proof that the supplied token identifier + * is contained in the merkle root held by + * the item in question's criteria element. + * Note that an empty criteria indicates + * that any (transferable) token identifier + * on the token in question is valid and + * that no associated proof needs to be + * supplied. + * @param fulfillerConduitKey A bytes32 value indicating what conduit, + * if any, to source the fulfiller's token + * approvals from. The zero hash signifies + * that no conduit should be used (and + * direct approvals set on this contract). + * @param recipient The intended recipient for all received + * items, with `address(0)` indicating that + * the caller should receive the items. + * + * @return fulfilled A boolean indicating whether the order has been + * successfully fulfilled. + */ + function fulfillAdvancedOrder( + /** + * @custom:name advancedOrder + */ + AdvancedOrder calldata advancedOrder, + /** + * @custom:name criteriaResolvers + */ + CriteriaResolver[] calldata criteriaResolvers, + bytes32 fulfillerConduitKey, + address recipient + ) public payable virtual override returns (bool fulfilled) { + if ( + advancedOrder.parameters.orderType != OrderType.FULL_RESTRICTED && + advancedOrder.parameters.orderType != OrderType.PARTIAL_RESTRICTED + ) { + revert OrderNotRestricted(); + } + + _rejectIfZoneInvalid(advancedOrder.parameters.zone); + + return + super.fulfillAdvancedOrder( + advancedOrder, + criteriaResolvers, + fulfillerConduitKey, + recipient + ); + } + + /** + * @notice Attempt to fill a group of orders, each with an arbitrary number + * of items for offer and consideration. Any order that is not + * currently active, has already been fully filled, or has been + * cancelled will be omitted. Remaining offer and consideration + * items will then be aggregated where possible as indicated by the + * supplied offer and consideration component arrays and aggregated + * items will be transferred to the fulfiller or to each intended + * recipient, respectively. Note that a failing item transfer or an + * issue with order formatting will cause the entire batch to fail. + * Note that this function does not support criteria-based orders or + * partial filling of orders (though filling the remainder of a + * partially-filled order is supported). + * + * @custom:param orders The orders to fulfill. Note that + * both the offerer and the + * fulfiller must first approve this + * contract (or the corresponding + * conduit if indicated) to transfer + * any relevant tokens on their + * behalf and that contracts must + * implement `onERC1155Received` to + * receive ERC1155 tokens as + * consideration. + * @custom:param offerFulfillments An array of FulfillmentComponent + * arrays indicating which offer + * items to attempt to aggregate + * when preparing executions. Note + * that any offer items not included + * as part of a fulfillment will be + * sent unaggregated to the caller. + * @custom:param considerationFulfillments An array of FulfillmentComponent + * arrays indicating which + * consideration items to attempt to + * aggregate when preparing + * executions. + * @param fulfillerConduitKey A bytes32 value indicating what + * conduit, if any, to source the + * fulfiller's token approvals from. + * The zero hash signifies that no + * conduit should be used (and + * direct approvals set on this + * contract). + * @param maximumFulfilled The maximum number of orders to + * fulfill. + * + * @return availableOrders An array of booleans indicating if each order + * with an index corresponding to the index of the + * returned boolean was fulfillable or not. + * @return executions An array of elements indicating the sequence of + * transfers performed as part of matching the given + * orders. + */ + function fulfillAvailableOrders( + /** + * @custom:name orders + */ + Order[] calldata orders, + /** + * @custom:name offerFulfillments + */ + FulfillmentComponent[][] calldata offerFulfillments, + /** + * @custom:name considerationFulfillments + */ + FulfillmentComponent[][] calldata considerationFulfillments, + bytes32 fulfillerConduitKey, + uint256 maximumFulfilled + ) + public + payable + virtual + override + returns ( + bool[] memory, + /* availableOrders */ Execution[] memory /* executions */ + ) + { + for (uint256 i = 0; i < orders.length; i++) { + Order memory order = orders[i]; + if ( + order.parameters.orderType != OrderType.FULL_RESTRICTED && + order.parameters.orderType != OrderType.PARTIAL_RESTRICTED + ) { + revert OrderNotRestricted(); + } + _rejectIfZoneInvalid(order.parameters.zone); + } + + return + super.fulfillAvailableOrders( + orders, + offerFulfillments, + considerationFulfillments, + fulfillerConduitKey, + maximumFulfilled + ); + } + + /** + * @notice Attempt to fill a group of orders, fully or partially, with an + * arbitrary number of items for offer and consideration per order + * alongside criteria resolvers containing specific token + * identifiers and associated proofs. Any order that is not + * currently active, has already been fully filled, or has been + * cancelled will be omitted. Remaining offer and consideration + * items will then be aggregated where possible as indicated by the + * supplied offer and consideration component arrays and aggregated + * items will be transferred to the fulfiller or to each intended + * recipient, respectively. Note that a failing item transfer or an + * issue with order formatting will cause the entire batch to fail. + * + * @custom:param advancedOrders The orders to fulfill along with + * the fraction of those orders to + * attempt to fill. Note that both + * the offerer and the fulfiller + * must first approve this contract + * (or their conduit if indicated by + * the order) to transfer any + * relevant tokens on their behalf + * and that contracts must implement + * `onERC1155Received` to receive + * ERC1155 tokens as consideration. + * Also note that all offer and + * consideration components must + * have no remainder after + * multiplication of the respective + * amount with the supplied fraction + * for an order's partial fill + * amount to be considered valid. + * @custom:param criteriaResolvers An array where each element + * contains a reference to a + * specific offer or consideration, + * a token identifier, and a proof + * that the supplied token + * identifier is contained in the + * merkle root held by the item in + * question's criteria element. Note + * that an empty criteria indicates + * that any (transferable) token + * identifier on the token in + * question is valid and that no + * associated proof needs to be + * supplied. + * @custom:param offerFulfillments An array of FulfillmentComponent + * arrays indicating which offer + * items to attempt to aggregate + * when preparing executions. Note + * that any offer items not included + * as part of a fulfillment will be + * sent unaggregated to the caller. + * @custom:param considerationFulfillments An array of FulfillmentComponent + * arrays indicating which + * consideration items to attempt to + * aggregate when preparing + * executions. + * @param fulfillerConduitKey A bytes32 value indicating what + * conduit, if any, to source the + * fulfiller's token approvals from. + * The zero hash signifies that no + * conduit should be used (and + * direct approvals set on this + * contract). + * @param recipient The intended recipient for all + * received items, with `address(0)` + * indicating that the caller should + * receive the offer items. + * @param maximumFulfilled The maximum number of orders to + * fulfill. + * + * @return availableOrders An array of booleans indicating if each order + * with an index corresponding to the index of the + * returned boolean was fulfillable or not. + * @return executions An array of elements indicating the sequence of + * transfers performed as part of matching the given + * orders. + */ + function fulfillAvailableAdvancedOrders( + /** + * @custom:name advancedOrders + */ + AdvancedOrder[] calldata advancedOrders, + /** + * @custom:name criteriaResolvers + */ + CriteriaResolver[] calldata criteriaResolvers, + /** + * @custom:name offerFulfillments + */ + FulfillmentComponent[][] calldata offerFulfillments, + /** + * @custom:name considerationFulfillments + */ + FulfillmentComponent[][] calldata considerationFulfillments, + bytes32 fulfillerConduitKey, + address recipient, + uint256 maximumFulfilled + ) + public + payable + virtual + override + returns ( + bool[] memory, + /* availableOrders */ Execution[] memory /* executions */ + ) + { + for (uint256 i = 0; i < advancedOrders.length; i++) { + AdvancedOrder memory advancedOrder = advancedOrders[i]; + if ( + advancedOrder.parameters.orderType != + OrderType.FULL_RESTRICTED && + advancedOrder.parameters.orderType != + OrderType.PARTIAL_RESTRICTED + ) { + revert OrderNotRestricted(); + } + + _rejectIfZoneInvalid(advancedOrder.parameters.zone); + } + + return + super.fulfillAvailableAdvancedOrders( + advancedOrders, + criteriaResolvers, + offerFulfillments, + considerationFulfillments, + fulfillerConduitKey, + recipient, + maximumFulfilled + ); + } + + /** + * @notice Match an arbitrary number of orders, each with an arbitrary + * number of items for offer and consideration along with a set of + * fulfillments allocating offer components to consideration + * components. Note that this function does not support + * criteria-based or partial filling of orders (though filling the + * remainder of a partially-filled order is supported). Any unspent + * offer item amounts or native tokens will be transferred to the + * caller. + * + * @custom:param orders The orders to match. Note that both the + * offerer and fulfiller on each order must first + * approve this contract (or their conduit if + * indicated by the order) to transfer any + * relevant tokens on their behalf and each + * consideration recipient must implement + * `onERC1155Received` to receive ERC1155 tokens. + * @custom:param fulfillments An array of elements allocating offer + * components to consideration components. Note + * that each consideration component must be + * fully met for the match operation to be valid, + * and that any unspent offer items will be sent + * unaggregated to the caller. + * + * @return executions An array of elements indicating the sequence of + * transfers performed as part of matching the given + * orders. Note that unspent offer item amounts or native + * tokens will not be reflected as part of this array. + */ + function matchOrders( + /** + * @custom:name orders + */ + Order[] calldata orders, + /** + * @custom:name fulfillments + */ + Fulfillment[] calldata fulfillments + ) + public + payable + virtual + override + returns (Execution[] memory /* executions */) + { + for (uint256 i = 0; i < orders.length; i++) { + Order memory order = orders[i]; + if ( + order.parameters.orderType != OrderType.FULL_RESTRICTED && + order.parameters.orderType != OrderType.PARTIAL_RESTRICTED + ) { + revert OrderNotRestricted(); + } + _rejectIfZoneInvalid(order.parameters.zone); + } + + return super.matchOrders(orders, fulfillments); + } + + /** + * @notice Match an arbitrary number of full, partial, or contract orders, + * each with an arbitrary number of items for offer and + * consideration, supplying criteria resolvers containing specific + * token identifiers and associated proofs as well as fulfillments + * allocating offer components to consideration components. Any + * unspent offer item amounts will be transferred to the designated + * recipient (with the null address signifying to use the caller) + * and any unspent native tokens will be returned to the caller. + * + * @custom:param advancedOrders The advanced orders to match. Note that + * both the offerer and fulfiller on each + * order must first approve this contract + * (or their conduit if indicated by the + * order) to transfer any relevant tokens on + * their behalf and each consideration + * recipient must implement + * `onERC1155Received` to receive ERC1155 + * tokens. Also note that the offer and + * consideration components for each order + * must have no remainder after multiplying + * the respective amount with the supplied + * fraction for the group of partial fills + * to be considered valid. + * @custom:param criteriaResolvers An array where each element contains a + * reference to a specific offer or + * consideration, a token identifier, and a + * proof that the supplied token identifier + * is contained in the merkle root held by + * the item in question's criteria element. + * Note that an empty criteria indicates + * that any (transferable) token identifier + * on the token in question is valid and + * that no associated proof needs to be + * supplied. + * @custom:param fulfillments An array of elements allocating offer + * components to consideration components. + * Note that each consideration component + * must be fully met for the match operation + * to be valid, and that any unspent offer + * items will be sent unaggregated to the + * designated recipient. + * @param recipient The intended recipient for all unspent + * offer item amounts, or the caller if the + * null address is supplied. + * + * @return executions An array of elements indicating the sequence of + * transfers performed as part of matching the given + * orders. Note that unspent offer item amounts or + * native tokens will not be reflected as part of this + * array. + */ + function matchAdvancedOrders( + /** + * @custom:name advancedOrders + */ + AdvancedOrder[] calldata advancedOrders, + /** + * @custom:name criteriaResolvers + */ + CriteriaResolver[] calldata criteriaResolvers, + /** + * @custom:name fulfillments + */ + Fulfillment[] calldata fulfillments, + address recipient + ) + public + payable + virtual + override + returns (Execution[] memory /* executions */) + { + for (uint256 i = 0; i < advancedOrders.length; i++) { + AdvancedOrder memory advancedOrder = advancedOrders[i]; + if ( + advancedOrder.parameters.orderType != + OrderType.FULL_RESTRICTED && + advancedOrder.parameters.orderType != + OrderType.PARTIAL_RESTRICTED + ) { + revert OrderNotRestricted(); + } + + _rejectIfZoneInvalid(advancedOrder.parameters.zone); + } + + return + super.matchAdvancedOrders( + advancedOrders, + criteriaResolvers, + fulfillments, + recipient + ); + } +} diff --git a/contracts/trading/seaport/conduit/ConduitController.sol b/contracts/trading/seaport/conduit/ConduitController.sol new file mode 100644 index 00000000..11c02ae3 --- /dev/null +++ b/contracts/trading/seaport/conduit/ConduitController.sol @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.14; + +import { ConduitController } from "seaport-core/src/conduit/ConduitController.sol"; diff --git a/contracts/trading/seaport/interfaces/ImmutableSeaportEvents.sol b/contracts/trading/seaport/interfaces/ImmutableSeaportEvents.sol new file mode 100644 index 00000000..d57fa0c4 --- /dev/null +++ b/contracts/trading/seaport/interfaces/ImmutableSeaportEvents.sol @@ -0,0 +1,14 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache-2 +pragma solidity 0.8.17; + +/** + * @notice ImmutableSeaportEvents contains events + * related to the ImmutableSeaport contract + */ +interface ImmutableSeaportEvents { + /** + * @dev Emit an event when an allowed zone status is updated + */ + event AllowedZoneSet(address zoneAddress, bool allowed); +} diff --git a/contracts/trading/seaport/test/SeaportTestContracts.sol b/contracts/trading/seaport/test/SeaportTestContracts.sol new file mode 100644 index 00000000..1e66da80 --- /dev/null +++ b/contracts/trading/seaport/test/SeaportTestContracts.sol @@ -0,0 +1,10 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache-2 +pragma solidity >=0.8.4; + +/** + * @dev Import test contract helpers from Immutable pinned fork of OpenSea's seaport + * These are not deployed - they are only used for testing + */ +import "seaport/contracts/test/TestERC721.sol"; +import "seaport/contracts/test/TestZone.sol"; diff --git a/contracts/trading/seaport/validators/ReadOnlyOrderValidator.sol b/contracts/trading/seaport/validators/ReadOnlyOrderValidator.sol new file mode 100644 index 00000000..8a823c82 --- /dev/null +++ b/contracts/trading/seaport/validators/ReadOnlyOrderValidator.sol @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import { ReadOnlyOrderValidator } from "seaport/contracts/helpers/order-validator/lib/ReadOnlyOrderValidator.sol"; diff --git a/contracts/trading/seaport/validators/SeaportValidator.sol b/contracts/trading/seaport/validators/SeaportValidator.sol new file mode 100644 index 00000000..b431bcb4 --- /dev/null +++ b/contracts/trading/seaport/validators/SeaportValidator.sol @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import { SeaportValidator } from "seaport/contracts/helpers/order-validator/SeaportValidator.sol"; diff --git a/contracts/trading/seaport/validators/SeaportValidatorHelper.sol b/contracts/trading/seaport/validators/SeaportValidatorHelper.sol new file mode 100644 index 00000000..0334c9cd --- /dev/null +++ b/contracts/trading/seaport/validators/SeaportValidatorHelper.sol @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import { SeaportValidatorHelper } from "seaport/contracts/helpers/order-validator/lib/SeaportValidatorHelper.sol"; diff --git a/contracts/trading/seaport/zones/ImmutableSignedZone.sol b/contracts/trading/seaport/zones/ImmutableSignedZone.sol new file mode 100644 index 00000000..733904b7 --- /dev/null +++ b/contracts/trading/seaport/zones/ImmutableSignedZone.sol @@ -0,0 +1,605 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache-2 +pragma solidity 0.8.17; + +import { + ZoneParameters, + Schema, + ReceivedItem +} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import { ZoneInterface } from "seaport/contracts/interfaces/ZoneInterface.sol"; +import { SIP7Interface } from "./interfaces/SIP7Interface.sol"; +import { SIP7EventsAndErrors } from "./interfaces/SIP7EventsAndErrors.sol"; +import { SIP6EventsAndErrors } from "./interfaces/SIP6EventsAndErrors.sol"; +import { SIP5Interface } from "./interfaces/SIP5Interface.sol"; +import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { ERC165 } from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; + +/** + * @title ImmutableSignedZone + * @author Immutable + * @notice ImmutableSignedZone is a zone implementation based on the + * SIP-7 standard https://github.com/ProjectOpenSea/SIPs/blob/main/SIPS/sip-7.md + * Implementing substandard 3 and 4. + * + * Inspiration and reference from the following contracts: + * https://github.com/ProjectOpenSea/seaport/blob/024dcc5cd70231ce6db27b4e12ea6fb736f69b06/contracts/zones/SignedZone.sol + * - We notably deviate from this contract by implementing substandard 3, and SIP-6. + * https://github.com/reservoirprotocol/seaport-oracle/blob/master/packages/contracts/src/zones/SignedZone.sol + * - We deviate from this contract by going with a no assembly code reference contract approach, and we do not have a substandard + * prefix as part of the context bytes of extraData. + * - We estimate that for a standard validateOrder call with 10 consideration items, this contract consumes 1.9% more gas than the above + * as a tradeoff for having no assembly code. + */ +contract ImmutableSignedZone is + ERC165, + SIP7EventsAndErrors, + SIP6EventsAndErrors, + ZoneInterface, + SIP5Interface, + SIP7Interface, + Ownable2Step +{ + /// @dev The EIP-712 digest parameters. + bytes32 internal immutable _VERSION_HASH = keccak256(bytes("1.0")); + bytes32 internal immutable _EIP_712_DOMAIN_TYPEHASH = + keccak256( + abi.encodePacked( + "EIP712Domain(", + "string name,", + "string version,", + "uint256 chainId,", + "address verifyingContract", + ")" + ) + ); + + bytes32 internal immutable _SIGNED_ORDER_TYPEHASH = + keccak256( + abi.encodePacked( + "SignedOrder(", + "address fulfiller,", + "uint64 expiration,", + "bytes32 orderHash,", + "bytes context", + ")" + ) + ); + + bytes internal constant CONSIDERATION_BYTES = + abi.encodePacked("Consideration(", "ReceivedItem[] consideration", ")"); + + bytes internal constant RECEIVED_ITEM_BYTES = + abi.encodePacked( + "ReceivedItem(", + "uint8 itemType,", + "address token,", + "uint256 identifier,", + "uint256 amount,", + "address recipient", + ")" + ); + + bytes32 internal constant RECEIVED_ITEM_TYPEHASH = + keccak256(RECEIVED_ITEM_BYTES); + + bytes32 internal constant CONSIDERATION_TYPEHASH = + keccak256(abi.encodePacked(CONSIDERATION_BYTES, RECEIVED_ITEM_BYTES)); + + uint256 internal immutable _CHAIN_ID = block.chainid; + bytes32 internal immutable _DOMAIN_SEPARATOR; + uint8 internal immutable _ACCEPTED_SIP6_VERSION = 0; + + /// @dev The name for this zone returned in getSeaportMetadata(). + string private _ZONE_NAME; + + bytes32 internal _NAME_HASH; + + /// @dev The allowed signers. + mapping(address => SignerInfo) private _signers; + + /// @dev The API endpoint where orders for this zone can be signed. + /// Request and response payloads are defined in SIP-7. + string private _sip7APIEndpoint; + + /// @dev The documentationURI; + string private _documentationURI; + + /** + * @notice Constructor to deploy the contract. + * + * @param zoneName The name for the zone returned in + * getSeaportMetadata(). + * @param apiEndpoint The API endpoint where orders for this zone can be + * signed. + * Request and response payloads are defined in SIP-7. + */ + constructor( + string memory zoneName, + string memory apiEndpoint, + string memory documentationURI + ) { + // Set the zone name. + _ZONE_NAME = zoneName; + // set name hash + _NAME_HASH = keccak256(bytes(zoneName)); + + // Set the API endpoint. + _sip7APIEndpoint = apiEndpoint; + _documentationURI = documentationURI; + + // Derive and set the domain separator. + _DOMAIN_SEPARATOR = _deriveDomainSeparator(); + + // Emit an event to signal a SIP-5 contract has been deployed. + emit SeaportCompatibleContractDeployed(); + } + + /** + * @notice Add a new signer to the zone. + * + * @param signer The new signer address to add. + */ + function addSigner(address signer) external override onlyOwner { + // Do not allow the zero address to be added as a signer. + if (signer == address(0)) { + revert SignerCannotBeZeroAddress(); + } + + // Revert if the signer is already added. + if (_signers[signer].active) { + revert SignerAlreadyActive(signer); + } + + // Revert if the signer was previously authorized. + // Specified in SIP-7 to prevent compromised signer from being + // Cycled back into use. + if (_signers[signer].previouslyActive) { + revert SignerCannotBeReauthorized(signer); + } + + // Set the signer info. + _signers[signer] = SignerInfo(true, true); + + // Emit an event that the signer was added. + emit SignerAdded(signer); + } + + /** + * @notice Remove an active signer from the zone. + * + * @param signer The signer address to remove. + */ + function removeSigner(address signer) external override onlyOwner { + // Revert if the signer is not active. + if (!_signers[signer].active) { + revert SignerNotActive(signer); + } + + // Set the signer's active status to false. + _signers[signer].active = false; + + // Emit an event that the signer was removed. + emit SignerRemoved(signer); + } + + /** + * @notice Check if a given order including extraData is currently valid. + * + * @dev This function is called by Seaport whenever any extraData is + * provided by the caller. + * + * @return validOrderMagicValue A magic value indicating if the order is + * currently valid. + */ + function validateOrder( + ZoneParameters calldata zoneParameters + ) external view override returns (bytes4 validOrderMagicValue) { + // Put the extraData and orderHash on the stack for cheaper access. + bytes calldata extraData = zoneParameters.extraData; + bytes32 orderHash = zoneParameters.orderHash; + + // Revert with an error if the extraData is empty. + if (extraData.length == 0) { + revert InvalidExtraData("extraData is empty", orderHash); + } + + // We expect the extraData to conform with SIP-6 as well as SIP-7 + // Therefore all SIP-7 related data is offset by one byte + // SIP-7 specifically requires SIP-6 as a prerequisite. + + // Revert with an error if the extraData does not have valid length. + if (extraData.length < 93) { + revert InvalidExtraData( + "extraData length must be at least 93 bytes", + orderHash + ); + } + + // Revert if SIP6 version is not accepted (0) + if (uint8(extraData[0]) != _ACCEPTED_SIP6_VERSION) { + revert UnsupportedExtraDataVersion(uint8(extraData[0])); + } + + // extraData bytes 1-21: expected fulfiller + // (zero address means not restricted) + address expectedFulfiller = address(bytes20(extraData[1:21])); + + // extraData bytes 21-29: expiration timestamp (uint64) + uint64 expiration = uint64(bytes8(extraData[21:29])); + + // extraData bytes 29-93: signature + // (strictly requires 64 byte compact sig, ERC2098) + bytes calldata signature = extraData[29:93]; + + // extraData bytes 93-end: context (optional, variable length) + bytes calldata context = extraData[93:]; + + // Revert if expired. + if (block.timestamp > expiration) { + revert SignatureExpired(block.timestamp, expiration, orderHash); + } + + // Put fulfiller on the stack for more efficient access. + address actualFulfiller = zoneParameters.fulfiller; + + // Revert unless + // Expected fulfiller is 0 address (any fulfiller) or + // Expected fulfiller is the same as actual fulfiller + if ( + expectedFulfiller != address(0) && + expectedFulfiller != actualFulfiller + ) { + revert InvalidFulfiller( + expectedFulfiller, + actualFulfiller, + orderHash + ); + } + + // validate supported substandards (3,4) + _validateSubstandards( + context, + _deriveConsiderationHash(zoneParameters.consideration), + zoneParameters + ); + + // Derive the signedOrder hash + bytes32 signedOrderHash = _deriveSignedOrderHash( + expectedFulfiller, + expiration, + orderHash, + context + ); + + // Derive the EIP-712 digest using the domain separator and signedOrder + // hash through openzepplin helper + bytes32 digest = ECDSA.toTypedDataHash( + _domainSeparator(), + signedOrderHash + ); + + // Recover the signer address from the digest and signature. + // Pass in R and VS from compact signature (ERC2098) + address recoveredSigner = ECDSA.recover( + digest, + bytes32(signature[0:32]), + bytes32(signature[32:64]) + ); + + // Revert if the signer is not active + // !This also reverts if the digest constructed on serverside is incorrect + if (!_signers[recoveredSigner].active) { + revert SignerNotActive(recoveredSigner); + } + + // All validation completes and passes with no reverts, return valid + validOrderMagicValue = ZoneInterface.validateOrder.selector; + } + + /** + * @dev Internal view function to get the EIP-712 domain separator. If the + * chainId matches the chainId set on deployment, the cached domain + * separator will be returned; otherwise, it will be derived from + * scratch. + * + * @return The domain separator. + */ + function _domainSeparator() internal view returns (bytes32) { + return + block.chainid == _CHAIN_ID + ? _DOMAIN_SEPARATOR + : _deriveDomainSeparator(); + } + + /** + * @dev Returns Seaport metadata for this contract, returning the + * contract name and supported schemas. + * + * @return name The contract name + * @return schemas The supported SIPs + */ + function getSeaportMetadata() + external + view + override(SIP5Interface, ZoneInterface) + returns (string memory name, Schema[] memory schemas) + { + name = _ZONE_NAME; + + // supported SIP (7) + schemas = new Schema[](1); + schemas[0].id = 7; + + schemas[0].metadata = abi.encode( + keccak256( + abi.encode( + _domainSeparator(), + _sip7APIEndpoint, + _getSupportedSubstandards(), + _documentationURI + ) + ) + ); + } + + /** + * @dev Internal view function to derive the EIP-712 domain separator. + * + * @return domainSeparator The derived domain separator. + */ + function _deriveDomainSeparator() + internal + view + returns (bytes32 domainSeparator) + { + return + keccak256( + abi.encode( + _EIP_712_DOMAIN_TYPEHASH, + _NAME_HASH, + _VERSION_HASH, + block.chainid, + address(this) + ) + ); + } + + /** + * @notice Update the API endpoint returned by this zone. + * + * @param newApiEndpoint The new API endpoint. + */ + function updateAPIEndpoint( + string calldata newApiEndpoint + ) external override onlyOwner { + // Update to the new API endpoint. + _sip7APIEndpoint = newApiEndpoint; + } + + /** + * @notice Returns signing information about the zone. + * + * @return domainSeparator The domain separator used for signing. + */ + function sip7Information() + external + view + override + returns ( + bytes32 domainSeparator, + string memory apiEndpoint, + uint256[] memory substandards, + string memory documentationURI + ) + { + domainSeparator = _domainSeparator(); + apiEndpoint = _sip7APIEndpoint; + + substandards = _getSupportedSubstandards(); + + documentationURI = _documentationURI; + } + + /** + * @dev validate substandards 3 and 4 based on context + * + * @param context bytes payload of context + */ + function _validateSubstandards( + bytes calldata context, + bytes32 actualConsiderationHash, + ZoneParameters calldata zoneParameters + ) internal pure { + // substandard 3 - validate consideration hash actual match expected + + // first 32bytes of context must be exactly a keccak256 hash of consideration item array + if (context.length < 32) { + revert InvalidExtraData( + "invalid context, expecting consideration hash followed by order hashes", + zoneParameters.orderHash + ); + } + + // revert if order hash in context and payload do not match + bytes32 expectedConsiderationHash = bytes32(context[0:32]); + if (expectedConsiderationHash != actualConsiderationHash) { + revert SubstandardViolation( + 3, + "invalid consideration hash", + zoneParameters.orderHash + ); + } + + // substandard 4 - validate order hashes actual match expected + + // byte 33 to end are orderHashes array for substandard 4 + bytes calldata orderHashesBytes = context[32:]; + // context must be a multiple of 32 bytes + if (orderHashesBytes.length % 32 != 0) { + revert InvalidExtraData( + "invalid context, order hashes bytes not an array of bytes32 hashes", + zoneParameters.orderHash + ); + } + + // compute expected order hashes array based on context bytes + bytes32[] memory expectedOrderHashes = new bytes32[]( + orderHashesBytes.length / 32 + ); + for (uint256 i = 0; i < orderHashesBytes.length / 32; i++) { + expectedOrderHashes[i] = bytes32( + orderHashesBytes[i * 32:i * 32 + 32] + ); + } + + // revert if order hashes in context and payload do not match + // every expected order hash need to exist in fulfilling order hashes + if ( + !_everyElementExists( + expectedOrderHashes, + zoneParameters.orderHashes + ) + ) { + revert SubstandardViolation( + 4, + "invalid order hashes", + zoneParameters.orderHash + ); + } + } + + /** + * @dev get the supported substandards of the contract + * + * @return substandards array of substandards supported + * + */ + function _getSupportedSubstandards() + internal + pure + returns (uint256[] memory substandards) + { + // support substandards 3 and 4 + substandards = new uint256[](2); + substandards[0] = 3; + substandards[1] = 4; + } + + /** + * @dev Derive the signedOrder hash from the orderHash and expiration. + * + * @param fulfiller The expected fulfiller address. + * @param expiration The signature expiration timestamp. + * @param orderHash The order hash. + * @param context The optional variable-length context. + * + * @return signedOrderHash The signedOrder hash. + * + */ + function _deriveSignedOrderHash( + address fulfiller, + uint64 expiration, + bytes32 orderHash, + bytes calldata context + ) internal view returns (bytes32 signedOrderHash) { + // Derive the signed order hash. + signedOrderHash = keccak256( + abi.encode( + _SIGNED_ORDER_TYPEHASH, + fulfiller, + expiration, + orderHash, + keccak256(context) + ) + ); + } + + /** + * @dev Derive the EIP712 consideration hash based on received item array + * @param consideration expected consideration array + */ + function _deriveConsiderationHash( + ReceivedItem[] calldata consideration + ) internal pure returns (bytes32) { + uint256 numberOfItems = consideration.length; + bytes32[] memory considerationHashes = new bytes32[](numberOfItems); + for (uint256 i; i < numberOfItems; i++) { + considerationHashes[i] = keccak256( + abi.encode( + RECEIVED_ITEM_TYPEHASH, + consideration[i].itemType, + consideration[i].token, + consideration[i].identifier, + consideration[i].amount, + consideration[i].recipient + ) + ); + } + return + keccak256( + abi.encode( + CONSIDERATION_TYPEHASH, + keccak256(abi.encodePacked(considerationHashes)) + ) + ); + } + + /** + * @dev helper function to check if every element of array1 exists in array2 + * optimised for performance checking arrays sized 0-15 + * + * @param array1 subset array + * @param array2 superset array + */ + function _everyElementExists( + bytes32[] memory array1, + bytes32[] calldata array2 + ) internal pure returns (bool) { + // cache the length in memory for loop optimisation + uint256 array1Size = array1.length; + uint256 array2Size = array2.length; + + // we can assume all items (order hashes) are unique + // therefore if subset is bigger than superset, revert + if (array1Size > array2Size) { + return false; + } + + // Iterate through each element and compare them + for (uint256 i = 0; i < array1Size; ) { + bool found = false; + bytes32 item = array1[i]; + for (uint256 j = 0; j < array2Size; ) { + if (item == array2[j]) { + // if item from array1 is in array2, break + found = true; + break; + } + unchecked { + j++; + } + } + if (!found) { + // if any item from array1 is not found in array2, return false + return false; + } + unchecked { + i++; + } + } + + // All elements from array1 exist in array2 + return true; + } + + function supportsInterface( + bytes4 interfaceId + ) public view override(ERC165, ZoneInterface) returns (bool) { + return + interfaceId == type(ZoneInterface).interfaceId || + super.supportsInterface(interfaceId); + } +} diff --git a/contracts/trading/seaport/zones/README.md b/contracts/trading/seaport/zones/README.md new file mode 100644 index 00000000..990b6728 --- /dev/null +++ b/contracts/trading/seaport/zones/README.md @@ -0,0 +1,49 @@ +# Test plan for ImmutableSignedZone + +ImmutableSignedZone is a implementation of the SIP-7 specification with substandard 3. + +## E2E tests with signing server + +E2E tests will be handled in the server repository seperate to the contract. + +## Validate order unit tests + +The core function of the contract is `validateOrder` where signature verification and a variety of validations of the `extraData` payload is verified by the zone to determine whether an order is considered valid for fulfillment. This function will be called by the settlement contract upon order fulfillment. + +| Test name | Description | Happy Case | Implemented | +| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------- | ----------- | +| validateOrder reverts without extraData | base failure case | No | Yes | +| validateOrder reverts with invalid extraData | base failure case | No | Yes | +| validateOrder reverts with expired timestamp | asserts the expiration verification behaviour | No | Yes | +| validateOrder reverts with invalid fulfiller | asserts the fulfiller verification behaviour | No | Yes | +| validateOrder reverts with non 0 SIP6 version | asserts the SIP6 version verification behaviour | No | Yes | +| validateOrder reverts with wrong consideration | asserts the consideration verification behaviour | No | Yes | +| validates correct signature with context | Happy path of a valid order | Yes | Yes | +| validateOrder validates correct context with multiple order hashes - equal arrays | Happy path with bulk order hashes - expected == actual | Yes | Yes | +| validateOrder validates correct context with multiple order hashes - partial arrays | Happy path with bulk order hashes - expected is a subset of actual | Yes | Yes | +| validateOrder reverts when not all expected order hashes are in zone parameters | Error case with bulk order hashes - actual is a subset of expected | No | Yes | +| validateOrder reverts incorrectly signed signature with context | asserts active signer behaviour | No | Yes | +| validateOrder reverts a valid order after expiration time passes | asserts active signer behaviour | No | Yes | + +## Ownership unit tests + +Test the ownership behaviour of the contract + +| Test name | Description | Happy Case | Implemented | +| ------------------------------- | --------------------------- | ---------- | ----------- | +| deployer becomes owner | base case | Yes | Yes | +| transferOwnership works | base case | Yes | Yes | +| non owner cannot add signers | asserts ownership behaviour | No | Yes | +| non owner cannot remove signers | asserts ownership behaviour | No | Yes | +| non owner cannot update owner | asserts ownership behaviour | No | Yes | + +## Active signer unit tests + +Test the signer management behaviour of the contract + +| Test name | Description | Happy Case | Implemented | +| ---------------------------------- | --------------------------------------------------- | ---------- | ----------- | +| owner can add active signer | base case | Yes | Yes | +| owner can deactivate signer | base case | Yes | Yes | +| deactivate non active signer fails | asserts signers can only be deactivated when active | No | Yes | +| activate deactivated signer fails | asserts signer cannot be recycled behaviour | No | Yes | diff --git a/contracts/trading/seaport/zones/interfaces/SIP5Interface.sol b/contracts/trading/seaport/zones/interfaces/SIP5Interface.sol new file mode 100644 index 00000000..9da10294 --- /dev/null +++ b/contracts/trading/seaport/zones/interfaces/SIP5Interface.sol @@ -0,0 +1,28 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache-2 +pragma solidity 0.8.17; + +import { Schema } from "seaport-types/src/lib/ConsiderationStructs.sol"; + +/** + * @dev SIP-5: Contract Metadata Interface for Seaport Contracts + * https://github.com/ProjectOpenSea/SIPs/blob/main/SIPS/sip-5.md + */ +interface SIP5Interface { + /** + * @dev An event that is emitted when a SIP-5 compatible contract is deployed. + */ + event SeaportCompatibleContractDeployed(); + + /** + * @dev Returns Seaport metadata for this contract, returning the + * contract name and supported schemas. + * + * @return name The contract name + * @return schemas The supported SIPs + */ + function getSeaportMetadata() + external + view + returns (string memory name, Schema[] memory schemas); +} diff --git a/contracts/trading/seaport/zones/interfaces/SIP6EventsAndErrors.sol b/contracts/trading/seaport/zones/interfaces/SIP6EventsAndErrors.sol new file mode 100644 index 00000000..338b3b27 --- /dev/null +++ b/contracts/trading/seaport/zones/interfaces/SIP6EventsAndErrors.sol @@ -0,0 +1,14 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache-2 +pragma solidity 0.8.17; + +/** + * @notice SIP6EventsAndErrors contains errors and events + * related to zone interaction as specified in the SIP6. + */ +interface SIP6EventsAndErrors { + /** + * @dev Revert with an error if SIP6 version is not supported + */ + error UnsupportedExtraDataVersion(uint8 version); +} diff --git a/contracts/trading/seaport/zones/interfaces/SIP7EventsAndErrors.sol b/contracts/trading/seaport/zones/interfaces/SIP7EventsAndErrors.sol new file mode 100644 index 00000000..75555db0 --- /dev/null +++ b/contracts/trading/seaport/zones/interfaces/SIP7EventsAndErrors.sol @@ -0,0 +1,75 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache-2 +pragma solidity 0.8.17; + +/** + * @notice SIP7EventsAndErrors contains errors and events + * related to zone interaction as specified in the SIP7. + */ +interface SIP7EventsAndErrors { + /** + * @dev Emit an event when a new signer is added. + */ + event SignerAdded(address signer); + + /** + * @dev Emit an event when a signer is removed. + */ + event SignerRemoved(address signer); + + /** + * @dev Revert with an error if trying to add a signer that is + * already active. + */ + error SignerAlreadyActive(address signer); + + /** + * @dev Revert with an error if trying to remove a signer that is + * not active + */ + error SignerNotActive(address signer); + + /** + * @dev Revert with an error if a new signer is the zero address. + */ + error SignerCannotBeZeroAddress(); + + /** + * @dev Revert with an error if a removed signer is trying to be + * reauthorized. + */ + error SignerCannotBeReauthorized(address signer); + + /** + * @dev Revert with an error when the signature has expired. + */ + error SignatureExpired( + uint256 currentTimestamp, + uint256 expiration, + bytes32 orderHash + ); + + /** + * @dev Revert with an error if the fulfiller does not match. + */ + error InvalidFulfiller( + address expectedFulfiller, + address actualFulfiller, + bytes32 orderHash + ); + + /** + * @dev Revert with an error if a substandard validation fails + */ + error SubstandardViolation( + uint256 substandardId, + string reason, + bytes32 orderHash + ); + + /** + * @dev Revert with an error if supplied order extraData is invalid + * or improperly formatted. + */ + error InvalidExtraData(string reason, bytes32 orderHash); +} diff --git a/contracts/trading/seaport/zones/interfaces/SIP7Interface.sol b/contracts/trading/seaport/zones/interfaces/SIP7Interface.sol new file mode 100644 index 00000000..47a16551 --- /dev/null +++ b/contracts/trading/seaport/zones/interfaces/SIP7Interface.sol @@ -0,0 +1,59 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache-2 +pragma solidity 0.8.17; + +/** + * @title SIP7Interface + * @author ryanio, Immutable + * @notice ImmutableSignedZone is an implementation of SIP-7 that requires orders + * to be signed by an approved signer. + * https://github.com/ProjectOpenSea/SIPs/blob/main/SIPS/sip-7.md + * + */ +interface SIP7Interface { + /** + * @dev The struct for storing signer info. + */ + struct SignerInfo { + bool active; /// If the signer is currently active. + bool previouslyActive; /// If the signer has been active before. + } + + /** + * @notice Add a new signer to the zone. + * + * @param signer The new signer address to add. + */ + function addSigner(address signer) external; + + /** + * @notice Remove an active signer from the zone. + * + * @param signer The signer address to remove. + */ + function removeSigner(address signer) external; + + /** + * @notice Update the API endpoint returned by this zone. + * + * @param newApiEndpoint The new API endpoint. + */ + function updateAPIEndpoint(string calldata newApiEndpoint) external; + + /** + * @notice Returns signing information about the zone. + * + * @return domainSeparator The domain separator used for signing. + * @return apiEndpoint The API endpoint to get signatures for orders + * using this zone. + */ + function sip7Information() + external + view + returns ( + bytes32 domainSeparator, + string memory apiEndpoint, + uint256[] memory substandards, + string memory documentationURI + ); +} diff --git a/foundry.toml b/foundry.toml index 9b60a8ec..cce0b1b8 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,5 +2,6 @@ src = 'contracts' out = 'foundry-out' libs = ["lib", "node_modules"] +remappings = [ "node_modules/seaport:@rari-capital/solmate/=node_modules/@rari-capital/solmate/" ] # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/hardhat.config.ts b/hardhat.config.ts index 25a399a8..e513296e 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -25,11 +25,62 @@ task("accounts", "Prints the list of accounts", async (taskArgs, hre) => { // Go to https://hardhat.org/config/ to learn more const config: HardhatUserConfig = { solidity: { - version: "0.8.19", - settings: { - optimizer: { - enabled: true, - runs: 200, + compilers: [ + { + version: "0.8.19", + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, + }, + { + version: "0.8.17", + settings: { + viaIR: true, + optimizer: { enabled: true, runs: 4_294_967_295 }, + metadata: { + bytecodeHash: "none", + }, + outputSelection: { + "*": { + "*": ["evm.assembly", "irOptimized", "devdoc"], + }, + }, + }, + }, + ], + overrides: { + "contracts/trading/seaport/ImmutableSeaport.sol": { + version: "0.8.17", + settings: { + viaIR: true, + optimizer: { + enabled: true, + runs: 10, + }, + }, + }, + "contracts/trading/seaport/conduit/Conduit.sol": { + version: "0.8.14", + settings: { + viaIR: true, + optimizer: { + enabled: true, + runs: 1000000, + }, + }, + }, + "contracts/trading/seaport/conduit/ConduitController.sol": { + version: "0.8.14", + settings: { + viaIR: true, + optimizer: { + enabled: true, + runs: 1000000, + }, + }, }, }, }, diff --git a/package.json b/package.json index d8f3385a..1e47f3a8 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dist" ], "scripts": { + "postinstall": "rm -f node_modules/seaport/foundry.toml", "compile": "hardhat compile", "build": "tsc", "test": "hardhat test", @@ -62,7 +63,9 @@ "typescript": "^4.9.5" }, "dependencies": { + "@rari-capital/solmate": "^6.4.0", "@openzeppelin/contracts": "^4.9.3", + "seaport": "https://github.com/immutable/seaport.git#1.5.0+im.1.3", "solidity-bits": "^0.4.0", "solidity-bytes-utils": "^0.8.0" } diff --git a/test/seaport/immutableseaport.test.ts b/test/seaport/immutableseaport.test.ts new file mode 100644 index 00000000..f866c51f --- /dev/null +++ b/test/seaport/immutableseaport.test.ts @@ -0,0 +1,438 @@ +/* eslint-disable no-unused-expressions */ +import { ethers, network } from "hardhat"; +import { randomBytes } from "crypto"; + +import type { ImmutableSeaport, ImmutableSignedZone, TestERC721 } from "../../typechain-types"; +import { constants } from "ethers"; +import type { Wallet, BigNumber, BigNumberish } from "ethers"; +import { deployImmutableContracts } from "./utils/deploy-immutable-contracts"; +import { faucet } from "./utils/faucet"; +import { buildResolver, getItemETH, toBN, toKey } from "./utils/encoding"; +import { deployERC721, getTestItem721, getTestItem721WithCriteria, mintAndApprove721 } from "./utils/erc721"; +import { createOrder, generateSip7Signature } from "./utils/order"; +import { expect } from "chai"; +import { merkleTree } from "./utils/criteria"; + +const { parseEther } = ethers.utils; + +describe(`ImmutableSeaport and ImmutableZone (Seaport v1.5)`, function () { + const { provider } = ethers; + const owner = new ethers.Wallet(randomBytes(32), provider); + const immutableSigner = new ethers.Wallet(randomBytes(32), provider); + + let immutableSignedZone: ImmutableSignedZone; + let immutableSeaport: ImmutableSeaport; + let conduitKey: string; + let conduitAddress: string; + + function getEthBalance(userAddress: string): Promise { + return provider.getBalance(userAddress); + } + + async function userIsOwnerOfNft(erc721: TestERC721, tokenId: BigNumberish, userAddress: string): Promise { + const ownerOf = await erc721.ownerOf(tokenId); + return ownerOf === userAddress; + } + + after(async () => { + await network.provider.request({ + method: "hardhat_reset", + }); + }); + + before(async () => { + await faucet(owner.address, provider); + const immutableContracts = await deployImmutableContracts(immutableSigner.address); + immutableSeaport = immutableContracts.immutableSeaport; + immutableSignedZone = immutableContracts.immutableSignedZone; + conduitKey = immutableContracts.conduitKey; + conduitAddress = immutableContracts.conduitAddress; + }); + + let buyer: Wallet; + let seller: Wallet; + + beforeEach(async () => { + buyer = new ethers.Wallet(randomBytes(32), provider); + seller = new ethers.Wallet(randomBytes(32), provider); + await faucet(buyer.address, provider); + await faucet(seller.address, provider); + }); + + describe("Events", () => { + it("Emits AllowedZoneSet event", async () => { + const zone = new ethers.Wallet(randomBytes(32)).address; + const allowed = true; + expect( + await immutableSeaport + .connect((await ethers.getSigners())[0]) // use default deployer (admin) + .setAllowedZone(zone, allowed) + ) + .to.emit(immutableSeaport, "AllowedZoneSet") + .withArgs(zone, allowed); + }); + }); + + describe("Order fulfillment", () => { + it("ImmutableSeaport can fulfill an Immutable-signed FULL_RESTRICTED advanced order", async () => { + const erc721 = await deployERC721(); + const nftId = await mintAndApprove721(erc721, seller, conduitAddress); + const offer = await getTestItem721(erc721.address, nftId); + const consideration = [getItemETH(parseEther("10"), parseEther("10"), seller.address)]; + const { order, orderHash, value } = await createOrder( + immutableSeaport, + seller, + immutableSignedZone, + [offer], + consideration, + 2, // FULL_RESTRICTED + undefined, + undefined, + undefined, + conduitKey + ); + + const extraData = await generateSip7Signature( + consideration, + orderHash, + buyer.address, + immutableSignedZone.address, + immutableSigner + ); + + // sign the orderHash with immutableSigner + order.extraData = extraData; + + const sellerBalanceBefore = await getEthBalance(seller.address); + const buyerBalanceBefore = await getEthBalance(seller.address); + + const tx = await immutableSeaport.connect(buyer).fulfillAdvancedOrder(order, [], conduitKey, buyer.address, { + value, + }); + + await tx.wait(); + + expect(await userIsOwnerOfNft(erc721, nftId, buyer.address)).to.be.true; + expect(await userIsOwnerOfNft(erc721, nftId, seller.address)).to.be.false; + expect(await getEthBalance(seller.address)).to.equal(sellerBalanceBefore.add(parseEther("10"))); + + const currentBalance = await getEthBalance(buyer.address); + const expectedBalance = buyerBalanceBefore.sub(parseEther("10")); + + // Balance is less than 10 because of gas fees + // Chai doesn't seem to like ethers.BigNumber comparisons + expect(currentBalance.lt(expectedBalance)).to.be.true; + }); + + it("ImmutableSeaport can fulfill an Immutable-signed PARTIAL_RESTRICTED advanced order", async () => { + const erc721 = await deployERC721(); + const nftId = await mintAndApprove721(erc721, seller, immutableSeaport.address); + const offer = await getTestItem721(erc721.address, nftId); + const consideration = [getItemETH(parseEther("10"), parseEther("10"), seller.address)]; + const { order, orderHash, value } = await createOrder( + immutableSeaport, + seller, + immutableSignedZone, + [offer], + consideration, + 3 // PARTIAL_RESTRICTED + ); + + const extraData = await generateSip7Signature( + consideration, + orderHash, + buyer.address, + immutableSignedZone.address, + immutableSigner + ); + + // sign the orderHash with immutableSigner + order.extraData = extraData; + + const sellerBalanceBefore = await getEthBalance(seller.address); + const buyerBalanceBefore = await getEthBalance(seller.address); + + const tx = await immutableSeaport.connect(buyer).fulfillAdvancedOrder(order, [], toKey(0), buyer.address, { + value, + }); + + await tx.wait(); + + expect(await userIsOwnerOfNft(erc721, nftId, buyer.address)).to.be.true; + expect(await userIsOwnerOfNft(erc721, nftId, seller.address)).to.be.false; + expect(await getEthBalance(seller.address)).to.equal(sellerBalanceBefore.add(parseEther("10"))); + // Balance is less than 10 because of gas fees + expect((await getEthBalance(buyer.address)).lt(buyerBalanceBefore.sub(parseEther("10")))).to.be.true; + }); + + it("ImmutableSeaport rejects unsupported zones", async () => { + const erc721 = await deployERC721(); + const nftId = await mintAndApprove721(erc721, seller, immutableSeaport.address); + const offer = await getTestItem721(erc721.address, nftId); + const consideration = [getItemETH(parseEther("10"), parseEther("10"), seller.address)]; + const { order, orderHash, value } = await createOrder( + immutableSeaport, + seller, + // Random address for zone + new ethers.Wallet(randomBytes(32)).address, + [offer], + consideration, + 2 // FULL_RESTRICTED + ); + + const extraData = await generateSip7Signature( + consideration, + orderHash, + buyer.address, + immutableSignedZone.address, + immutableSigner + ); + + // sign the orderHash with immutableSigner + order.extraData = extraData; + + await expect( + immutableSeaport + .connect(buyer) + .fulfillAdvancedOrder(order, [], toKey(0), ethers.constants.AddressZero, { + value, + }) + .then((tx) => tx.wait()) + ).to.be.revertedWith("InvalidZone"); + }); + + it("ImmutableSeaport rejects an Immutable-signed FULL_OPEN advanced order", async () => { + const erc721 = await deployERC721(); + const nftId = await mintAndApprove721(erc721, seller, immutableSeaport.address); + const offer = await getTestItem721(erc721.address, nftId); + const consideration = [getItemETH(parseEther("10"), parseEther("10"), seller.address)]; + const { order, orderHash, value } = await createOrder( + immutableSeaport, + seller, + immutableSignedZone, + [offer], + consideration, + 0 // FULL_OPEN + ); + + const extraData = await generateSip7Signature( + consideration, + orderHash, + buyer.address, + immutableSignedZone.address, + immutableSigner + ); + + // sign the orderHash with immutableSigner + order.extraData = extraData; + + await expect( + immutableSeaport + .connect(buyer) + .fulfillAdvancedOrder(order, [], toKey(0), ethers.constants.AddressZero, { + value, + }) + .then((tx) => tx.wait()) + ).to.be.revertedWith("OrderNotRestricted"); + }); + + it("ImmutableSeaport can fulfill an Immutable-signed FULL_RESTRICTED advanced order with criteria", async () => { + const erc721 = await deployERC721(); + const nftId = await mintAndApprove721(erc721, seller, immutableSeaport.address); + + const { root, proofs } = merkleTree([nftId]); + + const offer = [getTestItem721WithCriteria(erc721.address, root, toBN(1), toBN(1))]; + const consideration = [getItemETH(parseEther("10"), parseEther("10"), seller.address)]; + const criteriaResolvers = [buildResolver(0, 0, 0, nftId, proofs[nftId.toString()])]; + const { order, orderHash, value } = await createOrder( + immutableSeaport, + seller, + immutableSignedZone, + offer, + consideration, + 2 // FULL_RESTRICTED + ); + + const extraData = await generateSip7Signature( + consideration, + orderHash, + buyer.address, + immutableSignedZone.address, + immutableSigner + ); + + // sign the orderHash with immutableSigner + order.extraData = extraData; + + const sellerBalanceBefore = await getEthBalance(seller.address); + const buyerBalanceBefore = await getEthBalance(seller.address); + + const tx = await immutableSeaport + .connect(buyer) + .fulfillAdvancedOrder(order, criteriaResolvers, toKey(0), buyer.address, { + value, + }); + + await tx.wait(); + + expect(await userIsOwnerOfNft(erc721, nftId, buyer.address)).to.be.true; + expect(await userIsOwnerOfNft(erc721, nftId, seller.address)).to.be.false; + expect(await getEthBalance(seller.address)).to.equal(sellerBalanceBefore.add(parseEther("10"))); + // Balance is less than 10 because of gas fees + expect((await getEthBalance(buyer.address)).lt(buyerBalanceBefore.sub(parseEther("10")))).to.be.true; + }); + + it("ImmutableSeaport can fulfill an Immutable-signed PARTIAL_RESTRICTED advanced order", async () => { + const erc721 = await deployERC721(); + const nftId = await mintAndApprove721(erc721, seller, immutableSeaport.address); + const offer = await getTestItem721(erc721.address, nftId); + const consideration = [getItemETH(parseEther("10"), parseEther("10"), seller.address)]; + const { order, orderHash, value } = await createOrder( + immutableSeaport, + seller, + immutableSignedZone, + [offer], + consideration, + 3 // PARTIAL_RESTRICTED + ); + + const extraData = await generateSip7Signature( + consideration, + orderHash, + buyer.address, + immutableSignedZone.address, + immutableSigner + ); + + // sign the orderHash with immutableSigner + order.extraData = extraData; + + const sellerBalanceBefore = await getEthBalance(seller.address); + const buyerBalanceBefore = await getEthBalance(seller.address); + + const tx = await immutableSeaport.connect(buyer).fulfillAdvancedOrder(order, [], toKey(0), buyer.address, { + value, + }); + + await tx.wait(); + + expect(await userIsOwnerOfNft(erc721, nftId, buyer.address)).to.be.true; + expect(await userIsOwnerOfNft(erc721, nftId, seller.address)).to.be.false; + expect(await getEthBalance(seller.address)).to.equal(sellerBalanceBefore.add(parseEther("10"))); + // Balance is less than 10 because of gas fees + expect((await getEthBalance(buyer.address)).lt(buyerBalanceBefore.sub(parseEther("10")))).to.be.true; + }); + + it("Orders submitted against a zone that has been disabled are rejected", async () => { + const contracts = await deployImmutableContracts(immutableSigner.address); + const erc721 = await deployERC721(); + const nftId = await mintAndApprove721(erc721, seller, contracts.immutableSeaport.address); + const offer = await getTestItem721(erc721.address, nftId); + const consideration = [getItemETH(parseEther("10"), parseEther("10"), seller.address)]; + + // Disable the zone + await contracts.immutableSeaport.setAllowedZone(contracts.immutableSignedZone.address, false); + + const { order, orderHash, value } = await createOrder( + contracts.immutableSeaport, + seller, + contracts.immutableSignedZone, + [offer], + consideration, + 3 // PARTIAL_RESTRICTED + ); + + const extraData = await generateSip7Signature( + consideration, + orderHash, + buyer.address, + immutableSignedZone.address, + immutableSigner + ); + + // sign the orderHash with immutableSigner + order.extraData = extraData; + + await expect( + immutableSeaport + .connect(buyer) + .fulfillAdvancedOrder(order, [], toKey(0), ethers.constants.AddressZero, { + value, + }) + .then((tx) => tx.wait()) + ).to.be.revertedWith("InvalidZone"); + }); + + it("Orders with extraData signed by the wrong signer are rejected", async () => { + const erc721 = await deployERC721(); + const nftId = await mintAndApprove721(erc721, seller, immutableSeaport.address); + const offer = await getTestItem721(erc721.address, nftId); + const consideration = [getItemETH(parseEther("10"), parseEther("10"), seller.address)]; + const { order, orderHash, value } = await createOrder( + immutableSeaport, + seller, + immutableSignedZone, + [offer], + consideration, + 3 // PARTIAL_RESTRICTED + ); + + const extraData = await generateSip7Signature( + consideration, + orderHash, + buyer.address, + immutableSignedZone.address, + // Random signer + new ethers.Wallet(randomBytes(32), provider) + ); + + order.extraData = extraData; + + await expect( + immutableSeaport + .connect(buyer) + .fulfillAdvancedOrder(order, [], toKey(0), ethers.constants.AddressZero, { + value, + }) + .then((tx) => tx.wait()) + ).to.be.revertedWith("SignerNotActive"); + }); + + it("Orders with invalid extraData are rejected", async () => { + const erc721 = await deployERC721(); + const nftId = await mintAndApprove721(erc721, seller, immutableSeaport.address); + const offer = await getTestItem721(erc721.address, nftId); + const consideration = [getItemETH(parseEther("10"), parseEther("10"), seller.address)]; + const { order, value } = await createOrder( + immutableSeaport, + seller, + immutableSignedZone, + [offer], + consideration, + 3 // PARTIAL_RESTRICTED + ); + + const extraData = await generateSip7Signature( + consideration, + // Bad order hash + constants.HashZero, + buyer.address, + immutableSignedZone.address, + immutableSigner + ); + + // sign the orderHash with immutableSigner + order.extraData = extraData; + + await expect( + immutableSeaport + .connect(buyer) + .fulfillAdvancedOrder(order, [], toKey(0), ethers.constants.AddressZero, { + value, + }) + .then((tx) => tx.wait()) + ).to.be.revertedWith("SubstandardViolation"); + }); + }); +}); diff --git a/test/seaport/immutablesignedzone.test.ts b/test/seaport/immutablesignedzone.test.ts new file mode 100644 index 00000000..e60816c5 --- /dev/null +++ b/test/seaport/immutablesignedzone.test.ts @@ -0,0 +1,619 @@ +/* eslint-disable camelcase */ +import assert from "assert"; +import { expect } from "chai"; +import { Wallet, constants } from "ethers"; +import { keccak256 } from "ethers/lib/utils"; +import { ethers } from "hardhat"; + +import { ImmutableSignedZone__factory } from "../../typechain-types"; + +import { + CONSIDERATION_EIP712_TYPE, + EIP712_DOMAIN, + SIGNED_ORDER_EIP712_TYPE, + advanceBlockBySeconds, + autoMining, + convertSignatureToEIP2098, + getCurrentTimeStamp, +} from "./utils/signedZone"; + +import type { ImmutableSignedZone } from "../../typechain-types"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import type { BytesLike } from "ethers"; +import { ReceivedItemStruct } from "../../typechain-types/contracts/trading/seaport/ImmutableSeaport"; +import { ZoneParametersStruct } from "../typechain-types/contracts/validators/ImmutableSeaportValidator"; + +describe("ImmutableSignedZone", function () { + let deployer: SignerWithAddress; + let users: SignerWithAddress[]; + let contract: ImmutableSignedZone; + let chainId: number; + + beforeEach(async () => { + // automine ensure time based tests will work + await autoMining(); + chainId = (await ethers.provider.getNetwork()).chainId; + users = await ethers.getSigners(); + deployer = users[0]; + const factory = await ethers.getContractFactory("ImmutableSignedZone"); + const tx = await factory.connect(deployer).deploy("ImmutableSignedZone", "", ""); + + const address = (await tx.deployed()).address; + + contract = ImmutableSignedZone__factory.connect(address, deployer); + }); + + describe("Ownership", async function () { + it("deployer beomces owner", async () => { + assert((await contract.owner()) === deployer.address); + }); + + it("transferOwnership and acceptOwnership works", async () => { + assert((await contract.owner()) === deployer.address); + const transferTx = await contract.connect(deployer).transferOwnership(users[2].address); + await transferTx.wait(1); + + const acceptTx = await contract.connect(users[2]).acceptOwnership(); + await acceptTx.wait(1); + assert((await contract.owner()) === users[2].address); + }); + + it("non owner cannot transfer ownership", async () => { + assert((await contract.owner()) === deployer.address); + await expect(contract.connect(users[1]).transferOwnership(users[1].address)).to.be.revertedWith( + "Ownable: caller is not the owner" + ); + }); + + it("non owner cannot add signer", async () => { + await expect(contract.connect(users[1]).addSigner(users[1].address)).to.be.revertedWith( + "Ownable: caller is not the owner" + ); + }); + + it("non owner cannot remove signer", async () => { + await expect(contract.connect(users[1]).removeSigner(users[1].address)).to.be.revertedWith( + "Ownable: caller is not the owner" + ); + }); + }); + + describe("Signer management", async () => { + it("owner can add and remove active signer", async () => { + assert((await contract.owner()) === deployer.address); + const tx = await contract.connect(deployer).addSigner(users[1].address); + await tx.wait(1); + + await expect(contract.removeSigner(users[1].address)); + }); + + it("cannot add deactivated signer", async () => { + assert((await contract.owner()) === deployer.address); + const tx = await contract.connect(deployer).addSigner(users[1].address); + await tx.wait(1); + + (await contract.removeSigner(users[1].address)).wait(1); + + await expect(contract.addSigner(users[1].address)).to.be.revertedWith("SignerCannotBeReauthorized"); + }); + + it("already active signer cannot be added", async () => { + assert((await contract.owner()) === deployer.address); + const tx = await contract.connect(deployer).addSigner(users[1].address); + await tx.wait(1); + + await expect(contract.addSigner(users[1].address)).to.be.revertedWith("SignerAlreadyActive"); + }); + }); + + describe("Order Validation", async function () { + let signer: Wallet; + + beforeEach(async () => { + signer = ethers.Wallet.createRandom(); + // wait 1 block for all TXs + await (await contract.addSigner(signer.address)).wait(1); + }); + + it("validateOrder reverts without extraData", async function () { + await expect(contract.validateOrder(mockZoneParameter([]))).to.be.revertedWith("InvalidExtraData"); + }); + + it("validateOrder reverts with invalid extraData", async function () { + await expect(contract.validateOrder(mockZoneParameter([1, 2, 3]))).to.be.revertedWith("InvalidExtraData"); + }); + + it("validateOrder reverts with expired timestamp", async function () { + const orderHash = keccak256("0x1234"); + const expiration = await getCurrentTimeStamp(); + const fulfiller = constants.AddressZero; + const context = ethers.utils.randomBytes(32); + const signedOrder = { + fulfiller, + expiration, + orderHash, + context, + }; + + const signature = await signer._signTypedData( + EIP712_DOMAIN(chainId, contract.address), + SIGNED_ORDER_EIP712_TYPE, + signedOrder + ); + + const extraData = ethers.utils.solidityPack( + ["bytes1", "address", "uint64", "bytes", "bytes"], + [ + 0, // SIP6 version + fulfiller, + expiration, + convertSignatureToEIP2098(signature), + context, + ] + ); + + await advanceBlockBySeconds(100); + await expect(contract.validateOrder(mockZoneParameter(extraData))).to.be.revertedWith("SignatureExpired"); + }); + + it("validateOrder reverts with invalid fulfiller", async function () { + const orderHash = keccak256("0x1234"); + const expiration = (await getCurrentTimeStamp()) + 100; + const fulfiller = Wallet.createRandom().address; + const context = ethers.utils.randomBytes(32); + const signedOrder = { + fulfiller, + expiration, + orderHash, + context, + }; + + const signature = await signer._signTypedData( + EIP712_DOMAIN(chainId, contract.address), + SIGNED_ORDER_EIP712_TYPE, + signedOrder + ); + + const extraData = ethers.utils.solidityPack( + ["bytes1", "address", "uint64", "bytes", "bytes"], + [ + 0, // SIP6 version + fulfiller, + expiration, + convertSignatureToEIP2098(signature), + context, + ] + ); + + await expect(contract.validateOrder(mockZoneParameter(extraData))).to.be.revertedWith("InvalidFulfiller"); + }); + + it("validateOrder reverts with non 0 SIP6 version", async function () { + const orderHash = keccak256("0x1234"); + const expiration = (await getCurrentTimeStamp()) + 100; + const fulfiller = constants.AddressZero; + const context = ethers.utils.randomBytes(32); + const signedOrder = { + fulfiller, + expiration, + orderHash, + context, + }; + + const signature = await signer._signTypedData( + EIP712_DOMAIN(chainId, contract.address), + SIGNED_ORDER_EIP712_TYPE, + signedOrder + ); + + const extraData = ethers.utils.solidityPack( + ["bytes1", "address", "uint64", "bytes", "bytes"], + [ + 1, // SIP6 version + fulfiller, + expiration, + convertSignatureToEIP2098(signature), + context, + ] + ); + + await expect(contract.validateOrder(mockZoneParameter(extraData))).to.be.revertedWith( + "UnsupportedExtraDataVersion" + ); + }); + + it("validateOrder reverts with no context", async function () { + const orderHash = keccak256("0x1234"); + const expiration = (await getCurrentTimeStamp()) + 100; + const fulfiller = constants.AddressZero; + const context: BytesLike = []; + const signedOrder = { + fulfiller, + expiration, + orderHash, + context, + }; + + const signature = await signer._signTypedData( + EIP712_DOMAIN(chainId, contract.address), + SIGNED_ORDER_EIP712_TYPE, + signedOrder + ); + + const extraData = ethers.utils.solidityPack( + ["bytes1", "address", "uint64", "bytes", "bytes"], + [ + 0, // SIP6 version + fulfiller, + expiration, + convertSignatureToEIP2098(signature), + context, + ] + ); + + await expect(contract.validateOrder(mockZoneParameter(extraData))).to.be.revertedWith("InvalidExtraData"); + }); + + it("validateOrder reverts with wrong consideration", async function () { + const orderHash = keccak256("0x1234"); + const expiration = (await getCurrentTimeStamp()) + 100; + const fulfiller = constants.AddressZero; + const consideration = mockConsideration(); + const context: BytesLike = ethers.utils.solidityPack(["bytes"], [constants.HashZero]); + const signedOrder = { + fulfiller, + expiration, + orderHash, + context, + }; + + const signature = await signer._signTypedData( + EIP712_DOMAIN(chainId, contract.address), + SIGNED_ORDER_EIP712_TYPE, + signedOrder + ); + + const extraData = ethers.utils.solidityPack( + ["bytes1", "address", "uint64", "bytes", "bytes"], + [ + 0, // SIP6 version + fulfiller, + expiration, + convertSignatureToEIP2098(signature), + context, + ] + ); + + await expect(contract.validateOrder(mockZoneParameter(extraData, consideration))).to.be.revertedWith( + "SubstandardViolation" + ); + }); + + it("validates correct signature with context", async function () { + const orderHash = keccak256("0x1234"); + const expiration = (await getCurrentTimeStamp()) + 100; + const fulfiller = constants.AddressZero; + const consideration = mockConsideration(); + const considerationHash = ethers.utils._TypedDataEncoder.hashStruct("Consideration", CONSIDERATION_EIP712_TYPE, { + consideration, + }); + + const context: BytesLike = ethers.utils.solidityPack(["bytes", "bytes[]"], [considerationHash, [orderHash]]); + + const signedOrder = { + fulfiller, + expiration, + orderHash, + context, + }; + + const signature = await signer._signTypedData( + EIP712_DOMAIN(chainId, contract.address), + SIGNED_ORDER_EIP712_TYPE, + signedOrder + ); + + const extraData = ethers.utils.solidityPack( + ["bytes1", "address", "uint64", "bytes", "bytes"], + [0, fulfiller, expiration, convertSignatureToEIP2098(signature), context] + ); + + // esimate gas + // console.log( + // await contract.estimateGas.validateOrder( + // mockZoneParameter(extraData, consideration) + // ) + // ); + + expect(await contract.validateOrder(mockZoneParameter(extraData, consideration))).to.be.equal("0x17b1f942"); // ZoneInterface.validateOrder.selector + }); + + it("validateOrder reverts a valid order after expiration time passes ", async function () { + const orderHash = keccak256("0x1234"); + const expiration = (await getCurrentTimeStamp()) + 90; + const fulfiller = constants.AddressZero; + const consideration = mockConsideration(); + const considerationHash = ethers.utils._TypedDataEncoder.hashStruct("Consideration", CONSIDERATION_EIP712_TYPE, { + consideration, + }); + + const context: BytesLike = ethers.utils.solidityPack(["bytes", "bytes[]"], [considerationHash, [orderHash]]); + + const signedOrder = { + fulfiller, + expiration, + orderHash, + context, + }; + + const signature = await signer._signTypedData( + EIP712_DOMAIN(chainId, contract.address), + SIGNED_ORDER_EIP712_TYPE, + signedOrder + ); + + const extraData = ethers.utils.solidityPack( + ["bytes1", "address", "uint64", "bytes", "bytes"], + [0, fulfiller, expiration, convertSignatureToEIP2098(signature), context] + ); + + const selector = await contract.validateOrder(mockZoneParameter(extraData, consideration)); + + expect(selector).to.equal("0x17b1f942"); // ZoneInterface.validateOrder.selector + + await advanceBlockBySeconds(900); + + expect(contract.validateOrder(mockZoneParameter(extraData, consideration))).to.be.revertedWith( + "SignatureExpired" + ); // ZoneInterface.validateOrder.selector + }); + + it("validateOrder validates correct context with multiple order hashes - equal arrays", async function () { + const orderHash = keccak256("0x1234"); + const expiration = (await getCurrentTimeStamp()) + 90; + const fulfiller = constants.AddressZero; + const consideration = mockConsideration(); + const considerationHash = ethers.utils._TypedDataEncoder.hashStruct("Consideration", CONSIDERATION_EIP712_TYPE, { + consideration, + }); + + const context: BytesLike = ethers.utils.solidityPack( + ["bytes", "bytes[]"], + [considerationHash, mockBulkOrderHashes()] + ); + + const signedOrder = { + fulfiller, + expiration, + orderHash, + context, + }; + + const signature = await signer._signTypedData( + EIP712_DOMAIN(chainId, contract.address), + SIGNED_ORDER_EIP712_TYPE, + signedOrder + ); + + const extraData = ethers.utils.solidityPack( + ["bytes1", "address", "uint64", "bytes", "bytes"], + [0, fulfiller, expiration, convertSignatureToEIP2098(signature), context] + ); + + // gas estimation + // console.log( + // await contract.estimateGas.validateOrder( + // mockZoneParameter(extraData, consideration, mockBulkOrderHashes()) + // ) + // ); + + expect( + await contract.validateOrder(mockZoneParameter(extraData, consideration, mockBulkOrderHashes())) + ).to.be.equal("0x17b1f942"); // ZoneInterface.validateOrder.selector + }); + + it("validateOrder validates correct context with multiple order hashes - partial arrays", async function () { + const orderHash = keccak256("0x1234"); + const expiration = (await getCurrentTimeStamp()) + 90; + const fulfiller = constants.AddressZero; + const consideration = mockConsideration(); + const considerationHash = ethers.utils._TypedDataEncoder.hashStruct("Consideration", CONSIDERATION_EIP712_TYPE, { + consideration, + }); + + const context: BytesLike = ethers.utils.solidityPack( + ["bytes", "bytes[]"], + [considerationHash, mockBulkOrderHashes().splice(0, 2)] + ); + + const signedOrder = { + fulfiller, + expiration, + orderHash, + context, + }; + + const signature = await signer._signTypedData( + EIP712_DOMAIN(chainId, contract.address), + SIGNED_ORDER_EIP712_TYPE, + signedOrder + ); + + const extraData = ethers.utils.solidityPack( + ["bytes1", "address", "uint64", "bytes", "bytes"], + [0, fulfiller, expiration, convertSignatureToEIP2098(signature), context] + ); + + expect( + await contract.validateOrder(mockZoneParameter(extraData, consideration, mockBulkOrderHashes())) + ).to.be.equal("0x17b1f942"); // ZoneInterface.validateOrder.selector + }); + + it("validateOrder reverts when not all expected order hashes are in zone parameters", async function () { + // this triggers the early break in contract's array helper + const orderHash = keccak256("0x1234"); + const expiration = (await getCurrentTimeStamp()) + 90; + const fulfiller = constants.AddressZero; + const consideration = mockConsideration(); + const considerationHash = ethers.utils._TypedDataEncoder.hashStruct("Consideration", CONSIDERATION_EIP712_TYPE, { + consideration, + }); + + // context with 10 order hashes expected + const context: BytesLike = ethers.utils.solidityPack( + ["bytes", "bytes[]"], + [considerationHash, mockBulkOrderHashes()] + ); + + const signedOrder = { + fulfiller, + expiration, + orderHash, + context, + }; + + const signature = await signer._signTypedData( + EIP712_DOMAIN(chainId, contract.address), + SIGNED_ORDER_EIP712_TYPE, + signedOrder + ); + + const extraData = ethers.utils.solidityPack( + ["bytes1", "address", "uint64", "bytes", "bytes"], + [0, fulfiller, expiration, convertSignatureToEIP2098(signature), context] + ); + + await expect( + contract.validateOrder( + // only 8 order hashes actually filled + mockZoneParameter(extraData, consideration, mockBulkOrderHashes().splice(0, 2)) + ) + ).to.be.revertedWith("SubstandardViolation"); + }); + + it("validateOrder reverts when not all expected order hashes are in zone parameters variation", async function () { + // this doesn't trigger the early break in contract's array helper + const orderHash = keccak256("0x1234"); + const expiration = (await getCurrentTimeStamp()) + 90; + const fulfiller = constants.AddressZero; + const consideration = mockConsideration(); + const considerationHash = ethers.utils._TypedDataEncoder.hashStruct("Consideration", CONSIDERATION_EIP712_TYPE, { + consideration, + }); + + // context with 10 order hashes expected + const context: BytesLike = ethers.utils.solidityPack( + ["bytes", "bytes[]"], + [considerationHash, mockBulkOrderHashes()] + ); + + const signedOrder = { + fulfiller, + expiration, + orderHash, + context, + }; + + const signature = await signer._signTypedData( + EIP712_DOMAIN(chainId, contract.address), + SIGNED_ORDER_EIP712_TYPE, + signedOrder + ); + + const extraData = ethers.utils.solidityPack( + ["bytes1", "address", "uint64", "bytes", "bytes"], + [0, fulfiller, expiration, convertSignatureToEIP2098(signature), context] + ); + + // remove two and add two random order hashes + const mockActualOrderHashes = mockBulkOrderHashes().splice(0, 2); + mockActualOrderHashes.push(keccak256("0x55"), keccak256("0x66")); + + await expect( + contract.validateOrder( + // only 8 order hashes actually filled + mockZoneParameter(extraData, consideration, mockActualOrderHashes) + ) + ).to.be.revertedWith("SubstandardViolation"); + }); + + it("validateOrder reverts incorrectly signed signature with context", async function () { + const orderHash = keccak256("0x1234"); + const expiration = (await getCurrentTimeStamp()) + 100; + const fulfiller = constants.AddressZero; + const consideration = mockConsideration(); + const considerationHash = ethers.utils._TypedDataEncoder.hashStruct("Consideration", CONSIDERATION_EIP712_TYPE, { + consideration, + }); + + const context: BytesLike = ethers.utils.solidityPack(["bytes"], [considerationHash]); + + const signedOrder = { + fulfiller, + expiration, + orderHash, + context, + }; + + // sign with random user + const signature = await users[4]._signTypedData( + EIP712_DOMAIN(chainId, contract.address), + SIGNED_ORDER_EIP712_TYPE, + signedOrder + ); + + const extraData = ethers.utils.solidityPack( + ["bytes1", "address", "uint64", "bytes", "bytes"], + [0, fulfiller, expiration, convertSignatureToEIP2098(signature), context] + ); + + expect(contract.validateOrder(mockZoneParameter(extraData, consideration))).to.be.revertedWith("SignerNotActive"); + }); + }); +}); + +function mockConsideration(howMany: number = 10): ReceivedItemStruct[] { + const consideration = []; + for (let i = 0; i < howMany; i++) { + consideration.push({ + itemType: 0, + token: Wallet.createRandom().address, + identifier: 123, + amount: 12, + recipient: Wallet.createRandom().address, + }); + } + + return consideration; +} + +function mockBulkOrderHashes(howMany: number = 10): string[] { + const hashes = []; + for (let i = 0; i < howMany; i++) { + hashes.push(keccak256(`0x123${i >= 10 ? i + "0" : i}`)); + } + return hashes; +} + +function mockZoneParameter( + extraData: BytesLike, + consideration: ReceivedItemStruct[] = [], + orderHashes: string[] = [keccak256("0x1234")] +): ZoneParametersStruct { + return { + // fix order hash for testing (zone doesn't validate its actual validity) + orderHash: keccak256("0x1234"), + fulfiller: constants.AddressZero, + // zero address - also does not get validated in zone + offerer: constants.AddressZero, + // empty offer - no validation in zone + offer: [], + consideration, + extraData, + orderHashes, + startTime: 0, + endTime: 0, + // we do not use zone hash + zoneHash: constants.HashZero, + }; +} diff --git a/test/seaport/utils/criteria.ts b/test/seaport/utils/criteria.ts new file mode 100644 index 00000000..63fd3c29 --- /dev/null +++ b/test/seaport/utils/criteria.ts @@ -0,0 +1,97 @@ +import { ethers } from "ethers"; + +const { keccak256 } = ethers.utils; + +type BufferElementPositionIndex = { [key: string]: number }; + +export const merkleTree = (tokenIds: ethers.BigNumber[]) => { + const elements = tokenIds + .map((tokenId) => Buffer.from(tokenId.toHexString().slice(2).padStart(64, "0"), "hex")) + .sort(Buffer.compare) + .filter((el, idx, arr) => { + return idx === 0 || !arr[idx - 1].equals(el); + }); + + const bufferElementPositionIndex = elements.reduce((memo: BufferElementPositionIndex, el, index) => { + memo["0x" + el.toString("hex")] = index; + return memo; + }, {}); + + // Create layers + const layers = getLayers(elements); + + const root = "0x" + layers[layers.length - 1][0].toString("hex"); + + const proofs = Object.fromEntries( + elements.map((el) => [ethers.BigNumber.from(el).toString(), getHexProof(el, bufferElementPositionIndex, layers)]) + ); + + const maxProofLength = Math.max(...Object.values(proofs).map((i) => i.length)); + + return { + root, + proofs, + maxProofLength, + }; +}; + +const getLayers = (elements: Buffer[]) => { + if (elements.length === 0) { + throw new Error("empty tree"); + } + + const layers = []; + layers.push(elements.map((el) => Buffer.from(keccak256(el).slice(2), "hex"))); + + // Get next layer until we reach the root + while (layers[layers.length - 1].length > 1) { + layers.push(getNextLayer(layers[layers.length - 1])); + } + + return layers; +}; + +const getNextLayer = (elements: Buffer[]) => { + return elements.reduce((layer: Buffer[], el, idx, arr) => { + if (idx % 2 === 0) { + // Hash the current element with its pair element + layer.push(combinedHash(el, arr[idx + 1])); + } + + return layer; + }, []); +}; + +const combinedHash = (first: Buffer, second: Buffer) => { + if (!first) { + return second; + } + if (!second) { + return first; + } + + return Buffer.from(keccak256(Buffer.concat([first, second].sort(Buffer.compare))).slice(2), "hex"); +}; + +const getHexProof = (el: Buffer, bufferElementPositionIndex: BufferElementPositionIndex, layers: Buffer[][]) => { + let idx = bufferElementPositionIndex["0x" + el.toString("hex")]; + + if (typeof idx !== "number") { + throw new Error("Element does not exist in Merkle tree"); + } + + const proofBuffer = layers.reduce((proof: Buffer[], layer) => { + const pairIdx = idx % 2 === 0 ? idx + 1 : idx - 1; + const pairElement = pairIdx < layer.length ? layer[pairIdx] : null; + + if (pairElement) { + proof.push(pairElement); + } + + idx = Math.floor(idx / 2); + + return proof; + }, []); + + return proofBuffer.map((el) => "0x" + el.toString("hex")); +}; diff --git a/test/seaport/utils/deploy-immutable-contracts.ts b/test/seaport/utils/deploy-immutable-contracts.ts new file mode 100644 index 00000000..85f92581 --- /dev/null +++ b/test/seaport/utils/deploy-immutable-contracts.ts @@ -0,0 +1,64 @@ +import hre from "hardhat"; +import { ImmutableSeaport, ImmutableSignedZone } from "../../../typechain-types"; + +// Deploy the Immutable ecosystem contracts, returning the contract +// references +export async function deployImmutableContracts(serverSignerAddress: string): Promise<{ + immutableSeaport: ImmutableSeaport; + immutableSignedZone: ImmutableSignedZone; + conduitKey: string; + conduitAddress: string; +}> { + const accounts = await hre.ethers.getSigners(); + const seaportConduitControllerContractFactory = await hre.ethers.getContractFactory("ConduitController"); + const seaportConduitControllerContract = await seaportConduitControllerContractFactory.deploy(); + await seaportConduitControllerContract.deployed(); + + const readOnlyValidatorFactory = await hre.ethers.getContractFactory("ReadOnlyOrderValidator"); + const readOnlyValidatorContract = await readOnlyValidatorFactory.deploy(); + + const validatorHelperFactory = await hre.ethers.getContractFactory("SeaportValidatorHelper"); + const validatorHelperContract = await validatorHelperFactory.deploy(); + + const seaportValidatorFactory = await hre.ethers.getContractFactory("SeaportValidator"); + await seaportValidatorFactory.deploy( + readOnlyValidatorContract.address, + validatorHelperContract.address, + seaportConduitControllerContract.address + ); + + const immutableSignedZoneFactory = await hre.ethers.getContractFactory("ImmutableSignedZone"); + const immutableSignedZoneContract = (await immutableSignedZoneFactory.deploy( + "ImmutableSignedZone", + "", + "" + )) as ImmutableSignedZone; + await immutableSignedZoneContract.deployed(); + + const tx = await immutableSignedZoneContract.addSigner(serverSignerAddress); + await tx.wait(1); + + // conduit key: The conduit key used to deploy the conduit. Note that the first twenty bytes of the conduit key must match the caller of this contract. + const conduitKey = `${accounts[0].address}000000000000000000000000`; + + await (await seaportConduitControllerContract.createConduit(conduitKey, accounts[0].address)).wait(1); + + const { conduit: conduitAddress } = await seaportConduitControllerContract.getConduit(conduitKey); + + const seaportContractFactory = await hre.ethers.getContractFactory("ImmutableSeaport"); + const seaportContract = (await seaportContractFactory.deploy( + seaportConduitControllerContract.address + )) as ImmutableSeaport; + await seaportContract.deployed(); + + // add ImmutableZone + await (await seaportContract.connect(accounts[0]).setAllowedZone(immutableSignedZoneContract.address, true)).wait(1); + await (await seaportConduitControllerContract.updateChannel(conduitAddress, seaportContract.address, true)).wait(1); + + return { + immutableSeaport: seaportContract, + immutableSignedZone: immutableSignedZoneContract, + conduitKey, + conduitAddress, + }; +} diff --git a/test/seaport/utils/eip712/Eip712MerkleTree.ts b/test/seaport/utils/eip712/Eip712MerkleTree.ts new file mode 100644 index 00000000..6222f36d --- /dev/null +++ b/test/seaport/utils/eip712/Eip712MerkleTree.ts @@ -0,0 +1,110 @@ +import { _TypedDataEncoder as TypedDataEncoder } from "@ethersproject/hash"; +import { expect } from "chai"; +import { defaultAbiCoder, hexConcat, keccak256, toUtf8Bytes } from "ethers/lib/utils"; +import { MerkleTree } from "merkletreejs"; + +import { DefaultGetter } from "./defaults"; +import { bufferKeccak, bufferToHex, chunk, fillArray, getRoot, hexToBuffer } from "./utils"; + +import type { OrderComponents } from "../types"; +import type { EIP712TypeDefinitions } from "./defaults"; + +type BulkOrderElements = [OrderComponents, OrderComponents] | [BulkOrderElements, BulkOrderElements]; + +const getTree = (leaves: string[], defaultLeafHash: string) => + new MerkleTree(leaves.map(hexToBuffer), bufferKeccak, { + complete: true, + sort: false, + hashLeaves: false, + fillDefaultHash: hexToBuffer(defaultLeafHash), + }); + +const encodeProof = (key: number, proof: string[], signature = `0x${"ff".repeat(64)}`) => { + return hexConcat([ + signature, + `0x${key.toString(16).padStart(6, "0")}`, + defaultAbiCoder.encode([`uint256[${proof.length}]`], [proof]), + ]); +}; + +export class Eip712MerkleTree = any> { + tree: MerkleTree; + private leafHasher: (value: any) => string; + defaultNode: BaseType; + defaultLeaf: string; + encoder: TypedDataEncoder; + + get completedSize() { + return Math.pow(2, this.depth); + } + + /** Returns the array of elements in the tree, padded to the complete size with empty items. */ + getCompleteElements() { + const elements = this.elements; + return fillArray([...elements], this.completedSize, this.defaultNode); + } + + /** Returns the array of leaf nodes in the tree, padded to the complete size with default hashes. */ + getCompleteLeaves() { + const leaves = this.elements.map(this.leafHasher); + return fillArray([...leaves], this.completedSize, this.defaultLeaf); + } + + get root() { + return this.tree.getHexRoot(); + } + + getProof(i: number) { + const leaves = this.getCompleteLeaves(); + const leaf = leaves[i]; + const proof = this.tree.getHexProof(leaf, i); + const root = this.tree.getHexRoot(); + return { leaf, proof, root }; + } + + getEncodedProofAndSignature(i: number, signature: string) { + const { proof } = this.getProof(i); + return encodeProof(i, proof, signature); + } + + getDataToSign(): BulkOrderElements { + let layer = this.getCompleteElements() as any; + while (layer.length > 2) { + layer = chunk(layer, 2); + } + return layer; + } + + add(element: BaseType) { + this.elements.push(element); + } + + getBulkOrderHash() { + const structHash = this.encoder.hashStruct("BulkOrder", { + tree: this.getDataToSign(), + }); + const leaves = this.getCompleteLeaves().map(hexToBuffer); + const rootHash = bufferToHex(getRoot(leaves, false)); + const typeHash = keccak256(toUtf8Bytes(this.encoder._types.BulkOrder)); + const bulkOrderHash = keccak256(hexConcat([typeHash, rootHash])); + + expect(bulkOrderHash, "derived bulk order hash should match").to.equal(structHash); + + return structHash; + } + + constructor( + public types: EIP712TypeDefinitions, + public rootType: string, + public leafType: string, + public elements: BaseType[], + public depth: number + ) { + const encoder = TypedDataEncoder.from(types); + this.encoder = encoder; + this.leafHasher = (leaf: BaseType) => encoder.hashStruct(leafType, leaf); + this.defaultNode = DefaultGetter.from(types, leafType); + this.defaultLeaf = this.leafHasher(this.defaultNode); + this.tree = getTree(this.getCompleteLeaves(), this.defaultLeaf); + } +} diff --git a/test/seaport/utils/eip712/bulk-orders.ts b/test/seaport/utils/eip712/bulk-orders.ts new file mode 100644 index 00000000..dba562fe --- /dev/null +++ b/test/seaport/utils/eip712/bulk-orders.ts @@ -0,0 +1,80 @@ +import { _TypedDataEncoder, keccak256, toUtf8Bytes } from "ethers/lib/utils"; + +import { Eip712MerkleTree } from "./Eip712MerkleTree"; +import { DefaultGetter } from "./defaults"; +import { fillArray } from "./utils"; + +import type { OrderComponents } from "../types"; +import type { EIP712TypeDefinitions } from "./defaults"; + +const bulkOrderType = { + BulkOrder: [{ name: "tree", type: "OrderComponents[2][2][2][2][2][2][2]" }], + OrderComponents: [ + { name: "offerer", type: "address" }, + { name: "zone", type: "address" }, + { name: "offer", type: "OfferItem[]" }, + { name: "consideration", type: "ConsiderationItem[]" }, + { name: "orderType", type: "uint8" }, + { name: "startTime", type: "uint256" }, + { name: "endTime", type: "uint256" }, + { name: "zoneHash", type: "bytes32" }, + { name: "salt", type: "uint256" }, + { name: "conduitKey", type: "bytes32" }, + { name: "counter", type: "uint256" }, + ], + OfferItem: [ + { name: "itemType", type: "uint8" }, + { name: "token", type: "address" }, + { name: "identifierOrCriteria", type: "uint256" }, + { name: "startAmount", type: "uint256" }, + { name: "endAmount", type: "uint256" }, + ], + ConsiderationItem: [ + { name: "itemType", type: "uint8" }, + { name: "token", type: "address" }, + { name: "identifierOrCriteria", type: "uint256" }, + { name: "startAmount", type: "uint256" }, + { name: "endAmount", type: "uint256" }, + { name: "recipient", type: "address" }, + ], +}; +function getBulkOrderTypes(height: number): EIP712TypeDefinitions { + const types = { ...bulkOrderType }; + types.BulkOrder = [{ name: "tree", type: `OrderComponents${`[2]`.repeat(height)}` }]; + return types; +} + +export function getBulkOrderTreeHeight(length: number): number { + return Math.max(Math.ceil(Math.log2(length)), 1); +} + +export function getBulkOrderTree( + orderComponents: OrderComponents[], + startIndex = 0, + height = getBulkOrderTreeHeight(orderComponents.length + startIndex) +) { + const types = getBulkOrderTypes(height); + const defaultNode = DefaultGetter.from(types, "OrderComponents"); + let elements = [...orderComponents]; + + if (startIndex > 0) { + elements = [...fillArray([] as OrderComponents[], startIndex, defaultNode), ...orderComponents]; + } + const tree = new Eip712MerkleTree(types, "BulkOrder", "OrderComponents", elements, height); + return tree; +} + +export function getBulkOrderTypeHash(height: number): string { + const types = getBulkOrderTypes(height); + const encoder = _TypedDataEncoder.from(types); + const typeString = toUtf8Bytes(encoder._types.BulkOrder); + return keccak256(typeString); +} + +export function getBulkOrderTypeHashes(maxHeight: number): string[] { + const typeHashes: string[] = []; + for (let i = 0; i < maxHeight; i++) { + typeHashes.push(getBulkOrderTypeHash(i + 1)); + } + return typeHashes; +} diff --git a/test/seaport/utils/eip712/defaults.ts b/test/seaport/utils/eip712/defaults.ts new file mode 100644 index 00000000..6a46d446 --- /dev/null +++ b/test/seaport/utils/eip712/defaults.ts @@ -0,0 +1,102 @@ +/* eslint-disable no-dupe-class-members */ +/* eslint-disable no-unused-vars */ +import { Logger } from "@ethersproject/logger"; +import { hexZeroPad } from "ethers/lib/utils"; + +import type { TypedDataField } from "@ethersproject/abstract-signer"; + +const logger = new Logger("defaults"); + +const baseDefaults: Record = { + integer: 0, + address: hexZeroPad("0x", 20), + bool: false, + bytes: "0x", + string: "", +}; + +const isNullish = (value: any): boolean => { + if (value === undefined) return false; + + return ( + value !== undefined && + value !== null && + ((["string", "number"].includes(typeof value) && BigInt(value) === BigInt(0)) || + (Array.isArray(value) && value.every(isNullish)) || + (typeof value === "object" && Object.values(value).every(isNullish)) || + (typeof value === "boolean" && value === false)) + ); +}; + +function getDefaultForBaseType(type: string): any { + // bytesXX + const [, width] = type.match(/^bytes(\d+)$/) ?? []; + if (width) return hexZeroPad("0x", parseInt(width)); + + if (type.match(/^(u?)int(\d*)$/)) type = "integer"; + + return baseDefaults[type]; +} + +export type EIP712TypeDefinitions = Record; + +type DefaultMap = { + [K in keyof T]: any; +}; + +export class DefaultGetter { + defaultValues: DefaultMap = {} as DefaultMap; + + constructor(protected types: Types) { + for (const name in types) { + const defaultValue = this.getDefaultValue(name); + this.defaultValues[name] = defaultValue; + if (!isNullish(defaultValue)) { + logger.throwError(`Got non-empty value for type ${name} in default generator: ${defaultValue}`); + } + } + } + + static from(types: Types): DefaultMap; + + static from(types: Types, type: keyof Types): any; + + static from(types: Types, type?: keyof Types): DefaultMap { + const { defaultValues } = new DefaultGetter(types); + if (type) return defaultValues[type]; + return defaultValues; + } + + getDefaultValue(type: string): any { + if (this.defaultValues[type]) return this.defaultValues[type]; + // Basic type (address, bool, uint256, etc) + const basic = getDefaultForBaseType(type); + if (basic !== undefined) return basic; + + // Array + const match = type.match(/^(.*)(\x5b(\d*)\x5d)$/); + if (match) { + const subtype = match[1]; + const length = parseInt(match[3]); + if (length > 0) { + const baseValue = this.getDefaultValue(subtype); + return Array(length).fill(baseValue); + } + return []; + } + + // Struct + const fields = this.types[type]; + if (fields) { + return fields.reduce( + (obj, { name, type }) => ({ + ...obj, + [name]: this.getDefaultValue(type), + }), + {} + ); + } + + return logger.throwArgumentError(`unknown type: ${type}`, "type", type); + } +} diff --git a/test/seaport/utils/eip712/utils.ts b/test/seaport/utils/eip712/utils.ts new file mode 100644 index 00000000..f88fde65 --- /dev/null +++ b/test/seaport/utils/eip712/utils.ts @@ -0,0 +1,51 @@ +import { hexConcat, hexlify, keccak256 } from "ethers/lib/utils"; + +import type { BytesLike } from "ethers"; + +export const makeArray = (len: number, getValue: (i: number) => T) => + Array(len) + .fill(0) + .map((_, i) => getValue(i)); + +export const chunk = (array: T[], size: number) => { + return makeArray(Math.ceil(array.length / size), (i) => array.slice(i * size, (i + 1) * size)); +}; + +export const bufferToHex = (buf: Buffer) => hexlify(buf); + +export const hexToBuffer = (value: string) => Buffer.from(value.slice(2), "hex"); + +export const bufferKeccak = (value: BytesLike) => hexToBuffer(keccak256(value)); + +export const hashConcat = (arr: BytesLike[]) => bufferKeccak(hexConcat(arr)); + +export const fillArray = (arr: T[], length: number, value: T) => { + if (length > arr.length) arr.push(...Array(length - arr.length).fill(value)); + return arr; +}; + +export const getRoot = (elements: (Buffer | string)[], hashLeaves = true) => { + if (elements.length === 0) throw new Error("empty tree"); + + const leaves = elements.map((e) => { + const leaf = Buffer.isBuffer(e) ? e : hexToBuffer(e); + return hashLeaves ? bufferKeccak(leaf) : leaf; + }); + + const layers: Buffer[][] = [leaves]; + + // Get next layer until we reach the root + while (layers[layers.length - 1].length > 1) { + layers.push(getNextLayer(layers[layers.length - 1])); + } + + return layers[layers.length - 1][0]; +}; + +export const getNextLayer = (elements: Buffer[]) => { + return chunk(elements, 2).map(hashConcat); + // return elements.reduce((layer: Buffer[], el, idx, arr) => { + // if (idx % 2 === 0) layer.push(hashConcat(el, arr[idx + 1])); + // return layer; + // }, []); +}; diff --git a/test/seaport/utils/encoding.ts b/test/seaport/utils/encoding.ts new file mode 100644 index 00000000..0b0bb4a2 --- /dev/null +++ b/test/seaport/utils/encoding.ts @@ -0,0 +1,155 @@ +import { randomBytes } from "crypto"; +import { BigNumber, constants, utils } from "ethers"; +import type { BigNumberish } from "ethers"; +import { ConsiderationItem, CriteriaResolver, OfferItem, OrderComponents } from "./types"; +import { keccak256, toUtf8Bytes } from "ethers/lib/utils"; +import { expect } from "chai"; + +export const randomHex = (bytes = 32) => `0x${randomBytes(bytes).toString("hex")}`; +export const toBN = (n: BigNumberish) => BigNumber.from(toHex(n)); +export const randomBN = (bytes: number = 16) => toBN(randomHex(bytes)); +export const toKey = (n: BigNumberish) => toHex(n, 32); +export const toHex = (n: BigNumberish, numBytes: number = 0) => { + const asHexString = BigNumber.isBigNumber(n) + ? n.toHexString().slice(2) + : typeof n === "string" + ? hexRegex.test(n) + ? n.replace(/0x/, "") + : Number(n).toString(16) + : Number(n).toString(16); + return `0x${asHexString.padStart(numBytes * 2, "0")}`; +}; + +const hexRegex = /[A-Fa-fx]/g; + +export const getItemETH = (startAmount: BigNumberish = 1, endAmount: BigNumberish = 1, recipient?: string) => + getOfferOrConsiderationItem(0, constants.AddressZero, 0, toBN(startAmount), toBN(endAmount), recipient); + +export const getItem721 = ( + token: string, + identifierOrCriteria: BigNumberish, + startAmount: number = 1, + endAmount: number = 1, + recipient?: string +) => getOfferOrConsiderationItem(2, token, identifierOrCriteria, startAmount, endAmount, recipient); + +export const getOfferOrConsiderationItem = ( + itemType: number = 0, + token: string = constants.AddressZero, + identifierOrCriteria: BigNumberish = 0, + startAmount: BigNumberish = 1, + endAmount: BigNumberish = 1, + recipient?: RecipientType +): RecipientType extends string ? ConsiderationItem : OfferItem => { + const offerItem: OfferItem = { + itemType, + token, + identifierOrCriteria: toBN(identifierOrCriteria), + startAmount: toBN(startAmount), + endAmount: toBN(endAmount), + }; + if (typeof recipient === "string") { + return { + ...offerItem, + recipient: recipient as string, + } as ConsiderationItem; + } + return offerItem as any; +}; + +export const convertSignatureToEIP2098 = (signature: string) => { + if (signature.length === 130) { + return signature; + } + + expect(signature.length, "signature must be 64 or 65 bytes").to.eq(132); + + return utils.splitSignature(signature).compact; +}; + +export const calculateOrderHash = (orderComponents: OrderComponents) => { + const offerItemTypeString = + "OfferItem(uint8 itemType,address token,uint256 identifierOrCriteria,uint256 startAmount,uint256 endAmount)"; + const considerationItemTypeString = + "ConsiderationItem(uint8 itemType,address token,uint256 identifierOrCriteria,uint256 startAmount,uint256 endAmount,address recipient)"; + const orderComponentsPartialTypeString = + "OrderComponents(address offerer,address zone,OfferItem[] offer,ConsiderationItem[] consideration,uint8 orderType,uint256 startTime,uint256 endTime,bytes32 zoneHash,uint256 salt,bytes32 conduitKey,uint256 counter)"; + const orderTypeString = `${orderComponentsPartialTypeString}${considerationItemTypeString}${offerItemTypeString}`; + + const offerItemTypeHash = keccak256(toUtf8Bytes(offerItemTypeString)); + const considerationItemTypeHash = keccak256(toUtf8Bytes(considerationItemTypeString)); + const orderTypeHash = keccak256(toUtf8Bytes(orderTypeString)); + + const offerHash = keccak256( + "0x" + + orderComponents.offer + .map((offerItem) => { + return keccak256( + "0x" + + [ + offerItemTypeHash.slice(2), + offerItem.itemType.toString().padStart(64, "0"), + offerItem.token.slice(2).padStart(64, "0"), + toBN(offerItem.identifierOrCriteria).toHexString().slice(2).padStart(64, "0"), + toBN(offerItem.startAmount).toHexString().slice(2).padStart(64, "0"), + toBN(offerItem.endAmount).toHexString().slice(2).padStart(64, "0"), + ].join("") + ).slice(2); + }) + .join("") + ); + + const considerationHash = keccak256( + "0x" + + orderComponents.consideration + .map((considerationItem) => { + return keccak256( + "0x" + + [ + considerationItemTypeHash.slice(2), + considerationItem.itemType.toString().padStart(64, "0"), + considerationItem.token.slice(2).padStart(64, "0"), + toBN(considerationItem.identifierOrCriteria).toHexString().slice(2).padStart(64, "0"), + toBN(considerationItem.startAmount).toHexString().slice(2).padStart(64, "0"), + toBN(considerationItem.endAmount).toHexString().slice(2).padStart(64, "0"), + considerationItem.recipient.slice(2).padStart(64, "0"), + ].join("") + ).slice(2); + }) + .join("") + ); + + const derivedOrderHash = keccak256( + "0x" + + [ + orderTypeHash.slice(2), + orderComponents.offerer.slice(2).padStart(64, "0"), + orderComponents.zone.slice(2).padStart(64, "0"), + offerHash.slice(2), + considerationHash.slice(2), + orderComponents.orderType.toString().padStart(64, "0"), + toBN(orderComponents.startTime).toHexString().slice(2).padStart(64, "0"), + toBN(orderComponents.endTime).toHexString().slice(2).padStart(64, "0"), + orderComponents.zoneHash.slice(2), + orderComponents.salt.slice(2).padStart(64, "0"), + orderComponents.conduitKey.slice(2).padStart(64, "0"), + toBN(orderComponents.counter).toHexString().slice(2).padStart(64, "0"), + ].join("") + ); + + return derivedOrderHash; +}; + +export const buildResolver = ( + orderIndex: number, + side: 0 | 1, + index: number, + identifier: BigNumber, + criteriaProof: string[] +): CriteriaResolver => ({ + orderIndex, + side, + index, + identifier, + criteriaProof, +}); diff --git a/test/seaport/utils/erc721.ts b/test/seaport/utils/erc721.ts new file mode 100644 index 00000000..b3edf2e9 --- /dev/null +++ b/test/seaport/utils/erc721.ts @@ -0,0 +1,49 @@ +import { expect } from "chai"; +import hre from "hardhat"; + +import { getOfferOrConsiderationItem, randomBN } from "./encoding"; + +import type { TestERC721 } from "../../../typechain-types"; +import type { BigNumberish, BigNumber, Contract, Wallet } from "ethers"; + +export async function deployERC721(): Promise { + const erc721Factory = await hre.ethers.getContractFactory("TestERC721"); + const erc721 = await erc721Factory.deploy(); + return erc721.deployed(); +} + +export async function mint721(erc721: TestERC721, signer: Wallet | Contract): Promise { + const nftId = randomBN(); + await erc721.mint(signer.address, nftId); + return nftId; +} + +export async function set721ApprovalForAll(erc721: TestERC721, signer: Wallet, spender: string, approved = true) { + return expect(erc721.connect(signer).setApprovalForAll(spender, approved)) + .to.emit(erc721, "ApprovalForAll") + .withArgs(signer.address, spender, approved); +} + +export async function mintAndApprove721(erc721: TestERC721, signer: Wallet, spender: string): Promise { + await set721ApprovalForAll(erc721, signer, spender, true); + return mint721(erc721, signer); +} + +export async function getTestItem721( + tokenAddress: string, + identifierOrCriteria: BigNumberish, + startAmount: BigNumberish = 1, + endAmount: BigNumberish = 1, + recipient?: string +) { + return getOfferOrConsiderationItem(2, tokenAddress, identifierOrCriteria, startAmount, endAmount, recipient); +} +export function getTestItem721WithCriteria( + tokenAddress: string, + identifierOrCriteria: BigNumberish, + startAmount: BigNumberish = 1, + endAmount: BigNumberish = 1, + recipient?: string +) { + return getOfferOrConsiderationItem(4, tokenAddress, identifierOrCriteria, startAmount, endAmount, recipient); +} diff --git a/test/seaport/utils/faucet.ts b/test/seaport/utils/faucet.ts new file mode 100644 index 00000000..0b6e91f1 --- /dev/null +++ b/test/seaport/utils/faucet.ts @@ -0,0 +1,18 @@ +import { parseEther } from "@ethersproject/units"; +import { ethers } from "hardhat"; + +import { randomHex } from "./encoding"; + +import type { JsonRpcProvider } from "@ethersproject/providers"; + +const TEN_THOUSAND_ETH = parseEther("10000").toHexString().replace("0x0", "0x"); + +export const faucet = async (address: string, provider: JsonRpcProvider) => { + await provider.send("hardhat_setBalance", [address, TEN_THOUSAND_ETH]); +}; + +export const getWalletWithEther = async () => { + const wallet = new ethers.Wallet(randomHex(32), ethers.provider); + await faucet(wallet.address, ethers.provider); + return wallet; +}; diff --git a/test/seaport/utils/order.ts b/test/seaport/utils/order.ts new file mode 100644 index 00000000..07ec0b4a --- /dev/null +++ b/test/seaport/utils/order.ts @@ -0,0 +1,278 @@ +import { expect } from "chai"; +import { constants, utils } from "ethers"; +import { keccak256, recoverAddress } from "ethers/lib/utils"; +import { ethers } from "hardhat"; + +import { calculateOrderHash, convertSignatureToEIP2098, randomHex, toBN } from "./encoding"; + +import type { ConsiderationItem, OfferItem, OrderComponents } from "./types"; +import type { Contract, Wallet } from "ethers"; +import { ImmutableSeaport, TestZone } from "../../../typechain-types"; +import { getBulkOrderTree } from "./eip712/bulk-orders"; +import { ReceivedItemStruct } from "../../../typechain-types/contracts/ImmutableSeaport"; +import { CONSIDERATION_EIP712_TYPE, EIP712_DOMAIN, SIGNED_ORDER_EIP712_TYPE, getCurrentTimeStamp } from "./signedZone"; + +const orderType = { + OrderComponents: [ + { name: "offerer", type: "address" }, + { name: "zone", type: "address" }, + { name: "offer", type: "OfferItem[]" }, + { name: "consideration", type: "ConsiderationItem[]" }, + { name: "orderType", type: "uint8" }, + { name: "startTime", type: "uint256" }, + { name: "endTime", type: "uint256" }, + { name: "zoneHash", type: "bytes32" }, + { name: "salt", type: "uint256" }, + { name: "conduitKey", type: "bytes32" }, + { name: "counter", type: "uint256" }, + ], + OfferItem: [ + { name: "itemType", type: "uint8" }, + { name: "token", type: "address" }, + { name: "identifierOrCriteria", type: "uint256" }, + { name: "startAmount", type: "uint256" }, + { name: "endAmount", type: "uint256" }, + ], + ConsiderationItem: [ + { name: "itemType", type: "uint8" }, + { name: "token", type: "address" }, + { name: "identifierOrCriteria", type: "uint256" }, + { name: "startAmount", type: "uint256" }, + { name: "endAmount", type: "uint256" }, + { name: "recipient", type: "address" }, + ], +}; + +export async function getAndVerifyOrderHash(marketplace: ImmutableSeaport, orderComponents: OrderComponents) { + const orderHash = await marketplace.getOrderHash(orderComponents); + const derivedOrderHash = calculateOrderHash(orderComponents); + expect(orderHash).to.equal(derivedOrderHash); + return orderHash; +} + +export function getDomainData(marketplaceAddress: string, chainId: string) { + return { + name: "ImmutableSeaport", + version: "1.5", + chainId, + verifyingContract: marketplaceAddress, + }; +} + +export async function signOrder( + marketplace: ImmutableSeaport, + orderComponents: OrderComponents, + signer: Wallet | Contract +) { + const chainId = await signer.getChainId(); + const signature = await signer._signTypedData( + { ...getDomainData(marketplace.address, chainId), verifyingContract: marketplace.address }, + orderType, + orderComponents + ); + + const orderHash = await getAndVerifyOrderHash(marketplace, orderComponents); + + const { domainSeparator } = await marketplace.information(); + const digest = keccak256(`0x1901${domainSeparator.slice(2)}${orderHash.slice(2)}`); + const recoveredAddress = recoverAddress(digest, signature); + + expect(recoveredAddress).to.equal(signer.address); + + return signature; +} + +const signBulkOrder = async ( + marketplace: ImmutableSeaport, + orderComponents: OrderComponents[], + signer: Wallet | Contract, + startIndex = 0, + height?: number, + extraCheap?: boolean +) => { + const chainId = await signer.getChainId(); + const tree = getBulkOrderTree(orderComponents, startIndex, height); + const bulkOrderType = tree.types; + const chunks = tree.getDataToSign(); + let signature = await signer._signTypedData(getDomainData(marketplace.address, chainId), bulkOrderType, { + tree: chunks, + }); + + if (extraCheap) { + signature = convertSignatureToEIP2098(signature); + } + + const proofAndSignature = tree.getEncodedProofAndSignature(startIndex, signature); + + const orderHash = tree.getBulkOrderHash(); + + const { domainSeparator } = await marketplace.information(); + const digest = keccak256(`0x1901${domainSeparator.slice(2)}${orderHash.slice(2)}`); + const recoveredAddress = recoverAddress(digest, signature); + + expect(recoveredAddress).to.equal(signer.address); + + // Verify each individual order + for (const components of orderComponents) { + const individualOrderHash = await getAndVerifyOrderHash(marketplace, components); + const digest = keccak256(`0x1901${domainSeparator.slice(2)}${individualOrderHash.slice(2)}`); + const individualOrderSignature = await signer._signTypedData( + getDomainData(marketplace.address, chainId), + orderType, + components + ); + const recoveredAddress = recoverAddress(digest, individualOrderSignature); + expect(recoveredAddress).to.equal(signer.address); + } + + return proofAndSignature; +}; + +export async function createOrder( + marketplace: ImmutableSeaport, + offerer: Wallet | Contract, + zone: TestZone | Wallet | undefined | string = undefined, + offer: OfferItem[], + consideration: ConsiderationItem[], + orderType: number, + timeFlag?: string | null, + signer?: Wallet, + zoneHash = constants.HashZero, + conduitKey = constants.HashZero, + extraCheap = false, + useBulkSignature = false, + bulkSignatureIndex?: number, + bulkSignatureHeight?: number +) { + const counter = await marketplace.getCounter(offerer.address); + + const salt = !extraCheap ? randomHex() : constants.HashZero; + const startTime = timeFlag !== "NOT_STARTED" ? 0 : toBN("0xee00000000000000000000000000"); + const endTime = timeFlag !== "EXPIRED" ? toBN("0xff00000000000000000000000000") : 1; + + const orderParameters = { + offerer: offerer.address, + zone: !extraCheap ? (zone as Wallet).address ?? zone : constants.AddressZero, + offer, + consideration, + totalOriginalConsiderationItems: consideration.length, + orderType, + zoneHash, + salt, + conduitKey, + startTime, + endTime, + }; + + const orderComponents = { + ...orderParameters, + counter, + }; + + const orderHash = await getAndVerifyOrderHash(marketplace, orderComponents); + + const { isValidated, isCancelled, totalFilled, totalSize } = await marketplace.getOrderStatus(orderHash); + + expect(isCancelled).to.equal(false); + + const orderStatus = { + isValidated, + isCancelled, + totalFilled, + totalSize, + }; + + const flatSig = await signOrder(marketplace, orderComponents, signer ?? offerer); + + const order = { + parameters: orderParameters, + signature: !extraCheap ? flatSig : convertSignatureToEIP2098(flatSig), + numerator: 1, // only used for advanced orders + denominator: 1, // only used for advanced orders + extraData: "0x", // only used for advanced orders + }; + + if (useBulkSignature) { + order.signature = await signBulkOrder( + marketplace, + [orderComponents], + signer ?? offerer, + bulkSignatureIndex, + bulkSignatureHeight, + extraCheap + ); + + // Verify bulk signature length + expect(order.signature.slice(2).length / 2, "bulk signature length should be valid (98 < length < 837)") + .to.be.gt(98) + .and.lt(837); + expect( + (order.signature.slice(2).length / 2 - 67) % 32, + "bulk signature length should be valid ((length - 67) % 32 < 2)" + ).to.be.lt(2); + } + + // How much ether (at most) needs to be supplied when fulfilling the order + const value = offer + .map((x) => (x.itemType === 0 ? (x.endAmount.gt(x.startAmount) ? x.endAmount : x.startAmount) : toBN(0))) + .reduce((a, b) => a.add(b), toBN(0)) + .add( + consideration + .map((x) => (x.itemType === 0 ? (x.endAmount.gt(x.startAmount) ? x.endAmount : x.startAmount) : toBN(0))) + .reduce((a, b) => a.add(b), toBN(0)) + ); + + return { + order, + orderHash, + value, + orderStatus, + orderComponents, + startTime, + endTime, + }; +} + +export async function generateSip7Signature( + consideration: ConsiderationItem[], + orderHash: string, + fulfillerAddress: string, + immutableSignedZoneAddress: string, + immutableSigner: Wallet +) { + const considerationAsReceivedItem: ReceivedItemStruct[] = consideration.map((item) => { + return { + amount: item.startAmount, + identifier: item.identifierOrCriteria, + itemType: item.itemType, + recipient: item.recipient, + token: item.token, + }; + }); + + const expiration = (await getCurrentTimeStamp()) + 100; + const considerationHash = utils._TypedDataEncoder.hashStruct("Consideration", CONSIDERATION_EIP712_TYPE, { + consideration: considerationAsReceivedItem, + }); + + const context = utils.solidityPack(["bytes", "bytes[]"], [considerationHash, [orderHash]]); + + const signedOrder = { + fulfiller: fulfillerAddress, + expiration, + orderHash, + context, + }; + + const chainId = (await ethers.provider.getNetwork()).chainId; + const signature = await immutableSigner._signTypedData( + EIP712_DOMAIN(chainId, immutableSignedZoneAddress), + SIGNED_ORDER_EIP712_TYPE, + signedOrder + ); + + return utils.solidityPack( + ["bytes1", "address", "uint64", "bytes", "bytes"], + [0, fulfillerAddress, expiration, convertSignatureToEIP2098(signature), context] + ); +} diff --git a/test/seaport/utils/signedZone.ts b/test/seaport/utils/signedZone.ts new file mode 100644 index 00000000..2ac2bdc4 --- /dev/null +++ b/test/seaport/utils/signedZone.ts @@ -0,0 +1,104 @@ +import { utils } from "ethers"; +import { ethers } from "hardhat"; + +import type { BigNumber } from "ethers"; + +export const A_NON_ZERO_ADDRESS = "0x1234000000000000000000000000000000000000"; +export const SECONDS_IN_A_DAY = 24 * 60 * 60; + +export async function balance(address: string): Promise { + return await ethers.provider.getBalance(address); +} + +export async function getCurrentTimeStamp(): Promise { + const blockNumber = await ethers.provider.getBlockNumber(); + return (await ethers.provider.getBlock(blockNumber)).timestamp; +} + +export async function getCurrentBlockNumber(): Promise { + return await ethers.provider.getBlockNumber(); +} + +export async function advanceBlockAtTime(time: number): Promise { + await ethers.provider.send("evm_mine", [time]); +} + +export async function advanceBlockBySeconds(secondsToAdd: number): Promise { + const newTimestamp = (await getCurrentTimeStamp()) + secondsToAdd; + await ethers.provider.send("evm_mine", [newTimestamp]); +} + +export async function advanceTimeByDays(daysToAdd: number): Promise { + const secondsToAdd = daysToAdd * SECONDS_IN_A_DAY; + await ethers.provider.send("evm_increaseTime", [secondsToAdd]); + mine(); +} + +export async function mineBlocks(blocksToMineInHex: string = "0x100"): Promise { + await ethers.provider.send("hardhat_mine", [blocksToMineInHex]); +} + +export async function mine(): Promise { + await ethers.provider.send("evm_mine", []); +} + +export async function manualMining(): Promise { + await ethers.provider.send("evm_setAutomine", [false]); + await ethers.provider.send("evm_setIntervalMining", [0]); +} +export async function autoMining(): Promise { + await ethers.provider.send("evm_setAutomine", [true]); +} + +// Quick hack to remove array subobject from ethers results +export function cleanResult(result: { [key: string]: any }): any { + const clean = {} as { + [key: string]: any; + }; + Object.keys(result) + .filter((key) => isNaN(parseFloat(key))) + .reduce((obj, key) => { + clean[key] = result[key]; + return obj; + }, {}); + return clean; +} + +export const convertSignatureToEIP2098 = (signature: string) => { + if (signature.length === 130) { + return signature; + } + + if (signature.length !== 132) { + throw Error("invalid signature length (must be 64 or 65 bytes)"); + } + + return utils.splitSignature(signature).compact; +}; + +export const EIP712_DOMAIN = (chainId: number, contract: string) => ({ + name: "ImmutableSignedZone", + version: "1.0", + chainId, + verifyingContract: contract, +}); + +export const SIGNED_ORDER_EIP712_TYPE = { + SignedOrder: [ + { name: "fulfiller", type: "address" }, + { name: "expiration", type: "uint64" }, + { name: "orderHash", type: "bytes32" }, + { name: "context", type: "bytes" }, + ], +}; + +export const CONSIDERATION_EIP712_TYPE = { + Consideration: [{ name: "consideration", type: "ReceivedItem[]" }], + ReceivedItem: [ + { name: "itemType", type: "uint8" }, + { name: "token", type: "address" }, + { name: "identifier", type: "uint256" }, + { name: "amount", type: "uint256" }, + { name: "recipient", type: "address" }, + ], +}; diff --git a/test/seaport/utils/types.ts b/test/seaport/utils/types.ts new file mode 100644 index 00000000..699e8158 --- /dev/null +++ b/test/seaport/utils/types.ts @@ -0,0 +1,43 @@ +import { BigNumber } from "ethers"; + +export type ConsiderationItem = { + itemType: number; + token: string; + identifierOrCriteria: BigNumber; + startAmount: BigNumber; + endAmount: BigNumber; + recipient: string; +}; +export type OfferItem = { + itemType: number; + token: string; + identifierOrCriteria: BigNumber; + startAmount: BigNumber; + endAmount: BigNumber; +}; + +export type OrderParameters = { + offerer: string; + zone: string; + offer: OfferItem[]; + consideration: ConsiderationItem[]; + orderType: number; + startTime: string | BigNumber | number; + endTime: string | BigNumber | number; + zoneHash: string; + salt: string; + conduitKey: string; + totalOriginalConsiderationItems: string | BigNumber | number; +}; + +export type CriteriaResolver = { + orderIndex: number; + side: 0 | 1; + index: number; + identifier: BigNumber; + criteriaProof: string[]; +}; + +export type OrderComponents = Omit & { + counter: BigNumber; +}; diff --git a/yarn.lock b/yarn.lock index 8465a95c..59100abd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1035,6 +1035,13 @@ mcl-wasm "^0.7.1" rustbn.js "~0.2.0" +"@nomicfoundation/hardhat-network-helpers@^1.0.7": + version "1.0.10" + resolved "https://registry.yarnpkg.com/@nomicfoundation/hardhat-network-helpers/-/hardhat-network-helpers-1.0.10.tgz#c61042ceb104fdd6c10017859fdef6529c1d6585" + integrity sha512-R35/BMBlx7tWN5V6d/8/19QCwEmIdbnA4ZrsuXgvs8i2qFx5i7h6mH5pBS4Pwi4WigLH+upl6faYusrNPuzMrQ== + dependencies: + ethereumjs-util "^7.1.4" + "@nomicfoundation/solidity-analyzer-darwin-arm64@0.1.1": version "0.1.1" resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer-darwin-arm64/-/solidity-analyzer-darwin-arm64-0.1.1.tgz#4c858096b1c17fe58a474fe81b46815f93645c15" @@ -1142,6 +1149,11 @@ find-up "^4.1.0" fs-extra "^8.1.0" +"@openzeppelin/contracts@^4.9.2": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.5.tgz#1eed23d4844c861a1835b5d33507c1017fa98de8" + integrity sha512-ZK+W5mVhRppff9BE6YdR8CC52C8zAvsVAiWhEtQ5+oNxFE6h1WdeWo+FJSF8KKvtxxVYZ7MTP/5KoVpAU3aSWg== + "@openzeppelin/contracts@^4.9.3": version "4.9.3" resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.3.tgz#00d7a8cf35a475b160b3f0293a6403c511099364" @@ -1163,6 +1175,11 @@ web3 "^1.2.5" web3-utils "^1.2.5" +"@rari-capital/solmate@^6.4.0": + version "6.4.0" + resolved "https://registry.yarnpkg.com/@rari-capital/solmate/-/solmate-6.4.0.tgz#c6ee4110c8075f14b415e420b13bd8bdbbc93d9e" + integrity sha512-BXWIHHbG5Zbgrxi0qVYe0Zs+bfx+XgOciVUACjuIApV0KzC0kY8XdO1higusIei/ZKCC+GUKdcdQZflxYPUTKQ== + "@resolver-engine/core@^0.3.3": version "0.3.3" resolved "https://registry.yarnpkg.com/@resolver-engine/core/-/core-0.3.3.tgz#590f77d85d45bc7ecc4e06c654f41345db6ca967" @@ -2551,6 +2568,11 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +buffer-reverse@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-reverse/-/buffer-reverse-1.0.1.tgz#49283c8efa6f901bc01fa3304d06027971ae2f60" + integrity sha512-M87YIUBsZ6N924W57vDwT/aOu8hw7ZgdByz6ijksLjmHJELBASmYTTlNHRgjE+pTsT9oJXGaDSgqqwfdHotDUg== + buffer-to-arraybuffer@^0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/buffer-to-arraybuffer/-/buffer-to-arraybuffer-0.0.5.tgz#6064a40fa76eb43c723aba9ef8f6e1216d10511a" @@ -3208,6 +3230,11 @@ crypto-addr-codec@^0.1.7: safe-buffer "^5.2.0" sha3 "^2.1.1" +crypto-js@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + css-select@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" @@ -4298,6 +4325,11 @@ ethereumjs-vm@^2.3.4: rustbn.js "~0.2.0" safe-buffer "^5.1.1" +ethers-eip712@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/ethers-eip712/-/ethers-eip712-0.2.0.tgz#52973b3a9a22638f7357283bf66624994c6e91ed" + integrity sha512-fgS196gCIXeiLwhsWycJJuxI9nL/AoUPGSQ+yvd+8wdWR+43G+J1n69LmWVWvAON0M6qNaf2BF4/M159U8fujQ== + ethers@^4.0.32, ethers@^4.0.40: version "4.0.49" resolved "https://registry.yarnpkg.com/ethers/-/ethers-4.0.49.tgz#0eb0e9161a0c8b4761be547396bbe2fb121a8894" @@ -4313,7 +4345,7 @@ ethers@^4.0.32, ethers@^4.0.40: uuid "2.0.1" xmlhttprequest "1.8.0" -ethers@^5.0.13, ethers@^5.7.1, ethers@^5.7.2: +ethers@^5.0.13, ethers@^5.5.3, ethers@^5.7.1, ethers@^5.7.2: version "5.7.2" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" integrity sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg== @@ -5158,6 +5190,60 @@ hardhat@^2.12.7: uuid "^8.3.2" ws "^7.4.6" +hardhat@^2.17.3: + version "2.19.2" + resolved "https://registry.yarnpkg.com/hardhat/-/hardhat-2.19.2.tgz#815819e4efd234941d495decb718b358d572e2c8" + integrity sha512-CRU3+0Cc8Qh9UpxKd8cLADDPes7ZDtKj4dTK+ERtLBomEzhRPLWklJn4VKOwjre9/k8GNd/e9DYxpfuzcxbXPQ== + dependencies: + "@ethersproject/abi" "^5.1.2" + "@metamask/eth-sig-util" "^4.0.0" + "@nomicfoundation/ethereumjs-block" "5.0.2" + "@nomicfoundation/ethereumjs-blockchain" "7.0.2" + "@nomicfoundation/ethereumjs-common" "4.0.2" + "@nomicfoundation/ethereumjs-evm" "2.0.2" + "@nomicfoundation/ethereumjs-rlp" "5.0.2" + "@nomicfoundation/ethereumjs-statemanager" "2.0.2" + "@nomicfoundation/ethereumjs-trie" "6.0.2" + "@nomicfoundation/ethereumjs-tx" "5.0.2" + "@nomicfoundation/ethereumjs-util" "9.0.2" + "@nomicfoundation/ethereumjs-vm" "7.0.2" + "@nomicfoundation/solidity-analyzer" "^0.1.0" + "@sentry/node" "^5.18.1" + "@types/bn.js" "^5.1.0" + "@types/lru-cache" "^5.1.0" + adm-zip "^0.4.16" + aggregate-error "^3.0.0" + ansi-escapes "^4.3.0" + chalk "^2.4.2" + chokidar "^3.4.0" + ci-info "^2.0.0" + debug "^4.1.1" + enquirer "^2.3.0" + env-paths "^2.2.0" + ethereum-cryptography "^1.0.3" + ethereumjs-abi "^0.6.8" + find-up "^2.1.0" + fp-ts "1.19.3" + fs-extra "^7.0.1" + glob "7.2.0" + immutable "^4.0.0-rc.12" + io-ts "1.10.4" + keccak "^3.0.2" + lodash "^4.17.11" + mnemonist "^0.38.0" + mocha "^10.0.0" + p-map "^4.0.0" + raw-body "^2.4.1" + resolve "1.17.0" + semver "^6.3.0" + solc "0.7.3" + source-map-support "^0.5.13" + stacktrace-parser "^0.1.10" + tsort "0.0.1" + undici "^5.14.0" + uuid "^8.3.2" + ws "^7.4.6" + has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" @@ -6367,6 +6453,17 @@ merkle-patricia-tree@^4.2.2, merkle-patricia-tree@^4.2.4: readable-stream "^3.6.0" semaphore-async-await "^1.5.1" +merkletreejs@^0.3.11: + version "0.3.11" + resolved "https://registry.yarnpkg.com/merkletreejs/-/merkletreejs-0.3.11.tgz#e0de05c3ca1fd368de05a12cb8efb954ef6fc04f" + integrity sha512-LJKTl4iVNTndhL+3Uz/tfkjD0klIWsHlUzgtuNnNrsf7bAlXR30m+xYB7lHr5Z/l6e/yAIsr26Dabx6Buo4VGQ== + dependencies: + bignumber.js "^9.0.1" + buffer-reverse "^1.0.1" + crypto-js "^4.2.0" + treeify "^1.1.0" + web3-utils "^1.3.4" + methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -7786,6 +7883,47 @@ scrypt-js@3.0.1, scrypt-js@^3.0.0, scrypt-js@^3.0.1: resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-3.0.1.tgz#d314a57c2aef69d1ad98a138a21fe9eafa9ee312" integrity sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA== +seaport-core@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/seaport-core/-/seaport-core-0.0.1.tgz#99db0b605d0fbbfd43ca7a4724e64374ce47f6d4" + integrity sha512-fgdSIC0ru8xK+fdDfF4bgTFH8ssr6EwbPejC2g/JsWzxy+FvG7JfaX57yn/eIv6hoscgZL87Rm+kANncgwLH3A== + dependencies: + seaport-types "^0.0.1" + +seaport-core@immutable/seaport-core#1.5.0+im.1: + version "1.5.0" + resolved "https://codeload.github.com/immutable/seaport-core/tar.gz/33e9030f308500b422926a1be12d7a1e4d6adc06" + dependencies: + seaport-types "^0.0.1" + +seaport-sol@^1.5.0: + version "1.5.3" + resolved "https://registry.yarnpkg.com/seaport-sol/-/seaport-sol-1.5.3.tgz#ccb0047bcefb7d29bcd379faddf3a5a9902d0c3a" + integrity sha512-6g91hs15v4zBx/ZN9YD0evhQSDhdMKu83c2CgBgtc517D8ipaYaFO71ZD6V0Z6/I0cwNfuN/ZljcA1J+xXr65A== + dependencies: + seaport-core "^0.0.1" + seaport-types "^0.0.1" + +seaport-types@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/seaport-types/-/seaport-types-0.0.1.tgz#e2a32fe8641853d7dadb1b0232d911d88ccc3f1a" + integrity sha512-m7MLa7sq3YPwojxXiVvoX1PM9iNVtQIn7AdEtBnKTwgxPfGRWUlbs/oMgetpjT/ZYTmv3X5/BghOcstWYvKqRA== + +"seaport@https://github.com/immutable/seaport.git#1.5.0+im.1.3": + version "1.5.0" + resolved "https://github.com/immutable/seaport.git#ae061dc008105dd8d05937df9ad9a676f878cbf9" + dependencies: + "@nomicfoundation/hardhat-network-helpers" "^1.0.7" + "@openzeppelin/contracts" "^4.9.2" + ethers "^5.5.3" + ethers-eip712 "^0.2.0" + hardhat "^2.17.3" + merkletreejs "^0.3.11" + seaport-core immutable/seaport-core#1.5.0+im.1 + seaport-sol "^1.5.0" + seaport-types "^0.0.1" + solady "^0.0.84" + secp256k1@4.0.3, secp256k1@^4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-4.0.3.tgz#c4559ecd1b8d3c1827ed2d1b94190d69ce267303" @@ -8009,6 +8147,11 @@ snake-case@^2.1.0: dependencies: no-case "^2.2.0" +solady@^0.0.84: + version "0.0.84" + resolved "https://registry.yarnpkg.com/solady/-/solady-0.0.84.tgz#95476df1936ef349003e88d8a4853185eb0b7267" + integrity sha512-1ccuZWcMR+g8Ont5LUTqMSFqrmEC+rYAbo6DjZFzdL7AJAtLiaOzO25BKZR10h6YBZNaqO5zUOFy09R6/AzAKQ== + solc@0.7.3: version "0.7.3" resolved "https://registry.yarnpkg.com/solc/-/solc-0.7.3.tgz#04646961bd867a744f63d2b4e3c0701ffdc7d78a" @@ -8556,6 +8699,11 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +treeify@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/treeify/-/treeify-1.1.0.tgz#4e31c6a463accd0943879f30667c4fdaff411bb8" + integrity sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A== + ts-command-line-args@^2.2.0: version "2.5.1" resolved "https://registry.yarnpkg.com/ts-command-line-args/-/ts-command-line-args-2.5.1.tgz#e64456b580d1d4f6d948824c274cf6fa5f45f7f0" @@ -9413,6 +9561,20 @@ web3-utils@1.10.2, web3-utils@^1.0.0-beta.31, web3-utils@^1.2.5, web3-utils@^1.3 randombytes "^2.1.0" utf8 "3.0.0" +web3-utils@^1.3.4: + version "1.10.3" + resolved "https://registry.yarnpkg.com/web3-utils/-/web3-utils-1.10.3.tgz#f1db99c82549c7d9f8348f04ffe4e0188b449714" + integrity sha512-OqcUrEE16fDBbGoQtZXWdavsPzbGIDc5v3VrRTZ0XrIpefC/viZ1ZU9bGEemazyS0catk/3rkOOxpzTfY+XsyQ== + dependencies: + "@ethereumjs/util" "^8.1.0" + bn.js "^5.2.1" + ethereum-bloom-filters "^1.0.6" + ethereum-cryptography "^2.1.2" + ethjs-unit "0.1.6" + number-to-bn "1.7.0" + randombytes "^2.1.0" + utf8 "3.0.0" + web3@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/web3/-/web3-1.10.0.tgz#2fde0009f59aa756c93e07ea2a7f3ab971091274"