diff --git a/.gitmodules b/.gitmodules index 4497ba287..7188e920a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "submodules/seaport"] path = submodules/seaport url = git@github.com:ProjectOpenSea/seaport.git +[submodule "submodules/lssvm"] + path = submodules/lssvm + url = https://github.com/sudoswap/lssvm diff --git a/contracts/domain/BosonConstants.sol b/contracts/domain/BosonConstants.sol index b918f7e56..fcd78eea7 100644 --- a/contracts/domain/BosonConstants.sol +++ b/contracts/domain/BosonConstants.sol @@ -121,6 +121,12 @@ string constant EXCHANGE_IS_NOT_IN_A_FINAL_STATE = "Exchange is not in a final s string constant EXCHANGE_ALREADY_EXISTS = "Exchange already exists"; string constant INVALID_RANGE_LENGTH = "Range length is too large or zero"; +// Revert Reasons: Sequential commit related +string constant UNEXPECTED_ERC721_RECEIVED = "Unexpected ERC721 received"; +string constant FEE_AMOUNT_TOO_HIGH = "Fee amount is too high"; +string constant VOUCHER_NOT_RECEIVED = "Voucher not received"; +string constant NEGATIVE_PRICE_NOT_ALLOWED = "Negative price not allowed"; + // Revert Reasons: Twin related uint256 constant SINGLE_TWIN_RESERVED_GAS = 160000; uint256 constant MINIMAL_RESIDUAL_GAS = 230000; @@ -157,6 +163,7 @@ string constant TOKEN_TRANSFER_FAILED = "Token transfer failed"; string constant INSUFFICIENT_VALUE_RECEIVED = "Insufficient value received"; string constant INSUFFICIENT_AVAILABLE_FUNDS = "Insufficient available funds"; string constant NATIVE_NOT_ALLOWED = "Transfer of native currency not allowed"; +string constant ZERO_DEPOSIT_NOT_ALLOWED = "Zero deposit not allowed"; // Revert Reasons: Meta-Transactions related string constant NONCE_USED_ALREADY = "Nonce used already"; @@ -250,3 +257,16 @@ string constant RETRACT_DISPUTE = "retractDispute(uint256)"; string constant RAISE_DISPUTE = "raiseDispute(uint256)"; string constant ESCALATE_DISPUTE = "escalateDispute(uint256)"; string constant RESOLVE_DISPUTE = "resolveDispute(uint256,uint256,bytes32,bytes32,uint8)"; + +// Price discovery related +string constant PRICE_TOO_HIGH = "Price discovery returned a price that is too high"; +string constant PRICE_TOO_LOW = "Price discovery returned a price that is too low"; +string constant TOKEN_ID_MANDATORY = "Token id is mandatory for bid orders"; +string constant TOKEN_ID_MISMATCH = "Token id mismatch"; +string constant INVALID_PRICE_TYPE = "Invalid price type"; +string constant INVALID_PRICE_DISCOVERY = "Invalid price discovery argument"; +string constant INCOMING_VOUCHER_ALREADY_SET = "Incoming voucher already set"; +string constant NEW_OWNER_AND_BUYER_MUST_MATCH = "New owner and buyer must match"; +string constant PRICE_DISCOVERY_CONTRACTS_NOT_SET = "PriceDiscoveryContract and Conduit must be set"; +string constant TOKEN_ID_NOT_SET = "Token id not set"; +string constant VOUCHER_TRANSFER_NOT_ALLOWED = "Voucher transfer not allowed"; diff --git a/contracts/domain/BosonTypes.sol b/contracts/domain/BosonTypes.sol index c0e222f2d..50f40befd 100644 --- a/contracts/domain/BosonTypes.sol +++ b/contracts/domain/BosonTypes.sol @@ -88,6 +88,11 @@ contract BosonTypes { Clerk // Deprecated. } + enum PriceType { + Static, // Default should always be at index 0. Never change this value. + Discovery + } + struct AuthToken { uint256 tokenId; AuthTokenType tokenType; @@ -152,6 +157,7 @@ contract BosonTypes { string metadataHash; bool voided; uint256 collectionIndex; + PriceType priceType; } struct OfferDates { @@ -192,6 +198,13 @@ contract BosonTypes { ExchangeState state; } + struct ExchangeCosts { + uint256 resellerId; + uint256 price; + uint256 protocolFeeAmount; + uint256 royaltyAmount; + } + struct Voucher { uint256 committedDate; uint256 validUntilDate; @@ -300,4 +313,18 @@ contract BosonTypes { address collectionAddress; string externalId; } + + struct PriceDiscovery { + uint256 price; + Side side; + address priceDiscoveryContract; + address conduit; + bytes priceDiscoveryData; + } + + enum Side { + Ask, + Bid, + Wrapper // Side is not relevant from the protocol perspective + } } diff --git a/contracts/example/SnapshotGate/SnapshotGate.sol b/contracts/example/SnapshotGate/SnapshotGate.sol index e302246a3..bae18982a 100644 --- a/contracts/example/SnapshotGate/SnapshotGate.sol +++ b/contracts/example/SnapshotGate/SnapshotGate.sol @@ -6,7 +6,7 @@ import { IBosonOfferHandler } from "../../interfaces/handlers/IBosonOfferHandler import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { BosonTypes } from "../../domain/BosonTypes.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { ERC721 } from "./support/ERC721.sol"; +import { ERC721 } from "./../support/ERC721.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; /** diff --git a/contracts/example/Sudoswap/SudoswapWrapper.sol b/contracts/example/Sudoswap/SudoswapWrapper.sol new file mode 100644 index 000000000..6f41cd6b3 --- /dev/null +++ b/contracts/example/Sudoswap/SudoswapWrapper.sol @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.9; +import { IBosonExchangeHandler } from "../../interfaces/handlers/IBosonExchangeHandler.sol"; +import { IBosonOfferHandler } from "../../interfaces/handlers/IBosonOfferHandler.sol"; +import { DAIAliases as DAI } from "../../interfaces/DAIAliases.sol"; +import { BosonTypes } from "../../domain/BosonTypes.sol"; +import { ERC721 } from "./../support/ERC721.sol"; +import { IERC721Metadata } from "./../support/IERC721Metadata.sol"; +import { IERC165 } from "../../interfaces/IERC165.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IPool { + function swapTokenForSpecificNFTs( + uint256[] calldata nftIds, + uint256 maxExpectedTokenInput, + address nftRecipient, + bool isRouter, + address routerCaller + ) external payable returns (uint256 inputAmount); +} + +/** + * @title SudoswapWrapper + * @notice Wraps Boson Vouchers so they can be used with Sudoswap. + * + * Features: + * - Wraps vouchers into ERC721 tokens that can be used with Sudoswap. + * - Tracks the price agreed in Sudoswap. + * - Allows to unwrap the voucher and sends funds to the protocol. + * - Owner of wrapped voucher has the right to receive the true corresponding Boson voucher + * + * Out-of-band setup: + * - Create a seller in Boson Protocol and get the Boson Voucher address. + * - Deploy a SudoswapWrapper contract and pass the Boson Voucher address. + * - Approve SudoswapWrapper to transfer Boson Vouchers on behalf of the seller. + * + * Usage: + * - Seller wraps a voucher by calling `wrap` function. + * - Seller calls Sudoswap method `createAuction` with the wrapped voucher address. + * - Auction proceeds normally and either finishes with `endAuction` or `cancelAuction`. + * - If auction finishes with `endAuction`: + * - Bidder gets wrapped voucher and this contract gets the price. + * - `unwrap` must be executed via the Boson Protocol `commitToOffer` method. + * - If auction finishes with `cancelAuction`: + * - This contract gets wrapped voucher back and the bidder gets the price. + * - `unwrap` can be executed by the owner of the wrapped voucher. + * + * N.B. Although Sudoswap can send ethers, it's preffered to receive + * WETH instead. For that reason `receive` is not implemented, so it automatically sends WETH. + */ +contract SudoswapWrapper is BosonTypes, Ownable, ERC721 { + // Add safeTransferFrom to IERC20 + using SafeERC20 for IERC20; + + // Contract addresses + address private immutable voucherAddress; + address private poolAddress; + address private immutable factoryAddress; + address private immutable protocolAddress; + address private immutable wethAddress; + + // Mapping from token ID to price. If pendingTokenId == tokenId, this is not the final price. + mapping(uint256 => uint256) private price; + + // Mapping to cache exchange token address, so costly call to the protocol is not needed every time. + mapping(uint256 => address) private cachedExchangeToken; + + /** + * @notice Constructor + * + * @param _voucherAddress The address of the voucher that are wrapped by this contract. + * @param _factoryAddress The address of the Sudoswap factory. + * @param _protocolAddress The address of the Boson Protocol. + * @param _wethAddress The address of the WETH token. + */ + constructor( + address _voucherAddress, + address _factoryAddress, + address _protocolAddress, + address _wethAddress + ) ERC721(getVoucherName(_voucherAddress), getVoucherSymbol(_voucherAddress)) { + voucherAddress = _voucherAddress; + factoryAddress = _factoryAddress; + protocolAddress = _protocolAddress; + wethAddress = _wethAddress; + + // Approve pool to transfer wrapped vouchers + _setApprovalForAll(address(this), _factoryAddress, true); + } + + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[EIP section] + * to learn more about how these ids are created. + */ + function supportsInterface(bytes4 _interfaceId) public view virtual override(ERC721) returns (bool) { + return (_interfaceId == type(IERC721).interfaceId || _interfaceId == type(IERC165).interfaceId); + } + + /** + * @notice Wraps the vouchers, transfer true vouchers to this contract and mint wrapped vouchers + * + * Reverts if: + * - caller is not the contract owner + * + * @param _tokenIds The token ids. + */ + function wrap(uint256[] memory _tokenIds) external onlyOwner { + for (uint256 i = 0; i < _tokenIds.length; i++) { + uint256 tokenId = _tokenIds[i]; + + // Transfer vouchers to this contract + // Instead of msg.sender it could be voucherAddress, if vouchers were preminted to contract itself + // Not using safeTransferFrom since this contract is the recipient and we are sure it can handle the vouchers + IERC721(voucherAddress).transferFrom(msg.sender, address(this), tokenId); + + // Mint to caller, so it can be used with Sudoswap + _mint(msg.sender, tokenId); + } + } + + /** + * @notice Unwraps the voucher, transfer true voucher to owner and funds to the protocol. + * + * Reverts if: + * - caller is neither protocol nor voucher owner + * + * @param _tokenId The token id. + */ + function unwrap(uint256 _tokenId) external { + address wrappedVoucherOwner = ownerOf(_tokenId); + + // Either contract owner or protocol can unwrap + // If contract owner is unwrapping, this is equivalent to removing the voucher from the pool + require( + msg.sender == protocolAddress || wrappedVoucherOwner == msg.sender, + "SudoswapWrapper: Only owner or protocol can unwrap" + ); + + uint256 priceToPay = price[_tokenId]; + + // Delete price and pendingTokenId to prevent reentrancy + delete price[_tokenId]; + + // transfer Boson Voucher to voucher owner + IERC721(voucherAddress).safeTransferFrom(address(this), wrappedVoucherOwner, _tokenId); + + // Transfer token to protocol + if (priceToPay > 0) { + // This example only supports WETH + IERC20(cachedExchangeToken[_tokenId]).safeTransfer(protocolAddress, priceToPay); + } + + delete cachedExchangeToken[_tokenId]; // gas refund + + // Burn wrapped voucher + _burn(_tokenId); + } + + /** + * @notice Set the pool address + * + * @param _poolAddress The pool address + */ + function setPoolAddress(address _poolAddress) external onlyOwner { + poolAddress = _poolAddress; + } + + /** + * @notice swap token for specific NFT + * + * @param _tokenId - the token id + * @param _maxPrice - the max price + */ + function swapTokenForSpecificNFT(uint256 _tokenId, uint256 _maxPrice) external { + (address exchangeToken, uint256 balanceBefore) = getCurrentBalance(_tokenId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = _tokenId; + + IERC20(exchangeToken).safeTransferFrom(msg.sender, address(this), _maxPrice); + IERC20(exchangeToken).forceApprove(poolAddress, _maxPrice); + + IPool(poolAddress).swapTokenForSpecificNFTs(tokenIds, _maxPrice, msg.sender, false, address(0)); + + (, uint256 balanceAfter) = getCurrentBalance(_tokenId); + + uint256 actualPrice = balanceAfter - balanceBefore; + require(actualPrice <= _maxPrice, "SudoswapWrapper: Price too high"); + + price[_tokenId] = actualPrice; + } + + /** + * @notice Gets own token balance for the exchange token, associated with the token ID. + * + * @dev If the exchange token is not known, it is fetched from the protocol and cached for future use. + * + * @param _tokenId The token id. + */ + function getCurrentBalance(uint256 _tokenId) internal returns (address exchangeToken, uint256 balance) { + exchangeToken = cachedExchangeToken[_tokenId]; + + // If exchange token is not known, get it from the protocol. + if (exchangeToken == address(0)) { + uint256 offerId = _tokenId >> 128; // OfferId is the first 128 bits of the token ID. + + if (offerId == 0) { + // pre v2.2.0. Token does not have offerId, so we need to get it from the protocol. + // Get Boson exchange. Don't explicitly check if the exchange exists, since existance of the token implies it does. + uint256 exchangeId = _tokenId & type(uint128).max; // ExchangeId is the last 128 bits of the token ID. + (, BosonTypes.Exchange memory exchange, ) = IBosonExchangeHandler(protocolAddress).getExchange( + exchangeId + ); + offerId = exchange.offerId; + } + + // Get Boson offer. Don't explicitly check if the offer exists, since existance of the token implies it does. + (, BosonTypes.Offer memory offer, , , , ) = IBosonOfferHandler(protocolAddress).getOffer(offerId); + exchangeToken = offer.exchangeToken; + + // If exchange token is 0, it means native token is used. In that case, use WETH. + if (exchangeToken == address(0)) exchangeToken = wethAddress; + cachedExchangeToken[_tokenId] = exchangeToken; + } + + balance = IERC20(exchangeToken).balanceOf(address(this)); + } + + /** + * @notice Gets the Boson Voucher token name and adds "Wrapped" prefix. + * + * @dev Used only in the constructor. + * + * @param _voucherAddress Boson Voucher address + */ + function getVoucherName(address _voucherAddress) internal view returns (string memory) { + string memory name = IERC721Metadata(_voucherAddress).name(); + return string.concat("Wrapped ", name); + } + + /** + * @notice Gets the the Boson Voucher symbol and adds "W" prefix. + * + * @dev Used only in the constructor. + * + * @param _voucherAddress Boson Voucher address + */ + function getVoucherSymbol(address _voucherAddress) internal view returns (string memory) { + string memory symbol = IERC721Metadata(_voucherAddress).symbol(); + return string.concat("W", symbol); + } +} diff --git a/contracts/example/ZoraWrapper/ZoraWrapper.sol b/contracts/example/ZoraWrapper/ZoraWrapper.sol new file mode 100644 index 000000000..44071b319 --- /dev/null +++ b/contracts/example/ZoraWrapper/ZoraWrapper.sol @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.9; + +import { IBosonOfferHandler } from "../../interfaces/handlers/IBosonOfferHandler.sol"; +import { IBosonExchangeHandler } from "../../interfaces/handlers/IBosonExchangeHandler.sol"; +import { BosonTypes } from "../../domain/BosonTypes.sol"; +import { ERC721 } from "./../support/ERC721.sol"; +import { IERC721Metadata } from "./../support/IERC721Metadata.sol"; +import { IERC721 } from "../../interfaces/IERC721.sol"; +import { IERC165 } from "../../interfaces/IERC165.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC721Receiver } from "../../interfaces/IERC721Receiver.sol"; + +/** + * @title ZoraWrapper + * @notice Wraps Boson Vouchers so they can be used with Zora Auction House. + * + * Features: + * - Wraps vouchers into ERC721 tokens that can be used with Zora Auction House. + * - Tracks the price agreed in Zora Auction House. + * - Allows to unwrap the voucher and sends funds to the protocol. + * - Owner of wrapped voucher has the right to receive the true corresponding Boson voucher + * + * Out-of-band setup: + * - Create a seller in Boson Protocol and get the Boson Voucher address. + * - Deploy a ZoraWrapper contract and pass the Boson Voucher address. + * - Approve ZoraWrapper to transfer Boson Vouchers on behalf of the seller. + * + * Usage: + * - Seller wraps a voucher by calling `wrap` function. + * - Seller calls Zora Auction House method `createAuction` with the wrapped voucher address. + * - Auction proceeds normally and either finishes with `endAuction` or `cancelAuction`. + * - If auction finishes with `endAuction`: + * - Bidder gets wrapped voucher and this contract gets the price. + * - `unwrap` must be executed via the Boson Protocol `commitToOffer` method. + * - If auction finishes with `cancelAuction`: + * - This contract gets wrapped voucher back and the bidder gets the price. + * - `unwrap` can be executed by the owner of the wrapped voucher. + * + * N.B. Although Zora Auction House can send ethers, it's preffered to receive + * WETH instead. For that reason `receive` is not implemented, so it automatically sends WETH. + */ +contract ZoraWrapper is BosonTypes, ERC721, IERC721Receiver { + // Add safeTransferFrom to IERC20 + using SafeERC20 for IERC20; + + // Contract addresses + address private immutable voucherAddress; + address private immutable zoraAuctionHouseAddress; + address private immutable protocolAddress; + address private immutable wethAddress; + + // Token ID for which the price is not yet known + uint256 private pendingTokenId; + + // Mapping from token ID to price. If pendingTokenId == tokenId, this is not the final price. + mapping(uint256 => uint256) private price; + + // Mapping to cache exchange token address, so costly call to the protocol is not needed every time. + mapping(uint256 => address) private cachedExchangeToken; + + mapping(uint256 => address) private wrapper; + + /** + * @notice Constructor + * + * @param _voucherAddress The address of the voucher that are wrapped by this contract. + * @param _zoraAuctionHouseAddress The address of Zora Auction House. + */ + constructor( + address _voucherAddress, + address _zoraAuctionHouseAddress, + address _protocolAddress, + address _wethAddress + ) ERC721(getVoucherName(_voucherAddress), getVoucherSymbol(_voucherAddress)) { + voucherAddress = _voucherAddress; + zoraAuctionHouseAddress = _zoraAuctionHouseAddress; + protocolAddress = _protocolAddress; + wethAddress = _wethAddress; + + // Approve Zora Auction House to transfer wrapped vouchers + _setApprovalForAll(address(this), _zoraAuctionHouseAddress, true); + } + + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[EIP section] + * to learn more about how these ids are created. + */ + function supportsInterface(bytes4 _interfaceId) public view virtual override(ERC721) returns (bool) { + return (_interfaceId == type(IERC721).interfaceId || _interfaceId == type(IERC165).interfaceId); + } + + /** + * @notice Wraps the voucher, transfer true voucher to itself and approves the contract owner to operate on it. + * + * Reverts if: + * - caller is not the contract owner + * + * @param _tokenId The token id. + */ + function wrap(uint256 _tokenId) external { + // Transfer voucher to this contract + // Instead of msg.sender it could be voucherAddress, if vouchers were preminted to contract itself + // Not using safeTransferFrom since this contract is the recipient and we are sure it can handle the vouchers + IERC721(voucherAddress).transferFrom(msg.sender, address(this), _tokenId); + + // Mint to itself, so it can be used with Zora Auction House + _mint(address(this), _tokenId); // why not sender instead of address(this)? + + // Approves original token owner to operate on wrapped token + _approve(msg.sender, _tokenId); + + wrapper[_tokenId] = msg.sender; + } + + /** + * @notice Unwraps the voucher, transfer true voucher to owner and funds to the protocol. + * + * Reverts if: + * - caller is neither protocol nor voucher owner + * + * @param _tokenId The token id. + */ + function unwrap(uint256 _tokenId) external { + address wrappedVoucherOwner = ownerOf(_tokenId); + if (wrappedVoucherOwner == address(this)) wrappedVoucherOwner = wrapper[_tokenId]; + + // Either contract owner or protocol can unwrap + // If contract owner is unwrapping, this is equivalent to canceled auction + require( + msg.sender == protocolAddress || wrappedVoucherOwner == msg.sender, + "ZoraWrapper: Only owner or protocol can unwrap" + ); + + // If some token price is not know yet, update it now + if (pendingTokenId != 0) updatePendingTokenPrice(); + + uint256 priceToPay = price[_tokenId]; + + // Delete price and pendingTokenId to prevent reentrancy + delete price[_tokenId]; + delete pendingTokenId; + + // transfer voucher to voucher owner + IERC721(voucherAddress).safeTransferFrom(address(this), wrappedVoucherOwner, _tokenId); + + // Transfer token to protocol + if (priceToPay > 0) { + // No need to handle native separately, since Zora Auction House always sends WETH + IERC20(cachedExchangeToken[_tokenId]).safeTransfer(protocolAddress, priceToPay); + } + + delete cachedExchangeToken[_tokenId]; // gas refund + delete wrapper[_tokenId]; + + // Burn wrapped voucher + _burn(_tokenId); + } + + /** + * @notice Handle transfers out of Zora Auction House. + * + * @param _from The address of the sender. + * @param _to The address of the recipient. + * @param _tokenId The token id. + */ + function _beforeTokenTransfer(address _from, address _to, uint256 _tokenId) internal virtual override(ERC721) { + if (_from == zoraAuctionHouseAddress && _to != address(this)) { + // Auction is over, and wrapped voucher is being transferred to voucher owner + // If recipient is address(this), it means the auction was canceled and price updating can be skipped + + // If some token price is not know yet, update it now + if (pendingTokenId != 0) updatePendingTokenPrice(); + + // Store current balance and set the pending token id + price[_tokenId] = getCurrentBalance(_tokenId); + pendingTokenId = _tokenId; + } + + super._beforeTokenTransfer(_from, _to, _tokenId); + } + + function updatePendingTokenPrice() internal { + uint256 tokenId = pendingTokenId; + price[tokenId] = getCurrentBalance(tokenId) - price[tokenId]; + } + + /** + * @notice Gets own token balance for the exchange token, associated with the token ID. + * + * @dev If the exchange token is not known, it is fetched from the protocol and cached for future use. + * + * @param _tokenId The token id. + */ + function getCurrentBalance(uint256 _tokenId) internal returns (uint256) { + address exchangeToken = cachedExchangeToken[_tokenId]; + + // If exchange token is not known, get it from the protocol. + if (exchangeToken == address(0)) { + uint256 offerId = _tokenId >> 128; // OfferId is the first 128 bits of the token ID. + + if (offerId == 0) { + // pre v2.2.0. Token does not have offerId, so we need to get it from the protocol. + // Get Boson exchange. Don't explicitly check if the exchange exists, since existance of the token implies it does. + uint256 exchangeId = _tokenId & type(uint128).max; // ExchangeId is the last 128 bits of the token ID. + (, BosonTypes.Exchange memory exchange, ) = IBosonExchangeHandler(protocolAddress).getExchange( + exchangeId + ); + offerId = exchange.offerId; + } + + // Get Boson offer. Don't explicitly check if the offer exists, since existance of the token implies it does. + (, BosonTypes.Offer memory offer, , , , ) = IBosonOfferHandler(protocolAddress).getOffer(offerId); + exchangeToken = offer.exchangeToken; + + // If exchange token is 0, it means native token is used. In that case, use WETH. + if (exchangeToken == address(0)) exchangeToken = wethAddress; + cachedExchangeToken[_tokenId] = exchangeToken; + } + + return IERC20(exchangeToken).balanceOf(address(this)); + } + + /** + * @notice Gets the Boson Voucher token name and adds "Wrapped" prefix. + * + * @dev Used only in the constructor. + * + * @param _voucherAddress Boson Voucher address + */ + function getVoucherName(address _voucherAddress) internal view returns (string memory) { + string memory name = IERC721Metadata(_voucherAddress).name(); + return string.concat("Wrapped ", name); + } + + /** + * @notice Gets the the Boson Voucher symbol and adds "W" prefix. + * + * @dev Used only in the constructor. + * + * @param _voucherAddress Boson Voucher address + */ + function getVoucherSymbol(address _voucherAddress) internal view returns (string memory) { + string memory symbol = IERC721Metadata(_voucherAddress).symbol(); + return string.concat("W", symbol); + } + + /** + * @dev See {IERC721Receiver-onERC721Received}. + * + * Always returns `IERC721Receiver.onERC721Received.selector`. + */ + function onERC721Received(address, address, uint256, bytes calldata) public virtual override returns (bytes4) { + return this.onERC721Received.selector; + } +} diff --git a/contracts/example/SnapshotGate/support/ERC721.sol b/contracts/example/support/ERC721.sol similarity index 99% rename from contracts/example/SnapshotGate/support/ERC721.sol rename to contracts/example/support/ERC721.sol index ce7d60854..d58bb24a9 100644 --- a/contracts/example/SnapshotGate/support/ERC721.sol +++ b/contracts/example/support/ERC721.sol @@ -3,8 +3,8 @@ pragma solidity 0.8.21; -import "../../../interfaces/IERC721.sol"; -import "./IERC721Receiver.sol"; +import "../../interfaces/IERC721.sol"; +import "../../interfaces/IERC721Receiver.sol"; import "./IERC721Metadata.sol"; import "@openzeppelin/contracts/utils/Address.sol"; import "@openzeppelin/contracts/utils/Strings.sol"; diff --git a/contracts/example/SnapshotGate/support/IERC721Metadata.sol b/contracts/example/support/IERC721Metadata.sol similarity index 94% rename from contracts/example/SnapshotGate/support/IERC721Metadata.sol rename to contracts/example/support/IERC721Metadata.sol index b58d0a0c7..c8ffac583 100644 --- a/contracts/example/SnapshotGate/support/IERC721Metadata.sol +++ b/contracts/example/support/IERC721Metadata.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.21; -import "../../../interfaces/IERC721.sol"; +import "../../interfaces/IERC721.sol"; /** * @title ERC-721 Non-Fungible Token Standard, optional metadata extension diff --git a/contracts/example/SnapshotGate/support/IERC721Receiver.sol b/contracts/interfaces/IERC721Receiver.sol similarity index 100% rename from contracts/example/SnapshotGate/support/IERC721Receiver.sol rename to contracts/interfaces/IERC721Receiver.sol diff --git a/contracts/interfaces/IWrappedNative.sol b/contracts/interfaces/IWrappedNative.sol new file mode 100644 index 000000000..1e311d02a --- /dev/null +++ b/contracts/interfaces/IWrappedNative.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +/** + * @title IWrappedNative + * + * @notice Provides the minimum interface for native token wrapper + */ +interface IWrappedNative { + function withdraw(uint256) external; + + function deposit() external payable; + + function transfer(address, uint256) external returns (bool); + + function transferFrom(address, address, uint256) external returns (bool); + + function approve(address, uint256) external returns (bool); +} diff --git a/contracts/interfaces/handlers/IBosonExchangeHandler.sol b/contracts/interfaces/handlers/IBosonExchangeHandler.sol index da1464e0e..d5fbbb83e 100644 --- a/contracts/interfaces/handlers/IBosonExchangeHandler.sol +++ b/contracts/interfaces/handlers/IBosonExchangeHandler.sol @@ -11,11 +11,11 @@ import { IBosonFundsLibEvents } from "../events/IBosonFundsEvents.sol"; * * @notice Handles exchanges associated with offers within the protocol. * - * The ERC-165 identifier for this interface is: 0xf34a48fa + * The ERC-165 identifier for this interface is: 0x0e1fefcb */ interface IBosonExchangeHandler is IBosonExchangeEvents, IBosonFundsLibEvents, IBosonTwinEvents { /** - * @notice Commits to an offer (first step of an exchange). + * @notice Commits to a static offer (first step of an exchange). * * Emits a BuyerCommitted event if successful. * Issues a voucher to the buyer address. @@ -74,30 +74,6 @@ interface IBosonExchangeHandler is IBosonExchangeEvents, IBosonFundsLibEvents, I */ function commitToConditionalOffer(address payable _buyer, uint256 _offerId, uint256 _tokenId) external payable; - /** - * @notice Commits to a preminted offer (first step of an exchange). - * - * Emits a BuyerCommitted event if successful. - * - * Reverts if: - * - The exchanges region of protocol is paused - * - The buyers region of protocol is paused - * - Caller is not the voucher contract, owned by the seller - * - Exchange exists already - * - Offer has been voided - * - Offer has expired - * - Offer is not yet available for commits - * - Buyer account is inactive - * - Buyer is token-gated (conditional commit requirements not met or already used) - * - Buyer is token-gated and condition has a range. - * - Seller has less funds available than sellerDeposit and price - * - * @param _buyer - the buyer's address (caller can commit on behalf of a buyer) - * @param _offerId - the id of the offer to commit to - * @param _exchangeId - the id of the exchange - */ - function commitToPreMintedOffer(address payable _buyer, uint256 _offerId, uint256 _exchangeId) external; - /** * @notice Completes an exchange. * @@ -217,6 +193,7 @@ interface IBosonExchangeHandler is IBosonExchangeEvents, IBosonFundsLibEvents, I * Emits a VoucherTransferred event if successful. * * Reverts if + * - The exchanges region of protocol is paused * - The buyers region of protocol is paused * - Caller is not a clone address associated with the seller * - Exchange does not exist @@ -224,10 +201,34 @@ interface IBosonExchangeHandler is IBosonExchangeEvents, IBosonFundsLibEvents, I * - Voucher has expired * - New buyer's existing account is deactivated * - * @param _exchangeId - the id of the exchange + * @param _tokenId - the voucher id * @param _newBuyer - the address of the new buyer */ - function onVoucherTransferred(uint256 _exchangeId, address payable _newBuyer) external; + function onVoucherTransferred(uint256 _tokenId, address payable _newBuyer) external; + + /** + * @notice Handle pre-minted voucher transfer + * + * Reverts if: + * - The exchanges region of protocol is paused + * - The buyers region of protocol is paused + * - Caller is not a clone address associated with the seller + * - Incoming voucher clone address is not the caller + * - Offer price is discovery, transaction is not starting from protocol nor seller is _from address + * - Any reason that ExchangeHandler commitToOfferInternal reverts. See ExchangeHandler.commitToOfferInternal + * + * @param _tokenId - the voucher id + * @param _to - the receiver address + * @param _from - the address of current owner + * @param _rangeOwner - the address of the preminted range owner + * @return committed - true if the voucher was committed + */ + function onPremintedVoucherTransferred( + uint256 _tokenId, + address payable _to, + address _from, + address _rangeOwner + ) external returns (bool committed); /** * @notice Checks if the given exchange in a finalized state. diff --git a/contracts/interfaces/handlers/IBosonFundsHandler.sol b/contracts/interfaces/handlers/IBosonFundsHandler.sol index 01e3257ed..f2ddde462 100644 --- a/contracts/interfaces/handlers/IBosonFundsHandler.sol +++ b/contracts/interfaces/handlers/IBosonFundsHandler.sol @@ -20,9 +20,11 @@ interface IBosonFundsHandler is IBosonFundsEvents, IBosonFundsLibEvents { * * Reverts if: * - The funds region of protocol is paused + * - Amount to deposit is zero * - Seller id does not exist * - It receives some native currency (e.g. ETH), but token address is not zero * - It receives some native currency (e.g. ETH), and the amount does not match msg.value + * - It receives no native currency, but token address is zero * - Contract at token address does not support ERC20 function transferFrom * - Calling transferFrom on token fails for some reason (e.g. protocol is not approved to transfer) * - Received ERC20 token amount differs from the expected value diff --git a/contracts/interfaces/handlers/IBosonOfferHandler.sol b/contracts/interfaces/handlers/IBosonOfferHandler.sol index 172d4240e..9eefbaa3c 100644 --- a/contracts/interfaces/handlers/IBosonOfferHandler.sol +++ b/contracts/interfaces/handlers/IBosonOfferHandler.sol @@ -9,7 +9,7 @@ import { IBosonOfferEvents } from "../events/IBosonOfferEvents.sol"; * * @notice Handles creation, voiding, and querying of offers within the protocol. * - * The ERC-165 identifier for this interface is: 0xa1e3b91c + * The ERC-165 identifier for this interface is: 0x67991d09 */ interface IBosonOfferHandler is IBosonOfferEvents { /** diff --git a/contracts/interfaces/handlers/IBosonOrchestrationHandler.sol b/contracts/interfaces/handlers/IBosonOrchestrationHandler.sol index c71c6a781..b2de2bb5d 100644 --- a/contracts/interfaces/handlers/IBosonOrchestrationHandler.sol +++ b/contracts/interfaces/handlers/IBosonOrchestrationHandler.sol @@ -13,7 +13,7 @@ import { IBosonBundleEvents } from "../events/IBosonBundleEvents.sol"; * * @notice Combines creation of multiple entities (accounts, offers, groups, twins, bundles) in a single transaction * - * The ERC-165 identifier for this interface is: 0x7e216084 + * The ERC-165 identifier for this interface is: 0xb8b97453 */ interface IBosonOrchestrationHandler is IBosonAccountEvents, diff --git a/contracts/interfaces/handlers/IBosonPriceDiscoveryHandler.sol b/contracts/interfaces/handlers/IBosonPriceDiscoveryHandler.sol new file mode 100644 index 000000000..b93a7daf3 --- /dev/null +++ b/contracts/interfaces/handlers/IBosonPriceDiscoveryHandler.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.21; + +import { BosonTypes } from "../../domain/BosonTypes.sol"; +import { IBosonExchangeEvents } from "../events/IBosonExchangeEvents.sol"; +import { IBosonTwinEvents } from "../events/IBosonTwinEvents.sol"; +import { IBosonFundsLibEvents } from "../events/IBosonFundsEvents.sol"; + +/** + * @title IBosonPriceDiscoveryHandler + * + * @notice Handles exchanges associated with offers within the protocol. + * + * The ERC-165 identifier for this interface is: 0xdec319c9 + */ +interface IBosonPriceDiscoveryHandler is IBosonExchangeEvents, IBosonFundsLibEvents, IBosonTwinEvents { + /** + * @notice Commits to a price discovery offer (first step of an exchange). + * + * Emits a BuyerCommitted event if successful. + * Issues a voucher to the buyer address. + * + * Reverts if: + * - Offer price type is not price discovery. See BosonTypes.PriceType + * - Price discovery contract address is zero + * - Price discovery calldata is empty + * - Exchange exists already + * - Offer has been voided + * - Offer has expired + * - Offer is not yet available for commits + * - Buyer address is zero + * - Buyer account is inactive + * - Buyer is token-gated (conditional commit requirements not met or already used) + * - Any reason that PriceDiscoveryBase fulfilOrder reverts. See PriceDiscoveryBase.fulfilOrder + * - Any reason that ExchangeHandler onPremintedVoucherTransfer reverts. See ExchangeHandler.onPremintedVoucherTransfer + * + * @param _buyer - the buyer's address (caller can commit on behalf of a buyer) + * @param _tokenIdOrOfferId - the id of the offer to commit to or the id of the voucher (if pre-minted) + * @param _priceDiscovery - price discovery data (if applicable). See BosonTypes.PriceDiscovery + */ + function commitToPriceDiscoveryOffer( + address payable _buyer, + uint256 _tokenIdOrOfferId, + BosonTypes.PriceDiscovery calldata _priceDiscovery + ) external payable; +} diff --git a/contracts/interfaces/handlers/IBosonSequentialCommitHandler.sol b/contracts/interfaces/handlers/IBosonSequentialCommitHandler.sol new file mode 100644 index 000000000..461b58597 --- /dev/null +++ b/contracts/interfaces/handlers/IBosonSequentialCommitHandler.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.21; + +import { BosonTypes } from "../../domain/BosonTypes.sol"; +import { IBosonExchangeEvents } from "../events/IBosonExchangeEvents.sol"; +import { IBosonFundsLibEvents } from "../events/IBosonFundsEvents.sol"; + +/** + * @title ISequentialCommitHandler + * + * @notice Handles sequential commits. + * + * The ERC-165 identifier for this interface is: 0x34780cc6 + */ +interface IBosonSequentialCommitHandler is IBosonExchangeEvents, IBosonFundsLibEvents { + /** + * @notice Commits to an existing exchange. Price discovery is offloaded to external contract. + * + * Emits a BuyerCommitted event if successful. + * Transfers voucher to the buyer address. + * + * Reverts if: + * - The exchanges region of protocol is paused + * - The buyers region of protocol is paused + * - Buyer address is zero + * - Exchange does not exist + * - Exchange is not in Committed state + * - Voucher has expired + * - It is a bid order and: + * - Caller is not the voucher holder + * - Voucher owner did not approve protocol to transfer the voucher + * - Price received from price discovery is lower than the expected price + * - It is a ask order and: + * - Offer price is in native token and caller does not send enough + * - Offer price is in some ERC20 token and caller also sends native currency + * - Calling transferFrom on token fails for some reason (e.g. protocol is not approved to transfer) + * - Received ERC20 token amount differs from the expected value + * - Protocol does not receive the voucher + * - Transfer of voucher to the buyer fails for some reasong (e.g. buyer is contract that doesn't accept voucher) + * - Reseller did not approve protocol to transfer exchange token in escrow + * - Call to price discovery contract fails + * - Protocol fee and royalties combined exceed the secondary price + * - Transfer of exchange token fails + * + * @param _buyer - the buyer's address (caller can commit on behalf of a buyer) + * @param _exchangeId - the id of the exchange to commit to + * @param _priceDiscovery - the fully populated BosonTypes.PriceDiscovery struct + */ + function sequentialCommitToOffer( + address payable _buyer, + uint256 _exchangeId, + BosonTypes.PriceDiscovery calldata _priceDiscovery + ) external payable; +} diff --git a/contracts/mock/AuctionHouse.sol b/contracts/mock/AuctionHouse.sol new file mode 100644 index 000000000..6c304d5b8 --- /dev/null +++ b/contracts/mock/AuctionHouse.sol @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.9; +pragma experimental ABIEncoderV2; + +import { SafeMath } from "@openzeppelin/contracts/utils/math/SafeMath.sol"; +import { IERC721, IERC165 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { Counters } from "@openzeppelin/contracts/utils/Counters.sol"; +import { IAuctionHouse } from "./IAuctionHouse.sol"; + +interface IWETH { + function deposit() external payable; + + function withdraw(uint256 wad) external; + + function transfer(address to, uint256 value) external returns (bool); +} + +/** + * @title An open auction house, enabling collectors and curators to run their own auctions + * @dev This is an minimal clone of zora's AuctionHouse contract https://github.com/ourzora/auction-house/blob/01c4e8085c6815bf3233057dee8e628aca07813f/contracts/AuctionHouse.sol + */ +contract AuctionHouse is IAuctionHouse, ReentrancyGuard { + using SafeMath for uint256; + using SafeERC20 for IERC20; + using Counters for Counters.Counter; + + // The minimum amount of time left in an auction after a new bid is created + uint256 public timeBuffer; + + // The minimum percentage difference between the last bid amount and the current bid. + uint8 public minBidIncrementPercentage; + + // / The address of the WETH contract, so that any ETH transferred can be handled as an ERC-20 + address public wethAddress; + + // A mapping of all of the auctions currently running. + mapping(uint256 => IAuctionHouse.Auction) public auctions; + + bytes4 internal constant INTERFACE_ID = 0x80ac58cd; // 721 interface id + + Counters.Counter private _auctionIdTracker; + + /** + * @notice Require that the specified auction exists + */ + modifier auctionExists(uint256 auctionId) { + require(_exists(auctionId), "Auction doesn't exist"); + _; + } + + /* + * Constructor + */ + constructor(address _weth) { + wethAddress = _weth; + timeBuffer = 15 * 60; // extend 15 minutes after every bid made in last 15 minutes + minBidIncrementPercentage = 5; // 5% + } + + /** + * @notice Create an auction. + * @dev Store the auction details in the auctions mapping and emit an AuctionCreated event. + * If there is no curator, or if the curator is the auction creator, automatically approve the auction. + */ + function createAuction( + uint256 tokenId, + address tokenContract, + uint256 duration, + uint256 reservePrice, + address payable curator, + uint8 curatorFeePercentage, + address auctionCurrency + ) public override nonReentrant returns (uint256) { + require( + IERC165(tokenContract).supportsInterface(INTERFACE_ID), + "tokenContract does not support ERC721 interface" + ); + require(curatorFeePercentage < 100, "curatorFeePercentage must be less than 100"); + address tokenOwner = IERC721(tokenContract).ownerOf(tokenId); + require( + msg.sender == IERC721(tokenContract).getApproved(tokenId) || msg.sender == tokenOwner, + "Caller must be approved or owner for token id" + ); + uint256 auctionId = _auctionIdTracker.current(); + + Auction storage auction = auctions[auctionId]; + auction.tokenId = tokenId; + auction.tokenContract = tokenContract; + auction.approved = false; + auction.amount = 0; + auction.duration = duration; + auction.firstBidTime = 0; + auction.reservePrice = reservePrice; + auction.curatorFeePercentage = curatorFeePercentage; + auction.tokenOwner = tokenOwner; + auction.bidder = address(0); + auction.auctionCurrency = auctionCurrency; + auction.curator = curator; + + IERC721(tokenContract).transferFrom(tokenOwner, address(this), tokenId); + + _auctionIdTracker.increment(); + + emit AuctionCreated( + auctionId, + tokenId, + tokenContract, + duration, + reservePrice, + tokenOwner, + curator, + curatorFeePercentage, + auctionCurrency + ); + + if (auctions[auctionId].curator == address(0) || curator == tokenOwner) { + _approveAuction(auctionId, true); + } + + return auctionId; + } + + /** + * @notice Approve an auction, opening up the auction for bids. + * @dev Only callable by the curator. Cannot be called if the auction has already started. + */ + function setAuctionApproval(uint256 auctionId, bool approved) external override auctionExists(auctionId) { + require(msg.sender == auctions[auctionId].curator, "Must be auction curator"); + require(auctions[auctionId].firstBidTime == 0, "Auction has already started"); + _approveAuction(auctionId, approved); + } + + function setAuctionReservePrice( + uint256 auctionId, + uint256 reservePrice + ) external override auctionExists(auctionId) { + require( + msg.sender == auctions[auctionId].curator || msg.sender == auctions[auctionId].tokenOwner, + "Must be auction curator or token owner" + ); + require(auctions[auctionId].firstBidTime == 0, "Auction has already started"); + + auctions[auctionId].reservePrice = reservePrice; + + emit AuctionReservePriceUpdated( + auctionId, + auctions[auctionId].tokenId, + auctions[auctionId].tokenContract, + reservePrice + ); + } + + /** + * @notice Create a bid on a token, with a given amount. + * @dev If provided a valid bid, transfers the provided amount to this contract. + * If the auction is run in native ETH, the ETH is wrapped so it can be identically to other + * auction currencies in this contract. + */ + function createBid( + uint256 auctionId, + uint256 amount + ) external payable override auctionExists(auctionId) nonReentrant { + address lastBidder = auctions[auctionId].bidder; + require(auctions[auctionId].approved, "Auction must be approved by curator"); + require( + auctions[auctionId].firstBidTime == 0 || + block.timestamp < auctions[auctionId].firstBidTime.add(auctions[auctionId].duration), + "Auction expired" + ); + require(amount >= auctions[auctionId].reservePrice, "Must send at least reservePrice"); + require( + amount >= + auctions[auctionId].amount.add(auctions[auctionId].amount.mul(minBidIncrementPercentage).div(100)), + "Must send more than last bid by minBidIncrementPercentage amount" + ); + + // If this is the first valid bid, we should set the starting time now. + // If it's not, then we should refund the last bidder + if (auctions[auctionId].firstBidTime == 0) { + auctions[auctionId].firstBidTime = block.timestamp; + } else if (lastBidder != address(0)) { + _handleOutgoingBid(lastBidder, auctions[auctionId].amount, auctions[auctionId].auctionCurrency); + } + + _handleIncomingBid(amount, auctions[auctionId].auctionCurrency); + + auctions[auctionId].amount = amount; + auctions[auctionId].bidder = msg.sender; + + bool extended = false; + // at this point we know that the timestamp is less than start + duration (since the auction would be over, otherwise) + // we want to know by how much the timestamp is less than start + duration + // if the difference is less than the timeBuffer, increase the duration by the timeBuffer + if (auctions[auctionId].firstBidTime.add(auctions[auctionId].duration).sub(block.timestamp) < timeBuffer) { + // Playing code golf for gas optimization: + // uint256 expectedEnd = auctions[auctionId].firstBidTime.add(auctions[auctionId].duration); + // uint256 timeRemaining = expectedEnd.sub(block.timestamp); + // uint256 timeToAdd = timeBuffer.sub(timeRemaining); + // uint256 newDuration = auctions[auctionId].duration.add(timeToAdd); + uint256 oldDuration = auctions[auctionId].duration; + auctions[auctionId].duration = oldDuration.add( + timeBuffer.sub(auctions[auctionId].firstBidTime.add(oldDuration).sub(block.timestamp)) + ); + extended = true; + } + + emit AuctionBid( + auctionId, + auctions[auctionId].tokenId, + auctions[auctionId].tokenContract, + msg.sender, + amount, + lastBidder == address(0), // firstBid boolean + extended + ); + + if (extended) { + emit AuctionDurationExtended( + auctionId, + auctions[auctionId].tokenId, + auctions[auctionId].tokenContract, + auctions[auctionId].duration + ); + } + } + + /** + * @notice End an auction paying out the respective parties. + * @dev If for some reason the auction cannot be finalized (invalid token recipient, for example), + * The auction is reset and the NFT is transferred back to the auction creator. + */ + function endAuction(uint256 auctionId) external override auctionExists(auctionId) nonReentrant { + require(uint256(auctions[auctionId].firstBidTime) != 0, "Auction hasn't begun"); + require( + block.timestamp >= auctions[auctionId].firstBidTime.add(auctions[auctionId].duration), + "Auction hasn't completed" + ); + + address currency = auctions[auctionId].auctionCurrency == address(0) + ? wethAddress + : auctions[auctionId].auctionCurrency; + uint256 curatorFee = 0; + + uint256 tokenOwnerProfit = auctions[auctionId].amount; + + // Otherwise, transfer the token to the winner and pay out the participants below + try + IERC721(auctions[auctionId].tokenContract).safeTransferFrom( + address(this), + auctions[auctionId].bidder, + auctions[auctionId].tokenId + ) + {} catch { + _handleOutgoingBid( + auctions[auctionId].bidder, + auctions[auctionId].amount, + auctions[auctionId].auctionCurrency + ); + _cancelAuction(auctionId); + return; + } + + if (auctions[auctionId].curator != address(0)) { + curatorFee = tokenOwnerProfit.mul(auctions[auctionId].curatorFeePercentage).div(100); + tokenOwnerProfit = tokenOwnerProfit.sub(curatorFee); + _handleOutgoingBid(auctions[auctionId].curator, curatorFee, auctions[auctionId].auctionCurrency); + } + _handleOutgoingBid(auctions[auctionId].tokenOwner, tokenOwnerProfit, auctions[auctionId].auctionCurrency); + + emit AuctionEnded( + auctionId, + auctions[auctionId].tokenId, + auctions[auctionId].tokenContract, + auctions[auctionId].tokenOwner, + auctions[auctionId].curator, + auctions[auctionId].bidder, + tokenOwnerProfit, + curatorFee, + currency + ); + delete auctions[auctionId]; + } + + /** + * @notice Cancel an auction. + * @dev Transfers the NFT back to the auction creator and emits an AuctionCanceled event + */ + function cancelAuction(uint256 auctionId) external override nonReentrant auctionExists(auctionId) { + require( + auctions[auctionId].tokenOwner == msg.sender || auctions[auctionId].curator == msg.sender, + "Can only be called by auction creator or curator" + ); + require(uint256(auctions[auctionId].firstBidTime) == 0, "Can't cancel an auction once it's begun"); + _cancelAuction(auctionId); + } + + /** + * @dev Given an amount and a currency, transfer the currency to this contract. + * If the currency is ETH (0x0), attempt to wrap the amount as WETH + */ + function _handleIncomingBid(uint256 amount, address currency) internal { + // If this is an ETH bid, ensure they sent enough and convert it to WETH under the hood + if (currency == address(0)) { + require(msg.value == amount, "Sent ETH Value does not match specified bid amount"); + IWETH(wethAddress).deposit{ value: amount }(); + } else { + // We must check the balance that was actually transferred to the auction, + // as some tokens impose a transfer fee and would not actually transfer the + // full amount to the market, resulting in potentally locked funds + IERC20 token = IERC20(currency); + uint256 beforeBalance = token.balanceOf(address(this)); + token.safeTransferFrom(msg.sender, address(this), amount); + uint256 afterBalance = token.balanceOf(address(this)); + require(beforeBalance.add(amount) == afterBalance, "Token transfer call did not transfer expected amount"); + } + } + + function _handleOutgoingBid(address to, uint256 amount, address currency) internal { + // If the auction is in ETH, unwrap it from its underlying WETH and try to send it to the recipient. + if (currency == address(0)) { + IWETH(wethAddress).withdraw(amount); + + // If the ETH transfer fails (sigh), rewrap the ETH and try send it as WETH. + if (!_safeTransferETH(to, amount)) { + IWETH(wethAddress).deposit{ value: amount }(); + IERC20(wethAddress).safeTransfer(to, amount); + } + } else { + IERC20(currency).safeTransfer(to, amount); + } + } + + function _safeTransferETH(address to, uint256 value) internal returns (bool) { + (bool success, ) = to.call{ value: value }(new bytes(0)); + return success; + } + + function _cancelAuction(uint256 auctionId) internal { + address tokenOwner = auctions[auctionId].tokenOwner; + IERC721(auctions[auctionId].tokenContract).safeTransferFrom( + address(this), + tokenOwner, + auctions[auctionId].tokenId + ); + + emit AuctionCanceled(auctionId, auctions[auctionId].tokenId, auctions[auctionId].tokenContract, tokenOwner); + delete auctions[auctionId]; + } + + function _approveAuction(uint256 auctionId, bool approved) internal { + auctions[auctionId].approved = approved; + emit AuctionApprovalUpdated( + auctionId, + auctions[auctionId].tokenId, + auctions[auctionId].tokenContract, + approved + ); + } + + function _exists(uint256 auctionId) internal view returns (bool) { + return auctions[auctionId].tokenOwner != address(0); + } + + // TODO: consider reverting if the message sender is not WETH + receive() external payable {} + + fallback() external payable {} +} diff --git a/contracts/mock/IAuctionHouse.sol b/contracts/mock/IAuctionHouse.sol new file mode 100644 index 000000000..44181f24c --- /dev/null +++ b/contracts/mock/IAuctionHouse.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.9; +pragma experimental ABIEncoderV2; + +/** + * @title Interface for Auction Houses + */ +interface IAuctionHouse { + struct Auction { + // ID for the ERC721 token + uint256 tokenId; + // Address for the ERC721 contract + address tokenContract; + // Whether or not the auction curator has approved the auction to start + bool approved; + // The current highest bid amount + uint256 amount; + // The length of time to run the auction for, after the first bid was made + uint256 duration; + // The time of the first bid + uint256 firstBidTime; + // The minimum price of the first bid + uint256 reservePrice; + // The sale percentage to send to the curator + uint8 curatorFeePercentage; + // The address that should receive the funds once the NFT is sold. + address tokenOwner; + // The address of the current highest bid + address bidder; + // The address of the auction's curator. + // The curator can reject or approve an auction + address curator; + // The address of the ERC-20 currency to run the auction with. + // If set to 0x0, the auction will be run in ETH + address auctionCurrency; + } + + event AuctionCreated( + uint256 indexed auctionId, + uint256 indexed tokenId, + address indexed tokenContract, + uint256 duration, + uint256 reservePrice, + address tokenOwner, + address curator, + uint8 curatorFeePercentage, + address auctionCurrency + ); + + event AuctionApprovalUpdated( + uint256 indexed auctionId, + uint256 indexed tokenId, + address indexed tokenContract, + bool approved + ); + + event AuctionReservePriceUpdated( + uint256 indexed auctionId, + uint256 indexed tokenId, + address indexed tokenContract, + uint256 reservePrice + ); + + event AuctionBid( + uint256 indexed auctionId, + uint256 indexed tokenId, + address indexed tokenContract, + address sender, + uint256 value, + bool firstBid, + bool extended + ); + + event AuctionDurationExtended( + uint256 indexed auctionId, + uint256 indexed tokenId, + address indexed tokenContract, + uint256 duration + ); + + event AuctionEnded( + uint256 indexed auctionId, + uint256 indexed tokenId, + address indexed tokenContract, + address tokenOwner, + address curator, + address winner, + uint256 amount, + uint256 curatorFee, + address auctionCurrency + ); + + event AuctionCanceled( + uint256 indexed auctionId, + uint256 indexed tokenId, + address indexed tokenContract, + address tokenOwner + ); + + function createAuction( + uint256 tokenId, + address tokenContract, + uint256 duration, + uint256 reservePrice, + address payable curator, + uint8 curatorFeePercentages, + address auctionCurrency + ) external returns (uint256); + + function setAuctionApproval(uint256 auctionId, bool approved) external; + + function setAuctionReservePrice(uint256 auctionId, uint256 reservePrice) external; + + function createBid(uint256 auctionId, uint256 amount) external payable; + + function endAuction(uint256 auctionId) external; + + function cancelAuction(uint256 auctionId) external; +} diff --git a/contracts/mock/MockAuction.sol b/contracts/mock/MockAuction.sol new file mode 100644 index 000000000..3467e3088 --- /dev/null +++ b/contracts/mock/MockAuction.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.9; +pragma experimental ABIEncoderV2; + +import { IERC721, IERC165 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { Counters } from "@openzeppelin/contracts/utils/Counters.sol"; + +interface IWETH { + function deposit() external payable; + + function withdraw(uint256 wad) external; + + function transfer(address to, uint256 value) external returns (bool); +} + +/** + * @title An open auction house, enabling collectors and curators to run their own auctions + * @dev This is an inspired by zora's AuctionHouse contract https://github.com/ourzora/auction-house/blob/01c4e8085c6815bf3233057dee8e628aca07813f/contracts/AuctionHouse.sol + * But stripped down and only for test purposes + */ +contract MockAuction { + using SafeERC20 for IERC20; + + event AuctionCanceled(); + + struct Auction { + // ID for the ERC721 token + uint256 tokenId; + // Address for the ERC721 contract + address tokenContract; + // The current highest bid amount + uint256 amount; + // The address that should receive the funds once the NFT is sold. + address tokenOwner; + // The address of the current highest bid + address bidder; + // The address of the ERC-20 currency to run the auction with. + // If set to 0x0, the auction will be run in ETH + address auctionCurrency; + address curator; + } + + address public immutable wethAddress; + + // A mapping of all of the auctions currently running. + mapping(uint256 => Auction) public auctions; + + uint256 public auctionIdCounter; + + /* + * Constructor + */ + constructor(address _weth) { + wethAddress = _weth; + } + + /** + * @notice Create an auction. + * @dev Store the auction details in the auctions mapping + */ + function createAuction(uint256 tokenId, address tokenContract, address auctionCurrency, address curator) external { + address tokenOwner = IERC721(tokenContract).ownerOf(tokenId); + require( + msg.sender == IERC721(tokenContract).getApproved(tokenId) || msg.sender == tokenOwner, + "Caller must be approved or owner for token id" + ); + uint256 auctionId = auctionIdCounter++; + + Auction storage auction = auctions[auctionId]; + auction.tokenId = tokenId; + auction.tokenContract = tokenContract; + auction.amount = 0; + auction.tokenOwner = tokenOwner; + auction.bidder = address(0); + auction.auctionCurrency = auctionCurrency; + auction.curator = curator; + + IERC721(tokenContract).transferFrom(tokenOwner, address(this), tokenId); + } + + /** + * @notice Create a bid on a token, with a given amount. + * @dev If provided a valid bid, transfers the provided amount to this contract. + * If the auction is run in native ETH, the ETH is wrapped so it can be identically to other + * auction currencies in this contract. + */ + function createBid(uint256 auctionId, uint256 amount) external payable { + address lastBidder = auctions[auctionId].bidder; + + require( + amount > auctions[auctionId].amount, + "Must send more than last bid by minBidIncrementPercentage amount" + ); + + // If it's not, then we should refund the last bidder + if (lastBidder != address(0)) { + _handleOutgoingBid(lastBidder, auctions[auctionId].amount, auctions[auctionId].auctionCurrency); + } + + _handleIncomingBid(amount, auctions[auctionId].auctionCurrency); + + auctions[auctionId].amount = amount; + auctions[auctionId].bidder = msg.sender; + } + + /** + * @notice End an auction paying out the respective parties. + * @dev If for some reason the auction cannot be finalized (invalid token recipient, for example), + * The auction reverts. + */ + function endAuction(uint256 auctionId) external { + uint256 tokenOwnerProfit = auctions[auctionId].amount; + + // Otherwise, transfer the token to the winner and pay out the participants below + try + IERC721(auctions[auctionId].tokenContract).safeTransferFrom( + address(this), + auctions[auctionId].bidder, + auctions[auctionId].tokenId + ) + {} catch (bytes memory reason) { + if (reason.length == 0) { + revert("Voucher transfer failed"); + } else { + /// @solidity memory-safe-assembly + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + + _handleOutgoingBid(auctions[auctionId].tokenOwner, tokenOwnerProfit, auctions[auctionId].auctionCurrency); + + delete auctions[auctionId]; + } + + /** + * @notice Cancel an auction. + * @dev Transfers the NFT back to the auction creator and emits an AuctionCanceled event + */ + function cancelAuction(uint256 auctionId) external { + require( + auctions[auctionId].tokenOwner == msg.sender || auctions[auctionId].curator == msg.sender, + "Can only be called by auction creator or curator" + ); + _cancelAuction(auctionId); + } + + /** + * @dev Given an amount and a currency, transfer the currency to this contract. + * If the currency is ETH (0x0), attempt to wrap the amount as WETH + */ + function _handleIncomingBid(uint256 amount, address currency) internal { + // If this is an ETH bid, ensure they sent enough and convert it to WETH under the hood + if (currency == address(0)) { + require(msg.value == amount, "Sent ETH Value does not match specified bid amount"); + IWETH(wethAddress).deposit{ value: amount }(); + } else { + // We must check the balance that was actually transferred to the auction, + // as some tokens impose a transfer fee and would not actually transfer the + // full amount to the market, resulting in potentally locked funds + IERC20 token = IERC20(currency); + uint256 beforeBalance = token.balanceOf(address(this)); + token.safeTransferFrom(msg.sender, address(this), amount); + uint256 afterBalance = token.balanceOf(address(this)); + require(beforeBalance + amount == afterBalance, "Token transfer call did not transfer expected amount"); + } + } + + function _handleOutgoingBid(address to, uint256 amount, address currency) internal { + // If the auction is in ETH, unwrap it from its underlying WETH and try to send it to the recipient. + if (currency == address(0)) { + IWETH(wethAddress).withdraw(amount); + + // If the ETH transfer fails (sigh), rewrap the ETH and try send it as WETH. + if (!_safeTransferETH(to, amount)) { + IWETH(wethAddress).deposit{ value: amount }(); + IERC20(wethAddress).safeTransfer(to, amount); + } + } else { + IERC20(currency).safeTransfer(to, amount); + } + } + + function _safeTransferETH(address to, uint256 value) internal returns (bool) { + (bool success, ) = to.call{ value: value }(new bytes(0)); + return success; + } + + function _cancelAuction(uint256 auctionId) internal { + address tokenOwner = auctions[auctionId].tokenOwner; + IERC721(auctions[auctionId].tokenContract).safeTransferFrom( + address(this), + tokenOwner, + auctions[auctionId].tokenId + ); + + emit AuctionCanceled(); + delete auctions[auctionId]; + } + + receive() external payable {} + + fallback() external payable {} +} diff --git a/contracts/mock/PriceDiscovery.sol b/contracts/mock/PriceDiscovery.sol new file mode 100644 index 000000000..2888fb0a0 --- /dev/null +++ b/contracts/mock/PriceDiscovery.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.8.0) (metatx/MockForwarder.sol) +pragma solidity 0.8.21; + +import "../interfaces/IERC20.sol"; +import "../interfaces/IERC721.sol"; +import "./Foreign721.sol"; +import { IERC721Receiver } from "../interfaces/IERC721Receiver.sol"; + +/** + * @dev Simple price discovery contract used in tests + * + * This contract simulates external price discovery mechanism. + * When user commits to an offer, protocol talks to this contract to validate the exchange. + */ +contract PriceDiscovery { + struct Order { + address seller; + address buyer; + address voucherContract; // sold by seller + uint256 tokenId; // is exchange id + address exchangeToken; + uint256 price; + } + + /** + * @dev simple fulfillOrder that does not perform any checks + * It just transfers the voucher from the seller to the caller (buyer) and exchange token from the caller to the seller + * If any of the transfers fail, the whole transaction will revert + */ + function fulfilBuyOrder(Order memory _order) public payable virtual { + // transfer voucher + try IERC721(_order.voucherContract).safeTransferFrom(_order.seller, msg.sender, _order.tokenId) {} catch ( + bytes memory reason + ) { + if (reason.length == 0) { + revert("Voucher transfer failed"); + } else { + /// @solidity memory-safe-assembly + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + + // transfer exchange token + if (_order.exchangeToken == address(0)) { + (bool success, ) = payable(_order.seller).call{ value: _order.price }(""); + require(success, "Token transfer failed"); + + // return any extra ETH to the buyer + if (msg.value > _order.price) { + (success, ) = payable(msg.sender).call{ value: msg.value - _order.price }(""); + require(success, "ETH return failed"); + } + } else + try IERC20(_order.exchangeToken).transferFrom(msg.sender, _order.seller, _order.price) {} catch ( + bytes memory reason + ) { + if (reason.length == 0) { + revert("Token transfer failed"); + } else { + /// @solidity memory-safe-assembly + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + } + + function fulfilSellOrder(Order memory _order) public payable virtual { + // transfer voucher + try IERC721(_order.voucherContract).safeTransferFrom(msg.sender, _order.buyer, _order.tokenId) {} catch ( + bytes memory reason + ) { + if (reason.length == 0) { + revert("Voucher transfer failed"); + } else { + /// @solidity memory-safe-assembly + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + + // transfer exchange token + try IERC20(_order.exchangeToken).transferFrom(_order.buyer, msg.sender, _order.price) {} catch ( + bytes memory reason + ) { + if (reason.length == 0) { + revert("Token transfer failed"); + } else { + /// @solidity memory-safe-assembly + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + + // return half of the sent value back to the caller + payable(msg.sender).transfer(msg.value / 2); + } +} + +/** + * @dev Simple bad price discovery contract used in tests + * + * This contract modifies the token id, simulates bad/malicious contract + */ +contract PriceDiscoveryModifyTokenId is PriceDiscovery { + /** + * @dev simple fulfillOrder that does not perform any checks + * Bump token id by 1 + */ + function fulfilBuyOrder(Order memory _order) public payable override { + _order.tokenId++; + super.fulfilBuyOrder(_order); + } +} + +/** + * @dev Simple bad price discovery contract used in tests + * + * This contract modifies the erc721 token, simulates bad/malicious contract + */ +contract PriceDiscoveryModifyVoucherContract is PriceDiscovery { + Foreign721 private erc721; + + constructor(address _erc721) { + erc721 = Foreign721(_erc721); + } + + /** + * @dev simple fulfillOrder that does not perform any checks + * Change order voucher address with custom erc721 + * Mint tokenId on custom erc721 + */ + function fulfilBuyOrder(Order memory _order) public payable override { + erc721.mint(_order.tokenId, 1); + + _order.seller = address(this); + _order.voucherContract = address(erc721); + super.fulfilBuyOrder(_order); + } +} + +/** + * @dev Simple bad price discovery contract used in tests + * + * This contract simply does not transfer the voucher to the caller + */ +contract PriceDiscoveryNoTransfer is PriceDiscovery { + /** + * @dev do nothing + */ + function fulfilBuyOrder(Order memory _order) public payable override {} +} + +/** + * @dev Simple bad price discovery contract used in tests + * + * This contract transfers the voucher to itself instead of the origina msg.sender + */ +contract PriceDiscoveryTransferElsewhere is PriceDiscovery, IERC721Receiver { + /** + * @dev invoke fulfilBuyOrder on itself, making it the msg.sender + */ + function fulfilBuyOrderElsewhere(Order memory _order) public payable { + this.fulfilBuyOrder(_order); + } + + /** + * @dev See {IERC721Receiver-onERC721Received}. + * + * Always returns `IERC721Receiver.onERC721Received.selector`. + */ + function onERC721Received(address, address, uint256, bytes calldata) public virtual override returns (bytes4) { + return this.onERC721Received.selector; + } +} diff --git a/contracts/mock/TestProtocolFunctions.sol b/contracts/mock/TestProtocolFunctions.sol index 678a23530..dd808b7a8 100644 --- a/contracts/mock/TestProtocolFunctions.sol +++ b/contracts/mock/TestProtocolFunctions.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.21; +import { BosonTypes } from "../domain/BosonTypes.sol"; import { IBosonExchangeHandler } from "../interfaces/handlers/IBosonExchangeHandler.sol"; /** diff --git a/contracts/mock/WETH9.sol b/contracts/mock/WETH9.sol new file mode 100644 index 000000000..cbda7a81f --- /dev/null +++ b/contracts/mock/WETH9.sol @@ -0,0 +1,69 @@ +/** + * @title WETH + * + * @notice Mock WETH used for testing + * source: https://github.com/gnosis/canonical-weth/blob/master/contracts/WETH9.sol + */ + +// solhint-disable-next-line compiler-version +pragma solidity >=0.4.22 <0.6; + +contract WETH9 { + string public name = "Wrapped Ether"; + string public symbol = "WETH"; + uint8 public decimals = 18; + + event Approval(address indexed src, address indexed guy, uint256 wad); + event Transfer(address indexed src, address indexed dst, uint256 wad); + event Deposit(address indexed dst, uint256 wad); + event Withdrawal(address indexed src, uint256 wad); + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + function() external payable { + deposit(); + } + + function deposit() public payable { + balanceOf[msg.sender] += msg.value; + emit Deposit(msg.sender, msg.value); + } + + function withdraw(uint256 wad) public { + require(balanceOf[msg.sender] >= wad); + balanceOf[msg.sender] -= wad; + msg.sender.transfer(wad); + emit Withdrawal(msg.sender, wad); + } + + function totalSupply() public view returns (uint256) { + return address(this).balance; + } + + function approve(address guy, uint256 wad) public returns (bool) { + allowance[msg.sender][guy] = wad; + emit Approval(msg.sender, guy, wad); + return true; + } + + function transfer(address dst, uint256 wad) public returns (bool) { + return transferFrom(msg.sender, dst, wad); + } + + function transferFrom(address src, address dst, uint256 wad) public returns (bool) { + require(balanceOf[src] >= wad); + + if (src != msg.sender && allowance[src][msg.sender] != uint256(-1)) { + require(allowance[src][msg.sender] >= wad); + allowance[src][msg.sender] -= wad; + } + + balanceOf[src] -= wad; + balanceOf[dst] += wad; + + emit Transfer(src, dst, wad); + + return true; + } +} diff --git a/contracts/protocol/bases/BeaconClientBase.sol b/contracts/protocol/bases/BeaconClientBase.sol index a93222c3f..c80d2b9fb 100644 --- a/contracts/protocol/bases/BeaconClientBase.sol +++ b/contracts/protocol/bases/BeaconClientBase.sol @@ -62,12 +62,30 @@ abstract contract BeaconClientBase is BosonTypes { /** * @notice Informs protocol of new buyer associated with an exchange * - * @param _exchangeId - the id of the exchange + * @param _tokenId - the voucher id * @param _newBuyer - the address of the new buyer */ - function onVoucherTransferred(uint256 _exchangeId, address payable _newBuyer) internal { + function onVoucherTransferred(uint256 _tokenId, address payable _newBuyer) internal { + address protocolDiamond = IClientExternalAddresses(BeaconClientLib._beacon()).getProtocolAddress(); + IBosonExchangeHandler(protocolDiamond).onVoucherTransferred(_tokenId, _newBuyer); + } + + /** + * @notice Informs protocol of a pre-minted voucher transfer + * + * @param _tokenId - the voucher id + * @param _to - the address of the new buyer + * @param _from - the address of current owner + * @param _rangeOwner - the address of the preminted range owner + */ + function onPremintedVoucherTransferred( + uint256 _tokenId, + address payable _to, + address _from, + address _rangeOwner + ) internal returns (bool) { address protocolDiamond = IClientExternalAddresses(BeaconClientLib._beacon()).getProtocolAddress(); - IBosonExchangeHandler(protocolDiamond).onVoucherTransferred(_exchangeId, _newBuyer); + return IBosonExchangeHandler(protocolDiamond).onPremintedVoucherTransferred(_tokenId, _to, _from, _rangeOwner); } /** diff --git a/contracts/protocol/bases/BuyerBase.sol b/contracts/protocol/bases/BuyerBase.sol index a1a35cb00..6aa7e89e0 100644 --- a/contracts/protocol/bases/BuyerBase.sol +++ b/contracts/protocol/bases/BuyerBase.sol @@ -61,4 +61,34 @@ contract BuyerBase is ProtocolBase, IBosonAccountEvents { //Map the buyer's wallet address to the buyerId. protocolLookups().buyerIdByWallet[_buyer.wallet] = _buyer.id; } + + /** + * @notice Checks if buyer exists for buyer address. If not, account is created for buyer address. + * + * Reverts if buyer exists but is inactive. + * + * @param _buyer - the buyer address to check + * @return buyerId - the buyer id + */ + function getValidBuyer(address payable _buyer) internal returns (uint256 buyerId) { + // Find or create the account associated with the specified buyer address + bool exists; + (exists, buyerId) = getBuyerIdByWallet(_buyer); + + if (exists) { + // Fetch the existing buyer account + (, Buyer storage buyer) = fetchBuyer(buyerId); + + // Make sure buyer account is active + require(buyer.active, MUST_BE_ACTIVE); + } else { + // Create the buyer account + Buyer memory newBuyer; + newBuyer.wallet = _buyer; + newBuyer.active = true; + + createBuyerInternal(newBuyer); + buyerId = newBuyer.id; + } + } } diff --git a/contracts/protocol/bases/DisputeBase.sol b/contracts/protocol/bases/DisputeBase.sol index b88613d90..5f24cd83f 100644 --- a/contracts/protocol/bases/DisputeBase.sol +++ b/contracts/protocol/bases/DisputeBase.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.21; import { IBosonDisputeEvents } from "../../interfaces/events/IBosonDisputeEvents.sol"; +import { IBosonFundsLibEvents } from "../../interfaces/events/IBosonFundsEvents.sol"; import { ProtocolBase } from "./../bases/ProtocolBase.sol"; import { ProtocolLib } from "./../libs/ProtocolLib.sol"; import { FundsLib } from "./../libs/FundsLib.sol"; @@ -12,7 +13,7 @@ import "../../domain/BosonConstants.sol"; * @title DisputeBase * @notice Provides methods for dispute that can be shared across facets. */ -contract DisputeBase is ProtocolBase, IBosonDisputeEvents { +contract DisputeBase is ProtocolBase, IBosonDisputeEvents, IBosonFundsLibEvents { /** * @notice Raises a dispute * @@ -84,7 +85,8 @@ contract DisputeBase is ProtocolBase, IBosonDisputeEvents { (Exchange storage exchange, ) = getValidExchange(_exchangeId, ExchangeState.Disputed); // Make sure the caller is buyer associated with the exchange - checkBuyer(exchange.buyerId); + uint256 buyerId = exchange.buyerId; + checkBuyer(buyerId); // Fetch the dispute and dispute dates (, Dispute storage dispute, DisputeDates storage disputeDates) = fetchDispute(_exchangeId); @@ -105,7 +107,9 @@ contract DisputeBase is ProtocolBase, IBosonDisputeEvents { (, Offer storage offer) = fetchOffer(exchange.offerId); // make sure buyer sent enough funds to proceed - FundsLib.validateIncomingPayment(offer.exchangeToken, disputeResolutionTerms.buyerEscalationDeposit); + address exchangeToken = offer.exchangeToken; + uint256 buyerEscalationDeposit = disputeResolutionTerms.buyerEscalationDeposit; + FundsLib.validateIncomingPayment(exchangeToken, buyerEscalationDeposit); // fetch the escalation period from the storage uint256 escalationResponsePeriod = disputeResolutionTerms.escalationResponsePeriod; @@ -118,6 +122,8 @@ contract DisputeBase is ProtocolBase, IBosonDisputeEvents { dispute.state = DisputeState.Escalated; // Notify watchers of state change - emit DisputeEscalated(_exchangeId, disputeResolutionTerms.disputeResolverId, msgSender()); + address sender = msgSender(); + emit FundsEncumbered(buyerId, exchangeToken, buyerEscalationDeposit, sender); + emit DisputeEscalated(_exchangeId, disputeResolutionTerms.disputeResolverId, sender); } } diff --git a/contracts/protocol/bases/OfferBase.sol b/contracts/protocol/bases/OfferBase.sol index 238c4ff97..a7a7f05d4 100644 --- a/contracts/protocol/bases/OfferBase.sol +++ b/contracts/protocol/bases/OfferBase.sol @@ -224,9 +224,7 @@ contract OfferBase is ProtocolBase, IBosonOfferEvents { require(_offer.buyerCancelPenalty <= offerPrice, OFFER_PENALTY_INVALID); // Calculate and set the protocol fee - uint256 protocolFee = _offer.exchangeToken == protocolAddresses().token - ? protocolFees().flatBoson - : (protocolFees().percentage * offerPrice) / 10000; + uint256 protocolFee = getProtocolFee(_offer.exchangeToken, offerPrice); // Calculate the agent fee amount uint256 agentFeeAmount = (agent.feePercentage * offerPrice) / 10000; @@ -258,6 +256,7 @@ contract OfferBase is ProtocolBase, IBosonOfferEvents { offer.metadataUri = _offer.metadataUri; offer.metadataHash = _offer.metadataHash; offer.collectionIndex = _offer.collectionIndex; + offer.priceType = _offer.priceType; // Get storage location for offer dates OfferDates storage offerDates = fetchOfferDates(_offer.id); diff --git a/contracts/protocol/bases/PriceDiscoveryBase.sol b/contracts/protocol/bases/PriceDiscoveryBase.sol new file mode 100644 index 000000000..10dc1049b --- /dev/null +++ b/contracts/protocol/bases/PriceDiscoveryBase.sol @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import "../../domain/BosonConstants.sol"; +import { ProtocolLib } from "../libs/ProtocolLib.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IWrappedNative } from "../../interfaces/IWrappedNative.sol"; +import { IBosonVoucher } from "../../interfaces/clients/IBosonVoucher.sol"; +import { ProtocolBase } from "./../bases/ProtocolBase.sol"; +import { FundsLib } from "../libs/FundsLib.sol"; +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title PriceDiscoveryBase + * + * @dev Provides methods for fulfiling orders on external price discovery contracts. + */ +contract PriceDiscoveryBase is ProtocolBase { + using Address for address; + using SafeERC20 for IERC20; + + IWrappedNative internal immutable wNative; + uint256 private immutable EXCHANGE_ID_2_2_0; // solhint-disable-line + + /** + * @notice + * For offers with native exchange token, it is expected the the price discovery contracts will + * operate with wrapped native token. Set the address of the wrapped native token in the constructor. + * + * After v2.2.0, token ids are derived from offerId and exchangeId. + * EXCHANGE_ID_2_2_0 is the first exchange id to use for 2.2.0. + * Set EXCHANGE_ID_2_2_0 in the constructor. + * + * @param _wNative - the address of the wrapped native token + * @param _firstExchangeId2_2_0 - the first exchange id to use for 2.2.0 + */ + //solhint-disable-next-line + constructor(address _wNative, uint256 _firstExchangeId2_2_0) { + wNative = IWrappedNative(_wNative); + EXCHANGE_ID_2_2_0 = _firstExchangeId2_2_0; + } + + /** + * @notice Fulfils an order on an external contract. + * + * If the owner is price discovery contract, the protocol cannot act as an intermediary in the exchange, + * and sellers must use Wrapped's contract. Wrappers handle ask and bid orders in the same manner. + * + * See descriptions of `fulfilAskOrder`, `fulfilBidOrder` and handleWrapper for more details. + * + * @param _tokenId - the id of the token. Accepts whatever token is sent by price discovery contract when this value is zero. + * @param _offer - the fully populated BosonTypes.Offer struct + * @param _priceDiscovery - the fully populated BosonTypes.PriceDiscovery struct + * @param _seller - the seller's address + * @param _buyer - the buyer's address (caller can commit on behalf of a buyer) + * @return actualPrice - the actual price of the order + */ + function fulfilOrder( + uint256 _tokenId, + Offer storage _offer, + PriceDiscovery calldata _priceDiscovery, + address _seller, + address _buyer + ) internal returns (uint256 actualPrice) { + require( + _priceDiscovery.priceDiscoveryContract != address(0) && _priceDiscovery.conduit != address(0), + PRICE_DISCOVERY_CONTRACTS_NOT_SET + ); + + IBosonVoucher bosonVoucher = IBosonVoucher( + getCloneAddress(protocolLookups(), _offer.sellerId, _offer.collectionIndex) + ); + + // Set incoming voucher clone address + protocolStatus().incomingVoucherCloneAddress = address(bosonVoucher); + + if (_priceDiscovery.side == Side.Ask) { + return fulfilAskOrder(_tokenId, _offer.exchangeToken, _priceDiscovery, _buyer, bosonVoucher); + } else if (_priceDiscovery.side == Side.Bid) { + return fulfilBidOrder(_tokenId, _offer.exchangeToken, _priceDiscovery, _seller, bosonVoucher); + } else { + // _priceDiscovery.side == Side.Wrapper + // Handle wrapper voucher, there is no difference between ask and bid + return handleWrapper(_tokenId, _offer.exchangeToken, _priceDiscovery, bosonVoucher); + } + } + + /** + * @notice Fulfils an ask order on external contract. + * + * Reverts if: + * - Offer price is in native token and caller does not send enough + * - Offer price is in some ERC20 token and caller also sends native currency + * - Calling transferFrom on token fails for some reason (e.g. protocol is not approved to transfer) + * - Call to price discovery contract fails + * - Received amount is greater from price set in price discovery + * - Protocol does not receive the voucher + * - Transfer of voucher to the buyer fails for some reason (e.g. buyer is contract that doesn't accept voucher) + * - New voucher owner is not buyer wallet + * - Token id sent to buyer and token id set by the caller don't match (if caller has provided token id) + * + * @param _tokenId - the id of the token + * @param _exchangeToken - the address of the exchange contract + * @param _priceDiscovery - the fully populated BosonTypes.PriceDiscovery struct + * @param _buyer - the buyer's address (caller can commit on behalf of a buyer) + * @param _bosonVoucher - the boson voucher contract + * @return actualPrice - the actual price of the order + */ + function fulfilAskOrder( + uint256 _tokenId, + address _exchangeToken, + PriceDiscovery calldata _priceDiscovery, + address _buyer, + IBosonVoucher _bosonVoucher + ) internal returns (uint256 actualPrice) { + // Transfer buyers funds to protocol + FundsLib.validateIncomingPayment(_exchangeToken, _priceDiscovery.price); + + // If token is ERC20, approve price discovery contract to transfer protocol funds + if (_exchangeToken != address(0)) { + IERC20(_exchangeToken).forceApprove(_priceDiscovery.conduit, _priceDiscovery.price); + } + + uint256 protocolBalanceBefore = getBalance(_exchangeToken, address(this)); + + // Call the price discovery contract + _priceDiscovery.priceDiscoveryContract.functionCallWithValue(_priceDiscovery.priceDiscoveryData, msg.value); + + uint256 protocolBalanceAfter = getBalance(_exchangeToken, address(this)); + require(protocolBalanceBefore >= protocolBalanceAfter, NEGATIVE_PRICE_NOT_ALLOWED); + actualPrice = protocolBalanceBefore - protocolBalanceAfter; + + // If token is ERC20, reset approval + if (_exchangeToken != address(0)) { + IERC20(_exchangeToken).forceApprove(address(_priceDiscovery.conduit), 0); + } + + _tokenId = getAndVerifyTokenId(_tokenId); + + { + // Make sure that the price discovery contract has transferred the voucher to the protocol + require(_bosonVoucher.ownerOf(_tokenId) == address(this), VOUCHER_NOT_RECEIVED); + + // Transfer voucher to buyer + _bosonVoucher.transferFrom(address(this), _buyer, _tokenId); + } + + uint256 overchargedAmount = _priceDiscovery.price - actualPrice; + + if (overchargedAmount > 0) { + // Return the surplus to caller + FundsLib.transferFundsFromProtocol(_exchangeToken, payable(msgSender()), overchargedAmount); + } + } + + /** + * @notice Fulfils a bid order on external contract. + * + * Reverts if: + * - Token id not set by the caller + * - Calling transferFrom on token fails for some reason (e.g. protocol is not approved to transfer) + * - Transfer of voucher to the buyer fails for some reason (e.g. buyer is contract that doesn't accept voucher) + * - Received ERC20 token amount differs from the expected value + * - Call to price discovery contract fails + * - Protocol balance change after price discovery call is lower than the expected price + * - Reseller did not approve protocol to transfer exchange token in escrow + * - New voucher owner is not buyer wallet + * - Token id sent to buyer and token id set by the caller don't match + * + * @param _tokenId - the id of the token + * @param _exchangeToken - the address of the exchange token + * @param _priceDiscovery - the fully populated BosonTypes.PriceDiscovery struct + * @param _seller - the seller's address + * @param _bosonVoucher - the boson voucher contract + * @return actualPrice - the actual price of the order + */ + function fulfilBidOrder( + uint256 _tokenId, + address _exchangeToken, + PriceDiscovery calldata _priceDiscovery, + address _seller, + IBosonVoucher _bosonVoucher + ) internal returns (uint256 actualPrice) { + require(_tokenId != 0, TOKEN_ID_MANDATORY); + + address sender = msgSender(); + require(_seller == sender, NOT_VOUCHER_HOLDER); + + // Transfer seller's voucher to protocol + // Don't need to use safe transfer from, since that protocol can handle the voucher + _bosonVoucher.transferFrom(sender, address(this), _tokenId); + + // Approve conduit to transfer voucher. There is no need to reset approval afterwards, since protocol is not the voucher owner anymore + _bosonVoucher.approve(_priceDiscovery.conduit, _tokenId); + if (_exchangeToken == address(0)) _exchangeToken = address(wNative); + + // Track native balance just in case if seller sends some native currency or price discovery contract does + // This is the balance that protocol had, before commit to offer was called + uint256 protocolNativeBalanceBefore = getBalance(address(0), address(this)) - msg.value; + + // Get protocol balance before calling price discovery contract + uint256 protocolBalanceBefore = getBalance(_exchangeToken, address(this)); + + // Call the price discovery contract + _priceDiscovery.priceDiscoveryContract.functionCallWithValue(_priceDiscovery.priceDiscoveryData, msg.value); + + // Get protocol balance after calling price discovery contract + uint256 protocolBalanceAfter = getBalance(_exchangeToken, address(this)); + + // Check the native balance and return the surplus to seller + uint256 protocolNativeBalanceAfter = getBalance(address(0), address(this)); + if (protocolNativeBalanceAfter > protocolNativeBalanceBefore) { + // Return the surplus to seller + FundsLib.transferFundsFromProtocol( + address(0), + payable(sender), + protocolNativeBalanceAfter - protocolNativeBalanceBefore + ); + } + + // Calculate actual price + require(protocolBalanceAfter >= protocolBalanceBefore, NEGATIVE_PRICE_NOT_ALLOWED); + actualPrice = protocolBalanceAfter - protocolBalanceBefore; + + // Make sure that balance change is at least the expected price + require(actualPrice >= _priceDiscovery.price, INSUFFICIENT_VALUE_RECEIVED); + + // Verify that token id provided by caller matches the token id that the price discovery contract has sent to buyer + getAndVerifyTokenId(_tokenId); + } + + /* + * @notice Call `unwrap` (or equivalent) function on the price discovery contract. + * + * Reverts if: + * - Token id not set by the caller + * - Protocol balance doesn't increase by the expected amount. + * Balance change must be equal to the price set by the caller + * - Token id sent to buyer and token id set by the caller don't match + * + * @param _tokenId - the id of the token + * @param _exchangeToken - the address of the exchange contract + * @param _priceDiscovery - the fully populated BosonTypes.PriceDiscovery struct + * @param _bosonVoucher - the boson voucher contract + * @return actualPrice - the actual price of the order + */ + function handleWrapper( + uint256 _tokenId, + address _exchangeToken, + PriceDiscovery calldata _priceDiscovery, + IBosonVoucher _bosonVoucher + ) internal returns (uint256 actualPrice) { + require(_tokenId != 0, TOKEN_ID_MANDATORY); + + // If price discovery contract does not own the voucher, it cannot be classified as a wrapper + address owner = _bosonVoucher.ownerOf(_tokenId); + require(owner == _priceDiscovery.priceDiscoveryContract, NOT_VOUCHER_HOLDER); + + // Check balance before calling wrapper + bool isNative = _exchangeToken == address(0); + if (isNative) _exchangeToken = address(wNative); + uint256 protocolBalanceBefore = getBalance(_exchangeToken, address(this)); + + // Track native balance just in case if seller sends some native currency. + // All native currency is forwarded to the wrapper, which should not return any back. + // If it does, we revert later in the code. + uint256 protocolNativeBalanceBefore = getBalance(address(0), address(this)) - msg.value; + + // Call the price discovery contract + _priceDiscovery.priceDiscoveryContract.functionCallWithValue(_priceDiscovery.priceDiscoveryData, msg.value); + + // Check the native balance and revert if there is a surplus + uint256 protocolNativeBalanceAfter = getBalance(address(0), address(this)); + require(protocolNativeBalanceAfter == protocolNativeBalanceBefore); + + // Check balance after the price discovery call + uint256 protocolBalanceAfter = getBalance(_exchangeToken, address(this)); + + // Verify that actual price is within the expected range + require(protocolBalanceAfter >= protocolBalanceBefore, NEGATIVE_PRICE_NOT_ALLOWED); + actualPrice = protocolBalanceAfter - protocolBalanceBefore; + + // when working with wrappers, price is already known, so the caller should set it exactly + // If protocol receive more than expected, it does not return the surplus to the caller + require(actualPrice == _priceDiscovery.price, PRICE_TOO_LOW); + + // Verify that token id provided by caller matches the token id that the price discovery contract has sent to buyer + getAndVerifyTokenId(_tokenId); + } + + /** + * @notice Returns the balance of the protocol for the given token address + * + * @param _tokenAddress - the address of the token to check the balance for + * @return balance - the balance of the protocol for the given token address + */ + function getBalance(address _tokenAddress, address entity) internal view returns (uint256) { + return _tokenAddress == address(0) ? entity.balance : IERC20(_tokenAddress).balanceOf(entity); + } + + /* + * @notice Returns the token id that the price discovery contract has sent to the protocol or buyer + * + * Reverts if: + * - Caller has provided token id, but it does not match the token id that the price discovery contract has sent to the protocol + * + * @param _tokenId - the token id that the caller has provided + * @return tokenId - the token id that the price discovery contract has sent to the protocol + */ + function getAndVerifyTokenId(uint256 _tokenId) internal view returns (uint256) { + // Store the information about incoming voucher + ProtocolLib.ProtocolStatus storage ps = protocolStatus(); + + // If caller has provided token id, it must match the token id that the price discovery send to the protocol + if (_tokenId != 0) { + require(_tokenId == ps.incomingVoucherId, TOKEN_ID_MISMATCH); + } else { + // If caller has not provided token id, use the one stored in onPremintedVoucherTransfer function + _tokenId = ps.incomingVoucherId; + } + + // Token id cannot be zero at this point + require(_tokenId != 0, TOKEN_ID_NOT_SET); + + return _tokenId; + } + + /* + * @notice Resets value of incoming voucher id and incoming voucher clone address to 0 + * This is called at the end of the methods that interacts with price discovery contracts + * + */ + function clearPriceDiscoveryStorage() internal { + ProtocolLib.ProtocolStatus storage ps = protocolStatus(); + delete ps.incomingVoucherId; + delete ps.incomingVoucherCloneAddress; + } +} diff --git a/contracts/protocol/bases/ProtocolBase.sol b/contracts/protocol/bases/ProtocolBase.sol index 6ed2bf5c1..c8094f393 100644 --- a/contracts/protocol/bases/ProtocolBase.sol +++ b/contracts/protocol/bases/ProtocolBase.sol @@ -684,6 +684,21 @@ abstract contract ProtocolBase is PausableBase, ReentrancyGuardBase { exists = (_exchangeId > 0 && condition.method != EvaluationMethod.None); } + /** + * @notice calculate the protocol fee for a given exchange + * + * @param _exchangeToken - the token used for the exchange + * @param _price - the price of the exchange + * @return protocolFee - the protocol fee + */ + function getProtocolFee(address _exchangeToken, uint256 _price) internal view returns (uint256 protocolFee) { + // Calculate and set the protocol fee + return + _exchangeToken == protocolAddresses().token + ? protocolFees().flatBoson + : (protocolFees().percentage * _price) / 10000; + } + /** * @notice Fetches a clone address from storage by seller id and collection index * If the collection index is 0, the clone address is the seller's main collection, diff --git a/contracts/protocol/clients/voucher/BosonVoucher.sol b/contracts/protocol/clients/voucher/BosonVoucher.sol index 3a35f95be..a4ff269ef 100644 --- a/contracts/protocol/clients/voucher/BosonVoucher.sol +++ b/contracts/protocol/clients/voucher/BosonVoucher.sol @@ -52,10 +52,10 @@ contract BosonVoucherBase is IBosonVoucher, BeaconClientBase, OwnableUpgradeable // Map an offerId to a Range for pre-minted offers mapping(uint256 => Range) private _rangeByOfferId; - // Premint status, used only temporarly in transfers - bool private _isCommitable; + // Used only temporarly in transfers + bool private _isCommittable; - // Tell if preminted voucher has already been _committed + // Tell if voucher has already been _committed mapping(uint256 => bool) private _committed; /** @@ -368,10 +368,13 @@ contract BosonVoucherBase is IBosonVoucher, BeaconClientBase, OwnableUpgradeable // If _tokenId exists, it does not matter if vouchers were preminted or not return super.ownerOf(_tokenId); } else { - bool committable; // If _tokenId does not exist, but offer is committable, report contract owner as token owner - (committable, owner) = getPreMintStatus(_tokenId); - if (committable) return owner; + bool committable = isTokenCommittable(_tokenId); + + if (committable) { + owner = _rangeByOfferId[_tokenId >> 128].owner; + return owner; + } // Otherwise revert revert(ERC721_INVALID_TOKEN_ID); @@ -386,11 +389,15 @@ contract BosonVoucherBase is IBosonVoucher, BeaconClientBase, OwnableUpgradeable address _to, uint256 _tokenId ) public virtual override(ERC721Upgradeable, IERC721Upgradeable) { - (bool committable, ) = getPreMintStatus(_tokenId); + bool committable = isTokenCommittable(_tokenId); if (committable) { - // If offer is committable, temporarily update _owners, so transfer succeeds - silentMintAndSetPremintStatus(_from, _tokenId); + if (_from == address(this) || _from == owner()) { + // If offer is committable, temporarily update _owners, so transfer succeeds + silentMint(_from, _tokenId); + } + + _isCommittable = true; } super.transferFrom(_from, _to, _tokenId); @@ -405,11 +412,15 @@ contract BosonVoucherBase is IBosonVoucher, BeaconClientBase, OwnableUpgradeable uint256 _tokenId, bytes memory _data ) public virtual override(ERC721Upgradeable, IERC721Upgradeable) { - (bool committable, ) = getPreMintStatus(_tokenId); + bool committable = isTokenCommittable(_tokenId); if (committable) { - // If offer is committable, temporarily update _owners, so transfer succeeds - silentMintAndSetPremintStatus(_from, _tokenId); + if (_from == address(this) || _from == owner()) { + // If offer is committable, temporarily update _owners, so transfer succeeds + silentMint(_from, _tokenId); + } + + _isCommittable = true; } super.safeTransferFrom(_from, _to, _tokenId, _data); @@ -458,7 +469,8 @@ contract BosonVoucherBase is IBosonVoucher, BeaconClientBase, OwnableUpgradeable (bool exists, Offer memory offer) = getBosonOfferByExchangeId(exchangeId); if (!exists) { - (bool committable, ) = getPreMintStatus(_tokenId); + bool committable = isTokenCommittable(_tokenId); + if (committable) { uint256 offerId = _tokenId >> 128; exists = true; @@ -698,47 +710,36 @@ contract BosonVoucherBase is IBosonVoucher, BeaconClientBase, OwnableUpgradeable * @param - this parameter is ignored, but required to match the signature of the parent method */ function _beforeTokenTransfer(address _from, address _to, uint256 _tokenId, uint256) internal override { - // Derive the exchange id - uint256 exchangeId = _tokenId & type(uint128).max; - if (_isCommitable) { - // If is committable, invoke commitToPreMintedOffer on the protocol - + // If is committable, invoke onPremintedVoucherTransferred on the protocol + if (_isCommittable) { // Set _isCommitable to false - _isCommitable = false; + _isCommittable = false; - // Set the preminted token as committed - _committed[_tokenId] = true; + address rangeOwner = _rangeByOfferId[_tokenId >> 128].owner; - // Derive the offer id - uint256 offerId = _tokenId >> 128; + // Call protocol onPremintedVoucherTransferred + bool committed = onPremintedVoucherTransferred(_tokenId, payable(_to), _from, rangeOwner); - // If this is a transfer of preminted token, treat it differently - address protocolDiamond = IClientExternalAddresses(BeaconClientLib._beacon()).getProtocolAddress(); - IBosonExchangeHandler(protocolDiamond).commitToPreMintedOffer(payable(_to), offerId, exchangeId); + // Set committed status + _committed[_tokenId] = committed; } else if (_from != address(0) && _to != address(0) && _from != _to) { // Update the buyer associated with the voucher in the protocol // Only when transferring, not when minting or burning - onVoucherTransferred(exchangeId, payable(_to)); + onVoucherTransferred(_tokenId, payable(_to)); } } /** - * @dev Determines if a token is pre-minted and committable via transfer hook + * @notice Verify if token is committable. * - * Committable means: - * - does not yet have an owner - * - in a reserved range - * - has been pre-minted - * - has not been already burned + * @param _tokenId - the tokenId of the voucher that is being transferred * - * @param _tokenId - the token id to check - * @return committable - whether the token is committable - * @return owner - the token owner + * @return committable - true if the voucher is committable */ - function getPreMintStatus(uint256 _tokenId) public view returns (bool committable, address owner) { - // Not committable if _committed already or if token has an owner - - if (!_committed[_tokenId]) { + function isTokenCommittable(uint256 _tokenId) public view returns (bool committable) { + if (_committed[_tokenId]) { + return false; + } else { // it might be a pre-minted token. Preminted tokens have offerId in the upper 128 bits uint256 offerId = _tokenId >> 128; @@ -757,9 +758,8 @@ contract BosonVoucherBase is IBosonVoucher, BeaconClientBase, OwnableUpgradeable start + range.minted - 1 >= _tokenId && _tokenId > range.lastBurnedTokenId ) { - // Has it been pre-minted and not burned yet? + // Has it been pre-minted, not burned yet committable = true; - owner = range.owner; } } } @@ -818,14 +818,30 @@ contract BosonVoucherBase is IBosonVoucher, BeaconClientBase, OwnableUpgradeable /* * Updates owners, but do not emit Transfer event. Event was already emited during pre-mint. */ - function silentMintAndSetPremintStatus(address _from, uint256 _tokenId) internal { + function silentMint(address _from, uint256 _tokenId) internal { require(_from == owner() || _from == address(this), NO_SILENT_MINT_ALLOWED); // update data, so transfer will succeed getERC721UpgradeableStorage()._owners[_tokenId] = _from; + } + + /* + * Override ERC721Upgradeable._isApprovedOrOwner to check for pre-minted tokens + */ + function _isApprovedOrOwner(address spender, uint256 tokenId) internal view override returns (bool) { + address owner = ownerOf(tokenId); + return (spender == owner || isApprovedForAll(owner, spender) || getApproved(tokenId) == spender); + } + + /* + ** + * @dev Reverts if the `_tokenId` has not been minted yet and is not a pre-minted token. + */ + function _requireMinted(uint256 _tokenId) internal view override { + // If token is committable, it is a pre-minted token + bool committable = isTokenCommittable(_tokenId); - // Update commitable status - _isCommitable = true; + require(_exists(_tokenId) || committable, "ERC721: invalid token ID"); } } diff --git a/contracts/protocol/facets/ExchangeHandlerFacet.sol b/contracts/protocol/facets/ExchangeHandlerFacet.sol index 85e64f812..0c8c0f42f 100644 --- a/contracts/protocol/facets/ExchangeHandlerFacet.sol +++ b/contracts/protocol/facets/ExchangeHandlerFacet.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.21; +import "../../domain/BosonConstants.sol"; import { IBosonExchangeHandler } from "../../interfaces/handlers/IBosonExchangeHandler.sol"; -import { IBosonAccountHandler } from "../../interfaces/handlers/IBosonAccountHandler.sol"; import { IBosonVoucher } from "../../interfaces/clients/IBosonVoucher.sol"; import { ITwinToken } from "../../interfaces/ITwinToken.sol"; import { DiamondLib } from "../../diamond/DiamondLib.sol"; @@ -10,7 +10,7 @@ import { BuyerBase } from "../bases/BuyerBase.sol"; import { DisputeBase } from "../bases/DisputeBase.sol"; import { ProtocolLib } from "../libs/ProtocolLib.sol"; import { FundsLib } from "../libs/FundsLib.sol"; -import "../../domain/BosonConstants.sol"; +import { IERC721Receiver } from "../../interfaces/IERC721Receiver.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import { IERC1155 } from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; @@ -21,8 +21,9 @@ import { Address } from "@openzeppelin/contracts/utils/Address.sol"; * * @notice Handles exchanges associated with offers within the protocol. */ -contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { +contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase, IERC721Receiver { using Address for address; + using Address for address payable; uint256 private immutable EXCHANGE_ID_2_2_0; // solhint-disable-line @@ -47,7 +48,7 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { } /** - * @notice Commits to an offer (first step of an exchange). + * @notice Commits to a price static offer (first step of an exchange). * * Emits a BuyerCommitted event if successful. * Issues a voucher to the buyer address. @@ -56,6 +57,7 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { * - The exchanges region of protocol is paused * - The buyers region of protocol is paused * - OfferId is invalid + * - Offer price type is not static * - Offer has been voided * - Offer has expired * - Offer is not yet available for commits @@ -81,6 +83,7 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { require(_buyer != address(0), INVALID_ADDRESS); Offer storage offer = getValidOffer(_offerId); + require(offer.priceType == PriceType.Static, INVALID_PRICE_TYPE); // For there to be a condition, there must be a group. (bool exists, uint256 groupId) = getGroupIdByOffer(offer.id); @@ -133,6 +136,7 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { require(_buyer != address(0), INVALID_ADDRESS); Offer storage offer = getValidOffer(_offerId); + require(offer.priceType == PriceType.Static, INVALID_PRICE_TYPE); // For there to be a condition, there must be a group. (bool exists, uint256 groupId) = getGroupIdByOffer(offer.id); @@ -155,76 +159,7 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { } /** - * @notice Commits to a preminted offer (first step of an exchange). - * - * Emits BuyerCommitted and ConditionalCommitAuthorized events if successful. - * - * Reverts if: - * - The exchanges region of protocol is paused - * - The buyers region of protocol is paused - * - Caller is not the voucher contract, owned by the seller - * - Exchange exists already - * - Offer has been voided - * - Offer has expired - * - Offer is not yet available for commits - * - Buyer account is inactive - * - Buyer is token-gated (conditional commit requirements not met or already used) - * - Buyer is token-gated and condition has a range. - * - Seller has less funds available than sellerDeposit and price - * - * @param _buyer - the buyer's address (caller can commit on behalf of a buyer) - * @param _offerId - the id of the offer to commit to - * @param _exchangeId - the id of the exchange - */ - function commitToPreMintedOffer( - address payable _buyer, - uint256 _offerId, - uint256 _exchangeId - ) external exchangesNotPaused buyersNotPaused nonReentrant { - Offer storage offer = getValidOffer(_offerId); - ProtocolLib.ProtocolLookups storage lookups = protocolLookups(); - - // Make sure that the voucher was issued on the clone that is making a call - require(msg.sender == getCloneAddress(lookups, offer.sellerId, offer.collectionIndex), ACCESS_DENIED); - - // Exchange must not exist already - (bool exists, ) = fetchExchange(_exchangeId); - require(!exists, EXCHANGE_ALREADY_EXISTS); - - uint256 groupId; - (exists, groupId) = getGroupIdByOffer(offer.id); - - if (exists) { - // Get the condition - Condition storage condition = fetchCondition(groupId); - EvaluationMethod method = condition.method; - - if (method != EvaluationMethod.None) { - uint256 tokenId = 0; - - // Allow commiting only to unambigous conditions, i.e. conditions with a single token id - if (condition.method == EvaluationMethod.SpecificToken || condition.tokenType == TokenType.MultiToken) { - uint256 minTokenId = condition.minTokenId; - uint256 maxTokenId = condition.maxTokenId; - - require(minTokenId == maxTokenId || maxTokenId == 0, CANNOT_COMMIT); // legacy conditions have maxTokenId == 0 - - // Uses token id from the condition - tokenId = minTokenId; - } - - authorizeCommit(_buyer, condition, groupId, tokenId, _offerId); - - // Store the condition to be returned afterward on getReceipt function - lookups.exchangeCondition[_exchangeId] = condition; - } - } - - commitToOfferInternal(_buyer, offer, _exchangeId, true); - } - - /** - * @notice Commits to an offer. Helper function reused by commitToOffer and commitToPreMintedOffer. + * @notice Commits to an offer. Helper function reused by commitToOffer and onPremintedVoucherTransferred. * * Emits a BuyerCommitted event if successful. * Issues a voucher to the buyer address for non preminted offers. @@ -242,7 +177,9 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { * - Calling transferFrom on token fails for some reason (e.g. protocol is not approved to transfer) * - Received ERC20 token amount differs from the expected value * - Seller has less funds available than sellerDeposit - * - Seller has less funds available than sellerDeposit and price for preminted offers + * - For preminted offers: + * - Exchange aldready exists + * - Seller has less funds available than sellerDeposit and price for preminted offers that price type is static * * @param _buyer - the buyer's address (caller can commit on behalf of a buyer) * @param _offer - storage pointer to the offer @@ -257,7 +194,7 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { bool _isPreminted ) internal returns (uint256) { uint256 _offerId = _offer.id; - // Make sure offer is available, and isn't void, expired, or sold out + // Make sure offer is available, expired, or sold out OfferDates storage offerDates = fetchOfferDates(_offerId); require(block.timestamp >= offerDates.validFrom, OFFER_NOT_AVAILABLE); require(block.timestamp <= offerDates.validUntil, OFFER_HAS_EXPIRED); @@ -268,13 +205,18 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { // Get next exchange id for non-preminted offers _exchangeId = protocolCounters().nextExchangeId++; + } else { + // Exchange must not exist already + (bool exists, ) = fetchExchange(_exchangeId); + + require(!exists, EXCHANGE_ALREADY_EXISTS); } // Fetch or create buyer uint256 buyerId = getValidBuyer(_buyer); - // Encumber funds before creating the exchange - FundsLib.encumberFunds(_offerId, buyerId, _isPreminted); + // Encumber funds + FundsLib.encumberFunds(_offerId, buyerId, _offer.price, _isPreminted, _offer.priceType); // Create and store a new exchange Exchange storage exchange = protocolEntities().exchanges[_exchangeId]; @@ -593,6 +535,7 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { * Emits a VoucherTransferred event if successful. * * Reverts if + * - The exchanges region of protocol is paused * - The buyers region of protocol is paused * - Caller is not a clone address associated with the seller * - Exchange does not exist @@ -600,18 +543,21 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { * - Voucher has expired * - New buyer's existing account is deactivated * - * @param _exchangeId - the id of the exchange + * @param _tokenId - the voucher id * @param _newBuyer - the address of the new buyer */ function onVoucherTransferred( - uint256 _exchangeId, + uint256 _tokenId, address payable _newBuyer - ) external override buyersNotPaused nonReentrant { + ) external override buyersNotPaused exchangesNotPaused { + // Derive the exchange id + uint256 exchangeId = _tokenId & type(uint128).max; + // Cache protocol lookups for reference ProtocolLib.ProtocolLookups storage lookups = protocolLookups(); // Get the exchange, should be in committed state - (Exchange storage exchange, Voucher storage voucher) = getValidExchange(_exchangeId, ExchangeState.Committed); + (Exchange storage exchange, Voucher storage voucher) = getValidExchange(exchangeId, ExchangeState.Committed); // Make sure that the voucher is still valid require(block.timestamp <= voucher.validUntilDate, VOUCHER_HAS_EXPIRED); @@ -633,8 +579,150 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { // Increase voucher counter for new buyer lookups.voucherCount[buyerId]++; + ProtocolLib.ProtocolStatus storage ps = protocolStatus(); + + // Set incoming voucher id if we are in the middle of a price discovery call + if (ps.incomingVoucherCloneAddress != address(0)) { + uint256 incomingVoucherId = ps.incomingVoucherId; + if (incomingVoucherId != _tokenId) { + require(incomingVoucherId == 0, INCOMING_VOUCHER_ALREADY_SET); + ps.incomingVoucherId = _tokenId; + } + } + // Notify watchers of state change - emit VoucherTransferred(exchange.offerId, _exchangeId, buyerId, msgSender()); + emit VoucherTransferred(exchange.offerId, exchangeId, buyerId, msgSender()); + } + + /** + * @notice Handle pre-minted voucher transfer + * + * Reverts if: + * - The exchanges region of protocol is paused + * - The buyers region of protocol is paused + * - Caller is not a clone address associated with the seller + * - Incoming voucher clone address is not the caller + * - Offer price is discovery, transaction is not starting from protocol nor seller is _from address + * - Any reason that ExchangeHandler commitToOfferInternal reverts. See ExchangeHandler.commitToOfferInternal + * + * @param _tokenId - the voucher id + * @param _to - the receiver address + * @param _from - the address of current owner + * @param _rangeOwner - the address of the preminted range owner + * @return committed - true if the voucher was committed + */ + function onPremintedVoucherTransferred( + uint256 _tokenId, + address payable _to, + address _from, + address _rangeOwner + ) external override buyersNotPaused exchangesNotPaused returns (bool committed) { + // Cache protocol status for reference + ProtocolLib.ProtocolStatus storage ps = protocolStatus(); + + // Make sure that protocol is not reentered + // Cannot use modifier `nonReentrant` since it also changes reentrancyStatus to `ENTERED` + // This would break the flow since the protocol should be allowed to re-enter in this case. + require(ps.reentrancyStatus != ENTERED, REENTRANCY_GUARD); + + // Derive the offer id + uint256 offerId = _tokenId >> 128; + + // Derive the exchange id + uint256 exchangeId = _tokenId & type(uint128).max; + + // Get the offer + Offer storage offer = getValidOffer(offerId); + + ProtocolLib.ProtocolLookups storage lookups = protocolLookups(); + address bosonVoucher = getCloneAddress(lookups, offer.sellerId, offer.collectionIndex); + + // Make sure that the voucher was issued on the clone that is making a call + require(msg.sender == bosonVoucher, ACCESS_DENIED); + + (bool conditionExists, uint256 groupId) = getGroupIdByOffer(offerId); + + if (conditionExists) { + // Get the condition + Condition storage condition = fetchCondition(groupId); + EvaluationMethod method = condition.method; + + if (method != EvaluationMethod.None) { + uint256 tokenId = 0; + + // Allow commiting only to unambigous conditions, i.e. conditions with a single token id + if (method == EvaluationMethod.SpecificToken || condition.tokenType == TokenType.MultiToken) { + uint256 minTokenId = condition.minTokenId; + uint256 maxTokenId = condition.maxTokenId; + + require(minTokenId == maxTokenId || maxTokenId == 0, CANNOT_COMMIT); // legacy conditions have maxTokenId == 0 + + // Uses token id from the condition + tokenId = minTokenId; + } + + authorizeCommit(_to, condition, groupId, tokenId, offerId); + + // Store the condition to be returned afterward on getReceipt function + lookups.exchangeCondition[exchangeId] = condition; + } + } + + if (offer.priceType == PriceType.Discovery) { + // transaction start from `commitToPriceDiscoveryOffer`, should commit + if (ps.incomingVoucherCloneAddress != address(0)) { + // During price discovery, the voucher is firs transferred to the protocol, which should + // not resulte in a commit yet. The commit should happen when the voucher is transferred + // from the protocol to the buyer. + if (_to == address(this)) { + // can someone buys on protocol's behalf? what happens then + + // Avoid reentrancy + require(ps.incomingVoucherId == 0, INCOMING_VOUCHER_ALREADY_SET); + + // Store the information about incoming voucher + ps.incomingVoucherId = _tokenId; + } else { + if (ps.incomingVoucherId == 0) { + // Happens in wrapped voucher vase + ps.incomingVoucherId = _tokenId; + } else { + // In other cases voucher was already once transferred to the protocol, + // so ps.incomingVoucherId is set already. The incoming _tokenId must match. + require(ps.incomingVoucherId == _tokenId, TOKEN_ID_MISMATCH); + } + commitToOfferInternal(_to, offer, exchangeId, true); + + committed = true; + } + + return committed; + } + + // If `onPremintedVoucherTransferred` is invoked without `commitToPriceDiscoveryOffer` first, + // we reach this point. This can happen in the following scenarios: + // 1. The preminted voucher owner is transferring the voucher to PD contract ["deposit"] + // 2. The PD is transferring the voucher back to the original owner ["withdraw"]. Happens if voucher was not sold. + // 3. The PD is transferring the voucher to the buyer ["buy"]. Happens if voucher was sold. + // 4. The preminted voucher owner is transferring the voucher "directly" to the buyer. + + // 1. and 2. are allowed, while 3. and 4. and must revert. 3. and 4. should be executed via `commitToPriceDiscoveryOffer` + if (_from == _rangeOwner) { + // case 1. ["deposit"] + if (!_to.isContract()) { + // Prevent direct transfer to EOA (case 4.) + revert(VOUCHER_TRANSFER_NOT_ALLOWED); + } + } else { + // Case 2. ["withdraw"] + // Prevent transfer to the buyer (case 3.) + require(_to == _rangeOwner, VOUCHER_TRANSFER_NOT_ALLOWED); + } + } else if (offer.priceType == PriceType.Static) { + // If price type is static, transaction can start from anywhere + commitToOfferInternal(_to, offer, exchangeId, true); + committed = true; + } } /** @@ -1044,36 +1132,6 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { } } - /** - * @notice Checks if buyer exists for buyer address. If not, account is created for buyer address. - * - * Reverts if buyer exists but is inactive. - * - * @param _buyer - the buyer address to check - * @return buyerId - the buyer id - */ - function getValidBuyer(address payable _buyer) internal returns (uint256 buyerId) { - // Find or create the account associated with the specified buyer address - bool exists; - (exists, buyerId) = getBuyerIdByWallet(_buyer); - - if (!exists) { - // Create the buyer account - Buyer memory newBuyer; - newBuyer.wallet = _buyer; - newBuyer.active = true; - - createBuyerInternal(newBuyer); - buyerId = newBuyer.id; - } else { - // Fetch the existing buyer account - (, Buyer storage buyer) = fetchBuyer(buyerId); - - // Make sure buyer account is active - require(buyer.active, MUST_BE_ACTIVE); - } - } - /** * @notice Authorizes the potential buyer to commit to an offer * @@ -1262,6 +1320,26 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { } } + /** + * @dev See {IERC721Receiver-onERC721Received}. + * + * Always returns `IERC721Receiver.onERC721Received.selector`. + */ + function onERC721Received( + address, + address, + uint256 _tokenId, + bytes calldata + ) public virtual override returns (bytes4) { + ProtocolLib.ProtocolStatus storage ps = protocolStatus(); + + require( + ps.incomingVoucherId == _tokenId && ps.incomingVoucherCloneAddress == msg.sender, + UNEXPECTED_ERC721_RECEIVED + ); + return this.onERC721Received.selector; + } + /** * @notice Updates NFT ranges, so it's possible to reuse the tokens in other twins and to make * creation of new ranges viable diff --git a/contracts/protocol/facets/FundsHandlerFacet.sol b/contracts/protocol/facets/FundsHandlerFacet.sol index 3202fdd34..37fa66995 100644 --- a/contracts/protocol/facets/FundsHandlerFacet.sol +++ b/contracts/protocol/facets/FundsHandlerFacet.sol @@ -32,9 +32,11 @@ contract FundsHandlerFacet is IBosonFundsHandler, ProtocolBase { * * Reverts if: * - The funds region of protocol is paused + * - Amount to deposit is zero * - Seller id does not exist * - It receives some native currency (e.g. ETH), but token address is not zero * - It receives some native currency (e.g. ETH), and the amount does not match msg.value + * - It receives no native currency, but token address is zero * - Contract at token address does not support ERC20 function transferFrom * - Calling transferFrom on token fails for some reason (e.g. protocol is not approved to transfer) * - Received ERC20 token amount differs from the expected value @@ -48,6 +50,8 @@ contract FundsHandlerFacet is IBosonFundsHandler, ProtocolBase { address _tokenAddress, uint256 _amount ) external payable override fundsNotPaused nonReentrant { + require(_amount > 0, ZERO_DEPOSIT_NOT_ALLOWED); + // Check seller exists in sellers mapping (bool exists, , ) = fetchSeller(_sellerId); @@ -60,6 +64,7 @@ contract FundsHandlerFacet is IBosonFundsHandler, ProtocolBase { require(_amount == msg.value, NATIVE_WRONG_AMOUNT); } else { // Transfer tokens from the caller + require(_tokenAddress != address(0), INVALID_ADDRESS); FundsLib.transferFundsToProtocol(_tokenAddress, _amount); } diff --git a/contracts/protocol/facets/PriceDiscoveryHandlerFacet.sol b/contracts/protocol/facets/PriceDiscoveryHandlerFacet.sol new file mode 100644 index 000000000..8acabfdc1 --- /dev/null +++ b/contracts/protocol/facets/PriceDiscoveryHandlerFacet.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.21; + +import { BuyerBase } from "../bases/BuyerBase.sol"; +import { IBosonPriceDiscoveryHandler } from "../../interfaces/handlers/IBosonPriceDiscoveryHandler.sol"; +import { IBosonVoucher } from "../../interfaces/clients/IBosonVoucher.sol"; +import { ITwinToken } from "../../interfaces/ITwinToken.sol"; +import { DiamondLib } from "../../diamond/DiamondLib.sol"; +import { BuyerBase } from "../bases/BuyerBase.sol"; +import { DisputeBase } from "../bases/DisputeBase.sol"; +import { ProtocolLib } from "../libs/ProtocolLib.sol"; +import { FundsLib } from "../libs/FundsLib.sol"; +import "../../domain/BosonConstants.sol"; +import { IERC1155 } from "../../interfaces/IERC1155.sol"; +import { IERC721 } from "../../interfaces/IERC721.sol"; +import { IERC20 } from "../../interfaces/IERC20.sol"; +import { IERC721Receiver } from "../../interfaces/IERC721Receiver.sol"; +import { PriceDiscoveryBase } from "../bases/PriceDiscoveryBase.sol"; +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; + +/** + * @title PriceDiscoveryHandlerFacet + * + * @notice Handles exchanges associated with offers within the protocol. + */ +contract PriceDiscoveryHandlerFacet is IBosonPriceDiscoveryHandler, PriceDiscoveryBase, BuyerBase { + /** + * @notice + * For offers with native exchange token, it is expected the the price discovery contracts will + * operate with wrapped native token. Set the address of the wrapped native token in the constructor. + * + * After v2.2.0, token ids are derived from offerId and exchangeId. + * EXCHANGE_ID_2_2_0 is the first exchange id to use for 2.2.0. + * Set EXCHANGE_ID_2_2_0 in the constructor. + * + * @param _wNative - the address of the wrapped native token + * @param _firstExchangeId2_2_0 - the first exchange id to use for 2.2.0 + */ + //solhint-disable-next-line + constructor(address _wNative, uint256 _firstExchangeId2_2_0) PriceDiscoveryBase(_wNative, _firstExchangeId2_2_0) {} + + /** + * @notice Facet Initializer + * This function is callable only once. + */ + function initialize() public onlyUninitialized(type(IBosonPriceDiscoveryHandler).interfaceId) { + DiamondLib.addSupportedInterface(type(IBosonPriceDiscoveryHandler).interfaceId); + } + + /** + * @notice Commits to a price discovery offer (first step of an exchange). + * + * Emits a BuyerCommitted event if successful. + * Issues a voucher to the buyer address. + * + * Reverts if: + * - Offer price type is not price discovery. See BosonTypes.PriceType + * - Price discovery contract address is zero + * - Price discovery calldata is empty + * - Exchange exists already + * - Offer has been voided + * - Offer has expired + * - Offer is not yet available for commits + * - Buyer address is zero + * - Buyer account is inactive + * - Buyer is token-gated (conditional commit requirements not met or already used) + * - Any reason that PriceDiscoveryBase fulfilOrder reverts. See PriceDiscoveryBase.fulfilOrder + * - Any reason that ExchangeHandler onPremintedVoucherTransfer reverts. See ExchangeHandler.onPremintedVoucherTransfer + * + * @param _buyer - the buyer's address (caller can commit on behalf of a buyer) + * @param _tokenIdOrOfferId - the id of the offer to commit to or the id of the voucher (if pre-minted) + * @param _priceDiscovery - price discovery data (if applicable). See BosonTypes.PriceDiscovery + */ + function commitToPriceDiscoveryOffer( + address payable _buyer, + uint256 _tokenIdOrOfferId, + PriceDiscovery calldata _priceDiscovery + ) external payable override exchangesNotPaused buyersNotPaused { + // Make sure buyer address is not zero address + require(_buyer != address(0), INVALID_ADDRESS); + + // Make sure caller provided price discovery data + require( + _priceDiscovery.priceDiscoveryContract != address(0) && _priceDiscovery.priceDiscoveryData.length > 0, + INVALID_PRICE_DISCOVERY + ); + + bool isTokenId; + uint256 offerId = _tokenIdOrOfferId >> 128; + // if `_tokenIdOrOfferId` is a token id, then upper 128 bits represent the offer id. + // Therefore, if `offerId` is not 0, then `_tokenIdOrOfferId` represents a token id + // and if `offerId` is 0, then `_tokenIdOrOfferId` represents an offer id. + // N.B. token ids, corresponding to exchanges from v2.2.0 and earlier, have zero upper 128 bits + // and it seems we could confuse them with offer ids. However, the offers frm that time are all + // of type PriceType.Static and therefore will never be used here. + + if (offerId == 0) { + offerId = _tokenIdOrOfferId; + } else { + isTokenId = true; + } + + // Fetch offer with offerId + Offer storage offer = getValidOffer(offerId); + + // Make sure offer type is price discovery. Otherwise, use commitToOffer + require(offer.priceType == PriceType.Discovery, INVALID_PRICE_TYPE); + uint256 sellerId = offer.sellerId; + + uint256 actualPrice; + { + // Get seller address + address _seller; + (, Seller storage seller, ) = fetchSeller(sellerId); + _seller = seller.assistant; + + // Calls price discovery contract and gets the actual price. Use token id if caller has provided one, otherwise use offer id and accepts any voucher. + actualPrice = fulfilOrder(isTokenId ? _tokenIdOrOfferId : 0, offer, _priceDiscovery, _seller, _buyer); + } + + // Fetch token id on protocol status + uint256 tokenId = protocolStatus().incomingVoucherId; + + uint256 exchangeId = tokenId & type(uint128).max; + + // Get sequential commits for this exchange + ExchangeCosts[] storage exchangeCosts = protocolEntities().exchangeCosts[exchangeId]; + + // Calculate fees + address exchangeToken = offer.exchangeToken; + uint256 protocolFeeAmount = getProtocolFee(exchangeToken, actualPrice); + + { + // Calculate royalties + (, uint256 royaltyAmount) = IBosonVoucher( + getCloneAddress(protocolLookups(), sellerId, offer.collectionIndex) + ).royaltyInfo(exchangeId, actualPrice); + + // Verify that fees and royalties are not higher than the price. + require((protocolFeeAmount + royaltyAmount) <= actualPrice, FEE_AMOUNT_TOO_HIGH); + + // Store exchange costs so it can be released later. This is the first cost entry for this exchange. + exchangeCosts.push( + ExchangeCosts({ + resellerId: sellerId, + price: actualPrice, + protocolFeeAmount: protocolFeeAmount, + royaltyAmount: royaltyAmount + }) + ); + } + // Clear incoming voucher id and incoming voucher address + clearPriceDiscoveryStorage(); + + (, uint256 buyerId) = getBuyerIdByWallet(_buyer); + if (actualPrice > 0) { + if (_priceDiscovery.side == Side.Ask) { + // Price discovery should send funds to the seller + // Nothing in escrow, take it from the seller's pool + FundsLib.decreaseAvailableFunds(sellerId, exchangeToken, actualPrice); + + emit FundsEncumbered(sellerId, exchangeToken, actualPrice, msgSender()); + } else { + // when bid side or wrapper, we have full proceeds in escrow. + // If exchange token is 0, we need to unwrap it + if (exchangeToken == address(0)) { + wNative.withdraw(actualPrice); + } + emit FundsEncumbered(buyerId, exchangeToken, actualPrice, msgSender()); + } + + // Not emitting BuyerCommitted since it's emitted in commitToOfferInternal + } + } +} diff --git a/contracts/protocol/facets/SequentialCommitHandlerFacet.sol b/contracts/protocol/facets/SequentialCommitHandlerFacet.sol new file mode 100644 index 000000000..53f7be6b9 --- /dev/null +++ b/contracts/protocol/facets/SequentialCommitHandlerFacet.sol @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.21; + +import { IBosonSequentialCommitHandler } from "../../interfaces/handlers/IBosonSequentialCommitHandler.sol"; +import { IBosonVoucher } from "../../interfaces/clients/IBosonVoucher.sol"; +import { DiamondLib } from "../../diamond/DiamondLib.sol"; +import { PriceDiscoveryBase } from "../bases/PriceDiscoveryBase.sol"; +import { ProtocolLib } from "../libs/ProtocolLib.sol"; +import { FundsLib } from "../libs/FundsLib.sol"; +import "../../domain/BosonConstants.sol"; +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +/** + * @title SequentialCommitHandlerFacet + * + * @notice Handles sequential commits. + */ +contract SequentialCommitHandlerFacet is IBosonSequentialCommitHandler, PriceDiscoveryBase { + using Address for address; + + /** + * @notice + * For offers with native exchange token, it is expected the the price discovery contracts will + * operate with wrapped native token. Set the address of the wrapped native token in the constructor. + * + * After v2.2.0, token ids are derived from offerId and exchangeId. + * EXCHANGE_ID_2_2_0 is the first exchange id to use for 2.2.0. + * Set EXCHANGE_ID_2_2_0 in the constructor. + * + * @param _wNative - the address of the wrapped native token + * @param _firstExchangeId2_2_0 - the first exchange id to use for 2.2.0 + */ + //solhint-disable-next-line + constructor(address _wNative, uint256 _firstExchangeId2_2_0) PriceDiscoveryBase(_wNative, _firstExchangeId2_2_0) {} + + /** + * @notice Initializes facet. + * This function is callable only once. + */ + function initialize() public onlyUninitialized(type(IBosonSequentialCommitHandler).interfaceId) { + DiamondLib.addSupportedInterface(type(IBosonSequentialCommitHandler).interfaceId); + } + + /** + * @notice Commits to an existing exchange. Price discovery is offloaded to external contract. + * + * Emits a BuyerCommitted event if successful. + * Transfers voucher to the buyer address. + * + * Reverts if: + * - The exchanges region of protocol is paused + * - The buyers region of protocol is paused + * - Buyer address is zero + * - Exchange does not exist + * - Exchange is not in Committed state + * - Voucher has expired + * - It is a bid order and: + * - Caller is not the voucher holder + * - Voucher owner did not approve protocol to transfer the voucher + * - Price received from price discovery is lower than the expected price + * - It is a ask order and: + * - Offer price is in native token and caller does not send enough + * - Offer price is in some ERC20 token and caller also sends native currency + * - Calling transferFrom on token fails for some reason (e.g. protocol is not approved to transfer) + * - Received ERC20 token amount differs from the expected value + * - Protocol does not receive the voucher + * - Transfer of voucher to the buyer fails for some reasong (e.g. buyer is contract that doesn't accept voucher) + * - Reseller did not approve protocol to transfer exchange token in escrow + * - Call to price discovery contract fails + * - Protocol fee and royalties combined exceed the secondary price + * - Transfer of exchange token fails + * + * @param _buyer - the buyer's address (caller can commit on behalf of a buyer) + * @param _tokenId - the id of the token to commit to + * @param _priceDiscovery - the fully populated BosonTypes.PriceDiscovery struct + */ + function sequentialCommitToOffer( + address payable _buyer, + uint256 _tokenId, + PriceDiscovery calldata _priceDiscovery + ) external payable exchangesNotPaused buyersNotPaused nonReentrant { + // Make sure buyer address is not zero address + require(_buyer != address(0), INVALID_ADDRESS); + + // Make sure caller provided price discovery data + require( + _priceDiscovery.priceDiscoveryContract != address(0) && _priceDiscovery.priceDiscoveryData.length > 0, + INVALID_PRICE_DISCOVERY + ); + + uint256 exchangeId = _tokenId & type(uint128).max; + + // Exchange must exist + (Exchange storage exchange, Voucher storage voucher) = getValidExchange(exchangeId, ExchangeState.Committed); + + // Make sure the voucher is still valid + require(block.timestamp <= voucher.validUntilDate, VOUCHER_HAS_EXPIRED); + + // Create a memory struct for sequential commit and populate it as we go + // This is done to avoid stack too deep error, while still keeping the number of SLOADs to a minimum + ExchangeCosts memory exchangeCost; + + // Get current buyer address. This is actually the seller in sequential commit. Need to do it before voucher is transferred + address seller; + exchangeCost.resellerId = exchange.buyerId; + { + (, Buyer storage currentBuyer) = fetchBuyer(exchangeCost.resellerId); + seller = currentBuyer.wallet; + } + + // Fetch offer + uint256 offerId = exchange.offerId; + (, Offer storage offer) = fetchOffer(offerId); + + // First call price discovery and get actual price + // It might be lower than submitted for buy orders and higher for sell orders + exchangeCost.price = fulfilOrder(_tokenId, offer, _priceDiscovery, seller, _buyer); + + // Get token address + address exchangeToken = offer.exchangeToken; + + // Calculate the amount to be kept in escrow + uint256 escrowAmount; + uint256 payout; + { + // Get sequential commits for this exchange + ExchangeCosts[] storage exchangeCosts = protocolEntities().exchangeCosts[exchangeId]; + + { + // Calculate fees + exchangeCost.protocolFeeAmount = getProtocolFee(exchangeToken, exchangeCost.price); + + // Calculate royalties + (, exchangeCost.royaltyAmount) = IBosonVoucher( + getCloneAddress(protocolLookups(), offer.sellerId, offer.collectionIndex) + ).royaltyInfo(exchangeId, exchangeCost.price); + + // Verify that fees and royalties are not higher than the price. + require( + (exchangeCost.protocolFeeAmount + exchangeCost.royaltyAmount) <= exchangeCost.price, + FEE_AMOUNT_TOO_HIGH + ); + + // Get price paid by current buyer + uint256 len = exchangeCosts.length; + uint256 currentPrice = len == 0 ? offer.price : exchangeCosts[len - 1].price; + + // Calculate the minimal amount to be kept in the escrow + escrowAmount = + Math.max( + exchangeCost.price, + exchangeCost.protocolFeeAmount + exchangeCost.royaltyAmount + currentPrice + ) - + currentPrice; + + // Store the exchange cost, so it can be used in calculations when releasing funds + exchangeCosts.push(exchangeCost); + } + + // Make sure enough get escrowed + payout = exchangeCost.price - escrowAmount; + + if (_priceDiscovery.side == Side.Ask) { + if (escrowAmount > 0) { + // Price discovery should send funds to the seller + // Nothing in escrow, need to pull everything from seller + if (exchangeToken == address(0)) { + // If exchange is native currency, seller cannot directly approve protocol to transfer funds + // They need to approve wrapper contract, so protocol can pull funds from wrapper + FundsLib.transferFundsToProtocol(address(wNative), seller, escrowAmount); + // But since protocol otherwise normally operates with native currency, needs to unwrap it (i.e. withdraw) + wNative.withdraw(escrowAmount); + } else { + FundsLib.transferFundsToProtocol(exchangeToken, seller, escrowAmount); + } + } + } else { + // when bid side, we have full proceeds in escrow. Keep minimal in, return the difference + if (exchangeCost.price > 0 && exchangeToken == address(0)) { + wNative.withdraw(exchangeCost.price); + } + + if (payout > 0) { + FundsLib.transferFundsFromProtocol(exchangeToken, payable(seller), payout); // also emits FundsWithdrawn + } + } + } + + clearPriceDiscoveryStorage(); + + // Since exchange and voucher are passed by reference, they are updated + uint256 buyerId = exchange.buyerId; + address sender = msgSender(); + if (exchangeCost.price > 0) emit FundsEncumbered(buyerId, exchangeToken, exchangeCost.price, sender); + if (payout > 0) { + emit FundsReleased(exchangeId, exchangeCost.resellerId, exchangeToken, payout, sender); + emit FundsWithdrawn(exchangeCost.resellerId, seller, exchangeToken, payout, sender); + } + emit BuyerCommitted(offerId, buyerId, exchangeId, exchange, voucher, sender); + // No need to update exchange detail. Most fields stay as they are, and buyerId was updated at the same time voucher is transferred + } +} diff --git a/contracts/protocol/libs/FundsLib.sol b/contracts/protocol/libs/FundsLib.sol index c8119281b..77aafb6a7 100644 --- a/contracts/protocol/libs/FundsLib.sol +++ b/contracts/protocol/libs/FundsLib.sol @@ -60,9 +60,17 @@ library FundsLib { * * @param _offerId - id of the offer with the details * @param _buyerId - id of the buyer + * @param _price - the price, either price discovered externally or set on offer creation * @param _isPreminted - flag indicating if the offer is preminted + * @param _priceType - price type, either static or discovery */ - function encumberFunds(uint256 _offerId, uint256 _buyerId, bool _isPreminted) internal { + function encumberFunds( + uint256 _offerId, + uint256 _buyerId, + uint256 _price, + bool _isPreminted, + BosonTypes.PriceType _priceType + ) internal { // Load protocol entities storage ProtocolLib.ProtocolEntities storage pe = ProtocolLib.protocolEntities(); @@ -73,17 +81,18 @@ library FundsLib { // this will be called only from commitToOffer so we expect that exchange actually exist BosonTypes.Offer storage offer = pe.offers[_offerId]; address exchangeToken = offer.exchangeToken; - uint256 price = offer.price; // if offer is non-preminted, validate incoming payment if (!_isPreminted) { - validateIncomingPayment(exchangeToken, price); - emit FundsEncumbered(_buyerId, exchangeToken, price, sender); + validateIncomingPayment(exchangeToken, _price); + emit FundsEncumbered(_buyerId, exchangeToken, _price, sender); } + bool isPriceDiscovery = _priceType == BosonTypes.PriceType.Discovery; + // decrease available funds uint256 sellerId = offer.sellerId; - uint256 sellerFundsEncumbered = offer.sellerDeposit + (_isPreminted ? price : 0); // for preminted offer, encumber also price from seller's available funds + uint256 sellerFundsEncumbered = offer.sellerDeposit + (_isPreminted && !isPriceDiscovery ? _price : 0); // for preminted offer and price type is static, encumber also price from seller's available funds decreaseAvailableFunds(sellerId, exchangeToken, sellerFundsEncumbered); // notify external observers @@ -108,7 +117,7 @@ library FundsLib { */ function validateIncomingPayment(address _exchangeToken, uint256 _value) internal { if (_exchangeToken == address(0)) { - // if transfer is in the native currency, msg.value must match offer price + // if transfer is in the native currency, msg.value must match price require(msg.value == _value, INSUFFICIENT_VALUE_RECEIVED); } else { // when price is in an erc20 token, transferring the native currency is not allowed @@ -145,12 +154,11 @@ library FundsLib { uint256 agentFee; BosonTypes.OfferFees storage offerFee = pe.offerFees[exchange.offerId]; - + uint256 price = offer.price; { // scope to avoid stack too deep errors BosonTypes.ExchangeState exchangeState = exchange.state; uint256 sellerDeposit = offer.sellerDeposit; - uint256 price = offer.price; if (exchangeState == BosonTypes.ExchangeState.Completed) { // COMPLETED @@ -198,19 +206,31 @@ library FundsLib { } } - // Store payoffs to availablefunds and notify the external observers address exchangeToken = offer.exchangeToken; - uint256 sellerId = offer.sellerId; - uint256 buyerId = exchange.buyerId; + + // Original seller and last buyer are done + // Release funds to intermediate sellers (if they exist) + // and add the protocol fee to the total + { + (uint256 sequentialProtocolFee, uint256 sequentialRoyalties) = releaseFundsToIntermediateSellers( + _exchangeId, + exchange.state, + price, + exchangeToken + ); + sellerPayoff += sequentialRoyalties; + protocolFee += sequentialProtocolFee; + } + + // Store payoffs to availablefunds and notify the external observers address sender = EIP712Lib.msgSender(); if (sellerPayoff > 0) { - increaseAvailableFunds(sellerId, exchangeToken, sellerPayoff); - emit FundsReleased(_exchangeId, sellerId, exchangeToken, sellerPayoff, sender); + increaseAvailableFundsAndEmitEvent(_exchangeId, offer.sellerId, exchangeToken, sellerPayoff, sender); } if (buyerPayoff > 0) { - increaseAvailableFunds(buyerId, exchangeToken, buyerPayoff); - emit FundsReleased(_exchangeId, buyerId, exchangeToken, buyerPayoff, sender); + increaseAvailableFundsAndEmitEvent(_exchangeId, exchange.buyerId, exchangeToken, buyerPayoff, sender); } + if (protocolFee > 0) { increaseAvailableFunds(0, exchangeToken, protocolFee); emit ProtocolFeeCollected(_exchangeId, exchangeToken, protocolFee, sender); @@ -218,11 +238,145 @@ library FundsLib { if (agentFee > 0) { // Get the agent for offer uint256 agentId = ProtocolLib.protocolLookups().agentIdByOffer[exchange.offerId]; - increaseAvailableFunds(agentId, exchangeToken, agentFee); - emit FundsReleased(_exchangeId, agentId, exchangeToken, agentFee, sender); + increaseAvailableFundsAndEmitEvent(_exchangeId, agentId, exchangeToken, agentFee, sender); } } + /** + * @notice Takes the exchange id and releases the funds to original seller if offer.priceType is Discovery + * and to all intermediate resellers in case of sequential commit, depending on the state of the exchange. + * It is called only from releaseFunds. Protocol fee and royalties are calculated and returned to releaseFunds, where they are added to the total. + * + * Emits FundsReleased events for non zero payoffs. + * + * @param _exchangeId - exchange id + * @param _exchangeState - state of the exchange + * @param _initialPrice - initial price of the offer + * @param _exchangeToken - address of the token used for the exchange + * @return protocolFee - protocol fee from secondary sales + * @return royalties - royalties from secondary sales + */ + function releaseFundsToIntermediateSellers( + uint256 _exchangeId, + BosonTypes.ExchangeState _exchangeState, + uint256 _initialPrice, + address _exchangeToken + ) internal returns (uint256 protocolFee, uint256 royalties) { + BosonTypes.ExchangeCosts[] storage exchangeCosts; + + // calculate effective price multiplier + uint256 effectivePriceMultiplier; + { + ProtocolLib.ProtocolEntities storage pe = ProtocolLib.protocolEntities(); + + exchangeCosts = pe.exchangeCosts[_exchangeId]; + + // if price type was static and no sequential commit happened, just return + if (exchangeCosts.length == 0) { + return (0, 0); + } + + { + if (_exchangeState == BosonTypes.ExchangeState.Completed) { + // COMPLETED, buyer pays full price + effectivePriceMultiplier = 10000; + } else if ( + _exchangeState == BosonTypes.ExchangeState.Revoked || + _exchangeState == BosonTypes.ExchangeState.Canceled + ) { + // REVOKED or CANCELED, buyer pays nothing (buyerCancelationPenalty is not considered payment) + effectivePriceMultiplier = 0; + } else if (_exchangeState == BosonTypes.ExchangeState.Disputed) { + // DISPUTED + // get the information about the dispute, which must exist + BosonTypes.Dispute storage dispute = pe.disputes[_exchangeId]; + BosonTypes.DisputeState disputeState = dispute.state; + + if (disputeState == BosonTypes.DisputeState.Retracted) { + // RETRACTED - same as "COMPLETED" + effectivePriceMultiplier = 10000; + } else if (disputeState == BosonTypes.DisputeState.Refused) { + // REFUSED, buyer pays nothing + effectivePriceMultiplier = 0; + } else { + // RESOLVED or DECIDED + effectivePriceMultiplier = 10000 - dispute.buyerPercent; + } + } + } + } + + uint256 resellerBuyPrice = _initialPrice; // the price that reseller paid for the voucher + address msgSender = EIP712Lib.msgSender(); + uint256 len = exchangeCosts.length; + for (uint256 i = 0; i < len; i++) { + BosonTypes.ExchangeCosts storage sc = exchangeCosts[i]; + + // amount to be released + uint256 currentResellerAmount; + + // inside the scope to avoid stack too deep error + { + uint256 price = sc.price; + uint256 protocolFeeAmount = sc.protocolFeeAmount; + uint256 royaltyAmount = sc.royaltyAmount; + + protocolFee += protocolFeeAmount; + royalties += royaltyAmount; + + // secondary price without protocol fee and royalties + uint256 reducedSecondaryPrice = price - protocolFeeAmount - royaltyAmount; + + // current reseller gets the difference between final payout and the immediate payout they received at the time of secondary sale + currentResellerAmount = + ( + reducedSecondaryPrice > resellerBuyPrice + ? effectivePriceMultiplier * (reducedSecondaryPrice - resellerBuyPrice) + : (10000 - effectivePriceMultiplier) * (resellerBuyPrice - reducedSecondaryPrice) + ) / + 10000; + + resellerBuyPrice = price; + } + + if (currentResellerAmount > 0) { + increaseAvailableFundsAndEmitEvent( + _exchangeId, + sc.resellerId, + _exchangeToken, + currentResellerAmount, + msgSender + ); + } + } + + // protocolFee and royalties can be multiplied by effectivePriceMultiplier just at the end + protocolFee = (protocolFee * effectivePriceMultiplier) / 10000; + royalties = (royalties * effectivePriceMultiplier) / 10000; + } + + /** + * @notice Forwards values to increaseAvailableFunds and emits notifies external listeners. + * + * Emits FundsReleased events + * + * @param _exchangeId - exchange id + * @param _entityId - id of the entity to which the funds are released + * @param _tokenAddress - address of the token used for the exchange + * @param _amount - amount of tokens to be released + * @param _sender - address of the sender that executed the transaction + */ + function increaseAvailableFundsAndEmitEvent( + uint256 _exchangeId, + uint256 _entityId, + address _tokenAddress, + uint256 _amount, + address _sender + ) internal { + increaseAvailableFunds(_entityId, _tokenAddress, _amount); + emit FundsReleased(_exchangeId, _entityId, _tokenAddress, _amount, _sender); + } + /** * @notice Tries to transfer tokens from the caller to the protocol. * @@ -234,15 +388,16 @@ library FundsLib { * - Received ERC20 token amount differs from the expected value * * @param _tokenAddress - address of the token to be transferred + * @param _from - address to transfer funds from * @param _amount - amount to be transferred */ - function transferFundsToProtocol(address _tokenAddress, uint256 _amount) internal { + function transferFundsToProtocol(address _tokenAddress, address _from, uint256 _amount) internal { if (_amount > 0) { // protocol balance before the transfer uint256 protocolTokenBalanceBefore = IERC20(_tokenAddress).balanceOf(address(this)); // transfer ERC20 tokens from the caller - IERC20(_tokenAddress).safeTransferFrom(EIP712Lib.msgSender(), address(this), _amount); + IERC20(_tokenAddress).safeTransferFrom(_from, address(this), _amount); // protocol balance after the transfer uint256 protocolTokenBalanceAfter = IERC20(_tokenAddress).balanceOf(address(this)); @@ -252,6 +407,17 @@ library FundsLib { } } + /** + * @notice Same as transferFundsToProtocol(address _tokenAddress, address _from, uint256 _amount), + * but _from is message sender + * + * @param _tokenAddress - address of the token to be transferred + * @param _amount - amount to be transferred + */ + function transferFundsToProtocol(address _tokenAddress, uint256 _amount) internal { + transferFundsToProtocol(_tokenAddress, EIP712Lib.msgSender(), _amount); + } + /** * @notice Tries to transfer native currency or tokens from the protocol to the recipient. * @@ -263,6 +429,7 @@ library FundsLib { * - Contract at token address does not support ERC20 function transfer * - Available funds is less than amount to be decreased * + * @param _entityId - id of entity for which funds should be decreased, or 0 for protocol * @param _tokenAddress - address of the token to be transferred * @param _to - address of the recipient * @param _amount - amount to be transferred @@ -276,6 +443,28 @@ library FundsLib { // first decrease the amount to prevent the reentrancy attack decreaseAvailableFunds(_entityId, _tokenAddress, _amount); + // try to transfer the funds + transferFundsFromProtocol(_tokenAddress, _to, _amount); + + // notify the external observers + emit FundsWithdrawn(_entityId, _to, _tokenAddress, _amount, EIP712Lib.msgSender()); + } + + /** + * @notice Tries to transfer native currency or tokens from the protocol to the recipient. + * + * Emits ERC20 Transfer event in call stack if ERC20 token is withdrawn and transfer is successful. + * + * Reverts if: + * - Transfer of native currency is not successful (i.e. recipient is a contract which reverted) + * - Contract at token address does not support ERC20 function transfer + * - Available funds is less than amount to be decreased + * + * @param _tokenAddress - address of the token to be transferred + * @param _to - address of the recipient + * @param _amount - amount to be transferred + */ + function transferFundsFromProtocol(address _tokenAddress, address payable _to, uint256 _amount) internal { // try to transfer the funds if (_tokenAddress == address(0)) { // transfer native currency @@ -285,9 +474,6 @@ library FundsLib { // transfer ERC20 tokens IERC20(_tokenAddress).safeTransfer(_to, _amount); } - - // notify the external observers - emit FundsWithdrawn(_entityId, _to, _tokenAddress, _amount, EIP712Lib.msgSender()); } /** diff --git a/contracts/protocol/libs/ProtocolLib.sol b/contracts/protocol/libs/ProtocolLib.sol index 64b1600cd..d0c425db2 100644 --- a/contracts/protocol/libs/ProtocolLib.sol +++ b/contracts/protocol/libs/ProtocolLib.sol @@ -116,6 +116,8 @@ library ProtocolLib { mapping(uint256 => BosonTypes.Twin) twins; //entity id => auth token mapping(uint256 => BosonTypes.AuthToken) authTokens; + // exchange id => sequential commit info + mapping(uint256 => BosonTypes.ExchangeCosts[]) exchangeCosts; } // Protocol lookups storage @@ -247,6 +249,10 @@ library ProtocolLib { mapping(bytes32 => bool) initializedVersions; // Current protocol version bytes32 version; + // Incoming voucher id + uint256 incomingVoucherId; + // Incoming voucher clone address + address incomingVoucherCloneAddress; } /** diff --git a/docs/images/Boson_Protocol_V2_-_Protocol_Diamond.png b/docs/images/Boson_Protocol_V2_-_Protocol_Diamond.png index b13c6162f..01366e552 100644 Binary files a/docs/images/Boson_Protocol_V2_-_Protocol_Diamond.png and b/docs/images/Boson_Protocol_V2_-_Protocol_Diamond.png differ diff --git a/hardhat-fork.config.js b/hardhat-fork.config.js index 3157e7e81..45456a847 100644 --- a/hardhat-fork.config.js +++ b/hardhat-fork.config.js @@ -1,18 +1,46 @@ const defaultConfig = require("./hardhat.config.js"); +require("hardhat-preprocessor"); const environments = require("./environments"); +const fs = require("fs"); const { subtask } = require("hardhat/config"); const path = require("node:path"); const { glob } = require("glob"); const { TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS } = require("hardhat/builtin-tasks/task-names"); -subtask(TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS, async (_, { config }) => { - const contracts = await glob(path.join(config.paths.root, "contracts/**/*.sol")); - const submodulesContracts = await glob(path.join(config.paths.root, "submodules/**/contracts/*.sol"), { - ignore: path.join(config.paths.root, "submodules/**/node_modules/**"), +subtask(TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS, async (_, { config }, runSuper) => { + const files = await runSuper(); + + const submodules = await glob(path.join(config.paths.root, "submodules/**/{src,contracts}/**/*.sol"), { + ignore: [ + path.join(config.paths.root, "submodules/**/node_modules/**"), + path.join(config.paths.root, "submodules/**/test/**"), + path.join(config.paths.root, "submodules/**/src/test/*.sol"), + path.join(config.paths.root, "submodules/**/src/mocks/*.sol"), + path.join(config.paths.root, "submodules/**/lib/**/*.sol"), + path.join(config.paths.root, "submodules/**/artifacts/**"), + path.join(config.paths.root, "submodules/**/typechain-types/**"), + ], + }); + + // Include files inside lib folder when it is inside src folder + const submodulesWithLib = await glob(path.join(config.paths.root, "submodules/**/{src,contracts}/lib/**/*.sol"), { + ignore: [ + path.join(config.paths.root, "submodules/**/test/**"), + path.join(config.paths.root, "submodules/**/artifacts/**"), + ], }); - return [...contracts, ...submodulesContracts].map(path.normalize); + return [...files, ...submodules, ...submodulesWithLib].map(path.normalize); }); + +function getRemappings() { + return fs + .readFileSync("remappings.txt", "utf8") + .split("\n") + .filter(Boolean) // remove empty lines + .map((line) => line.trim().split("=")); +} + module.exports = { ...defaultConfig, networks: { @@ -22,6 +50,26 @@ module.exports = { blockNumber: 40119033, }, accounts: { mnemonic: environments.hardhat.mnemonic }, + allowUnlimitedContractSize: true, }, }, + preprocess: { + eachLine: () => ({ + transform: (line, { absolutePath }) => { + if (absolutePath.includes("submodules")) { + const submodule = absolutePath.split("submodules/")[1].split("/")[0]; + if (line.match(/^\s*import /i)) { + for (const [from, to] of getRemappings()) { + if (line.includes(from)) { + line = line.replace(from, to.replace("${submodule}", submodule)); + break; + } + } + } + } + + return line; + }, + }), + }, }; diff --git a/hardhat.config.js b/hardhat.config.js index d392f5d73..42cd6d5a2 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -143,6 +143,8 @@ module.exports = { networks: { hardhat: { accounts: { mnemonic: environments.hardhat.mnemonic }, + gasPrice: 0, + initialBaseFeePerGas: 0, }, localhost: { url: environments.localhost.txNode || "http://127.0.0.1:8545", @@ -181,6 +183,9 @@ module.exports = { }, solidity: { compilers: [ + { + version: "0.5.17", // Mock weth contract + }, { version: "0.8.9", settings: { @@ -191,11 +196,18 @@ module.exports = { yul: true, }, }, + outputSelection: { + "*": { + "*": ["evm.bytecode.object", "evm.deployedBytecode*"], + }, + }, }, + viaIR: true, }, { version: "0.8.21", settings: { + viaIR: false, optimizer: { enabled: true, runs: 200, @@ -203,7 +215,7 @@ module.exports = { yul: true, }, }, - evmVersion: "london", // for ethereum mainnet, use shanghai + evmVersion: "shanghai", // for ethereum mainnet, use shanghai }, }, { diff --git a/package-lock.json b/package-lock.json index e0ac94776..49ed5db72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@nomicfoundation/hardhat-toolbox": "^3.0.0", "@nomiclabs/hardhat-web3": "^2.0.0", "@openzeppelin/test-helpers": "^0.5.16", + "@zoralabs/core": "^1.0.8", "coveralls": "^3.1.1", "decache": "^4.6.1", "dotenv": "^16.3.0", @@ -28,12 +29,15 @@ "eslint-plugin-no-only-tests": "^3.1.0", "ethereum-input-data-decoder": "^0.4.2", "ethers": "^6.6.7", + "ethersv5": "npm:ethers@^5.7.2", "glob": "^10.2.7", "hardhat": "^2.17.1", "hardhat-contract-sizer": "^2.7.0", "hardhat-preprocessor": "^0.1.5", "husky": "^8.0.3", "lodash": "^4.17.21", + "merkletreejs": "^0.3.10", + "opensea-js": "^6.1.12", "prettier": "^2.8.4", "prettier-plugin-solidity": "^1.0.0-beta.19", "simple-statistics": "^7.8.2", @@ -42,6 +46,48 @@ "web3": "^1.8.1" } }, + "node_modules/@0xsequence/abi": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/abi/-/abi-0.43.34.tgz", + "integrity": "sha512-wZ3JLA4kw2em8A7gFW5oESdo+F3G/WjIhCp/aZ0x3UgayBxrQjwBURoqDQPrY5k/BJ4R68LIEabLTrpSXesh1g==", + "dev": true + }, + "node_modules/@0xsequence/api": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/api/-/api-0.43.34.tgz", + "integrity": "sha512-YmV65zn9vZiprEXLfLVIWANK3WBag3d+N0Sc5Br19ezmCFBg52DdzumJIM+8S3maUE2JdL9RbgBLZ+9JOBKnEg==", + "dev": true + }, + "node_modules/@0xsequence/ethauth": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@0xsequence/ethauth/-/ethauth-0.8.1.tgz", + "integrity": "sha512-P21cxRSS+2mDAqFVAJt0lwQFtbObX+Ewlj8DMyDELp81+QbfHFh6LCyu8dTXNdBx6UbmRFOCSBno5Txd50cJPQ==", + "dev": true, + "dependencies": { + "js-base64": "^3.7.2" + }, + "peerDependencies": { + "ethers": ">=5.5" + } + }, + "node_modules/@0xsequence/guard": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/guard/-/guard-0.43.34.tgz", + "integrity": "sha512-U8uIjC8nifDgugo+4V3siu5fs86TqOmsb4Wvx0n6G/zbX2LaPGOYwHqCYkWrukETnk/FYiy8GoTuV11T9jIrSg==", + "dev": true + }, + "node_modules/@0xsequence/indexer": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/indexer/-/indexer-0.43.34.tgz", + "integrity": "sha512-u7dnbLGH447Utph3Ebvfmi98kTebdc8+we1L6FSYpodpvN3q/lb5de8BL1Jbmry0m9MSLy1iGwdGA0AivwNgtA==", + "dev": true + }, + "node_modules/@0xsequence/metadata": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/metadata/-/metadata-0.43.34.tgz", + "integrity": "sha512-ZJO+cerq2gQqktqyCsD1zfAAeOzsCDZXEDTO47oT5v42Bl4L50Vlj1PxNlo9iKzYooCA2LZjeWJkrvzfa0cvjA==", + "dev": true + }, "node_modules/@adraffy/ens-normalize": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.9.2.tgz", @@ -2268,6 +2314,216 @@ "web3": "^1.0.0-beta.36" } }, + "node_modules/@opensea/seaport-js": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@opensea/seaport-js/-/seaport-js-2.0.8.tgz", + "integrity": "sha512-uOjqXtXK49vbOoxXy/z54evxV3OtExjNe81E9CK6v6nOX0vDZH6dnDSTbC8ELL1RrAXM8dmDMtA0szepDyoaKQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@0xsequence/multicall": "^0.43.29", + "ethers": "^5.7.2", + "merkletreejs": "^0.3.10" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@opensea/seaport-js/node_modules/@0xsequence/auth": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/auth/-/auth-0.43.34.tgz", + "integrity": "sha512-dw58nX2gc5QkIkzeVCheFZrRQgHwp4ZlJdg2e5gk7jU8eEu48oWP6faz30MFfiJfUCaysbGZ0o9+mGPqwpPG2g==", + "dev": true, + "dependencies": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/api": "^0.43.34", + "@0xsequence/config": "^0.43.34", + "@0xsequence/ethauth": "^0.8.0", + "@0xsequence/indexer": "^0.43.34", + "@0xsequence/metadata": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/provider": "^0.43.34", + "@0xsequence/utils": "^0.43.34", + "@0xsequence/wallet": "^0.43.34" + }, + "peerDependencies": { + "ethers": ">=5.5 < 6" + } + }, + "node_modules/@opensea/seaport-js/node_modules/@0xsequence/config": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/config/-/config-0.43.34.tgz", + "integrity": "sha512-rOkNLB7z64ZkURzTXMF+4zTPo17VUei6vT5sp9Uzd5zamEneWGFdUJltzDc8sLdUWTEVdkyckaTSTS+8/sHuLw==", + "dev": true, + "dependencies": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/multicall": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/utils": "^0.43.34" + }, + "peerDependencies": { + "ethers": ">=5.5 < 6" + } + }, + "node_modules/@opensea/seaport-js/node_modules/@0xsequence/multicall": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/multicall/-/multicall-0.43.34.tgz", + "integrity": "sha512-7gLlX3TOi+qZYe28DVdqkQJBeibl9JOdCcHaw9zkQYAZ+2WLouZl5Rlv0ZHEwX46gOiG1mCt/tZugoRkguKE0Q==", + "dev": true, + "dependencies": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/utils": "^0.43.34" + }, + "peerDependencies": { + "ethers": ">=5.5 < 6" + } + }, + "node_modules/@opensea/seaport-js/node_modules/@0xsequence/network": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/network/-/network-0.43.34.tgz", + "integrity": "sha512-KH2k4zEiXBHBathU+T7AXxzSDRm0XJ2+bJSSKci+RWesLPT2TwZY7YLfSWjSyp20EPqeyuaG7Snn86e60Zi/eg==", + "dev": true, + "dependencies": { + "@0xsequence/indexer": "^0.43.34", + "@0xsequence/provider": "^0.43.34", + "@0xsequence/relayer": "^0.43.34", + "@0xsequence/utils": "^0.43.34" + }, + "peerDependencies": { + "ethers": ">=5.5 < 6" + } + }, + "node_modules/@opensea/seaport-js/node_modules/@0xsequence/provider": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/provider/-/provider-0.43.34.tgz", + "integrity": "sha512-AuMiP3budYbtql1L8eemcmxknuN5QJcPirr4DtkCnifCMGDoF/savSuue6+7K65HGj/8yzdFrRlt0MYavYWVoA==", + "dev": true, + "dependencies": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/auth": "^0.43.34", + "@0xsequence/config": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/relayer": "^0.43.34", + "@0xsequence/transactions": "^0.43.34", + "@0xsequence/utils": "^0.43.34", + "@0xsequence/wallet": "^0.43.34", + "eventemitter2": "^6.4.5", + "webextension-polyfill": "^0.10.0" + }, + "peerDependencies": { + "ethers": ">=5.5 < 6" + } + }, + "node_modules/@opensea/seaport-js/node_modules/@0xsequence/relayer": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/relayer/-/relayer-0.43.34.tgz", + "integrity": "sha512-Kl6LitpG24i3ha6CxBRnFAD1/vAbC1+pub7yywhwH8jmnd7KncHAZNgYT48BZI6B2bOeQiY+tTevUcgYw0hSzA==", + "dev": true, + "dependencies": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/config": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/transactions": "^0.43.34", + "@0xsequence/utils": "^0.43.34" + }, + "peerDependencies": { + "ethers": ">=5.5 < 6" + } + }, + "node_modules/@opensea/seaport-js/node_modules/@0xsequence/transactions": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/transactions/-/transactions-0.43.34.tgz", + "integrity": "sha512-C6xDBqDOpx3+fuZ4OWStpAgAMKW7het1a6cwuQRalN8s+3n/SkjgzSK8Xc/5FT4FVExJuwo/D/AkvyOFz7AaCg==", + "dev": true, + "dependencies": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/config": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/utils": "^0.43.34" + }, + "peerDependencies": { + "ethers": ">=5.5 < 6" + } + }, + "node_modules/@opensea/seaport-js/node_modules/@0xsequence/utils": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/utils/-/utils-0.43.34.tgz", + "integrity": "sha512-Rp0vVeBUeTmOSpXwy+Adlycitg0V4qjao1QvCqONgu9Rh1NIVpocVLx42iSopFQFIALhYB0ZrHp+ns6QsC08+A==", + "dev": true, + "dependencies": { + "js-base64": "^3.7.2" + }, + "peerDependencies": { + "ethers": ">=5.5 < 6" + } + }, + "node_modules/@opensea/seaport-js/node_modules/@0xsequence/wallet": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/wallet/-/wallet-0.43.34.tgz", + "integrity": "sha512-8ZojYXcLnItXfmBy1PRR4qf25GKV5E0bcGLb3tuw/7M6QlFi1CqgRcHuuXYZ4XYyLxLBaKUC1+3sNqcFJGAirA==", + "dev": true, + "dependencies": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/config": "^0.43.34", + "@0xsequence/guard": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/relayer": "^0.43.34", + "@0xsequence/transactions": "^0.43.34", + "@0xsequence/utils": "^0.43.34" + }, + "peerDependencies": { + "ethers": ">=5.5 < 6" + } + }, + "node_modules/@opensea/seaport-js/node_modules/ethers": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", + "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@ethersproject/abi": "5.7.0", + "@ethersproject/abstract-provider": "5.7.0", + "@ethersproject/abstract-signer": "5.7.0", + "@ethersproject/address": "5.7.0", + "@ethersproject/base64": "5.7.0", + "@ethersproject/basex": "5.7.0", + "@ethersproject/bignumber": "5.7.0", + "@ethersproject/bytes": "5.7.0", + "@ethersproject/constants": "5.7.0", + "@ethersproject/contracts": "5.7.0", + "@ethersproject/hash": "5.7.0", + "@ethersproject/hdnode": "5.7.0", + "@ethersproject/json-wallets": "5.7.0", + "@ethersproject/keccak256": "5.7.0", + "@ethersproject/logger": "5.7.0", + "@ethersproject/networks": "5.7.1", + "@ethersproject/pbkdf2": "5.7.0", + "@ethersproject/properties": "5.7.0", + "@ethersproject/providers": "5.7.2", + "@ethersproject/random": "5.7.0", + "@ethersproject/rlp": "5.7.0", + "@ethersproject/sha2": "5.7.0", + "@ethersproject/signing-key": "5.7.0", + "@ethersproject/solidity": "5.7.0", + "@ethersproject/strings": "5.7.0", + "@ethersproject/transactions": "5.7.0", + "@ethersproject/units": "5.7.0", + "@ethersproject/wallet": "5.7.0", + "@ethersproject/web": "5.7.1", + "@ethersproject/wordlists": "5.7.0" + } + }, "node_modules/@openzeppelin/contract-loader": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@openzeppelin/contract-loader/-/contract-loader-0.6.3.tgz", @@ -3933,6 +4189,63 @@ "@types/node": "*" } }, + "node_modules/@zoralabs/core": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@zoralabs/core/-/core-1.0.8.tgz", + "integrity": "sha512-KDqO4RXUAVHUYjtJlAZErX8hOYXLmmJzK3/gdhwKQITXy/SHzLzpE7adPSbHRCE2SncfFTW6tbCfxIvXno6MrQ==", + "dev": true, + "dependencies": { + "ethers": "^5.0.19" + } + }, + "node_modules/@zoralabs/core/node_modules/ethers": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", + "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@ethersproject/abi": "5.7.0", + "@ethersproject/abstract-provider": "5.7.0", + "@ethersproject/abstract-signer": "5.7.0", + "@ethersproject/address": "5.7.0", + "@ethersproject/base64": "5.7.0", + "@ethersproject/basex": "5.7.0", + "@ethersproject/bignumber": "5.7.0", + "@ethersproject/bytes": "5.7.0", + "@ethersproject/constants": "5.7.0", + "@ethersproject/contracts": "5.7.0", + "@ethersproject/hash": "5.7.0", + "@ethersproject/hdnode": "5.7.0", + "@ethersproject/json-wallets": "5.7.0", + "@ethersproject/keccak256": "5.7.0", + "@ethersproject/logger": "5.7.0", + "@ethersproject/networks": "5.7.1", + "@ethersproject/pbkdf2": "5.7.0", + "@ethersproject/properties": "5.7.0", + "@ethersproject/providers": "5.7.2", + "@ethersproject/random": "5.7.0", + "@ethersproject/rlp": "5.7.0", + "@ethersproject/sha2": "5.7.0", + "@ethersproject/signing-key": "5.7.0", + "@ethersproject/solidity": "5.7.0", + "@ethersproject/strings": "5.7.0", + "@ethersproject/transactions": "5.7.0", + "@ethersproject/units": "5.7.0", + "@ethersproject/wallet": "5.7.0", + "@ethersproject/web": "5.7.1", + "@ethersproject/wordlists": "5.7.0" + } + }, "node_modules/abbrev": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", @@ -5148,6 +5461,12 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/buffer-reverse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-reverse/-/buffer-reverse-1.0.1.tgz", + "integrity": "sha512-M87YIUBsZ6N924W57vDwT/aOu8hw7ZgdByz6ijksLjmHJELBASmYTTlNHRgjE+pTsT9oJXGaDSgqqwfdHotDUg==", + "dev": true + }, "node_modules/buffer-to-arraybuffer": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/buffer-to-arraybuffer/-/buffer-to-arraybuffer-0.0.5.tgz", @@ -6227,6 +6546,12 @@ "sha3": "^2.1.1" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "dev": true + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -8540,6 +8865,55 @@ "integrity": "sha512-N+0kuo9KgrUQ1Sn/ifDXsvg0TTleP7rIy4zOBGECxAljqvqfqpTfzx0Q1NUedOixRMBfe2Whhb056a42cWs26Q==", "dev": true }, + "node_modules/ethersv5": { + "name": "ethers", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", + "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@ethersproject/abi": "5.7.0", + "@ethersproject/abstract-provider": "5.7.0", + "@ethersproject/abstract-signer": "5.7.0", + "@ethersproject/address": "5.7.0", + "@ethersproject/base64": "5.7.0", + "@ethersproject/basex": "5.7.0", + "@ethersproject/bignumber": "5.7.0", + "@ethersproject/bytes": "5.7.0", + "@ethersproject/constants": "5.7.0", + "@ethersproject/contracts": "5.7.0", + "@ethersproject/hash": "5.7.0", + "@ethersproject/hdnode": "5.7.0", + "@ethersproject/json-wallets": "5.7.0", + "@ethersproject/keccak256": "5.7.0", + "@ethersproject/logger": "5.7.0", + "@ethersproject/networks": "5.7.1", + "@ethersproject/pbkdf2": "5.7.0", + "@ethersproject/properties": "5.7.0", + "@ethersproject/providers": "5.7.2", + "@ethersproject/random": "5.7.0", + "@ethersproject/rlp": "5.7.0", + "@ethersproject/sha2": "5.7.0", + "@ethersproject/signing-key": "5.7.0", + "@ethersproject/solidity": "5.7.0", + "@ethersproject/strings": "5.7.0", + "@ethersproject/transactions": "5.7.0", + "@ethersproject/units": "5.7.0", + "@ethersproject/wallet": "5.7.0", + "@ethersproject/web": "5.7.1", + "@ethersproject/wordlists": "5.7.0" + } + }, "node_modules/ethjs-abi": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/ethjs-abi/-/ethjs-abi-0.2.1.tgz", @@ -8611,6 +8985,12 @@ "node": ">=6" } }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "dev": true + }, "node_modules/eventemitter3": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.4.tgz", @@ -11644,6 +12024,12 @@ "node": ">= 0.6.0" } }, + "node_modules/js-base64": { + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.5.tgz", + "integrity": "sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==", + "dev": true + }, "node_modules/js-sdsl": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.1.tgz", @@ -12653,6 +13039,31 @@ "node": ">= 8" } }, + "node_modules/merkletreejs": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/merkletreejs/-/merkletreejs-0.3.11.tgz", + "integrity": "sha512-LJKTl4iVNTndhL+3Uz/tfkjD0klIWsHlUzgtuNnNrsf7bAlXR30m+xYB7lHr5Z/l6e/yAIsr26Dabx6Buo4VGQ==", + "dev": true, + "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" + }, + "engines": { + "node": ">= 7.6.0" + } + }, + "node_modules/merkletreejs/node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -13593,6 +14004,68 @@ "node": ">=6" } }, + "node_modules/opensea-js": { + "version": "6.1.12", + "resolved": "https://registry.npmjs.org/opensea-js/-/opensea-js-6.1.12.tgz", + "integrity": "sha512-JRTFnvh/R1QGj5TOJbJeWnFcjvQEAK92748XmOdf7UmBR4y6DdFwP4kdK6mJAudfe82EsZDUBC0jm9RaASRR7w==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@opensea/seaport-js": "^2.0.8", + "ethers": "^5.7.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/opensea-js/node_modules/ethers": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", + "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@ethersproject/abi": "5.7.0", + "@ethersproject/abstract-provider": "5.7.0", + "@ethersproject/abstract-signer": "5.7.0", + "@ethersproject/address": "5.7.0", + "@ethersproject/base64": "5.7.0", + "@ethersproject/basex": "5.7.0", + "@ethersproject/bignumber": "5.7.0", + "@ethersproject/bytes": "5.7.0", + "@ethersproject/constants": "5.7.0", + "@ethersproject/contracts": "5.7.0", + "@ethersproject/hash": "5.7.0", + "@ethersproject/hdnode": "5.7.0", + "@ethersproject/json-wallets": "5.7.0", + "@ethersproject/keccak256": "5.7.0", + "@ethersproject/logger": "5.7.0", + "@ethersproject/networks": "5.7.1", + "@ethersproject/pbkdf2": "5.7.0", + "@ethersproject/properties": "5.7.0", + "@ethersproject/providers": "5.7.2", + "@ethersproject/random": "5.7.0", + "@ethersproject/rlp": "5.7.0", + "@ethersproject/sha2": "5.7.0", + "@ethersproject/signing-key": "5.7.0", + "@ethersproject/solidity": "5.7.0", + "@ethersproject/strings": "5.7.0", + "@ethersproject/transactions": "5.7.0", + "@ethersproject/units": "5.7.0", + "@ethersproject/wallet": "5.7.0", + "@ethersproject/web": "5.7.1", + "@ethersproject/wordlists": "5.7.0" + } + }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -17791,6 +18264,15 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "dev": true }, + "node_modules/treeify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/treeify/-/treeify-1.1.0.tgz", + "integrity": "sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, "node_modules/trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", @@ -19028,6 +19510,12 @@ "node": ">=8.0.0" } }, + "node_modules/webextension-polyfill": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/webextension-polyfill/-/webextension-polyfill-0.10.0.tgz", + "integrity": "sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==", + "dev": true + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -19633,6 +20121,45 @@ } }, "dependencies": { + "@0xsequence/abi": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/abi/-/abi-0.43.34.tgz", + "integrity": "sha512-wZ3JLA4kw2em8A7gFW5oESdo+F3G/WjIhCp/aZ0x3UgayBxrQjwBURoqDQPrY5k/BJ4R68LIEabLTrpSXesh1g==", + "dev": true + }, + "@0xsequence/api": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/api/-/api-0.43.34.tgz", + "integrity": "sha512-YmV65zn9vZiprEXLfLVIWANK3WBag3d+N0Sc5Br19ezmCFBg52DdzumJIM+8S3maUE2JdL9RbgBLZ+9JOBKnEg==", + "dev": true + }, + "@0xsequence/ethauth": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@0xsequence/ethauth/-/ethauth-0.8.1.tgz", + "integrity": "sha512-P21cxRSS+2mDAqFVAJt0lwQFtbObX+Ewlj8DMyDELp81+QbfHFh6LCyu8dTXNdBx6UbmRFOCSBno5Txd50cJPQ==", + "dev": true, + "requires": { + "js-base64": "^3.7.2" + } + }, + "@0xsequence/guard": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/guard/-/guard-0.43.34.tgz", + "integrity": "sha512-U8uIjC8nifDgugo+4V3siu5fs86TqOmsb4Wvx0n6G/zbX2LaPGOYwHqCYkWrukETnk/FYiy8GoTuV11T9jIrSg==", + "dev": true + }, + "@0xsequence/indexer": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/indexer/-/indexer-0.43.34.tgz", + "integrity": "sha512-u7dnbLGH447Utph3Ebvfmi98kTebdc8+we1L6FSYpodpvN3q/lb5de8BL1Jbmry0m9MSLy1iGwdGA0AivwNgtA==", + "dev": true + }, + "@0xsequence/metadata": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/metadata/-/metadata-0.43.34.tgz", + "integrity": "sha512-ZJO+cerq2gQqktqyCsD1zfAAeOzsCDZXEDTO47oT5v42Bl4L50Vlj1PxNlo9iKzYooCA2LZjeWJkrvzfa0cvjA==", + "dev": true + }, "@adraffy/ens-normalize": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.9.2.tgz", @@ -21194,6 +21721,177 @@ "@types/bignumber.js": "^5.0.0" } }, + "@opensea/seaport-js": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@opensea/seaport-js/-/seaport-js-2.0.8.tgz", + "integrity": "sha512-uOjqXtXK49vbOoxXy/z54evxV3OtExjNe81E9CK6v6nOX0vDZH6dnDSTbC8ELL1RrAXM8dmDMtA0szepDyoaKQ==", + "dev": true, + "requires": { + "@0xsequence/multicall": "^0.43.29", + "ethers": "^5.7.2", + "merkletreejs": "^0.3.10" + }, + "dependencies": { + "@0xsequence/auth": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/auth/-/auth-0.43.34.tgz", + "integrity": "sha512-dw58nX2gc5QkIkzeVCheFZrRQgHwp4ZlJdg2e5gk7jU8eEu48oWP6faz30MFfiJfUCaysbGZ0o9+mGPqwpPG2g==", + "dev": true, + "requires": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/api": "^0.43.34", + "@0xsequence/config": "^0.43.34", + "@0xsequence/ethauth": "^0.8.0", + "@0xsequence/indexer": "^0.43.34", + "@0xsequence/metadata": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/provider": "^0.43.34", + "@0xsequence/utils": "^0.43.34", + "@0xsequence/wallet": "^0.43.34" + } + }, + "@0xsequence/config": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/config/-/config-0.43.34.tgz", + "integrity": "sha512-rOkNLB7z64ZkURzTXMF+4zTPo17VUei6vT5sp9Uzd5zamEneWGFdUJltzDc8sLdUWTEVdkyckaTSTS+8/sHuLw==", + "dev": true, + "requires": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/multicall": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/utils": "^0.43.34" + } + }, + "@0xsequence/multicall": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/multicall/-/multicall-0.43.34.tgz", + "integrity": "sha512-7gLlX3TOi+qZYe28DVdqkQJBeibl9JOdCcHaw9zkQYAZ+2WLouZl5Rlv0ZHEwX46gOiG1mCt/tZugoRkguKE0Q==", + "dev": true, + "requires": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/utils": "^0.43.34" + } + }, + "@0xsequence/network": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/network/-/network-0.43.34.tgz", + "integrity": "sha512-KH2k4zEiXBHBathU+T7AXxzSDRm0XJ2+bJSSKci+RWesLPT2TwZY7YLfSWjSyp20EPqeyuaG7Snn86e60Zi/eg==", + "dev": true, + "requires": { + "@0xsequence/indexer": "^0.43.34", + "@0xsequence/provider": "^0.43.34", + "@0xsequence/relayer": "^0.43.34", + "@0xsequence/utils": "^0.43.34" + } + }, + "@0xsequence/provider": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/provider/-/provider-0.43.34.tgz", + "integrity": "sha512-AuMiP3budYbtql1L8eemcmxknuN5QJcPirr4DtkCnifCMGDoF/savSuue6+7K65HGj/8yzdFrRlt0MYavYWVoA==", + "dev": true, + "requires": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/auth": "^0.43.34", + "@0xsequence/config": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/relayer": "^0.43.34", + "@0xsequence/transactions": "^0.43.34", + "@0xsequence/utils": "^0.43.34", + "@0xsequence/wallet": "^0.43.34", + "eventemitter2": "^6.4.5", + "webextension-polyfill": "^0.10.0" + } + }, + "@0xsequence/relayer": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/relayer/-/relayer-0.43.34.tgz", + "integrity": "sha512-Kl6LitpG24i3ha6CxBRnFAD1/vAbC1+pub7yywhwH8jmnd7KncHAZNgYT48BZI6B2bOeQiY+tTevUcgYw0hSzA==", + "dev": true, + "requires": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/config": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/transactions": "^0.43.34", + "@0xsequence/utils": "^0.43.34" + } + }, + "@0xsequence/transactions": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/transactions/-/transactions-0.43.34.tgz", + "integrity": "sha512-C6xDBqDOpx3+fuZ4OWStpAgAMKW7het1a6cwuQRalN8s+3n/SkjgzSK8Xc/5FT4FVExJuwo/D/AkvyOFz7AaCg==", + "dev": true, + "requires": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/config": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/utils": "^0.43.34" + } + }, + "@0xsequence/utils": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/utils/-/utils-0.43.34.tgz", + "integrity": "sha512-Rp0vVeBUeTmOSpXwy+Adlycitg0V4qjao1QvCqONgu9Rh1NIVpocVLx42iSopFQFIALhYB0ZrHp+ns6QsC08+A==", + "dev": true, + "requires": { + "js-base64": "^3.7.2" + } + }, + "@0xsequence/wallet": { + "version": "0.43.34", + "resolved": "https://registry.npmjs.org/@0xsequence/wallet/-/wallet-0.43.34.tgz", + "integrity": "sha512-8ZojYXcLnItXfmBy1PRR4qf25GKV5E0bcGLb3tuw/7M6QlFi1CqgRcHuuXYZ4XYyLxLBaKUC1+3sNqcFJGAirA==", + "dev": true, + "requires": { + "@0xsequence/abi": "^0.43.34", + "@0xsequence/config": "^0.43.34", + "@0xsequence/guard": "^0.43.34", + "@0xsequence/network": "^0.43.34", + "@0xsequence/relayer": "^0.43.34", + "@0xsequence/transactions": "^0.43.34", + "@0xsequence/utils": "^0.43.34" + } + }, + "ethers": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", + "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", + "dev": true, + "requires": { + "@ethersproject/abi": "5.7.0", + "@ethersproject/abstract-provider": "5.7.0", + "@ethersproject/abstract-signer": "5.7.0", + "@ethersproject/address": "5.7.0", + "@ethersproject/base64": "5.7.0", + "@ethersproject/basex": "5.7.0", + "@ethersproject/bignumber": "5.7.0", + "@ethersproject/bytes": "5.7.0", + "@ethersproject/constants": "5.7.0", + "@ethersproject/contracts": "5.7.0", + "@ethersproject/hash": "5.7.0", + "@ethersproject/hdnode": "5.7.0", + "@ethersproject/json-wallets": "5.7.0", + "@ethersproject/keccak256": "5.7.0", + "@ethersproject/logger": "5.7.0", + "@ethersproject/networks": "5.7.1", + "@ethersproject/pbkdf2": "5.7.0", + "@ethersproject/properties": "5.7.0", + "@ethersproject/providers": "5.7.2", + "@ethersproject/random": "5.7.0", + "@ethersproject/rlp": "5.7.0", + "@ethersproject/sha2": "5.7.0", + "@ethersproject/signing-key": "5.7.0", + "@ethersproject/solidity": "5.7.0", + "@ethersproject/strings": "5.7.0", + "@ethersproject/transactions": "5.7.0", + "@ethersproject/units": "5.7.0", + "@ethersproject/wallet": "5.7.0", + "@ethersproject/web": "5.7.1", + "@ethersproject/wordlists": "5.7.0" + } + } + } + }, "@openzeppelin/contract-loader": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@openzeppelin/contract-loader/-/contract-loader-0.6.3.tgz", @@ -22704,6 +23402,55 @@ "@types/node": "*" } }, + "@zoralabs/core": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@zoralabs/core/-/core-1.0.8.tgz", + "integrity": "sha512-KDqO4RXUAVHUYjtJlAZErX8hOYXLmmJzK3/gdhwKQITXy/SHzLzpE7adPSbHRCE2SncfFTW6tbCfxIvXno6MrQ==", + "dev": true, + "requires": { + "ethers": "^5.0.19" + }, + "dependencies": { + "ethers": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", + "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", + "dev": true, + "requires": { + "@ethersproject/abi": "5.7.0", + "@ethersproject/abstract-provider": "5.7.0", + "@ethersproject/abstract-signer": "5.7.0", + "@ethersproject/address": "5.7.0", + "@ethersproject/base64": "5.7.0", + "@ethersproject/basex": "5.7.0", + "@ethersproject/bignumber": "5.7.0", + "@ethersproject/bytes": "5.7.0", + "@ethersproject/constants": "5.7.0", + "@ethersproject/contracts": "5.7.0", + "@ethersproject/hash": "5.7.0", + "@ethersproject/hdnode": "5.7.0", + "@ethersproject/json-wallets": "5.7.0", + "@ethersproject/keccak256": "5.7.0", + "@ethersproject/logger": "5.7.0", + "@ethersproject/networks": "5.7.1", + "@ethersproject/pbkdf2": "5.7.0", + "@ethersproject/properties": "5.7.0", + "@ethersproject/providers": "5.7.2", + "@ethersproject/random": "5.7.0", + "@ethersproject/rlp": "5.7.0", + "@ethersproject/sha2": "5.7.0", + "@ethersproject/signing-key": "5.7.0", + "@ethersproject/solidity": "5.7.0", + "@ethersproject/strings": "5.7.0", + "@ethersproject/transactions": "5.7.0", + "@ethersproject/units": "5.7.0", + "@ethersproject/wallet": "5.7.0", + "@ethersproject/web": "5.7.1", + "@ethersproject/wordlists": "5.7.0" + } + } + } + }, "abbrev": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", @@ -23650,6 +24397,12 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "buffer-reverse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-reverse/-/buffer-reverse-1.0.1.tgz", + "integrity": "sha512-M87YIUBsZ6N924W57vDwT/aOu8hw7ZgdByz6ijksLjmHJELBASmYTTlNHRgjE+pTsT9oJXGaDSgqqwfdHotDUg==", + "dev": true + }, "buffer-to-arraybuffer": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/buffer-to-arraybuffer/-/buffer-to-arraybuffer-0.0.5.tgz", @@ -24518,6 +25271,12 @@ "sha3": "^2.1.1" } }, + "crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "dev": true + }, "css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -26342,6 +27101,44 @@ } } }, + "ethersv5": { + "version": "npm:ethers@5.7.2", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", + "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", + "dev": true, + "requires": { + "@ethersproject/abi": "5.7.0", + "@ethersproject/abstract-provider": "5.7.0", + "@ethersproject/abstract-signer": "5.7.0", + "@ethersproject/address": "5.7.0", + "@ethersproject/base64": "5.7.0", + "@ethersproject/basex": "5.7.0", + "@ethersproject/bignumber": "5.7.0", + "@ethersproject/bytes": "5.7.0", + "@ethersproject/constants": "5.7.0", + "@ethersproject/contracts": "5.7.0", + "@ethersproject/hash": "5.7.0", + "@ethersproject/hdnode": "5.7.0", + "@ethersproject/json-wallets": "5.7.0", + "@ethersproject/keccak256": "5.7.0", + "@ethersproject/logger": "5.7.0", + "@ethersproject/networks": "5.7.1", + "@ethersproject/pbkdf2": "5.7.0", + "@ethersproject/properties": "5.7.0", + "@ethersproject/providers": "5.7.2", + "@ethersproject/random": "5.7.0", + "@ethersproject/rlp": "5.7.0", + "@ethersproject/sha2": "5.7.0", + "@ethersproject/signing-key": "5.7.0", + "@ethersproject/solidity": "5.7.0", + "@ethersproject/strings": "5.7.0", + "@ethersproject/transactions": "5.7.0", + "@ethersproject/units": "5.7.0", + "@ethersproject/wallet": "5.7.0", + "@ethersproject/web": "5.7.1", + "@ethersproject/wordlists": "5.7.0" + } + }, "ethjs-abi": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/ethjs-abi/-/ethjs-abi-0.2.1.tgz", @@ -26402,6 +27199,12 @@ "dev": true, "optional": true }, + "eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "dev": true + }, "eventemitter3": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.4.tgz", @@ -28682,6 +29485,12 @@ "integrity": "sha512-+kHj8HXArPfpPEKGLZ+kB5ONRTCiGQXo8RQYL0hH8t6pWXUBBK5KkkQmTNOwKK4LEsd0yTsgtjJVm4UBSZea4w==", "dev": true }, + "js-base64": { + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.5.tgz", + "integrity": "sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==", + "dev": true + }, "js-sdsl": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.1.tgz", @@ -29500,6 +30309,27 @@ "dev": true, "peer": true }, + "merkletreejs": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/merkletreejs/-/merkletreejs-0.3.11.tgz", + "integrity": "sha512-LJKTl4iVNTndhL+3Uz/tfkjD0klIWsHlUzgtuNnNrsf7bAlXR30m+xYB7lHr5Z/l6e/yAIsr26Dabx6Buo4VGQ==", + "dev": true, + "requires": { + "bignumber.js": "^9.0.1", + "buffer-reverse": "^1.0.1", + "crypto-js": "^4.2.0", + "treeify": "^1.1.0", + "web3-utils": "^1.3.4" + }, + "dependencies": { + "bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "dev": true + } + } + }, "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -30231,6 +31061,56 @@ } } }, + "opensea-js": { + "version": "6.1.12", + "resolved": "https://registry.npmjs.org/opensea-js/-/opensea-js-6.1.12.tgz", + "integrity": "sha512-JRTFnvh/R1QGj5TOJbJeWnFcjvQEAK92748XmOdf7UmBR4y6DdFwP4kdK6mJAudfe82EsZDUBC0jm9RaASRR7w==", + "dev": true, + "requires": { + "@opensea/seaport-js": "^2.0.8", + "ethers": "^5.7.2" + }, + "dependencies": { + "ethers": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", + "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", + "dev": true, + "requires": { + "@ethersproject/abi": "5.7.0", + "@ethersproject/abstract-provider": "5.7.0", + "@ethersproject/abstract-signer": "5.7.0", + "@ethersproject/address": "5.7.0", + "@ethersproject/base64": "5.7.0", + "@ethersproject/basex": "5.7.0", + "@ethersproject/bignumber": "5.7.0", + "@ethersproject/bytes": "5.7.0", + "@ethersproject/constants": "5.7.0", + "@ethersproject/contracts": "5.7.0", + "@ethersproject/hash": "5.7.0", + "@ethersproject/hdnode": "5.7.0", + "@ethersproject/json-wallets": "5.7.0", + "@ethersproject/keccak256": "5.7.0", + "@ethersproject/logger": "5.7.0", + "@ethersproject/networks": "5.7.1", + "@ethersproject/pbkdf2": "5.7.0", + "@ethersproject/properties": "5.7.0", + "@ethersproject/providers": "5.7.2", + "@ethersproject/random": "5.7.0", + "@ethersproject/rlp": "5.7.0", + "@ethersproject/sha2": "5.7.0", + "@ethersproject/signing-key": "5.7.0", + "@ethersproject/solidity": "5.7.0", + "@ethersproject/strings": "5.7.0", + "@ethersproject/transactions": "5.7.0", + "@ethersproject/units": "5.7.0", + "@ethersproject/wallet": "5.7.0", + "@ethersproject/web": "5.7.1", + "@ethersproject/wordlists": "5.7.0" + } + } + } + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -33606,6 +34486,12 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "dev": true }, + "treeify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/treeify/-/treeify-1.1.0.tgz", + "integrity": "sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==", + "dev": true + }, "trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", @@ -34575,6 +35461,12 @@ "utf8": "3.0.0" } }, + "webextension-polyfill": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/webextension-polyfill/-/webextension-polyfill-0.10.0.tgz", + "integrity": "sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==", + "dev": true + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 597b510c0..ce69890f1 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "@nomicfoundation/hardhat-toolbox": "^3.0.0", "@nomiclabs/hardhat-web3": "^2.0.0", "@openzeppelin/test-helpers": "^0.5.16", + "@zoralabs/core": "^1.0.8", "coveralls": "^3.1.1", "decache": "^4.6.1", "dotenv": "^16.3.0", @@ -95,12 +96,15 @@ "eslint-plugin-no-only-tests": "^3.1.0", "ethereum-input-data-decoder": "^0.4.2", "ethers": "^6.6.7", + "ethersv5": "npm:ethers@^5.7.2", "glob": "^10.2.7", "hardhat": "^2.17.1", "hardhat-contract-sizer": "^2.7.0", "hardhat-preprocessor": "^0.1.5", "husky": "^8.0.3", "lodash": "^4.17.21", + "merkletreejs": "^0.3.10", + "opensea-js": "^6.1.12", "prettier": "^2.8.4", "prettier-plugin-solidity": "^1.0.0-beta.19", "simple-statistics": "^7.8.2", diff --git a/remappings.txt b/remappings.txt index e69de29bb..35f405f5a 100644 --- a/remappings.txt +++ b/remappings.txt @@ -0,0 +1,5 @@ +ds-test/=submodules/lssvm/lib/ds-test/src/ +solmate/=submodules/${submodule}/lib/solmate/src/ +@manifoldxyz/=submodules/lssvm/lib/ +manifoldxyz/=submodules/lssvm/lib/royalty-registry-solidity.git/contracts/ +@sudoswap/=submodules/lssvm/src/ diff --git a/scripts/config/facet-deploy.js b/scripts/config/facet-deploy.js index c2d9f0edd..7426e80fe 100644 --- a/scripts/config/facet-deploy.js +++ b/scripts/config/facet-deploy.js @@ -64,6 +64,8 @@ const noArgFacetNames = [ "TwinHandlerFacet", "PauseHandlerFacet", "ProtocolInitializationHandlerFacet", // args are generated on cutDiamond function + "SequentialCommitHandlerFacet", + "PriceDiscoveryHandlerFacet", ]; async function getFacets(config) { @@ -76,6 +78,14 @@ async function getFacets(config) { facetArgs["ConfigHandlerFacet"] = { init: ConfigHandlerFacetInitArgs }; facetArgs["ExchangeHandlerFacet"] = { init: [], constructorArgs: [protocolConfig.EXCHANGE_ID_2_2_0[network]] }; + facetArgs["SequentialCommitHandlerFacet"] = { + init: [], + constructorArgs: [protocolConfig.WrappedNative[network], protocolConfig.EXCHANGE_ID_2_2_0[network]], + }; + facetArgs["PriceDiscoveryHandlerFacet"] = { + init: [], + constructorArgs: [protocolConfig.WrappedNative[network], protocolConfig.EXCHANGE_ID_2_2_0[network]], + }; // metaTransactionsHandlerFacet initializer arguments. const MetaTransactionsHandlerFacetInitArgs = await getMetaTransactionsHandlerFacetInitArgs( diff --git a/scripts/config/protocol-parameters.js b/scripts/config/protocol-parameters.js index 1632a238a..977b9b80f 100644 --- a/scripts/config/protocol-parameters.js +++ b/scripts/config/protocol-parameters.js @@ -83,4 +83,14 @@ module.exports = { goerli: 1, mainnet: 1, }, + + WrappedNative: { + mainnet: "0x4102621Ac55e068e148Da09151ce92102c952aab", //dummy + hardhat: "0x4102621Ac55e068e148Da09151ce92102c952aab", //dummy + localhost: "0x4102621Ac55e068e148Da09151ce92102c952aab", //dummy + test: "0x4102621Ac55e068e148Da09151ce92102c952aab", //dummy + mumbai: "0x4102621Ac55e068e148Da09151ce92102c952aab", //dummy + polygon: "0x17CDD65bebDe68cd8A4045422Fcff825A0740Ef9", //dummy + goerli: "0x17CDD65bebDe68cd8A4045422Fcff825A0740Ef9", //dummy + }, }; diff --git a/scripts/config/revert-reasons.js b/scripts/config/revert-reasons.js index c657294eb..e0e57d5f1 100644 --- a/scripts/config/revert-reasons.js +++ b/scripts/config/revert-reasons.js @@ -11,6 +11,7 @@ exports.RevertReasons = { // Pause related NOT_PAUSED: "Protocol is not currently paused", REGION_PAUSED: "This region of the protocol is currently paused", + ZERO_DEPOSIT_NOT_ALLOWED: "Zero deposit not allowed", // General INVALID_ADDRESS: "Invalid address", @@ -130,6 +131,11 @@ exports.RevertReasons = { INVALID_RANGE_LENGTH: "Range length is too large or zero", EXCHANGE_ALREADY_EXISTS: "Exchange already exists", + // Sequential commit related + UNEXPECTED_ERC721_RECEIVED: "Unexpected ERC721 received", + FEE_AMOUNT_TOO_HIGH: "Fee amount is too high", + VOUCHER_NOT_RECEIVED: "Voucher not received", + // Voucher related EXCHANGE_ID_IN_RESERVED_RANGE: "Exchange id falls within a pre-minted offer's range", NO_RESERVED_RANGE_FOR_OFFER: "Offer id not associated with a reserved range", @@ -209,4 +215,10 @@ exports.RevertReasons = { INIT_ZERO_ADDRESS_NON_EMPTY_CALLDATA: "LibDiamondCut: _init is address(0) but _calldata is not empty", INIT_EMPTY_CALLDATA_NON_ZERO_ADDRESS: "LibDiamondCut: _calldata is empty but _init is not address(0)", INIT_ADDRESS_WITH_NO_CODE: "LibDiamondCut: _init address has no code", + + // Price discovery related + PRICE_TOO_HIGH: "Price discovery returned a price that is too high", + PRICE_TOO_LOW: "Price discovery returned a price that is too low", + TOKEN_ID_MISMATCH: "Token id mismatch", + VOUCHER_TRANSFER_NOT_ALLOWED: "Voucher transfer not allowed", }; diff --git a/scripts/config/supported-interfaces.js b/scripts/config/supported-interfaces.js index 2b2cc535a..9ff8eb2aa 100644 --- a/scripts/config/supported-interfaces.js +++ b/scripts/config/supported-interfaces.js @@ -27,6 +27,8 @@ const interfaceImplementers = { ERC165Facet: "IERC165Extended", ConfigHandlerFacet: "IBosonConfigHandler", ProtocolInitializationHandlerFacet: "IBosonProtocolInitializationHandler", + SequentialCommitHandlerFacet: "IBosonSequentialCommitHandler", + PriceDiscoveryHandlerFacet: "IBosonPriceDiscoveryHandler", }; let interfacesCache; // if getInterfaceIds is called multiple times (e.g. during tests), calculate ids only once and store them to cache @@ -53,6 +55,7 @@ async function getInterfaceIds(useCache = true) { "contracts/interfaces/IERC721.sol:IERC721", "contracts/interfaces/IERC2981.sol:IERC2981", "IAccessControl", + "IBosonSequentialCommitHandler", ].forEach((iFace) => { skipBaseCheck[iFace] = false; }); diff --git a/scripts/domain/Offer.js b/scripts/domain/Offer.js index 323963a41..8bb2142ac 100644 --- a/scripts/domain/Offer.js +++ b/scripts/domain/Offer.js @@ -1,4 +1,11 @@ -const { bigNumberIsValid, addressIsValid, booleanIsValid, stringIsValid } = require("../util/validations.js"); +const { + bigNumberIsValid, + addressIsValid, + booleanIsValid, + stringIsValid, + enumIsValid, +} = require("../util/validations.js"); +const PriceType = require("./PriceType.js"); /** * Boson Protocol Domain Entity: Offer @@ -19,6 +26,7 @@ class Offer { string metadataHash; bool voided; uint256 collectionIndex; + PriceType priceType; } */ @@ -33,7 +41,8 @@ class Offer { metadataUri, metadataHash, voided, - collectionIndex + collectionIndex, + priceType ) { this.id = id; this.sellerId = sellerId; @@ -46,6 +55,7 @@ class Offer { this.metadataHash = metadataHash; this.voided = voided; this.collectionIndex = collectionIndex; + this.priceType = priceType; } /** @@ -66,6 +76,7 @@ class Offer { metadataHash, voided, collectionIndex, + priceType, } = o; return new Offer( @@ -79,7 +90,8 @@ class Offer { metadataUri, metadataHash, voided, - collectionIndex + collectionIndex, + priceType ); } @@ -99,7 +111,8 @@ class Offer { metadataUri, metadataHash, voided, - collectionIndex; + collectionIndex, + priceType; // destructure struct [ @@ -114,6 +127,7 @@ class Offer { metadataHash, voided, collectionIndex, + priceType, ] = struct; if (!collectionIndex) { collectionIndex = 0; @@ -131,6 +145,7 @@ class Offer { metadataHash, voided, collectionIndex: collectionIndex.toString(), + priceType: Number(priceType), }); } @@ -167,6 +182,7 @@ class Offer { this.metadataHash, this.voided, this.collectionIndex, + this.priceType, ]; } @@ -280,6 +296,14 @@ class Offer { return bigNumberIsValid(this.collectionIndex); } + /** + * Is this Offer instance's priceType field valid? + * @returns {boolean} + */ + priceTypeIsValid() { + return enumIsValid(this.priceType, PriceType.Types); + } + /** * Is this Offer instance valid? * @returns {boolean} @@ -296,7 +320,8 @@ class Offer { this.metadataUriIsValid() && this.metadataHashIsValid() && this.voidedIsValid() && - this.collectionIndexIsValid() + this.collectionIndexIsValid() && + this.priceTypeIsValid() ); } } diff --git a/scripts/domain/PriceDiscovery.js b/scripts/domain/PriceDiscovery.js new file mode 100644 index 000000000..3affcdae1 --- /dev/null +++ b/scripts/domain/PriceDiscovery.js @@ -0,0 +1,151 @@ +const Side = require("./Side"); +const { bigNumberIsValid, addressIsValid, bytesIsValid, enumIsValid } = require("../util/validations.js"); + +/** + * Boson Client Entity: PriceDiscovery + * + * See: {BosonVoucher.PriceDiscovery} + */ +class PriceDiscovery { + /* + struct PriceDiscovery { + uint256 price; + Side side; + address priceDiscoveryContract; + address conduit; + bytes priceDiscoveryData; + } + */ + + constructor(price, side, priceDiscoveryContract, conduit, priceDiscoveryData) { + this.price = price; + this.side = side; + this.priceDiscoveryContract = priceDiscoveryContract; + this.priceDiscoveryData = priceDiscoveryData; + this.conduit = conduit; + } + + /** + * Get a new PriceDiscovery instance from a pojo representation + * @param o + * @returns {PriceDiscovery} + */ + static fromObject(o) { + const { price, side, priceDiscoveryContract, conduit, priceDiscoveryData } = o; + return new PriceDiscovery(price, side, priceDiscoveryContract, conduit, priceDiscoveryData); + } + + /** + * Get a new PriceDiscovery instance from a returned struct representation + * @param struct + * @returns {*} + */ + static fromStruct(struct) { + let price, side, priceDiscoveryContract, conduit, priceDiscoveryData; + + // destructure struct + [price, side, priceDiscoveryContract, conduit, priceDiscoveryData] = struct; + + return PriceDiscovery.fromObject({ + price: price.toString(), + side, + priceDiscoveryContract: priceDiscoveryContract, + conduit, + priceDiscoveryData: priceDiscoveryData, + }); + } + + /** + * Get a database representation of this PriceDiscovery instance + * @returns {object} + */ + toObject() { + return JSON.parse(this.toString()); + } + + /** + * Get a string representation of this PriceDiscovery instance + * @returns {string} + */ + toString() { + return JSON.stringify(this); + } + + /** + * Get a struct representation of this PriceDiscovery instance + * @returns {string} + */ + toStruct() { + return [this.price, this.side, this.priceDiscoveryContract, this.conduit, this.priceDiscoveryData]; + } + + /** + * Clone this PriceDiscovery + * @returns {PriceDiscovery} + */ + clone() { + return PriceDiscovery.fromObject(this.toObject()); + } + + /** + * Is this PriceDiscovery instance's price field valid? + * Must be a string representation of a big number + * @returns {boolean} + */ + priceIsValid() { + return bigNumberIsValid(this.price); + } + + /** + * Is this PriceDiscovery instance's side field valid? + * Must be a number belonging to the Side enum + * @returns {boolean} + */ + sideIsValid() { + return enumIsValid(this.side, Side.Types); + } + + /** + * Is this PriceDiscovery instance's priceDiscoveryContract field valid? + * Must be a eip55 compliant Ethereum address + * @returns {boolean} + */ + priceDiscoveryContractIsValid() { + return addressIsValid(this.priceDiscoveryContract); + } + + /** + * Is this PriceDiscovery instance's conduit field valid? + * Must be a eip55 compliant Ethereum address + * @returns {boolean} + */ + conduitIsValid() { + return addressIsValid(this.conduit); + } + + /** + * Is this PriceDiscovery instance's priceDiscoveryData field valid? + * If present, must be a string representation of bytes + * @returns {boolean} + */ + priceDiscoveryDataIsValid() { + return bytesIsValid(this.priceDiscoveryData); + } + + /** + * Is this PriceDiscovery instance valid? + * @returns {boolean} + */ + isValid() { + return ( + this.priceIsValid() && + this.sideIsValid() && + this.priceDiscoveryContractIsValid() && + this.conduitIsValid() && + this.priceDiscoveryDataIsValid() + ); + } +} + +// Export +module.exports = PriceDiscovery; diff --git a/scripts/domain/PriceType.js b/scripts/domain/PriceType.js new file mode 100644 index 000000000..2645d993a --- /dev/null +++ b/scripts/domain/PriceType.js @@ -0,0 +1,12 @@ +/** + * Boson Protocol Domain Enum: PriceType + */ +class PriceType {} + +PriceType.Static = 0; +PriceType.Discovery = 1; + +PriceType.Types = [PriceType.Static, PriceType.Discovery]; + +// Export +module.exports = PriceType; diff --git a/scripts/domain/Side.js b/scripts/domain/Side.js new file mode 100644 index 000000000..211b92f3e --- /dev/null +++ b/scripts/domain/Side.js @@ -0,0 +1,13 @@ +/** + * Boson Protocol Domain Enum: Side + */ +class Side {} + +Side.Ask = 0; +Side.Bid = 1; +Side.Wrapper = 2; + +Side.Types = [Side.Ask, Side.Bid, Side.Wrapper]; + +// Export +module.exports = Side; diff --git a/scripts/manage-roles.js b/scripts/manage-roles.js index 520f4655e..6a11d525b 100644 --- a/scripts/manage-roles.js +++ b/scripts/manage-roles.js @@ -92,7 +92,7 @@ async function main(env) { // Revoke role if previously granted if (hasRole) { - await accessController.revokeRole(role, config.address()); + await accessController.revokeRole(role, config.address); } // Report status diff --git a/scripts/util/constants.js b/scripts/util/constants.js index 53daa9163..af64e8be1 100644 --- a/scripts/util/constants.js +++ b/scripts/util/constants.js @@ -1,3 +1,10 @@ -const interfacesWithMultipleArtifacts = ["IERC2981", "IERC1155", "IERC165", "IERC721", "IERC721Receiver"]; +const interfacesWithMultipleArtifacts = [ + "IERC2981", + "IERC1155", + "IERC165", + "IERC721", + "IERC721Receiver", + "IAccessControl", +]; exports.interfacesWithMultipleArtifacts = interfacesWithMultipleArtifacts; diff --git a/scripts/util/deploy-protocol-handler-facets.js b/scripts/util/deploy-protocol-handler-facets.js index 4178d7e53..39ed80926 100644 --- a/scripts/util/deploy-protocol-handler-facets.js +++ b/scripts/util/deploy-protocol-handler-facets.js @@ -98,7 +98,10 @@ async function deployProtocolFacets(facetNames, facetsToInit, maxPriorityFeePerG }; if (facetsToInit[facetName] && facetsToInit[facetName].init && facetName !== "ProtocolInitializationHandlerFacet") { - const calldata = facetContract.interface.encodeFunctionData("initialize", facetsToInit[facetName].init || []); + const calldata = facetContract.interface.encodeFunctionData( + "initialize", + (facetsToInit[facetName].init.length && facetsToInit[facetName].init) || [] + ); deployedFacet.initialize = calldata; } diff --git a/scripts/util/validations.js b/scripts/util/validations.js index bc4e226d7..47f6dc28e 100644 --- a/scripts/util/validations.js +++ b/scripts/util/validations.js @@ -126,6 +126,14 @@ function bytes4ArrayIsValid(bytes4Array) { return valid; } +function bytesIsValid(bytes) { + let valid = false; + try { + valid = typeof bytes === "string" && bytes.startsWith("0x") && bytes.length % 2 === 0; + } catch (e) {} + return valid; +} + exports.bigNumberIsValid = bigNumberIsValid; exports.enumIsValid = enumIsValid; exports.addressIsValid = addressIsValid; @@ -133,4 +141,5 @@ exports.booleanIsValid = booleanIsValid; exports.bigNumberArrayIsValid = bigNumberArrayIsValid; exports.stringIsValid = stringIsValid; exports.bytes4ArrayIsValid = bytes4ArrayIsValid; +exports.bytesIsValid = bytesIsValid; exports.bytes32IsValid = bytes32IsValid; diff --git a/submodules/lssvm b/submodules/lssvm new file mode 160000 index 000000000..3fc947819 --- /dev/null +++ b/submodules/lssvm @@ -0,0 +1 @@ +Subproject commit 3fc9478190cc44beee57de57128c59bb44f079ec diff --git a/test/domain/OfferTest.js b/test/domain/OfferTest.js index 8fc255e1f..e952fa084 100644 --- a/test/domain/OfferTest.js +++ b/test/domain/OfferTest.js @@ -2,6 +2,7 @@ const hre = require("hardhat"); const { getSigners, parseUnits, ZeroAddress } = hre.ethers; const { expect } = require("chai"); const Offer = require("../../scripts/domain/Offer"); +const PriceType = require("../../scripts/domain/PriceType"); /** * Test the Offer domain entity @@ -20,7 +21,8 @@ describe("Offer", function () { metadataUri, metadataHash, voided, - collectionIndex; + collectionIndex, + priceType; beforeEach(async function () { // Get a list of accounts @@ -37,6 +39,7 @@ describe("Offer", function () { metadataUri = `https://ipfs.io/ipfs/${metadataHash}`; voided = false; collectionIndex = "2"; + priceType = PriceType.Static; }); context("📋 Constructor", async function () { @@ -53,7 +56,8 @@ describe("Offer", function () { metadataUri, metadataHash, voided, - collectionIndex + collectionIndex, + priceType ); expect(offer.idIsValid()).is.true; expect(offer.sellerIdIsValid()).is.true; @@ -65,8 +69,9 @@ describe("Offer", function () { expect(offer.metadataUriIsValid()).is.true; expect(offer.metadataHashIsValid()).is.true; expect(offer.voidedIsValid()).is.true; - expect(offer.isValid()).is.true; expect(offer.collectionIndexIsValid()).is.true; + expect(offer.priceTypeIsValid()).is.true; + expect(offer.isValid()).is.true; }); }); @@ -84,7 +89,8 @@ describe("Offer", function () { metadataUri, metadataHash, voided, - collectionIndex + collectionIndex, + priceType ); expect(offer.isValid()).is.true; }); @@ -325,6 +331,43 @@ describe("Offer", function () { expect(offer.collectionIndexIsValid()).is.true; expect(offer.isValid()).is.true; }); + + it("Always present, priceType must be a valid PriceType", async function () { + // Invalid field value + offer.priceType = "zedzdeadbaby"; + expect(offer.priceTypeIsValid()).is.false; + expect(offer.isValid()).is.false; + + // Invalid field value + offer.priceType = new Date(); + expect(offer.priceTypeIsValid()).is.false; + expect(offer.isValid()).is.false; + + // Invalid field value + offer.priceType = 12; + expect(offer.priceTypeIsValid()).is.false; + expect(offer.isValid()).is.false; + + // Invalid field value + offer.priceType = "0"; + expect(offer.priceTypeIsValid()).is.false; + expect(offer.isValid()).is.false; + + // Invalid field value + offer.priceType = "126"; + expect(offer.priceTypeIsValid()).is.false; + expect(offer.isValid()).is.false; + + // Valid field value + offer.priceType = PriceType.Static; + expect(offer.priceTypeIsValid()).is.true; + expect(offer.isValid()).is.true; + + // Valid field value + offer.priceType = PriceType.Discovery; + expect(offer.priceTypeIsValid()).is.true; + expect(offer.isValid()).is.true; + }); }); context("📋 Utility functions", async function () { @@ -344,7 +387,8 @@ describe("Offer", function () { metadataUri, metadataHash, voided, - collectionIndex + collectionIndex, + priceType ); expect(offer.isValid()).is.true; @@ -361,6 +405,7 @@ describe("Offer", function () { metadataHash, voided, collectionIndex, + priceType, }; }); @@ -391,6 +436,7 @@ describe("Offer", function () { offer.metadataHash, offer.voided, offer.collectionIndex, + offer.priceType, ]; // Get struct diff --git a/test/domain/PriceDiscoveryTest.js b/test/domain/PriceDiscoveryTest.js new file mode 100644 index 000000000..0f6aea284 --- /dev/null +++ b/test/domain/PriceDiscoveryTest.js @@ -0,0 +1,251 @@ +const hre = require("hardhat"); +const ethers = hre.ethers; +const { expect } = require("chai"); +const PriceDiscovery = require("../../scripts/domain/PriceDiscovery"); +const Side = require("../../scripts/domain/Side"); + +/** + * Test the PriceDiscovery domain entity + */ +describe("PriceDiscovery", function () { + // Suite-wide scope + let priceDiscovery, object, promoted, clone, dehydrated, rehydrated, key, value, struct; + let accounts, price, side, priceDiscoveryContract, conduit, priceDiscoveryData; + + beforeEach(async function () { + // Get a list of accounts + accounts = await ethers.getSigners(); + + // Required constructor params + price = "150"; + priceDiscoveryContract = accounts[1].address; + conduit = accounts[2].address; + priceDiscoveryData = "0xdeadbeef"; + side = Side.Ask; + }); + + context("📋 Constructor", async function () { + it("Should allow creation of valid, fully populated PriceDiscovery instance", async function () { + priceDiscovery = new PriceDiscovery(price, side, priceDiscoveryContract, conduit, priceDiscoveryData); + expect(priceDiscovery.priceIsValid()).is.true; + expect(priceDiscovery.sideIsValid()).is.true; + expect(priceDiscovery.priceDiscoveryContractIsValid()).is.true; + expect(priceDiscovery.conduitIsValid()).is.true; + expect(priceDiscovery.priceDiscoveryDataIsValid()).is.true; + expect(priceDiscovery.isValid()).is.true; + }); + }); + + context("📋 Field validations", async function () { + beforeEach(async function () { + // Create a valid priceDiscovery, then set fields in tests directly + priceDiscovery = new PriceDiscovery(price, side, priceDiscoveryContract, conduit, priceDiscoveryData); + expect(priceDiscovery.isValid()).is.true; + }); + + it("Always present, price must be the string representation of a BigNumber", async function () { + // Invalid field value + priceDiscovery.price = "zedzdeadbaby"; + expect(priceDiscovery.priceIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Invalid field value + priceDiscovery.price = new Date(); + expect(priceDiscovery.priceIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Invalid field value + priceDiscovery.price = 12; + expect(priceDiscovery.priceIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Valid field value + priceDiscovery.price = "0"; + expect(priceDiscovery.priceIsValid()).is.true; + expect(priceDiscovery.isValid()).is.true; + + // Valid field value + priceDiscovery.price = "126"; + expect(priceDiscovery.priceIsValid()).is.true; + expect(priceDiscovery.isValid()).is.true; + }); + + it("If present, side must be a Side enum", async function () { + // Invalid field value + priceDiscovery.side = "zedzdeadbaby"; + expect(priceDiscovery.sideIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Invalid field value + priceDiscovery.side = new Date(); + expect(priceDiscovery.sideIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Invalid field value + priceDiscovery.side = 12; + expect(priceDiscovery.sideIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Valid field value + priceDiscovery.side = Side.Bid; + expect(priceDiscovery.sideIsValid()).is.true; + expect(priceDiscovery.isValid()).is.true; + }); + + it("Always present, priceDiscoveryContract must be a string representation of an EIP-55 compliant address", async function () { + // Invalid field value + priceDiscovery.priceDiscoveryContract = "0xASFADF"; + expect(priceDiscovery.priceDiscoveryContractIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Invalid field value + priceDiscovery.priceDiscoveryContract = "zedzdeadbaby"; + expect(priceDiscovery.priceDiscoveryContractIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Valid field value + priceDiscovery.priceDiscoveryContract = accounts[0].address; + expect(priceDiscovery.priceDiscoveryContractIsValid()).is.true; + expect(priceDiscovery.isValid()).is.true; + + // Valid field value + priceDiscovery.priceDiscoveryContract = "0xec2fd5bd6fc7b576dae82c0b9640969d8de501a2"; + expect(priceDiscovery.priceDiscoveryContractIsValid()).is.true; + expect(priceDiscovery.isValid()).is.true; + }); + + it("Always present, conduit must be a string representation of an EIP-55 compliant address", async function () { + // Invalid field value + priceDiscovery.conduit = "0xASFADF"; + expect(priceDiscovery.conduitIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Invalid field value + priceDiscovery.conduit = "zedzdeadbaby"; + expect(priceDiscovery.conduitIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Valid field value + priceDiscovery.conduit = accounts[0].address; + expect(priceDiscovery.conduitIsValid()).is.true; + expect(priceDiscovery.isValid()).is.true; + + // Valid field value + priceDiscovery.conduit = "0xec2fd5bd6fc7b576dae82c0b9640969d8de501a2"; + expect(priceDiscovery.conduitIsValid()).is.true; + expect(priceDiscovery.isValid()).is.true; + }); + + it("If present, priceDiscoveryData must be the string representation of bytes", async function () { + // Invalid field value + priceDiscovery.priceDiscoveryData = "zedzdeadbaby"; + expect(priceDiscovery.priceDiscoveryDataIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Invalid field value + priceDiscovery.priceDiscoveryData = new Date(); + expect(priceDiscovery.priceDiscoveryDataIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Invalid field value + priceDiscovery.priceDiscoveryData = 12; + expect(priceDiscovery.priceDiscoveryDataIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Invalid field value + priceDiscovery.priceDiscoveryData = "0x1"; + expect(priceDiscovery.priceDiscoveryDataIsValid()).is.false; + expect(priceDiscovery.isValid()).is.false; + + // Valid field value + priceDiscovery.priceDiscoveryData = "0x"; + expect(priceDiscovery.priceDiscoveryDataIsValid()).is.true; + expect(priceDiscovery.isValid()).is.true; + + // Valid field value + priceDiscovery.priceDiscoveryData = "0x1234567890abcdef"; + expect(priceDiscovery.priceDiscoveryDataIsValid()).is.true; + expect(priceDiscovery.isValid()).is.true; + }); + }); + + context("📋 Utility functions", async function () { + beforeEach(async function () { + // Create a valid priceDiscovery, then set fields in tests directly + priceDiscovery = new PriceDiscovery(price, side, priceDiscoveryContract, conduit, priceDiscoveryData); + expect(priceDiscovery.isValid()).is.true; + + // Get plain object + object = { + price, + side, + priceDiscoveryContract, + conduit, + priceDiscoveryData, + }; + + // Struct representation + struct = [price, side, priceDiscoveryContract, conduit, priceDiscoveryData]; + }); + + context("👉 Static", async function () { + it("PriceDiscovery.fromObject() should return a PriceDiscovery instance with the same values as the given plain object", async function () { + // Promote to instance + promoted = PriceDiscovery.fromObject(object); + + // Is a PriceDiscovery instance + expect(promoted instanceof PriceDiscovery).is.true; + + // Key values all match + for ([key, value] of Object.entries(priceDiscovery)) { + expect(JSON.stringify(promoted[key]) === JSON.stringify(value)).is.true; + } + }); + + it("PriceDiscovery.fromStruct() should return an PriceDiscovery instance from a struct representation", async function () { + // Get instance from struct + priceDiscovery = PriceDiscovery.fromStruct(struct); + + // Ensure it marshals back to a valid priceDiscovery + expect(priceDiscovery.isValid()).to.be.true; + }); + }); + + context("👉 Instance", async function () { + it("instance.toString() should return a JSON string representation of the PriceDiscovery instance", async function () { + dehydrated = priceDiscovery.toString(); + rehydrated = JSON.parse(dehydrated); + + for ([key, value] of Object.entries(priceDiscovery)) { + expect(JSON.stringify(rehydrated[key]) === JSON.stringify(value)).is.true; + } + }); + + it("instance.clone() should return another PriceDiscovery instance with the same property values", async function () { + // Get plain object + clone = priceDiscovery.clone(); + + // Is an PriceDiscovery instance + expect(clone instanceof PriceDiscovery).is.true; + + // Key values all match + for ([key, value] of Object.entries(priceDiscovery)) { + expect(JSON.stringify(clone[key]) === JSON.stringify(value)).is.true; + } + }); + + it("instance.toObject() should return a plain object representation of the PriceDiscovery instance", async function () { + // Get plain object + object = priceDiscovery.toObject(); + + // Not an PriceDiscovery instance + expect(object instanceof PriceDiscovery).is.false; + + // Key values all match + for ([key, value] of Object.entries(priceDiscovery)) { + expect(JSON.stringify(object[key]) === JSON.stringify(value)).is.true; + } + }); + }); + }); +}); diff --git a/test/integration/price-discovery/auction.js b/test/integration/price-discovery/auction.js new file mode 100644 index 000000000..698c5385f --- /dev/null +++ b/test/integration/price-discovery/auction.js @@ -0,0 +1,294 @@ +const { ethers } = require("hardhat"); +const { ZeroAddress, getContractFactory, getContractAt, provider } = ethers; +const { + deriveTokenId, + getCurrentBlockAndSetTimeForward, + setupTestEnvironment, + revertToSnapshot, + getSnapshot, + calculateBosonProxyAddress, + calculateCloneAddress, +} = require("../../util/utils"); +const { oneWeek } = require("../../util/constants"); +const { + mockSeller, + mockAuthToken, + mockVoucherInitValues, + mockOffer, + mockDisputeResolver, + accountId, +} = require("../../util/mock"); +const { expect } = require("chai"); +const { DisputeResolverFee } = require("../../../scripts/domain/DisputeResolverFee"); +const PriceType = require("../../../scripts/domain/PriceType"); +const PriceDiscovery = require("../../../scripts/domain/PriceDiscovery"); +const Side = require("../../../scripts/domain/Side"); + +const MASK = (1n << 128n) - 1n; + +describe("[@skip-on-coverage] auction integration", function () { + let bosonVoucher; + let assistant, buyer, DR, rando; + let offer, offerDates; + let exchangeHandler; + let priceDiscoveryHandler; + let weth; + let seller; + let snapshotId; + + before(async function () { + accountId.next(true); + + // Specify contracts needed for this test + const contracts = { + accountHandler: "IBosonAccountHandler", + offerHandler: "IBosonOfferHandler", + fundsHandler: "IBosonFundsHandler", + exchangeHandler: "IBosonExchangeHandler", + priceDiscoveryHandler: "IBosonPriceDiscoveryHandler", + }; + + const wethFactory = await getContractFactory("WETH9"); + weth = await wethFactory.deploy(); + await weth.waitForDeployment(); + + let accountHandler, offerHandler, fundsHandler; + + ({ + signers: [assistant, buyer, DR, rando], + contractInstances: { accountHandler, offerHandler, fundsHandler, exchangeHandler, priceDiscoveryHandler }, + extraReturnValues: { bosonVoucher }, + } = await setupTestEnvironment(contracts, { wethAddress: await weth.getAddress() })); + + seller = mockSeller(assistant.address, assistant.address, ZeroAddress, assistant.address); + + const emptyAuthToken = mockAuthToken(); + const voucherInitValues = mockVoucherInitValues(); + await accountHandler.connect(assistant).createSeller(seller, emptyAuthToken, voucherInitValues); + + const disputeResolver = mockDisputeResolver(DR.address, DR.address, ZeroAddress, DR.address, true); + + const disputeResolverFees = [ + new DisputeResolverFee(ZeroAddress, "Native Currency", "0"), + new DisputeResolverFee(await weth.getAddress(), "WETH", "0"), + ]; + const sellerAllowList = [seller.id]; + + await accountHandler.connect(DR).createDisputeResolver(disputeResolver, disputeResolverFees, sellerAllowList); + + let offerDurations, disputeResolverId; + ({ offer, offerDates, offerDurations, disputeResolverId } = await mockOffer()); + offer.quantityAvailable = 10; + offer.priceType = PriceType.Discovery; + // offer.exchangeToken = weth.address; + + await offerHandler + .connect(assistant) + .createOffer(offer.toStruct(), offerDates.toStruct(), offerDurations.toStruct(), disputeResolverId, "0"); + + const beaconProxyAddress = await calculateBosonProxyAddress(await accountHandler.getAddress()); + const voucherAddress = calculateCloneAddress(await accountHandler.getAddress(), beaconProxyAddress, seller.admin); + bosonVoucher = await getContractAt("BosonVoucher", voucherAddress); + + // Pre mint range + await offerHandler.connect(assistant).reserveRange(offer.id, offer.quantityAvailable, assistant.address); + await bosonVoucher.connect(assistant).preMint(offer.id, offer.quantityAvailable); + + // Deposit seller funds so the commit will succeed + await fundsHandler + .connect(assistant) + .depositFunds(seller.id, ZeroAddress, offer.sellerDeposit, { value: offer.sellerDeposit }); + + // Get snapshot id + snapshotId = await getSnapshot(); + }); + + afterEach(async function () { + await revertToSnapshot(snapshotId); + snapshotId = await getSnapshot(); + }); + + context("Zora auction", async function () { + let tokenId, zoraAuction, amount, auctionId; + + beforeEach(async function () { + // 1. Deploy Zora Auction + const ZoraAuctionFactory = await getContractFactory("AuctionHouse"); + zoraAuction = await ZoraAuctionFactory.deploy(await weth.getAddress()); + + tokenId = deriveTokenId(offer.id, 2); + }); + + it("Transfer can't happens outside protocol", async function () { + // 2. Set approval for all + await bosonVoucher.connect(assistant).setApprovalForAll(await zoraAuction.getAddress(), true); + + // 3. Create an auction + const tokenContract = await bosonVoucher.getAddress(); + const duration = oneWeek; + const reservePrice = 1; + const curator = ZeroAddress; + const curatorFeePercentage = 0; + const auctionCurrency = offer.exchangeToken; + + await zoraAuction + .connect(assistant) + .createAuction(tokenId, tokenContract, duration, reservePrice, curator, curatorFeePercentage, auctionCurrency); + + // 4. Bid + auctionId = 0; + amount = 10; + await zoraAuction.connect(buyer).createBid(auctionId, amount, { value: amount }); + + // Set time forward + await getCurrentBlockAndSetTimeForward(oneWeek); + + // Zora should be the owner of the token + expect(await bosonVoucher.ownerOf(tokenId)).to.equal(await zoraAuction.getAddress()); + + // safe transfer from will fail on onPremintedTransferredHook and transaction should fail + await expect(zoraAuction.connect(rando).endAuction(auctionId)).to.emit(zoraAuction, "AuctionCanceled"); + + // Exchange doesn't exist + const exchangeId = tokenId & MASK; + const [exist, ,] = await exchangeHandler.getExchange(exchangeId); + + expect(exist).to.equal(false); + }); + + context("Works with Zora auction wrapper", async function () { + let wrappedBosonVoucher; + + beforeEach(async function () { + // 2. Create wrapped voucher + const wrappedBosonVoucherFactory = await ethers.getContractFactory("ZoraWrapper"); + wrappedBosonVoucher = await wrappedBosonVoucherFactory + .connect(assistant) + .deploy( + await bosonVoucher.getAddress(), + await zoraAuction.getAddress(), + await exchangeHandler.getAddress(), + await weth.getAddress() + ); + + // 3. Wrap voucher + await bosonVoucher.connect(assistant).setApprovalForAll(await wrappedBosonVoucher.getAddress(), true); + await wrappedBosonVoucher.connect(assistant).wrap(tokenId); + + // 4. Create an auction + const tokenContract = await wrappedBosonVoucher.getAddress(); + const duration = oneWeek; + const reservePrice = 1; + const curator = assistant.address; + const curatorFeePercentage = 0; + const auctionCurrency = offer.exchangeToken; + + await zoraAuction + .connect(assistant) + .createAuction( + tokenId, + tokenContract, + duration, + reservePrice, + curator, + curatorFeePercentage, + auctionCurrency + ); + + auctionId = 0; + await zoraAuction.connect(assistant).setAuctionApproval(auctionId, true); + }); + + it("Auction ends normally", async function () { + // 5. Bid + const amount = 10; + + await zoraAuction.connect(buyer).createBid(auctionId, amount, { value: amount }); + + // 6. End auction + await getCurrentBlockAndSetTimeForward(oneWeek); + await zoraAuction.connect(assistant).endAuction(auctionId); + + expect(await wrappedBosonVoucher.ownerOf(tokenId)).to.equal(buyer.address); + expect(await weth.balanceOf(await wrappedBosonVoucher.getAddress())).to.equal(amount); + + // 7. Commit to offer + const calldata = wrappedBosonVoucher.interface.encodeFunctionData("unwrap", [tokenId]); + const priceDiscovery = new PriceDiscovery( + amount, + Side.Bid, + await wrappedBosonVoucher.getAddress(), + await wrappedBosonVoucher.getAddress(), + calldata + ); + + const protocolBalanceBefore = await provider.getBalance(await exchangeHandler.getAddress()); + + const tx = await priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery); + const { timestamp } = await provider.getBlock(tx.blockNumber); + + expect(await bosonVoucher.ownerOf(tokenId)).to.equal(buyer.address); + expect(await provider.getBalance(await exchangeHandler.getAddress())).to.equal( + protocolBalanceBefore + BigInt(amount) + ); + + const exchangeId = tokenId & MASK; + const [, , voucher] = await exchangeHandler.getExchange(exchangeId); + + expect(voucher.committedDate).to.equal(timestamp); + }); + + it("Cancel auction", async function () { + // 6. Cancel auction + await zoraAuction.connect(assistant).cancelAuction(auctionId); + + // 7. Unwrap token + const protocolBalanceBefore = await provider.getBalance(await exchangeHandler.getAddress()); + await wrappedBosonVoucher.connect(assistant).unwrap(tokenId); + + expect(await bosonVoucher.ownerOf(tokenId)).to.equal(assistant.address); + expect(await provider.getBalance(await exchangeHandler.getAddress())).to.equal(protocolBalanceBefore); + + const exchangeId = tokenId & MASK; + const [exists, , voucher] = await exchangeHandler.getExchange(exchangeId); + + expect(exists).to.equal(false); + expect(voucher.committedDate).to.equal(0); + }); + + it("Cancel auction and unwrap via commitToPriceDiscoveryOffer", async function () { + // How sensible is this scenario? Should it be prevented? + + // 6. Cancel auction + await zoraAuction.connect(assistant).cancelAuction(auctionId); + + // 7. Unwrap token via commitToOffer + const protocolBalanceBefore = await provider.getBalance(await exchangeHandler.getAddress()); + + const calldata = wrappedBosonVoucher.interface.encodeFunctionData("unwrap", [tokenId]); + const priceDiscovery = new PriceDiscovery( + 0, + Side.Bid, + await wrappedBosonVoucher.getAddress(), + await wrappedBosonVoucher.getAddress(), + calldata + ); + const tx = await priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(assistant.address, tokenId, priceDiscovery); + const { timestamp } = await provider.getBlock(tx.blockNumber); + + expect(await bosonVoucher.ownerOf(tokenId)).to.equal(assistant.address); + expect(await provider.getBalance(await exchangeHandler.getAddress())).to.equal(protocolBalanceBefore); + + const exchangeId = tokenId & MASK; + const [exists, , voucher] = await exchangeHandler.getExchange(exchangeId); + + expect(exists).to.equal(true); + expect(voucher.committedDate).to.equal(timestamp); + }); + }); + }); +}); diff --git a/test/integration/price-discovery/seaport.js b/test/integration/price-discovery/seaport.js new file mode 100644 index 000000000..542e0cb30 --- /dev/null +++ b/test/integration/price-discovery/seaport.js @@ -0,0 +1,194 @@ +const { ethers } = require("hardhat"); +const { ZeroHash, ZeroAddress, getContractAt, getContractFactory } = ethers; + +const { + calculateBosonProxyAddress, + calculateCloneAddress, + deriveTokenId, + getEvent, + setupTestEnvironment, + revertToSnapshot, + getSnapshot, + objectToArray, +} = require("../../util/utils"); + +const { + mockSeller, + mockAuthToken, + mockVoucherInitValues, + mockOffer, + mockDisputeResolver, + accountId, +} = require("../../util/mock"); +const { assert } = require("chai"); +const { DisputeResolverFee } = require("../../../scripts/domain/DisputeResolverFee"); +const SeaportSide = require("../seaport/SideEnum"); +const Side = require("../../../scripts/domain/Side"); +const PriceDiscovery = require("../../../scripts/domain/PriceDiscovery"); +const PriceType = require("../../../scripts/domain/PriceType"); +const { seaportFixtures } = require("../seaport/fixtures"); +const { SEAPORT_ADDRESS } = require("../../util/constants"); +const ItemType = require("../seaport/ItemTypeEnum"); + +describe("[@skip-on-coverage] seaport integration", function () { + this.timeout(100000000); + let bosonVoucher; + let assistant, buyer, DR; + let fixtures; + let offer, offerDates; + let exchangeHandler, priceDiscoveryHandler; + let weth; + let seller; + let seaport; + let snapshotId; + + before(async function () { + accountId.next(); + // Specify contracts needed for this test + const contracts = { + accountHandler: "IBosonAccountHandler", + offerHandler: "IBosonOfferHandler", + fundsHandler: "IBosonFundsHandler", + exchangeHandler: "IBosonExchangeHandler", + priceDiscoveryHandler: "IBosonPriceDiscoveryHandler", + }; + + const wethFactory = await getContractFactory("WETH9"); + weth = await wethFactory.deploy(); + await weth.waitForDeployment(); + + let accountHandler, offerHandler, fundsHandler; + + ({ + signers: [, assistant, buyer, DR], + contractInstances: { accountHandler, offerHandler, fundsHandler, exchangeHandler, priceDiscoveryHandler }, + extraReturnValues: { bosonVoucher }, + } = await setupTestEnvironment(contracts, { wethAddress: await weth.getAddress() })); + + seller = mockSeller(assistant.address, assistant.address, ZeroAddress, assistant.address); + + const emptyAuthToken = mockAuthToken(); + const voucherInitValues = mockVoucherInitValues(); + await accountHandler.connect(assistant).createSeller(seller, emptyAuthToken, voucherInitValues); + + const disputeResolver = mockDisputeResolver(DR.address, DR.address, ZeroAddress, DR.address, true); + + const disputeResolverFees = [new DisputeResolverFee(ZeroAddress, "Native", "0")]; + const sellerAllowList = [seller.id]; + + await accountHandler.connect(DR).createDisputeResolver(disputeResolver, disputeResolverFees, sellerAllowList); + + let offerDurations, disputeResolverId; + ({ offer, offerDates, offerDurations, disputeResolverId } = await mockOffer()); + offer.quantityAvailable = 10; + offer.priceType = PriceType.Discovery; + + await offerHandler + .connect(assistant) + .createOffer(offer.toStruct(), offerDates.toStruct(), offerDurations.toStruct(), disputeResolverId, "0"); + + const beaconProxyAddress = await calculateBosonProxyAddress(await accountHandler.getAddress()); + const voucherAddress = calculateCloneAddress(await accountHandler.getAddress(), beaconProxyAddress, seller.admin); + bosonVoucher = await getContractAt("BosonVoucher", voucherAddress); + + seaport = await getContractAt("Seaport", SEAPORT_ADDRESS); + + await bosonVoucher.connect(assistant).setApprovalForAllToContract(SEAPORT_ADDRESS, true); + + fixtures = await seaportFixtures(seaport); + + // Pre mint range + await offerHandler.connect(assistant).reserveRange(offer.id, offer.quantityAvailable, voucherAddress); + await bosonVoucher.connect(assistant).preMint(offer.id, offer.quantityAvailable); + + // Deposit seller funds so the commit will succeed + await fundsHandler + .connect(assistant) + .depositFunds(seller.id, ZeroAddress, offer.sellerDeposit, { value: offer.sellerDeposit }); + + // Get snapshot id + snapshotId = await getSnapshot(); + }); + + afterEach(async function () { + await revertToSnapshot(snapshotId); + snapshotId = await getSnapshot(); + }); + + it("Seaport criteria-based order is used as price discovery mechanism for a BP offer", async function () { + // Create seaport offer which tokenId 1 + const seaportOffer = fixtures.getTestVoucher( + ItemType.ERC721_WITH_CRITERIA, + 0, + await bosonVoucher.getAddress(), + 1, + 1 + ); + const consideration = fixtures.getTestToken( + ItemType.NATIVE, + 0, + ZeroAddress, + offer.price, + offer.price, + await bosonVoucher.getAddress() + ); + + const { order, orderHash, value } = await fixtures.getOrder( + bosonVoucher, + undefined, + [seaportOffer], //offer + [consideration], + 0, // full + offerDates.validFrom, // startDate + offerDates.validUntil // endDate + ); + + const orders = [objectToArray(order)]; + const calldata = seaport.interface.encodeFunctionData("validate", [orders]); + + const seaportAddress = await seaport.getAddress(); + await bosonVoucher.connect(assistant).callExternalContract(seaportAddress, calldata); + await bosonVoucher.connect(assistant).setApprovalForAllToContract(seaportAddress, true); + + let totalFilled, isValidated; + + ({ isValidated, totalFilled } = await seaport.getOrderStatus(orderHash)); + assert(isValidated, "Order is not validated"); + assert.equal(totalFilled, 0n); + + // turn order into advanced order + order.denominator = 1; + order.numerator = 1; + order.extraData = "0x"; + + const identifier = deriveTokenId(offer.id, 2); + const resolvers = [fixtures.getCriteriaResolver(0, SeaportSide.OFFER, 0, identifier, [])]; + + const priceDiscoveryData = seaport.interface.encodeFunctionData("fulfillAdvancedOrder", [ + order, + resolvers, + ZeroHash, + ZeroAddress, + ]); + + const priceDiscovery = new PriceDiscovery(value, seaportAddress, priceDiscoveryData, Side.Ask); + + // Seller needs to deposit weth in order to fill the escrow at the last step + await weth.connect(buyer).deposit({ value }); + await weth.connect(buyer).approve(await exchangeHandler.getAddress(), value); + + const tx = await priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, offer.id, priceDiscovery, { + value, + }); + + const receipt = await tx.wait(); + + ({ totalFilled } = await seaport.getOrderStatus(orderHash)); + assert.equal(totalFilled, 1n); + const event = getEvent(receipt, seaport, "OrderFulfilled"); + + assert.equal(orderHash, event[0]); + }); +}); diff --git a/test/integration/price-discovery/sudoswap.js b/test/integration/price-discovery/sudoswap.js new file mode 100644 index 000000000..9f52eab53 --- /dev/null +++ b/test/integration/price-discovery/sudoswap.js @@ -0,0 +1,228 @@ +const { ethers } = require("hardhat"); +const { ZeroAddress, MaxUint256, getContractFactory, getContractAt, parseUnits, provider, id } = ethers; +const { + mockSeller, + mockAuthToken, + mockVoucherInitValues, + mockOffer, + mockDisputeResolver, + accountId, +} = require("../../util/mock"); +const { expect } = require("chai"); +const { + calculateBosonProxyAddress, + calculateCloneAddress, + deriveTokenId, + setupTestEnvironment, +} = require("../../util/utils"); + +const { DisputeResolverFee } = require("../../../scripts/domain/DisputeResolverFee"); +const Side = require("../../../scripts/domain/Side"); +const PriceDiscovery = require("../../../scripts/domain/PriceDiscovery"); +const PriceType = require("../../../scripts/domain/PriceType"); + +const MASK = (1n << 128n) - 1n; + +describe("[@skip-on-coverage] sudoswap integration", function () { + this.timeout(100000000); + let lssvmPairFactory, linearCurve; + let bosonVoucher; + let deployer, assistant, buyer, DR; + let offer; + let exchangeHandler, priceDiscoveryHandler; + let weth, wethAddress; + let seller; + + before(async function () { + accountId.next(); + + // Specify contracts needed for this test + const contracts = { + accountHandler: "IBosonAccountHandler", + offerHandler: "IBosonOfferHandler", + fundsHandler: "IBosonFundsHandler", + exchangeHandler: "IBosonExchangeHandler", + priceDiscoveryHandler: "IBosonPriceDiscoveryHandler", + }; + + const wethFactory = await getContractFactory("WETH9"); + weth = await wethFactory.deploy(); + await weth.waitForDeployment(); + wethAddress = await weth.getAddress(); + + let accountHandler, offerHandler, fundsHandler; + + ({ + signers: [deployer, assistant, buyer, DR], + contractInstances: { accountHandler, offerHandler, fundsHandler, exchangeHandler, priceDiscoveryHandler }, + extraReturnValues: { bosonVoucher }, + } = await setupTestEnvironment(contracts, { wethAddress })); + + const LSSVMPairEnumerableETH = await getContractFactory("LSSVMPairEnumerableETH", deployer); + const lssvmPairEnumerableETH = await LSSVMPairEnumerableETH.deploy(); + await lssvmPairEnumerableETH.waitForDeployment(); + + const LSSVMPairEnumerableERC20 = await getContractFactory("LSSVMPairEnumerableERC20", deployer); + const lssvmPairEnumerableERC20 = await LSSVMPairEnumerableERC20.deploy(); + await lssvmPairEnumerableERC20.waitForDeployment(); + + const LSSVMPairMissingEnumerableETH = await getContractFactory("LSSVMPairMissingEnumerableETH", deployer); + const lssvmPairMissingEnumerableETH = await LSSVMPairMissingEnumerableETH.deploy(); + + const LSSVMPairMissingEnumerableERC20 = await getContractFactory("LSSVMPairMissingEnumerableERC20", deployer); + const lssvmPairMissingEnumerableERC20 = await LSSVMPairMissingEnumerableERC20.deploy(); + + const LSSVMPairFactory = await getContractFactory("LSSVMPairFactory", deployer); + + lssvmPairFactory = await LSSVMPairFactory.deploy( + await lssvmPairEnumerableETH.getAddress(), + await lssvmPairMissingEnumerableETH.getAddress(), + await lssvmPairEnumerableERC20.getAddress(), + await lssvmPairMissingEnumerableERC20.getAddress(), + deployer.address, + "0" + ); + await lssvmPairFactory.waitForDeployment(); + + // Deploy bonding curves + const LinearCurve = await getContractFactory("LinearCurve", deployer); + linearCurve = await LinearCurve.deploy(); + await linearCurve.waitForDeployment(); + + // Whitelist bonding curve + await lssvmPairFactory.setBondingCurveAllowed(await linearCurve.getAddress(), true); + + seller = mockSeller(assistant.address, assistant.address, ZeroAddress, assistant.address); + + const emptyAuthToken = mockAuthToken(); + const voucherInitValues = mockVoucherInitValues(); + await accountHandler.connect(assistant).createSeller(seller, emptyAuthToken, voucherInitValues); + + const disputeResolver = mockDisputeResolver(DR.address, DR.address, ZeroAddress, DR.address, true); + + const disputeResolverFees = [new DisputeResolverFee(wethAddress, "WETH", "0")]; + const sellerAllowList = [seller.id]; + + await accountHandler.connect(DR).createDisputeResolver(disputeResolver, disputeResolverFees, sellerAllowList); + + let offerDates, offerDurations, disputeResolverId; + ({ offer, offerDates, offerDurations, disputeResolverId } = await mockOffer()); + offer.exchangeToken = wethAddress; + offer.quantityAvailable = 10; + offer.priceType = PriceType.Discovery; + + await offerHandler + .connect(assistant) + .createOffer(offer.toStruct(), offerDates.toStruct(), offerDurations.toStruct(), disputeResolverId, "0"); + + const pool = BigInt(offer.sellerDeposit) * BigInt(offer.quantityAvailable); + + await weth.connect(assistant).deposit({ value: pool }); + + // Approves protocol to transfer sellers weth + await weth.connect(assistant).approve(await fundsHandler.getAddress(), pool); + + // Deposit funds + await fundsHandler.connect(assistant).depositFunds(seller.id, wethAddress, pool); + + // Reverse range + await offerHandler.connect(assistant).reserveRange(offer.id, offer.quantityAvailable, assistant.address); + + // Gets boson voucher contract + const beaconProxyAddress = await calculateBosonProxyAddress(await accountHandler.getAddress()); + const voucherAddress = calculateCloneAddress(await accountHandler.getAddress(), beaconProxyAddress, seller.admin); + bosonVoucher = await getContractAt("BosonVoucher", voucherAddress); + + // Pre mint range + await bosonVoucher.connect(assistant).preMint(offer.id, offer.quantityAvailable); + }); + + it("Works with wrapped vouchers", async function () { + const poolType = 1; // NFT + const delta = parseUnits("0.25", "ether").toString(); + const fee = "0"; + const spotPrice = offer.price; + const nftIds = []; + + for (let i = 1; i <= offer.quantityAvailable; i++) { + const tokenId = deriveTokenId(offer.id, i); + nftIds.push(tokenId); + } + + const initialPoolBalance = parseUnits("10", "ether").toString(); + await weth.connect(assistant).deposit({ value: initialPoolBalance }); + await weth.connect(assistant).approve(await lssvmPairFactory.getAddress(), MaxUint256); + + const WrappedBosonVoucherFactory = await getContractFactory("SudoswapWrapper"); + const wrappedBosonVoucher = await WrappedBosonVoucherFactory.connect(assistant).deploy( + await bosonVoucher.getAddress(), + await lssvmPairFactory.getAddress(), + await exchangeHandler.getAddress(), + wethAddress + ); + const wrappedBosonVoucherAddress = await wrappedBosonVoucher.getAddress(); + + await bosonVoucher.connect(assistant).setApprovalForAll(wrappedBosonVoucherAddress, true); + + await wrappedBosonVoucher.connect(assistant).wrap(nftIds); + + const createPairERC20Parameters = { + token: wethAddress, + nft: wrappedBosonVoucherAddress, + bondingCurve: await linearCurve.getAddress(), + assetRecipient: wrappedBosonVoucherAddress, + poolType, + delta, + fee, + spotPrice, + initialNFTIDs: nftIds, + initialTokenBalance: initialPoolBalance, + }; + + await wrappedBosonVoucher.connect(assistant).setApprovalForAll(await lssvmPairFactory.getAddress(), true); + + let tx = await lssvmPairFactory.connect(assistant).createPairERC20(createPairERC20Parameters); + + const { logs } = await tx.wait(); + + const NewPairTopic = id("NewPair(address)"); + const [poolAddress] = logs.find((e) => e?.topics[0] === NewPairTopic).args; + + await wrappedBosonVoucher.connect(assistant).setPoolAddress(poolAddress); + + const pool = await getContractAt("LSSVMPairMissingEnumerable", poolAddress); + + const [, , , inputAmount] = await pool.getBuyNFTQuote(1); + + await weth.connect(buyer).deposit({ value: inputAmount * 2n }); + await weth.connect(buyer).approve(wrappedBosonVoucherAddress, inputAmount * 2n); + + const tokenId = deriveTokenId(offer.id, 1); + + const swapTokenTx = await wrappedBosonVoucher.connect(buyer).swapTokenForSpecificNFT(tokenId, inputAmount); + + expect(swapTokenTx).to.emit(pool, "SwapTokenForAnyNFTs"); + + const calldata = wrappedBosonVoucher.interface.encodeFunctionData("unwrap", [tokenId]); + + const priceDiscovery = new PriceDiscovery(inputAmount, wrappedBosonVoucherAddress, calldata, Side.Ask); + + const protocolBalanceBefore = await weth.balanceOf(await exchangeHandler.getAddress()); + + tx = await priceDiscoveryHandler.connect(buyer).commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery); + + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + + const { timestamp } = await provider.getBlock(tx.blockNumber); + expect(await bosonVoucher.ownerOf(tokenId)).to.equal(buyer.address); + + const protocolBalanceAfter = await weth.balanceOf(await exchangeHandler.getAddress()); + + expect(protocolBalanceAfter).to.equal(protocolBalanceBefore + inputAmount); + + const exchangeId = tokenId & MASK; + const [, , voucher] = await exchangeHandler.getExchange(exchangeId); + + expect(voucher.committedDate).to.equal(timestamp); + }); +}); diff --git a/test/integration/seaport/ItemTypeEnum.js b/test/integration/seaport/ItemTypeEnum.js new file mode 100644 index 000000000..1aff910a0 --- /dev/null +++ b/test/integration/seaport/ItemTypeEnum.js @@ -0,0 +1,23 @@ +/** + * Seaport Domain Enum: ItemType + */ +class ItemType {} + +ItemType.NATIVE = 0; +ItemType.ERC20 = 1; +ItemType.ERC721 = 2; +ItemType.ERC1155 = 3; +ItemType.ERC721_WITH_CRITERIA = 4; +ItemType.ERC1155_WITH_CRITERIA = 5; + +ItemType.Types = [ + ItemType.NATIVE, + ItemType.ERC20, + ItemType.ERC721, + ItemType.ERC1155, + ItemType.ERC721_WITH_CRITERIA, + ItemType.ERC1155_WITH_CRITERIA, +]; + +// Export +module.exports = ItemType; diff --git a/test/integration/seaport/SideEnum.js b/test/integration/seaport/SideEnum.js new file mode 100644 index 000000000..cf3a5e844 --- /dev/null +++ b/test/integration/seaport/SideEnum.js @@ -0,0 +1,12 @@ +/** + * Seaport Domain Enum: Side + */ +class Side {} + +Side.OFFER = 0; +Side.CONSIDERATION = 1; + +Side.Types = [Side.OFFER, Side.CONSIDERATION]; + +// Export +module.exports = Side; diff --git a/test/integration/seaport/fixtures.js b/test/integration/seaport/fixtures.js index c65b4462a..5ac10cab7 100644 --- a/test/integration/seaport/fixtures.js +++ b/test/integration/seaport/fixtures.js @@ -3,14 +3,29 @@ const { ZeroHash, ZeroAddress } = hre.ethers; const { getOfferOrConsiderationItem, calculateOrderHash } = require("./utils"); const { expect } = require("chai"); const OrderType = require("./OrderTypeEnum"); +const ItemType = require("./ItemTypeEnum"); +const Side = require("./SideEnum"); const seaportFixtures = async (seaport) => { - const getTestVoucher = function (identifierOrCriteria, token, startAmount = 1, endAmount = 1) { - return getOfferOrConsiderationItem(2, token, identifierOrCriteria, startAmount, endAmount); + const getTestVoucher = function ( + itemType = ItemType.ERC721, + identifierOrCriteria, + token, + startAmount = 1, + endAmount = 1 + ) { + return getOfferOrConsiderationItem(itemType, token, identifierOrCriteria, startAmount, endAmount); }; - const getTestToken = function (identifierOrCriteria, token = ZeroAddress, startAmount = 1, endAmount = 1, recipient) { - return getOfferOrConsiderationItem(0, token, identifierOrCriteria, startAmount, endAmount, recipient); + const getTestToken = function ( + itemType = ItemType.NATIVE, + identifierOrCriteria, + token = ZeroAddress, + startAmount = 1, + endAmount = 1, + recipient + ) { + return getOfferOrConsiderationItem(itemType, token, identifierOrCriteria, startAmount, endAmount, recipient); }; const getAndVerifyOrderHash = async (orderComponents) => { @@ -59,14 +74,13 @@ const seaportFixtures = async (seaport) => { }; // 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) : BigInt(0))) - .reduce((a, b) => a + b, BigInt(0)) - .add( - consideration - .map((x) => (x.itemType === 0 ? (x.endAmount.gt(x.startAmount) ? x.endAmount : x.startAmount) : BigInt(0))) - .reduce((a, b) => a + b, BigInt(0)) - ); + const value = + offer + .map((x) => (x.itemType === 0 ? (x.endAmount > x.startAmount ? x.endAmount : x.startAmount) : 0n)) + .reduce((a, b) => a + b, 0n) + + consideration + .map((x) => (x.itemType === 0 ? (x.endAmount > x.startAmount ? x.endAmount : x.startAmount) : 0n)) + .reduce((a, b) => a + b, 0n); return { order, @@ -75,10 +89,57 @@ const seaportFixtures = async (seaport) => { }; }; + const getAdvancedOrder = async function ( + offerer, + zone = ZeroAddress, + offer, + consideration, + orderType = OrderType.FULL_OPEN, + startTime, + endTime, + zoneHash = ZeroHash, + salt = 0, + conduitKey = ZeroHash, + numerator = 1, + denominator = 1 + ) { + let order, orderHash, value; + ({ order, orderHash, value } = await getOrder( + offerer, + zone, + offer, + consideration, + orderType, + startTime, + endTime, + zoneHash, + salt, + conduitKey + )); + + order.numerator = numerator; + order.denominator = denominator; + order.extraData = ZeroHash; + + return { order, orderHash, value }; + }; + + const getCriteriaResolver = (orderIndex = 0, side = Side.OFFER, index = 0, identifier = 1, criteriaProof) => { + return { + orderIndex, + side, + index, + identifier, + criteriaProof, + }; + }; + return { getOrder, getTestVoucher, getTestToken, + getCriteriaResolver, + getAdvancedOrder, }; }; diff --git a/test/integration/seaport/seaport-integration.js b/test/integration/seaport/seaport-integration.js index 86d4181c6..c129cf23e 100644 --- a/test/integration/seaport/seaport-integration.js +++ b/test/integration/seaport/seaport-integration.js @@ -1,6 +1,12 @@ const { ethers } = require("hardhat"); const { ZeroAddress, BigNumber, getContractAt, ZeroHash } = ethers; -const { setupTestEnvironment, getEvent, calculateContractAddress, objectToArray } = require("../../util/utils"); +const { + setupTestEnvironment, + getEvent, + calculateBosonProxyAddress, + calculateCloneAddress, + objectToArray, +} = require("../../util/utils"); const { SEAPORT_ADDRESS } = require("../../util/constants"); const { @@ -14,7 +20,14 @@ const { const { assert, expect } = require("chai"); let { seaportFixtures } = require("./fixtures.js"); const { DisputeResolverFee } = require("../../../scripts/domain/DisputeResolverFee"); - +const ItemType = require("./ItemTypeEnum"); + +// This test checks whether the Boson Voucher contract can be used as the offerer in Seaport offers. +// We need to handle funds internally on the protocol, and Seaport sends the money to the offerer's account. +// Therefore, we need to use the BV contract as the offerer to manage funds. +// Seaport allows offer creation in two ways: signing a message or calling an on-chain validate function. +// Contract accounts cannot sign messages. Therefore, we have created a function on the BV contract that can run arbitrary methods, +// which only the BV contract owner (assistant) can call. // Requirements to run this test: // - Seaport submodule contains a `artifacts` folder inside it. Run `git submodule update --init --recursive` to get it. // - Set hardhat config to hardhat-fork.config.js. e.g.: @@ -75,7 +88,8 @@ describe("[@skip-on-coverage] Seaport integration", function () { .connect(assistant) .createOffer(offer.toStruct(), offerDates.toStruct(), offerDurations.toStruct(), disputeResolverId, "0"); - const voucherAddress = calculateContractAddress(await accountHandler.getAddress(), seller.id); + const beaconProxyAddress = await calculateBosonProxyAddress(await accountHandler.getAddress()); + const voucherAddress = calculateCloneAddress(await accountHandler.getAddress(), beaconProxyAddress, seller.admin); bosonVoucher = await getContractAt("BosonVoucher", voucherAddress); // Pool needs to cover both seller deposit and price @@ -85,15 +99,13 @@ describe("[@skip-on-coverage] Seaport integration", function () { }); // Pre mint range - await offerHandler - .connect(assistant) - .reserveRange(offer.id, offer.quantityAvailable, await bosonVoucher.getAddress()); + await offerHandler.connect(assistant).reserveRange(offer.id, offer.quantityAvailable, voucherAddress); await bosonVoucher.connect(assistant).preMint(offer.id, offer.quantityAvailable); // Create seaport offer which tokenId 1 const endDate = "0xff00000000000000000000000000"; - const seaportOffer = seaportFixtures.getTestVoucher(1, await bosonVoucher.getAddress(), 1, 1); - const consideration = seaportFixtures.getTestToken(0, undefined, 1, 2, await bosonVoucher.getAddress()); + const seaportOffer = seaportFixtures.getTestVoucher(ItemType.ERC721, 1, voucherAddress, 1, 1); + const consideration = seaportFixtures.getTestToken(ItemType.NATIVE, 0, undefined, 1, 2, voucherAddress); ({ order, orderHash, value } = await seaportFixtures.getOrder( bosonVoucher, undefined, diff --git a/test/integration/seaport/utils.js b/test/integration/seaport/utils.js index 365259318..6bd0d526c 100644 --- a/test/integration/seaport/utils.js +++ b/test/integration/seaport/utils.js @@ -1,7 +1,9 @@ -const { BigNumber, utils, ZeroAddress } = require("ethers"); +const { ZeroAddress, keccak256, id, solidityPackedKeccak256 } = require("ethers"); +const { ItemType } = require("./ItemTypeEnum.js"); +const { MerkleTree } = require("merkletreejs"); const getOfferOrConsiderationItem = function ( - itemType = 0, + itemType = ItemType.NATIVE, token = ZeroAddress, identifierOrCriteria = 0, startAmount = 1, @@ -11,9 +13,9 @@ const getOfferOrConsiderationItem = function ( const item = { itemType, token, - identifierOrCriteria: BigNumber.from(identifierOrCriteria), - startAmount: BigNumber.from(startAmount), - endAmount: BigNumber.from(endAmount), + identifierOrCriteria: BigInt(identifierOrCriteria), + startAmount: BigInt(startAmount), + endAmount: BigInt(endAmount), }; if (recipient) { @@ -31,72 +33,92 @@ const calculateOrderHash = (orderComponents) => { "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 = utils.keccak256(utils.toUtf8Bytes(offerItemTypeString)); - const considerationItemTypeHash = utils.keccak256(utils.toUtf8Bytes(considerationItemTypeString)); - const orderTypeHash = utils.keccak256(utils.toUtf8Bytes(orderTypeString)); + const offerItemTypeHash = id(offerItemTypeString); + const considerationItemTypeHash = id(considerationItemTypeString); + const orderTypeHash = id(orderTypeString); - const offerHash = utils.keccak256( - "0x" + - orderComponents.offer - .map((offerItem) => { - return utils - .keccak256( - "0x" + - [ - offerItemTypeHash.slice(2), - offerItem.itemType.toString().padStart(64, "0"), - offerItem.token.slice(2).padStart(64, "0"), - BigNumber.from(offerItem.identifierOrCriteria).toString(16).slice(2).padStart(64, "0"), - BigNumber.from(offerItem.startAmount).toString(16).slice(2).padStart(64, "0"), - BigNumber.from(offerItem.endAmount).toString(16).slice(2).padStart(64, "0"), - ].join("") - ) - .slice(2); - }) - .join("") + const offerHash = solidityPackedKeccak256( + new Array(orderComponents.offer.length).fill("bytes32"), + orderComponents.offer.map((offerItem) => { + return solidityPackedKeccak256( + ["bytes32", "uint256", "uint256", "uint256", "uint256", "uint256"], + + [ + offerItemTypeHash, + offerItem.itemType, + offerItem.token, + offerItem.identifierOrCriteria, + offerItem.startAmount, + offerItem.endAmount, + ] + ); + }) ); - const considerationHash = utils.keccak256( - "0x" + - orderComponents.consideration - .map((considerationItem) => { - return utils - .keccak256( - "0x" + - [ - considerationItemTypeHash.slice(2), - considerationItem.itemType.toString().padStart(64, "0"), - considerationItem.token.slice(2).padStart(64, "0"), - BigNumber.from(considerationItem.identifierOrCriteria).toString(16).slice(2).padStart(64, "0"), - BigNumber.from(considerationItem.startAmount).toString(16).slice(2).padStart(64, "0"), - BigNumber.from(considerationItem.endAmount).toString(16).slice(2).padStart(64, "0"), - considerationItem.recipient.slice(2).padStart(64, "0"), - ].join("") - ) - .slice(2); - }) - .join("") + const considerationHash = solidityPackedKeccak256( + new Array(orderComponents.consideration.length).fill("bytes32"), + orderComponents.consideration.map((considerationItem) => { + return solidityPackedKeccak256( + ["bytes32", "uint256", "uint256", "uint256", "uint256", "uint256", "uint256"], + [ + considerationItemTypeHash, + considerationItem.itemType, + considerationItem.token, + considerationItem.identifierOrCriteria, + considerationItem.startAmount, + considerationItem.endAmount, + considerationItem.recipient, + ] + ); + }) ); - const derivedOrderHash = utils.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"), - BigNumber.from(orderComponents.startTime).toString(16).slice(2).padStart(64, "0"), - BigNumber.from(orderComponents.endTime).toString(16).slice(2).padStart(64, "0"), - orderComponents.zoneHash.slice(2), - BigNumber.from(orderComponents.salt).toString(16).slice(2).padStart(64, "0"), - orderComponents.conduitKey.slice(2).padStart(64, "0"), - BigNumber.from(orderComponents.counter).toString(16).slice(2).padStart(64, "0"), - ].join("") + const derivedOrderHash = solidityPackedKeccak256( + [ + "bytes32", + "uint256", + "uint256", + "bytes32", + "bytes32", + "uint256", + "uint256", + "uint256", + "bytes32", + "uint256", + "uint256", + "uint256", + ], + [ + orderTypeHash, + orderComponents.offerer, + orderComponents.zone, + offerHash, + considerationHash, + orderComponents.orderType, + orderComponents.startTime, + orderComponents.endTime, + orderComponents.zoneHash, + orderComponents.salt, + orderComponents.conduitKey, + orderComponents.counter, + ] ); return derivedOrderHash; }; + +function getRootAndProof(start, end, leaf) { + const leaves = []; + + for (let i = start; i <= end; i++) { + leaves.push(i); + } + const merkleTree = new MerkleTree(leaves, keccak256, { hashLeaves: true }); + + const proof = merkleTree.getHexProof(keccak256(leaf)); + + return { root: merkleTree.getHexRoot(), proof }; +} exports.getOfferOrConsiderationItem = getOfferOrConsiderationItem; exports.calculateOrderHash = calculateOrderHash; +exports.getRootAndProof = getRootAndProof; diff --git a/test/protocol/AccountHandlerTest.js b/test/protocol/AccountHandlerTest.js index a8b230949..b31e97453 100644 --- a/test/protocol/AccountHandlerTest.js +++ b/test/protocol/AccountHandlerTest.js @@ -1,5 +1,5 @@ -const hre = require("hardhat"); -const { ZeroAddress } = hre.ethers; +const { ethers } = require("hardhat"); +const { ZeroAddress } = ethers; const { expect } = require("chai"); const { DisputeResolverFee } = require("../../scripts/domain/DisputeResolverFee"); diff --git a/test/protocol/AgentHandlerTest.js b/test/protocol/AgentHandlerTest.js index 1147cec43..1aed229dd 100644 --- a/test/protocol/AgentHandlerTest.js +++ b/test/protocol/AgentHandlerTest.js @@ -1,5 +1,5 @@ -const hre = require("hardhat"); -const { ZeroAddress } = hre.ethers; +const { ethers } = require("hardhat"); +const { ZeroAddress } = ethers; const { expect } = require("chai"); const Agent = require("../../scripts/domain/Agent"); diff --git a/test/protocol/DisputeHandlerTest.js b/test/protocol/DisputeHandlerTest.js index f2580707e..25162fdab 100644 --- a/test/protocol/DisputeHandlerTest.js +++ b/test/protocol/DisputeHandlerTest.js @@ -1260,11 +1260,17 @@ describe("IBosonDisputeHandler", function () { timeout = BigInt(disputedDate) + resolutionPeriod.toString(); }); - it("should emit a DisputeEscalated event", async function () { + it("should emit FundsEncumbered and DisputeEscalated events", async function () { // Escalate the dispute, testing for the event - await expect( - disputeHandler.connect(buyer).escalateDispute(exchangeId, { value: buyerEscalationDepositNative }) - ) + const tx = await disputeHandler + .connect(buyer) + .escalateDispute(exchangeId, { value: buyerEscalationDepositNative }); + + await expect(tx) + .to.emit(disputeHandler, "FundsEncumbered") + .withArgs(buyerId, ZeroAddress, buyerEscalationDepositNative, await buyer.getAddress()); + + await expect(tx) .to.emit(disputeHandler, "DisputeEscalated") .withArgs(exchangeId, disputeResolverId, await buyer.getAddress()); }); @@ -1320,8 +1326,13 @@ describe("IBosonDisputeHandler", function () { // Protocol balance before const escrowBalanceBefore = await mockToken.balanceOf(await disputeHandler.getAddress()); - // Escalate the dispute, testing for the event - await expect(disputeHandler.connect(buyer).escalateDispute(exchangeId)) + // Escalate the dispute, testing for the events + const tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); + await expect(tx) + .to.emit(disputeHandler, "FundsEncumbered") + .withArgs(buyerId, await mockToken.getAddress(), buyerEscalationDepositToken, await buyer.getAddress()); + + await expect(tx) .to.emit(disputeHandler, "DisputeEscalated") .withArgs(exchangeId, disputeResolverId, await buyer.getAddress()); diff --git a/test/protocol/ExchangeHandlerTest.js b/test/protocol/ExchangeHandlerTest.js index aff81cd60..1963a67ec 100644 --- a/test/protocol/ExchangeHandlerTest.js +++ b/test/protocol/ExchangeHandlerTest.js @@ -28,6 +28,7 @@ const Bundle = require("../../scripts/domain/Bundle"); const ExchangeState = require("../../scripts/domain/ExchangeState"); const DisputeState = require("../../scripts/domain/DisputeState"); const Group = require("../../scripts/domain/Group"); +const Condition = require("../../scripts/domain/Condition"); const EvaluationMethod = require("../../scripts/domain/EvaluationMethod"); const GatingType = require("../../scripts/domain/GatingType"); const { DisputeResolverFee } = require("../../scripts/domain/DisputeResolverFee"); @@ -91,6 +92,7 @@ describe("IBosonExchangeHandler", function () { adminDR, clerkDR, treasuryDR; + let erc165, accessController, accountHandler, @@ -307,6 +309,8 @@ describe("IBosonExchangeHandler", function () { offer.quantityAvailable = "10"; disputeResolverId = mo.disputeResolverId; + offerDurations.voucherValid = (oneMonth * 12n).toString(); + // Check if domains are valid expect(offer.isValid()).is.true; expect(offerDates.isValid()).is.true; @@ -838,7 +842,7 @@ describe("IBosonExchangeHandler", function () { .connect(assistant) .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId); - // Attempt to commit to the not availabe offer, expecting revert + // Attempt to commit to the not available offer, expecting revert await expect( exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), ++offerId, { value: price }) ).to.revertedWith(RevertReasons.OFFER_NOT_AVAILABLE); @@ -860,7 +864,7 @@ describe("IBosonExchangeHandler", function () { await offerHandler .connect(assistant) .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId); - // Commit to offer, so it's not availble anymore + // Commit to offer, so it's not available anymore await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), ++offerId, { value: price }); // Attempt to commit to the sold out offer, expecting revert @@ -890,7 +894,10 @@ describe("IBosonExchangeHandler", function () { }); }); - context("👉 commitToPremintedOffer()", async function () { + context("👉 onPremintedVoucherTransferred()", async function () { + // These tests are mainly for preminted vouchers of fixed price offers + // The part of onPremintedVoucherTransferred that is specific to + // price discovery offers is indirectly tested in `PriceDiscoveryHandlerFacet.js` let tokenId; beforeEach(async function () { // Reserve range @@ -1361,7 +1368,14 @@ describe("IBosonExchangeHandler", function () { it("Caller is not the voucher contract, owned by the seller", async function () { // Attempt to commit to preminted offer, expecting revert await expect( - exchangeHandler.connect(rando).commitToPreMintedOffer(await buyer.getAddress(), offerId, tokenId) + exchangeHandler + .connect(rando) + .onPremintedVoucherTransferred( + tokenId, + await buyer.getAddress(), + await assistant.getAddress(), + await assistant.getAddress() + ) ).to.revertedWith(RevertReasons.ACCESS_DENIED); }); @@ -1382,7 +1396,12 @@ describe("IBosonExchangeHandler", function () { await expect( exchangeHandler .connect(impersonatedBosonVoucher) - .commitToPreMintedOffer(await buyer.getAddress(), offerId, exchangeId) + .onPremintedVoucherTransferred( + tokenId, + await buyer.getAddress(), + await assistant.getAddress(), + await assistant.getAddress() + ) ).to.revertedWith(RevertReasons.EXCHANGE_ALREADY_EXISTS); }); @@ -1447,7 +1466,7 @@ describe("IBosonExchangeHandler", function () { await offerHandler .connect(assistant) .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId); - // Commit to offer, so it's not availble anymore + // Commit to offer, so it's not available anymore await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), ++offerId, { value: price }); // Attempt to commit to the sold out offer, expecting revert @@ -2414,7 +2433,7 @@ describe("IBosonExchangeHandler", function () { // add offer to group await groupHandler.connect(assistant).addOffersToGroup(groupId, [++offerId]); - // Commit to offer, so it's not availble anymore + // Commit to offer, so it's not available anymore await exchangeHandler .connect(buyer) .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); @@ -5752,6 +5771,249 @@ describe("IBosonExchangeHandler", function () { }); context("👉 onVoucherTransferred()", async function () { + // majority of lines from onVoucherTransferred() are tested in indirectly in + // `commitToPremintedOffer()` + + beforeEach(async function () { + // Commit to offer, retrieving the event + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + + // Client used for tests + bosonVoucherCloneAddress = calculateCloneAddress( + await accountHandler.getAddress(), + beaconProxyAddress, + admin.address + ); + bosonVoucherClone = await getContractAt("IBosonVoucher", bosonVoucherCloneAddress); + + tokenId = deriveTokenId(offerId, exchange.id); + }); + + it("should emit an VoucherTransferred event when called by CLIENT-roled address", async function () { + // Get the next buyer id + nextAccountId = await accountHandler.connect(rando).getNextAccountId(); + + // Call onVoucherTransferred, expecting event + await expect( + bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId) + ) + .to.emit(exchangeHandler, "VoucherTransferred") + .withArgs(offerId, exchange.id, nextAccountId, await bosonVoucherClone.getAddress()); + }); + + it("should update exchange when new buyer (with existing, active account) is passed", async function () { + // Get the next buyer id + nextAccountId = await accountHandler.connect(rando).getNextAccountId(); + + // Create a buyer account for the new owner + await accountHandler.connect(newOwner).createBuyer(mockBuyer(await newOwner.getAddress())); + + // Call onVoucherTransferred + await bosonVoucherClone + .connect(buyer) + .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId); + + // Get the exchange + [exists, response] = await exchangeHandler.connect(rando).getExchange(exchange.id); + + // Marshal response to entity + exchange = Exchange.fromStruct(response); + expect(exchange.isValid()); + + // Exchange's voucher expired flag should be true + assert.equal(exchange.buyerId, nextAccountId, "Exchange.buyerId not updated"); + }); + + it("should update exchange when new buyer (no account) is passed", async function () { + // Get the next buyer id + nextAccountId = await accountHandler.connect(rando).getNextAccountId(); + + // Call onVoucherTransferred + await bosonVoucherClone + .connect(buyer) + .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId); + + // Get the exchange + [exists, response] = await exchangeHandler.connect(rando).getExchange(exchange.id); + + // Marshal response to entity + exchange = Exchange.fromStruct(response); + expect(exchange.isValid()); + + // Exchange's voucher expired flag should be true + assert.equal(exchange.buyerId, nextAccountId, "Exchange.buyerId not updated"); + }); + + it("should be triggered when a voucher is transferred", async function () { + // Transfer voucher, expecting event + await expect( + bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId) + ).to.emit(exchangeHandler, "VoucherTransferred"); + }); + + it("should not be triggered when a voucher is issued", async function () { + // Get the next exchange id + nextExchangeId = await exchangeHandler.getNextExchangeId(); + + // Create a buyer account + await accountHandler.connect(newOwner).createBuyer(mockBuyer(await newOwner.getAddress())); + + // Grant PROTOCOL role to EOA address for test + await accessController.grantRole(Role.PROTOCOL, await rando.getAddress()); + + // Issue voucher, expecting no event + await expect( + bosonVoucherClone.connect(rando).issueVoucher(nextExchangeId, await buyer.getAddress()) + ).to.not.emit(exchangeHandler, "VoucherTransferred"); + }); + + it("should not be triggered when a voucher is burned", async function () { + // Grant PROTOCOL role to EOA address for test + await accessController.grantRole(Role.PROTOCOL, await rando.getAddress()); + + // Burn voucher, expecting no event + await expect(bosonVoucherClone.connect(rando).burnVoucher(tokenId)).to.not.emit( + exchangeHandler, + "VoucherTransferred" + ); + }); + + it("Should not be triggered when from and to addresses are the same", async function () { + // Transfer voucher, expecting event + await expect( + bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await buyer.getAddress(), tokenId) + ).to.not.emit(exchangeHandler, "VoucherTransferred"); + }); + + it("Should not be triggered when first transfer of preminted voucher happens", async function () { + // Transfer voucher, expecting event + await expect( + bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await buyer.getAddress(), tokenId) + ).to.not.emit(exchangeHandler, "VoucherTransferred"); + }); + + it("should work with additional collections", async function () { + // Create a new collection + const externalId = `Brand1`; + voucherInitValues.collectionSalt = encodeBytes32String(externalId); + await accountHandler.connect(assistant).createNewCollection(externalId, voucherInitValues); + + offer.collectionIndex = 1; + offer.id = await offerHandler.getNextOfferId(); + exchange.id = await exchangeHandler.getNextExchangeId(); + bosonVoucherCloneAddress = calculateCloneAddress( + await accountHandler.getAddress(), + beaconProxyAddress, + admin.address, + voucherInitValues.collectionSalt + ); + bosonVoucherClone = await getContractAt("IBosonVoucher", bosonVoucherCloneAddress); + const tokenId = deriveTokenId(offer.id, exchange.id); + + // Create the offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId); + + // Commit to offer, creating a new exchange + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id, { value: price }); + + // Get the next buyer id + nextAccountId = await accountHandler.connect(rando).getNextAccountId(); + + // Call onVoucherTransferred, expecting event + await expect(bosonVoucherClone.connect(buyer).transferFrom(buyer.address, newOwner.address, tokenId)) + .to.emit(exchangeHandler, "VoucherTransferred") + .withArgs(offer.id, exchange.id, nextAccountId, await bosonVoucherClone.getAddress()); + }); + + context("💔 Revert Reasons", async function () { + it("The buyers region of protocol is paused", async function () { + // Pause the buyers region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Buyers]); + + // Attempt to create a buyer, expecting revert + await expect( + bosonVoucherClone + .connect(buyer) + .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId) + ).to.revertedWith(RevertReasons.REGION_PAUSED); + }); + + it("Caller is not a clone address", async function () { + // Attempt to call onVoucherTransferred, expecting revert + await expect( + exchangeHandler.connect(rando).onVoucherTransferred(exchange.id, await newOwner.getAddress()) + ).to.revertedWith(RevertReasons.ACCESS_DENIED); + }); + + it("Caller is not a clone address associated with the seller", async function () { + // Create a new seller to get new clone + seller = mockSeller( + await rando.getAddress(), + await rando.getAddress(), + ZeroAddress, + await rando.getAddress() + ); + expect(seller.isValid()).is.true; + + await accountHandler.connect(rando).createSeller(seller, emptyAuthToken, voucherInitValues); + expectedCloneAddress = calculateCloneAddress( + await accountHandler.getAddress(), + beaconProxyAddress, + rando.address + ); + const bosonVoucherClone2 = await getContractAt("IBosonVoucher", expectedCloneAddress); + + // For the sake of test, mint token on bv2 with the id of token on bv1 + // Temporarily grant PROTOCOL role to deployer account + await accessController.grantRole(Role.PROTOCOL, await deployer.getAddress()); + + const newBuyer = mockBuyer(await buyer.getAddress()); + newBuyer.id = buyerId; + await bosonVoucherClone2.issueVoucher(exchange.id, newBuyer.wallet); + + // Attempt to call onVoucherTransferred, expecting revert + await expect( + bosonVoucherClone2 + .connect(buyer) + .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), exchange.id) + ).to.revertedWith(RevertReasons.ACCESS_DENIED); + }); + + it("exchange id is invalid", async function () { + // An invalid exchange id + exchangeId = "666"; + + // Attempt to call onVoucherTransferred, expecting revert + await expect( + exchangeHandler.connect(fauxClient).onVoucherTransferred(exchangeId, await newOwner.getAddress()) + ).to.revertedWith(RevertReasons.NO_SUCH_EXCHANGE); + }); + + it("exchange is not in committed state", async function () { + // Revoke the voucher + await exchangeHandler.connect(assistant).revokeVoucher(exchange.id); + + // Attempt to call onVoucherTransferred, expecting revert + await expect( + exchangeHandler.connect(fauxClient).onVoucherTransferred(exchangeId, await newOwner.getAddress()) + ).to.revertedWith(RevertReasons.INVALID_STATE); + }); + + it("Voucher has expired", async function () { + // Set time forward past the voucher's validUntilDate + await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); + + // Attempt to call onVoucherTransferred, expecting revert + await expect( + exchangeHandler.connect(fauxClient).onVoucherTransferred(exchangeId, await newOwner.getAddress()) + ).to.revertedWith(RevertReasons.VOUCHER_HAS_EXPIRED); + }); + }); + }); + + context("👉 onPremintedVoucherTransferred()", async function () { beforeEach(async function () { // Commit to offer, retrieving the event await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); @@ -7088,7 +7350,7 @@ describe("IBosonExchangeHandler", function () { }); context("👉 isEligibleToCommit()", async function () { - context("✋ No condition", async function () { + context("✋ No group", async function () { it("buyer is eligible, no commits yet", async function () { const [isEligible, commitCount, maxCommits] = await exchangeHandler.isEligibleToCommit( buyer.address, @@ -7120,6 +7382,62 @@ describe("IBosonExchangeHandler", function () { }); }); + context("✋ Condition None", async function () { + beforeEach(async function () { + // Required constructor params for Group + groupId = "1"; + offerIds = [offerId]; + + // Create Condition + condition = new Condition(EvaluationMethod.None, 0, ZeroAddress, 0, 0, 0, 0, 0); + // expect(condition.isValid()).to.be.true; + + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; + await groupHandler.connect(assistant).createGroup(group, condition); + }); + + it("buyer is eligible, no commits yet", async function () { + const [isEligible, commitCount, maxCommits] = await exchangeHandler.isEligibleToCommit( + buyer.address, + offerId, + 0 + ); + + expect(isEligible).to.be.true; + expect(commitCount).to.equal(0); + expect(maxCommits).to.equal(condition.maxCommits); + }); + + it("buyer is eligible, with existing commits", async function () { + // Commit to offer the maximum number of times + for (let i = 0; i < Number(condition.maxCommits); i++) { + // Commit to offer. + await exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }); + + const [isEligible, commitCount, maxCommits] = await exchangeHandler.isEligibleToCommit( + buyer.address, + offerId, + 0 + ); + + expect(isEligible).to.equal(i + 1 < Number(condition.maxCommits)); + expect(commitCount).to.equal(i + 1); + expect(maxCommits).to.equal(condition.maxCommits); + } + }); + + context("💔 Revert Reasons", async function () { + it("Caller sends non-zero tokenId", async function () {}); + await expect(exchangeHandler.connect(buyer).isEligibleToCommit(buyer.address, offerId, 1)).to.revertedWith( + RevertReasons.INVALID_TOKEN_ID + ); + }); + }); + context("✋ Threshold ERC20", async function () { beforeEach(async function () { // Required constructor params for Group diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index ea91b0c93..600620a2d 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -5,6 +5,8 @@ const Role = require("../../scripts/domain/Role"); const { Funds, FundsList } = require("../../scripts/domain/Funds"); const { DisputeResolverFee } = require("../../scripts/domain/DisputeResolverFee"); const PausableRegion = require("../../scripts/domain/PausableRegion.js"); +const PriceDiscovery = require("../../scripts/domain/PriceDiscovery"); +const Side = require("../../scripts/domain/Side"); const { getInterfaceIds } = require("../../scripts/config/supported-interfaces.js"); const { RevertReasons } = require("../../scripts/config/revert-reasons.js"); const { deployMockTokens } = require("../../scripts/util/deploy-mock-tokens"); @@ -60,7 +62,8 @@ describe("IBosonFundsHandler", function () { offerHandler, configHandler, disputeHandler, - pauseHandler; + pauseHandler, + sequentialCommitHandler; let support; let seller; let buyer, offerToken, offerNative; @@ -96,8 +99,10 @@ describe("IBosonFundsHandler", function () { expectedAgentAvailableFunds, agentAvailableFunds; let DRFee, buyerEscalationDeposit; + let buyer1, buyer2, buyer3; let protocolDiamondAddress; let snapshotId; + let priceDiscoveryContract; let beaconProxyAddress; before(async function () { @@ -116,10 +121,24 @@ describe("IBosonFundsHandler", function () { configHandler: "IBosonConfigHandler", pauseHandler: "IBosonPauseHandler", disputeHandler: "IBosonDisputeHandler", + sequentialCommitHandler: "IBosonSequentialCommitHandler", }; ({ - signers: [pauser, admin, treasury, rando, buyer, feeCollector, adminDR, treasuryDR, other], + signers: [ + pauser, + admin, + treasury, + rando, + buyer, + feeCollector, + adminDR, + treasuryDR, + other, + buyer1, + buyer2, + buyer3, + ], contractInstances: { erc165, accountHandler, @@ -129,6 +148,7 @@ describe("IBosonFundsHandler", function () { configHandler, pauseHandler, disputeHandler, + sequentialCommitHandler, }, protocolConfig: [, , { percentage: protocolFeePercentage, buyerEscalationDepositPercentage }], diamondAddress: protocolDiamondAddress, @@ -145,6 +165,11 @@ describe("IBosonFundsHandler", function () { // Deploy the mock token [mockToken] = await deployMockTokens(["Foreign20"]); + // Deploy PriceDiscovery contract + const PriceDiscoveryFactory = await ethers.getContractFactory("PriceDiscovery"); + priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); + await priceDiscoveryContract.waitForDeployment(); + // Get the beacon proxy address beaconProxyAddress = await calculateBosonProxyAddress(await configHandler.getAddress()); @@ -289,6 +314,15 @@ describe("IBosonFundsHandler", function () { ).to.revertedWith(RevertReasons.REGION_PAUSED); }); + it("Amount to deposit is zero", async function () { + depositAmount = 0; + + // Attempt to deposit funds, expecting revert + await expect( + fundsHandler.connect(assistant).depositFunds(seller.id, await mockToken.getAddress(), depositAmount) + ).to.revertedWith(RevertReasons.ZERO_DEPOSIT_NOT_ALLOWED); + }); + it("Seller id does not exist", async function () { // Attempt to deposit the funds, expecting revert seller.id = "555"; @@ -325,6 +359,13 @@ describe("IBosonFundsHandler", function () { ).to.revertedWith(RevertReasons.SAFE_ERC20_LOW_LEVEL_CALL); }); + it("No native currency deposited and token address is zero", async function () { + // Attempt to deposit the funds, expecting revert + await expect(fundsHandler.connect(rando).depositFunds(seller.id, ZeroAddress, depositAmount)).to.revertedWith( + RevertReasons.INVALID_ADDRESS + ); + }); + it("Token address is not a contract", async function () { // Attempt to deposit the funds, expecting revert await expect( @@ -1362,7 +1403,7 @@ describe("IBosonFundsHandler", function () { it("Returns info even if name consumes all the gas", async function () { // Deploy the mock token that consumes all gas in the name getter const [mockToken, mockToken2] = await deployMockTokens(["Foreign20", "Foreign20MaliciousName"]); - const ERC20 = await getContractFactory("ERC20"); + const ERC20 = await getContractFactory("@openzeppelin/contracts/token/ERC20/ERC20.sol:ERC20"); const mockToken3 = await ERC20.deploy("SomeToken", "STK"); // top up assistants account @@ -2120,6 +2161,7 @@ describe("IBosonFundsHandler", function () { // commit to offer await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerToken.id); }); + context("Final state COMPLETED", async function () { beforeEach(async function () { // Set time forward to the offer's voucherRedeemableFrom @@ -4095,7 +4137,7 @@ describe("IBosonFundsHandler", function () { }); it("should emit a FundsReleased event", async function () { - // Expire the dispute, expecting event + // Refuse the dispute, expecting event const tx = await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); await expect(tx) @@ -4146,7 +4188,7 @@ describe("IBosonFundsHandler", function () { expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // Expire the escalated dispute, so the funds are released + // Refuse the escalated dispute, so the funds are released await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); // Available funds should be increased for @@ -4233,7 +4275,7 @@ describe("IBosonFundsHandler", function () { expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // Expire the escalated dispute, so the funds are released + // Refuse the escalated dispute, so the funds are released await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); // Available funds should be increased for @@ -4433,5 +4475,1875 @@ describe("IBosonFundsHandler", function () { }); }); }); + + context("👉 releaseFunds() - Sequential commit", async function () { + let resellersAvailableFunds, expectedResellersAvailableFunds; + + const directions = ["increasing", "constant", "decreasing", "mixed"]; + + let buyerChains; + beforeEach(async function () { + buyerChains = { + increasing: [ + { buyer: buyer1, price: "150" }, + { buyer: buyer2, price: "160" }, + { buyer: buyer3, price: "400" }, + ], + constant: [ + { buyer: buyer1, price: "100" }, + { buyer: buyer2, price: "100" }, + { buyer: buyer3, price: "100" }, + ], + decreasing: [ + { buyer: buyer1, price: "90" }, + { buyer: buyer2, price: "85" }, + { buyer: buyer3, price: "50" }, + ], + mixed: [ + { buyer: buyer1, price: "130" }, + { buyer: buyer2, price: "130" }, + { buyer: buyer3, price: "120" }, + ], + }; + + await configHandler.connect(deployer).setMaxTotalOfferFeePercentage("10000"); // 100% + }); + + const fees = [ + { + protocol: 0, + royalties: 0, + }, + { + protocol: 1000, + royalties: 0, + }, + { + protocol: 0, + royalties: 600, + }, + { + protocol: 300, + royalties: 400, // less than profit + }, + { + protocol: 8500, // ridiculously high + royalties: 700, + }, + ]; + + directions.forEach((direction) => { + let bosonVoucherClone; + let offer; + let mockTokenAddress; + + context(`Direction: ${direction}`, async function () { + fees.forEach((fee) => { + context(`protocol fee: ${fee.protocol / 100}%; royalties: ${fee.royalties / 100}%`, async function () { + let voucherOwner, previousPrice; + let payoutInformation; + let totalRoyalties, totalProtocolFee; + + beforeEach(async function () { + payoutInformation = []; + + const expectedCloneAddress = calculateCloneAddress( + await accountHandler.getAddress(), + beaconProxyAddress, + admin.address + ); + bosonVoucherClone = await ethers.getContractAt("IBosonVoucher", expectedCloneAddress); + + // set fees + await configHandler.setProtocolFeePercentage(fee.protocol); + await bosonVoucherClone.connect(assistant).setRoyaltyPercentage(fee.royalties); + + offer = offerToken.clone(); + offer.id = "3"; + offer.price = "100"; + offer.sellerDeposit = "10"; + offer.buyerCancelPenalty = "30"; + + // deposit to seller's pool + await fundsHandler.connect(assistant).withdrawFunds(seller.id, [], []); // withdraw all, so it's easier to test + await mockToken.connect(assistant).mint(assistant.address, offer.sellerDeposit); + await mockToken.connect(assistant).approve(await fundsHandler.getAddress(), offer.sellerDeposit); + await fundsHandler + .connect(assistant) + .depositFunds(seller.id, await mockToken.getAddress(), offer.sellerDeposit); + + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, 0); + + // ids + exchangeId = "1"; + agentId = "3"; + buyerId = 5; + protocolId = 0; + + // Create buyer with protocol address to not mess up ids in tests + await accountHandler.createBuyer(mockBuyer(await exchangeHandler.getAddress())); + + // commit to offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id); + + voucherOwner = buyer; // voucherOwner is the first buyer + previousPrice = BigInt(offer.price); + totalRoyalties = 0n; + totalProtocolFee = 0n; + for (const trade of buyerChains[direction]) { + // Prepare calldata for PriceDiscovery contract + const tokenId = deriveTokenId(offer.id, exchangeId); + let order = { + seller: voucherOwner.address, + buyer: trade.buyer.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: offer.exchangeToken, + price: BigInt(trade.price), + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [ + order, + ]); + + const priceDiscovery = new PriceDiscovery( + order.price, + Side.Ask, + await priceDiscoveryContract.getAddress(), + await priceDiscoveryContract.getAddress(), + priceDiscoveryData + ); + + // voucher owner approves protocol to transfer the tokens + await mockToken.mint(voucherOwner.address, order.price); + await mockToken.connect(voucherOwner).approve(protocolDiamondAddress, order.price); + + // Voucher owner approves PriceDiscovery contract to transfer the tokens + await bosonVoucherClone + .connect(voucherOwner) + .setApprovalForAll(await priceDiscoveryContract.getAddress(), true); + + // Buyer approves protocol to transfer the tokens + await mockToken.mint(trade.buyer.address, order.price); + await mockToken.connect(trade.buyer).approve(protocolDiamondAddress, order.price); + + // commit to offer + await sequentialCommitHandler + .connect(trade.buyer) + .sequentialCommitToOffer(trade.buyer.address, tokenId, priceDiscovery, { + gasPrice: 0, + }); + + // Fees, royalties and immediate payout + const royalties = applyPercentage(order.price, fee.royalties); + const protocolFee = applyPercentage(order.price, fee.protocol); + const reducedSecondaryPrice = order.price - BigInt(royalties) - BigInt(protocolFee); + const immediatePayout = + reducedSecondaryPrice <= previousPrice ? reducedSecondaryPrice : previousPrice; + payoutInformation.push({ buyerId: buyerId++, immediatePayout, previousPrice, reducedSecondaryPrice }); + + // Total royalties and fees + totalRoyalties = totalRoyalties + BigInt(royalties); + totalProtocolFee = totalProtocolFee + BigInt(protocolFee); + + voucherOwner = trade.buyer; // last buyer is voucherOwner in next iteration + previousPrice = order.price; + + mockTokenAddress = await mockToken.getAddress(); + } + }); + + context("Final state COMPLETED", async function () { + let resellerPayoffs; + beforeEach(async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // succesfully redeem exchange + await exchangeHandler.connect(voucherOwner).redeemVoucher(exchangeId); + + // expected payoffs + // last buyer: 0 + + // resellers: difference between the secondary price and immediate payout + resellerPayoffs = payoutInformation.map((pi) => { + return { + id: pi.buyerId, + payoff: (pi.reducedSecondaryPrice - BigInt(pi.immediatePayout)).toString(), + }; + }); + + // seller: sellerDeposit + price - protocolFee + royalties + const initialFee = applyPercentage(offer.price, fee.protocol); + sellerPayoff = ( + BigInt(offer.sellerDeposit) + + BigInt(offer.price) + + BigInt(totalRoyalties) - + BigInt(initialFee) + ).toString(); + + // protocol: protocolFee + protocolPayoff = (totalProtocolFee + BigInt(initialFee)).toString(); + }); + + it("should emit a FundsReleased event", async function () { + // Complete the exchange, expecting event + const tx = await exchangeHandler.connect(voucherOwner).completeExchange(exchangeId); + + // seller + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, voucherOwner.address); + + // resellers + let expectedEventCount = 1; // 1 for seller + for (const resellerPayoff of resellerPayoffs) { + if (resellerPayoff.payoff != "0") { + expectedEventCount++; + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs( + exchangeId, + resellerPayoff.id, + offer.exchangeToken, + resellerPayoff.payoff, + voucherOwner.address + ); + } + } + + // Make sure exact number of FundsReleased events was emitted + const eventCount = (await tx.wait()).logs.filter((e) => e.eventName == "FundsReleased").length; + expect(eventCount).to.equal(expectedEventCount); + + // protocol + if (protocolPayoff != "0") { + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offer.exchangeToken, protocolPayoff, voucherOwner.address); + } else { + await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); + } + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expectedResellersAvailableFunds = new Array(resellerPayoffs.length).fill(new FundsList([])); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + + // Complete the exchange so the funds are released + await exchangeHandler.connect(voucherOwner).completeExchange(exchangeId); + + // Available funds should be increased for + // buyer: 0 + // seller: sellerDeposit + price - protocolFee - agentFee + royalties + // resellers: difference between the secondary price and immediate payout + // protocol: protocolFee + // agent: 0 + expectedSellerAvailableFunds.funds.push(new Funds(mockTokenAddress, "Foreign20", sellerPayoff)); + if (protocolPayoff != "0") { + expectedProtocolAvailableFunds.funds.push(new Funds(mockTokenAddress, "Foreign20", protocolPayoff)); + } + expectedResellersAvailableFunds = resellerPayoffs.map((r) => { + return new FundsList(r.payoff != "0" ? [new Funds(mockTokenAddress, "Foreign20", r.payoff)] : []); + }); + + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + }); + }); + + context("Final state REVOKED", async function () { + let resellerPayoffs; + beforeEach(async function () { + // expected payoffs + // last buyer: sellerDeposit + price + buyerPayoff = (BigInt(offer.sellerDeposit) + BigInt(offer.price)).toString(); + + // resellers: difference between original price and immediate payoff + resellerPayoffs = payoutInformation.map((pi) => { + return { id: pi.buyerId, payoff: (pi.previousPrice - BigInt(pi.immediatePayout)).toString() }; + }); + + // seller: 0 + sellerPayoff = 0; + + // protocol: 0 + protocolPayoff = 0; + }); + + it("should emit a FundsReleased event", async function () { + // Revoke the voucher, expecting event + const tx = await exchangeHandler.connect(assistant).revokeVoucher(exchangeId); + + // Buyer + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); + + // Resellers + let expectedEventCount = 1; // 1 for buyer + for (const resellerPayoff of resellerPayoffs) { + if (resellerPayoff.payoff != "0") { + expectedEventCount++; + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs( + exchangeId, + resellerPayoff.id, + offer.exchangeToken, + resellerPayoff.payoff, + assistant.address + ); + } + } + + // Make sure exact number of FundsReleased events was emitted + const eventCount = (await tx.wait()).logs.filter((e) => e.eventName == "FundsReleased").length; + expect(eventCount).to.equal(expectedEventCount); + + // Expect no protocol fee + await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expectedResellersAvailableFunds = new Array(resellerPayoffs.length).fill(new FundsList([])); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + + // Revoke the voucher so the funds are released + await exchangeHandler.connect(assistant).revokeVoucher(exchangeId); + + // Available funds should be increased for + // buyer: sellerDeposit + price + // seller: 0 + // resellers: difference between original price and immediate payoff + // protocol: 0 + // agent: 0 + expectedBuyerAvailableFunds.funds.push(new Funds(mockTokenAddress, "Foreign20", buyerPayoff)); + expectedResellersAvailableFunds = resellerPayoffs.map((r) => { + return new FundsList(r.payoff != "0" ? [new Funds(mockTokenAddress, "Foreign20", r.payoff)] : []); + }); + + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + }); + }); + + context("Final state CANCELED", async function () { + let resellerPayoffs; + beforeEach(async function () { + // expected payoffs + // last buyer: price - buyerCancelPenalty + buyerPayoff = (BigInt(offer.price) - BigInt(offer.buyerCancelPenalty)).toString(); + + // resellers: difference between original price and immediate payoff + resellerPayoffs = payoutInformation.map((pi) => { + return { id: pi.buyerId, payoff: (pi.previousPrice - BigInt(pi.immediatePayout)).toString() }; + }); + + // seller: sellerDeposit + buyerCancelPenalty + sellerPayoff = (BigInt(offer.sellerDeposit) + BigInt(offer.buyerCancelPenalty)).toString(); + + // protocol: 0 + protocolPayoff = 0; + }); + + it("should emit a FundsReleased event", async function () { + // Cancel the voucher, expecting event + const tx = await exchangeHandler.connect(voucherOwner).cancelVoucher(exchangeId); + + // Buyer + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, voucherOwner.address); + + // Seller + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, voucherOwner.address); + + // Resellers + let expectedEventCount = 2; // 1 for buyer, 1 for seller + for (const resellerPayoff of resellerPayoffs) { + if (resellerPayoff.payoff != "0") { + expectedEventCount++; + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs( + exchangeId, + resellerPayoff.id, + offer.exchangeToken, + resellerPayoff.payoff, + voucherOwner.address + ); + } + } + + // Make sure exact number of FundsReleased events was emitted + const eventCount = (await tx.wait()).logs.filter((e) => e.eventName == "FundsReleased").length; + expect(eventCount).to.equal(expectedEventCount); + + // Expect no protocol fee + await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expectedResellersAvailableFunds = new Array(resellerPayoffs.length).fill(new FundsList([])); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + + // Cancel the voucher, so the funds are released + await exchangeHandler.connect(voucherOwner).cancelVoucher(exchangeId); + + // Available funds should be increased for + // buyer: price - buyerCancelPenalty + // seller: sellerDeposit + buyerCancelPenalty + // resellers: difference between original price and immediate payoff + // protocol: 0 + // agent: 0 + expectedSellerAvailableFunds.funds[0] = new Funds(mockTokenAddress, "Foreign20", sellerPayoff); + expectedBuyerAvailableFunds.funds.push(new Funds(mockTokenAddress, "Foreign20", buyerPayoff)); + expectedResellersAvailableFunds = resellerPayoffs.map((r) => { + return new FundsList(r.payoff != "0" ? [new Funds(mockTokenAddress, "Foreign20", r.payoff)] : []); + }); + + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + }); + }); + + context("Final state DISPUTED", async function () { + beforeEach(async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // succesfully redeem exchange + await exchangeHandler.connect(voucherOwner).redeemVoucher(exchangeId); + + // raise the dispute + tx = await disputeHandler.connect(voucherOwner).raiseDispute(exchangeId); + + // Get the block timestamp of the confirmed tx and set disputedDate + blockNumber = tx.blockNumber; + block = await provider.getBlock(blockNumber); + disputedDate = block.timestamp.toString(); + timeout = (BigInt(disputedDate) + BigInt(resolutionPeriod) + 1n).toString(); + }); + + context("Final state DISPUTED - RETRACTED", async function () { + let resellerPayoffs; + beforeEach(async function () { + // expected payoffs + // last buyer: 0 + buyerPayoff = 0; + + // resellers: difference between the secondary price and immediate payout + resellerPayoffs = payoutInformation.map((pi) => { + return { + id: pi.buyerId, + payoff: (pi.reducedSecondaryPrice - BigInt(pi.immediatePayout)).toString(), + }; + }); + + // seller: sellerDeposit + price - protocolFee + royalties + const initialFee = applyPercentage(offer.price, fee.protocol); + sellerPayoff = ( + BigInt(offer.sellerDeposit) + + BigInt(offer.price) + + BigInt(totalRoyalties) - + BigInt(initialFee) + ).toString(); + + // protocol: protocolFee + protocolPayoff = (totalProtocolFee + BigInt(initialFee)).toString(); + }); + + it("should emit a FundsReleased event", async function () { + // Retract from the dispute, expecting event + const tx = await disputeHandler.connect(voucherOwner).retractDispute(exchangeId); + + // seller + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, voucherOwner.address); + + // resellers + let expectedEventCount = 1; // 1 for seller + for (const resellerPayoff of resellerPayoffs) { + if (resellerPayoff.payoff != "0") { + expectedEventCount++; + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs( + exchangeId, + resellerPayoff.id, + offer.exchangeToken, + resellerPayoff.payoff, + voucherOwner.address + ); + } + } + + // Make sure exact number of FundsReleased events was emitted + const eventCount = (await tx.wait()).logs.filter((e) => e.eventName == "FundsReleased").length; + expect(eventCount).to.equal(expectedEventCount); + + // protocol + if (protocolPayoff != "0") { + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offer.exchangeToken, protocolPayoff, voucherOwner.address); + } else { + await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); + } + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expectedResellersAvailableFunds = new Array(resellerPayoffs.length).fill(new FundsList([])); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + + // Retract from the dispute, so the funds are released + await disputeHandler.connect(voucherOwner).retractDispute(exchangeId); + + // Available funds should be increased for + // buyer: 0 + // seller: sellerDeposit + price - protocolFee - agentFee + royalties + // resellers: difference between the secondary price and immediate payout + // protocol: protocolFee + // agent: 0 + expectedSellerAvailableFunds.funds.push(new Funds(mockTokenAddress, "Foreign20", sellerPayoff)); + if (protocolPayoff != "0") { + expectedProtocolAvailableFunds.funds.push( + new Funds(mockTokenAddress, "Foreign20", protocolPayoff) + ); + } + expectedResellersAvailableFunds = resellerPayoffs.map((r) => { + return new FundsList(r.payoff != "0" ? [new Funds(mockTokenAddress, "Foreign20", r.payoff)] : []); + }); + + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + }); + }); + + context("Final state DISPUTED - RETRACTED via expireDispute", async function () { + let resellerPayoffs; + beforeEach(async function () { + // expected payoffs + // last buyer: 0 + buyerPayoff = 0; + + // resellers: difference between the secondary price and immediate payout + resellerPayoffs = payoutInformation.map((pi) => { + return { + id: pi.buyerId, + payoff: (pi.reducedSecondaryPrice - BigInt(pi.immediatePayout)).toString(), + }; + }); + + // seller: sellerDeposit + price - protocolFee + royalties + const initialFee = applyPercentage(offer.price, fee.protocol); + sellerPayoff = ( + BigInt(offer.sellerDeposit) + + BigInt(offer.price) + + BigInt(totalRoyalties) - + BigInt(initialFee) + ).toString(); + + // protocol: protocolFee + protocolPayoff = (totalProtocolFee + BigInt(initialFee)).toString(); + + await setNextBlockTimestamp(Number(timeout)); + }); + + it("should emit a FundsReleased event", async function () { + // Expire the dispute, expecting event + const tx = await disputeHandler.connect(rando).expireDispute(exchangeId); + + // seller + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, rando.address); + + // resellers + let expectedEventCount = 1; // 1 for seller + for (const resellerPayoff of resellerPayoffs) { + if (resellerPayoff.payoff != "0") { + expectedEventCount++; + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs( + exchangeId, + resellerPayoff.id, + offer.exchangeToken, + resellerPayoff.payoff, + rando.address + ); + } + } + + // Make sure exact number of FundsReleased events was emitted + const eventCount = (await tx.wait()).logs.filter((e) => e.eventName == "FundsReleased").length; + expect(eventCount).to.equal(expectedEventCount); + + // protocol + if (protocolPayoff != "0") { + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offer.exchangeToken, protocolPayoff, rando.address); + } else { + await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); + } + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expectedResellersAvailableFunds = new Array(resellerPayoffs.length).fill(new FundsList([])); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + + // Expire the dispute, so the funds are released + await disputeHandler.connect(rando).expireDispute(exchangeId); + + // Available funds should be increased for + // buyer: 0 + // seller: sellerDeposit + price - protocolFee - agentFee + royalties + // resellers: difference between the secondary price and immediate payout + // protocol: protocolFee + // agent: 0 + expectedSellerAvailableFunds.funds.push(new Funds(mockTokenAddress, "Foreign20", sellerPayoff)); + if (protocolPayoff != "0") { + expectedProtocolAvailableFunds.funds.push( + new Funds(mockTokenAddress, "Foreign20", protocolPayoff) + ); + } + expectedResellersAvailableFunds = resellerPayoffs.map((r) => { + return new FundsList(r.payoff != "0" ? [new Funds(mockTokenAddress, "Foreign20", r.payoff)] : []); + }); + + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + }); + }); + + context("Final state DISPUTED - RESOLVED", async function () { + let resellerPayoffs; + beforeEach(async function () { + buyerPercentBasisPoints = "5566"; // 55.66% + const sellerPercentBasisPoints = 10000 - parseInt(buyerPercentBasisPoints); // 44.34% + + // expected payoffs + // last buyer: (price + sellerDeposit)*buyerPercentage + buyerPayoff = applyPercentage( + BigInt(offer.price) + BigInt(offer.sellerDeposit), + buyerPercentBasisPoints + ); + + // resellers: difference between the secondary price and immediate payout + resellerPayoffs = payoutInformation.map((pi) => { + const diff = pi.reducedSecondaryPrice - BigInt(pi.previousPrice); + const payoff = + diff > 0n + ? applyPercentage(diff, sellerPercentBasisPoints) + : applyPercentage(diff * -1n, buyerPercentBasisPoints); + return { id: pi.buyerId, payoff }; + }); + + // seller: sellerDeposit + price + royalties + const initialFee = applyPercentage(offer.price, "0"); + sellerPayoff = ( + BigInt(offer.sellerDeposit) + + BigInt(offer.price) - + BigInt(buyerPayoff) + + BigInt(applyPercentage(totalRoyalties, sellerPercentBasisPoints)) + ).toString(); + + // protocol: protocolFee (only secondary market) + protocolPayoff = applyPercentage(totalProtocolFee + BigInt(initialFee), sellerPercentBasisPoints); + + // Set the message Type, needed for signature + resolutionType = [ + { name: "exchangeId", type: "uint256" }, + { name: "buyerPercentBasisPoints", type: "uint256" }, + ]; + + customSignatureType = { + Resolution: resolutionType, + }; + + message = { + exchangeId: exchangeId, + buyerPercentBasisPoints, + }; + + // Collect the signature components + ({ r, s, v } = await prepareDataSignatureParameters( + voucherOwner, // Assistant is the caller, seller should be the signer. + customSignatureType, + "Resolution", + message, + await disputeHandler.getAddress() + )); + }); + + it("should emit a FundsReleased event", async function () { + // Resolve the dispute, expecting event + const tx = await disputeHandler + .connect(assistant) + .resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); + + // seller + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistant.address); + + // buyer + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); + + // resellers + let expectedEventCount = 2; // 1 for seller, 1 for buyer + for (const resellerPayoff of resellerPayoffs) { + if (resellerPayoff.payoff != "0") { + expectedEventCount++; + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs( + exchangeId, + resellerPayoff.id, + offer.exchangeToken, + resellerPayoff.payoff, + assistant.address + ); + } + } + + // Make sure exact number of FundsReleased events was emitted + const eventCount = (await tx.wait()).logs.filter((e) => e.eventName == "FundsReleased").length; + expect(eventCount).to.equal(expectedEventCount); + + // protocol + if (protocolPayoff != "0") { + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offer.exchangeToken, protocolPayoff, assistant.address); + } else { + await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); + } + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expectedResellersAvailableFunds = new Array(resellerPayoffs.length).fill(new FundsList([])); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + + // Resolve the dispute, so the funds are released + await disputeHandler + .connect(assistant) + .resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); + + // Available funds should be increased for + // buyer: (price + sellerDeposit)*buyerPercentage + // seller: (price + sellerDeposit)*(1-buyerPercentage) + // resellers: (difference between the secondary price and immediate payout)*(1-buyerPercentage) + // protocol: protocolFee (secondary market only) + // agent: 0 + expectedSellerAvailableFunds.funds.push(new Funds(mockTokenAddress, "Foreign20", sellerPayoff)); + expectedBuyerAvailableFunds = new FundsList([ + new Funds(mockTokenAddress, "Foreign20", buyerPayoff), + ]); + if (protocolPayoff != "0") { + expectedProtocolAvailableFunds.funds.push( + new Funds(mockTokenAddress, "Foreign20", protocolPayoff) + ); + } + expectedResellersAvailableFunds = resellerPayoffs.map((r) => { + return new FundsList(r.payoff != "0" ? [new Funds(mockTokenAddress, "Foreign20", r.payoff)] : []); + }); + + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + }); + }); + + context("Final state DISPUTED - ESCALATED - RETRACTED", async function () { + let resellerPayoffs; + beforeEach(async function () { + // expected payoffs + // last buyer: 0 + buyerPayoff = 0; + + // resellers: difference between the secondary price and immediate payout + resellerPayoffs = payoutInformation.map((pi) => { + return { + id: pi.buyerId, + payoff: (pi.reducedSecondaryPrice - BigInt(pi.immediatePayout)).toString(), + }; + }); + + // seller: sellerDeposit + price - protocolFee + royalties + const initialFee = applyPercentage(offer.price, fee.protocol); + sellerPayoff = ( + BigInt(offer.sellerDeposit) + + BigInt(offer.price) + + BigInt(totalRoyalties) - + BigInt(initialFee) + ).toString(); + + // protocol: protocolFee + protocolPayoff = (totalProtocolFee + BigInt(initialFee)).toString(); + + // Escalate the dispute + await disputeHandler.connect(voucherOwner).escalateDispute(exchangeId); + }); + + it("should emit a FundsReleased event", async function () { + // Retract from the dispute, expecting event + const tx = await disputeHandler.connect(voucherOwner).retractDispute(exchangeId); + + // seller + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, voucherOwner.address); + + // resellers + let expectedEventCount = 1; // 1 for seller + for (const resellerPayoff of resellerPayoffs) { + if (resellerPayoff.payoff != "0") { + expectedEventCount++; + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs( + exchangeId, + resellerPayoff.id, + offer.exchangeToken, + resellerPayoff.payoff, + voucherOwner.address + ); + } + } + + // Make sure exact number of FundsReleased events was emitted + const eventCount = (await tx.wait()).logs.filter((e) => e.eventName == "FundsReleased").length; + expect(eventCount).to.equal(expectedEventCount); + + // protocol + if (protocolPayoff != "0") { + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offer.exchangeToken, protocolPayoff, voucherOwner.address); + } else { + await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); + } + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expectedResellersAvailableFunds = new Array(resellerPayoffs.length).fill(new FundsList([])); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + + // Retract from the dispute, so the funds are released + await disputeHandler.connect(voucherOwner).retractDispute(exchangeId); + + // Available funds should be increased for + // buyer: 0 + // seller: sellerDeposit + price - protocolFee - agentFee + royalties + // resellers: difference between the secondary price and immediate payout + // protocol: protocolFee + // agent: 0 + expectedSellerAvailableFunds.funds.push(new Funds(mockTokenAddress, "Foreign20", sellerPayoff)); + if (protocolPayoff != "0") { + expectedProtocolAvailableFunds.funds.push( + new Funds(mockTokenAddress, "Foreign20", protocolPayoff) + ); + } + expectedResellersAvailableFunds = resellerPayoffs.map((r) => { + return new FundsList(r.payoff != "0" ? [new Funds(mockTokenAddress, "Foreign20", r.payoff)] : []); + }); + + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + }); + }); + + context("Final state DISPUTED - ESCALATED - RESOLVED", async function () { + let resellerPayoffs; + beforeEach(async function () { + buyerPercentBasisPoints = "5566"; // 55.66% + const sellerPercentBasisPoints = 10000 - parseInt(buyerPercentBasisPoints); // 44.34% + + // expected payoffs + // last buyer: (price + sellerDeposit)*buyerPercentage + buyerPayoff = applyPercentage( + BigInt(offer.price) + BigInt(offer.sellerDeposit), + buyerPercentBasisPoints + ); + + // resellers: difference between the secondary price and immediate payout + resellerPayoffs = payoutInformation.map((pi) => { + const diff = pi.reducedSecondaryPrice - BigInt(pi.previousPrice); + const payoff = + diff > 0n + ? applyPercentage(diff, sellerPercentBasisPoints) + : applyPercentage(diff * -1n, buyerPercentBasisPoints); + return { id: pi.buyerId, payoff }; + }); + + // seller: (sellerDeposit + price + royalties)*(1-buyerPercentage) + const initialFee = applyPercentage(offer.price, "0"); + sellerPayoff = ( + BigInt(offer.sellerDeposit) + + BigInt(offer.price) - + BigInt(buyerPayoff) + + BigInt(applyPercentage(totalRoyalties, sellerPercentBasisPoints)) + ).toString(); + + // protocol: protocolFee *(1-buyerPercentage) + protocolPayoff = applyPercentage(totalProtocolFee + BigInt(initialFee), sellerPercentBasisPoints); + + // Set the message Type, needed for signature + resolutionType = [ + { name: "exchangeId", type: "uint256" }, + { name: "buyerPercentBasisPoints", type: "uint256" }, + ]; + + customSignatureType = { + Resolution: resolutionType, + }; + + message = { + exchangeId: exchangeId, + buyerPercentBasisPoints, + }; + + // Collect the signature components + ({ r, s, v } = await prepareDataSignatureParameters( + voucherOwner, // Assistant is the caller, seller should be the signer. + customSignatureType, + "Resolution", + message, + await disputeHandler.getAddress() + )); + + // Escalate the dispute + await disputeHandler.connect(voucherOwner).escalateDispute(exchangeId); + }); + + it("should emit a FundsReleased event", async function () { + // Resolve the dispute, expecting event + const tx = await disputeHandler + .connect(assistant) + .resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); + + // seller + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistant.address); + + // buyer + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); + + // resellers + let expectedEventCount = 2; // 1 for seller, 1 for buyer + for (const resellerPayoff of resellerPayoffs) { + if (resellerPayoff.payoff != "0") { + expectedEventCount++; + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs( + exchangeId, + resellerPayoff.id, + offer.exchangeToken, + resellerPayoff.payoff, + assistant.address + ); + } + } + + // Make sure exact number of FundsReleased events was emitted + const eventCount = (await tx.wait()).logs.filter((e) => e.eventName == "FundsReleased").length; + expect(eventCount).to.equal(expectedEventCount); + + // protocol + if (protocolPayoff != "0") { + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offer.exchangeToken, protocolPayoff, assistant.address); + } else { + await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); + } + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expectedResellersAvailableFunds = new Array(resellerPayoffs.length).fill(new FundsList([])); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + + // Resolve the dispute, so the funds are released + await disputeHandler + .connect(assistant) + .resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); + + // Available funds should be increased for + // buyer: (price + sellerDeposit)*buyerPercentage + // seller: (price + sellerDeposit + royalties)*(1-buyerPercentage) + // resellers: (difference between the secondary price and immediate payout)*(1-buyerPercentage) + // protocol: protocolFee *(1-buyerPercentage) + // agent: 0 + expectedSellerAvailableFunds.funds.push(new Funds(mockTokenAddress, "Foreign20", sellerPayoff)); + expectedBuyerAvailableFunds = new FundsList([ + new Funds(mockTokenAddress, "Foreign20", buyerPayoff), + ]); + if (protocolPayoff != "0") { + expectedProtocolAvailableFunds.funds.push( + new Funds(mockTokenAddress, "Foreign20", protocolPayoff) + ); + } + expectedResellersAvailableFunds = resellerPayoffs.map((r) => { + return new FundsList(r.payoff != "0" ? [new Funds(mockTokenAddress, "Foreign20", r.payoff)] : []); + }); + + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + }); + }); + + context("Final state DISPUTED - ESCALATED - DECIDED", async function () { + let resellerPayoffs; + beforeEach(async function () { + buyerPercentBasisPoints = "4321"; // 43.21% + const sellerPercentBasisPoints = 10000 - parseInt(buyerPercentBasisPoints); // 44.34% + + // expected payoffs + // last buyer: (price + sellerDeposit)*buyerPercentage + buyerPayoff = applyPercentage( + BigInt(offer.price) + BigInt(offer.sellerDeposit), + buyerPercentBasisPoints + ); + + // resellers: difference between the secondary price and immediate payout + resellerPayoffs = payoutInformation.map((pi) => { + const diff = pi.reducedSecondaryPrice - BigInt(pi.previousPrice); + const payoff = + diff > 0n + ? applyPercentage(diff, sellerPercentBasisPoints) + : applyPercentage(diff * -1n, buyerPercentBasisPoints); + return { id: pi.buyerId, payoff }; + }); + + // seller: (sellerDeposit + price + royalties)*(1-buyerPercentage) + const initialFee = applyPercentage(offer.price, "0"); + sellerPayoff = ( + BigInt(offer.sellerDeposit) + + BigInt(offer.price) - + BigInt(buyerPayoff) + + BigInt(applyPercentage(totalRoyalties, sellerPercentBasisPoints)) + ).toString(); + + // protocol: protocolFee*(1-buyerPercentage) + protocolPayoff = applyPercentage(totalProtocolFee + BigInt(initialFee), sellerPercentBasisPoints); + + // Escalate the dispute + await disputeHandler.connect(voucherOwner).escalateDispute(exchangeId); + }); + + it("should emit a FundsReleased event", async function () { + // Decide the dispute, expecting event + const tx = await disputeHandler + .connect(assistantDR) + .decideDispute(exchangeId, buyerPercentBasisPoints); + + // seller + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistantDR.address); + + // buyer + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistantDR.address); + + // resellers + let expectedEventCount = 2; // 1 for seller, 1 for buyer + for (const resellerPayoff of resellerPayoffs) { + if (resellerPayoff.payoff != "0") { + expectedEventCount++; + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs( + exchangeId, + resellerPayoff.id, + offer.exchangeToken, + resellerPayoff.payoff, + assistantDR.address + ); + } + } + + // Make sure exact number of FundsReleased events was emitted + const eventCount = (await tx.wait()).logs.filter((e) => e.eventName == "FundsReleased").length; + expect(eventCount).to.equal(expectedEventCount); + + // protocol + if (protocolPayoff != "0") { + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offer.exchangeToken, protocolPayoff, assistantDR.address); + } else { + await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); + } + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expectedResellersAvailableFunds = new Array(resellerPayoffs.length).fill(new FundsList([])); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + + // Decide the dispute, so the funds are released + await disputeHandler.connect(assistantDR).decideDispute(exchangeId, buyerPercentBasisPoints); + + // Available funds should be increased for + // buyer: (price + sellerDeposit)*buyerPercentage + // seller: (price + sellerDeposit + royalties)*(1-buyerPercentage) + // resellers: (difference between the secondary price and immediate payout)*(1-buyerPercentage) + // protocol: protocolFee *(1-buyerPercentage) + // agent: 0 + expectedSellerAvailableFunds.funds.push(new Funds(mockTokenAddress, "Foreign20", sellerPayoff)); + expectedBuyerAvailableFunds = new FundsList([ + new Funds(mockTokenAddress, "Foreign20", buyerPayoff), + ]); + if (protocolPayoff != "0") { + expectedProtocolAvailableFunds.funds.push( + new Funds(mockTokenAddress, "Foreign20", protocolPayoff) + ); + } + expectedResellersAvailableFunds = resellerPayoffs.map((r) => { + return new FundsList(r.payoff != "0" ? [new Funds(mockTokenAddress, "Foreign20", r.payoff)] : []); + }); + + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + }); + }); + + context( + "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)", + async function () { + let resellerPayoffs; + beforeEach(async function () { + // expected payoffs + // last buyer: price + buyerEscalationDeposit + buyerPayoff = (BigInt(offer.price) + BigInt(buyerEscalationDeposit)).toString(); + + // resellers: difference between original price and immediate payoff + resellerPayoffs = payoutInformation.map((pi) => { + return { id: pi.buyerId, payoff: (pi.previousPrice - BigInt(pi.immediatePayout)).toString() }; + }); + + // seller: sellerDeposit + sellerPayoff = offer.sellerDeposit; + + // protocol: 0 + protocolPayoff = 0; + + // Escalate the dispute + tx = await disputeHandler.connect(voucherOwner).escalateDispute(exchangeId); + + // Get the block timestamp of the confirmed tx and set escalatedDate + blockNumber = tx.blockNumber; + block = await ethers.provider.getBlock(blockNumber); + escalatedDate = block.timestamp.toString(); + + await setNextBlockTimestamp( + Number(escalatedDate) + Number(disputeResolver.escalationResponsePeriod) + 1 + ); + }); + + it("should emit a FundsReleased event", async function () { + // Expire the dispute, expecting event + const tx = await disputeHandler.connect(rando).expireEscalatedDispute(exchangeId); + + // seller + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, rando.address); + + // buyer + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, rando.address); + + // resellers + let expectedEventCount = 2; // 1 for seller, 1 for buyer + for (const resellerPayoff of resellerPayoffs) { + if (resellerPayoff.payoff != "0") { + expectedEventCount++; + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs( + exchangeId, + resellerPayoff.id, + offer.exchangeToken, + resellerPayoff.payoff, + rando.address + ); + } + } + + // Make sure exact number of FundsReleased events was emitted + const eventCount = (await tx.wait()).logs.filter((e) => e.eventName == "FundsReleased").length; + expect(eventCount).to.equal(expectedEventCount); + + // protocol + await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct( + await fundsHandler.getAllAvailableFunds(protocolId) + ); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expectedResellersAvailableFunds = new Array(resellerPayoffs.length).fill(new FundsList([])); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + + // Expire the escalated dispute, so the funds are released + await disputeHandler.connect(rando).expireEscalatedDispute(exchangeId); + + // Available funds should be increased for + // buyer: price + buyerEscalationDeposit + // seller: sellerDeposit + // resellers: difference between the secondary price and immediate payout + // protocol: 0 + // agent: 0 + expectedBuyerAvailableFunds.funds[0] = new Funds(mockTokenAddress, "Foreign20", buyerPayoff); + expectedSellerAvailableFunds.funds.push(new Funds(mockTokenAddress, "Foreign20", sellerPayoff)); + expectedResellersAvailableFunds = resellerPayoffs.map((r) => { + return new FundsList( + r.payoff != "0" ? [new Funds(mockTokenAddress, "Foreign20", r.payoff)] : [] + ); + }); + + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct( + await fundsHandler.getAllAvailableFunds(protocolId) + ); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + }); + } + ); + + context( + "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)", + async function () { + let resellerPayoffs; + beforeEach(async function () { + // expected payoffs + // last buyer: price + buyerEscalationDeposit + buyerPayoff = (BigInt(offer.price) + BigInt(buyerEscalationDeposit)).toString(); + + // resellers: difference between original price and immediate payoff + resellerPayoffs = payoutInformation.map((pi) => { + return { id: pi.buyerId, payoff: (pi.previousPrice - BigInt(pi.immediatePayout)).toString() }; + }); + + // seller: sellerDeposit + sellerPayoff = offer.sellerDeposit; + + // protocol: 0 + protocolPayoff = 0; + + // Escalate the dispute + await disputeHandler.connect(voucherOwner).escalateDispute(exchangeId); + }); + + it("should emit a FundsReleased event", async function () { + // Refuse the dispute, expecting event + const tx = await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); + + // seller + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistantDR.address); + + // buyer + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistantDR.address); + + // resellers + let expectedEventCount = 2; // 1 for seller, 1 for buyer + for (const resellerPayoff of resellerPayoffs) { + if (resellerPayoff.payoff != "0") { + expectedEventCount++; + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs( + exchangeId, + resellerPayoff.id, + offer.exchangeToken, + resellerPayoff.payoff, + assistantDR.address + ); + } + } + + // Make sure exact number of FundsReleased events was emitted + const eventCount = (await tx.wait()).logs.filter((e) => e.eventName == "FundsReleased").length; + expect(eventCount).to.equal(expectedEventCount); + + // protocol + await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct( + await fundsHandler.getAllAvailableFunds(protocolId) + ); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expectedResellersAvailableFunds = new Array(resellerPayoffs.length).fill(new FundsList([])); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + + // Refuse the escalated dispute, so the funds are released + await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); + + // Available funds should be increased for + // buyer: price + buyerEscalationDeposit + // seller: sellerDeposit + // resellers: difference between the secondary price and immediate payout + // protocol: 0 + // agent: 0 + expectedBuyerAvailableFunds.funds[0] = new Funds(mockTokenAddress, "Foreign20", buyerPayoff); + expectedSellerAvailableFunds.funds.push(new Funds(mockTokenAddress, "Foreign20", sellerPayoff)); + expectedResellersAvailableFunds = resellerPayoffs.map((r) => { + return new FundsList( + r.payoff != "0" ? [new Funds(mockTokenAddress, "Foreign20", r.payoff)] : [] + ); + }); + + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct( + await fundsHandler.getAllAvailableFunds(protocolId) + ); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAllAvailableFunds(agentId)); + resellersAvailableFunds = ( + await Promise.all(resellerPayoffs.map((r) => fundsHandler.getAllAvailableFunds(r.id))) + ).map((returnedValue) => FundsList.fromStruct(returnedValue)); + + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(resellersAvailableFunds).to.eql(expectedResellersAvailableFunds); + }); + } + ); + }); + }); + }); + + context("Changing the protocol fee and royalties", async function () { + let voucherOwner, previousPrice; + let payoutInformation; + let totalRoyalties, totalProtocolFee; + let resellerPayoffs; + + beforeEach(async function () { + payoutInformation = []; + + const fees = [ + { protocol: 100, royalties: 50 }, + { protocol: 400, royalties: 200 }, + { protocol: 300, royalties: 300 }, + { protocol: 700, royalties: 100 }, + ]; + + let feeIndex = 0; + let fee = fees[feeIndex]; + + // set fees + const expectedCloneAddress = calculateCloneAddress( + await accountHandler.getAddress(), + beaconProxyAddress, + admin.address + ); + const bosonVoucherClone = await ethers.getContractAt("IBosonVoucher", expectedCloneAddress); + await configHandler.setProtocolFeePercentage(fee.protocol); + await bosonVoucherClone.connect(assistant).setRoyaltyPercentage(fee.royalties); + + // create a new offer + offer = offerToken.clone(); + offer.id = "3"; + offer.price = "100"; + offer.sellerDeposit = "10"; + offer.buyerCancelPenalty = "30"; + + // deposit to seller's pool + await fundsHandler.connect(assistant).withdrawFunds(seller.id, [], []); // withdraw all, so it's easier to test + await mockToken.connect(assistant).mint(assistant.address, offer.sellerDeposit); + await mockToken.connect(assistant).approve(await fundsHandler.getAddress(), offer.sellerDeposit); + await fundsHandler + .connect(assistant) + .depositFunds(seller.id, await mockToken.getAddress(), offer.sellerDeposit); + + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, 0); + + // ids + exchangeId = "1"; + agentId = "3"; + buyerId = 5; + + // Create buyer with protocol address to not mess up ids in tests + await accountHandler.createBuyer(mockBuyer(await exchangeHandler.getAddress())); + + // commit to offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id); + + voucherOwner = buyer; // voucherOwner is the first buyer + previousPrice = BigInt(offer.price); + totalRoyalties = 0n; + totalProtocolFee = 0n; + for (const trade of buyerChains[direction]) { + feeIndex++; + fee = fees[feeIndex]; + + // set new fee + await configHandler.setProtocolFeePercentage(fee.protocol); + await bosonVoucherClone.connect(assistant).setRoyaltyPercentage(fee.royalties); + + // Prepare calldata for PriceDiscovery contract + const tokenId = deriveTokenId(offer.id, exchangeId); + let order = { + seller: voucherOwner.address, + buyer: trade.buyer.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: offer.exchangeToken, + price: BigInt(trade.price), + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [ + order, + ]); + + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + const priceDiscovery = new PriceDiscovery( + order.price, + Side.Ask, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // voucher owner approves protocol to transfer the tokens + await mockToken.mint(voucherOwner.address, order.price); + await mockToken.connect(voucherOwner).approve(protocolDiamondAddress, order.price); + + // Voucher owner approves PriceDiscovery contract to transfer the tokens + await bosonVoucherClone.connect(voucherOwner).setApprovalForAll(priceDiscoveryContractAddress, true); + + // Buyer approves protocol to transfer the tokens + await mockToken.mint(trade.buyer.address, order.price); + await mockToken.connect(trade.buyer).approve(protocolDiamondAddress, order.price); + + // commit to offer + await sequentialCommitHandler + .connect(trade.buyer) + .sequentialCommitToOffer(trade.buyer.address, tokenId, priceDiscovery, { + gasPrice: 0, + }); + + // Fees, royalties and immediate payout + const royalties = applyPercentage(order.price, fee.royalties); + const protocolFee = applyPercentage(order.price, fee.protocol); + const reducedSecondaryPrice = order.price - BigInt(royalties) - BigInt(protocolFee); + const immediatePayout = reducedSecondaryPrice <= previousPrice ? reducedSecondaryPrice : previousPrice; + payoutInformation.push({ buyerId: buyerId++, immediatePayout, previousPrice, reducedSecondaryPrice }); + + // Total royalties and fees + totalRoyalties = totalRoyalties + BigInt(royalties); + totalProtocolFee = totalProtocolFee + BigInt(protocolFee); + + voucherOwner = trade.buyer; // last buyer is voucherOwner in next iteration + previousPrice = order.price; + } + + // expected payoffs + // buyer: 0 + buyerPayoff = 0; + + // resellers: difference between the secondary price and immediate payout + resellerPayoffs = payoutInformation.map((pi) => { + return { id: pi.buyerId, payoff: (pi.reducedSecondaryPrice - BigInt(pi.immediatePayout)).toString() }; + }); + + // seller: sellerDeposit + price - protocolFee + royalties + const initialFee = applyPercentage(offer.price, fees[0].protocol); + sellerPayoff = ( + BigInt(offer.sellerDeposit) + + BigInt(offer.price) + + BigInt(totalRoyalties) - + BigInt(initialFee) + ).toString(); + + // protocol: protocolFee + protocolPayoff = (totalProtocolFee + BigInt(initialFee)).toString(); + }); + + it("Fees and royalties should be the same as at the commit time", async function () { + // set the new protocol fee + protocolFeePercentage = "300"; // 3% + await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); + + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // succesfully redeem exchange + await exchangeHandler.connect(voucherOwner).redeemVoucher(exchangeId); + + // complete exchange + tx = await exchangeHandler.connect(voucherOwner).completeExchange(exchangeId); + + // seller + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, voucherOwner.address); + + // resellers + let expectedEventCount = 1; // 1 for seller + for (const resellerPayoff of resellerPayoffs) { + if (resellerPayoff.payoff != "0") { + expectedEventCount++; + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs( + exchangeId, + resellerPayoff.id, + offer.exchangeToken, + resellerPayoff.payoff, + voucherOwner.address + ); + } + } + + // Make sure exact number of FundsReleased events was emitted + const eventCount = (await tx.wait()).logs.filter((e) => e.eventName == "FundsReleased").length; + expect(eventCount).to.equal(expectedEventCount); + + // protocol + if (protocolPayoff != "0") { + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offer.exchangeToken, protocolPayoff, voucherOwner.address); + } else { + await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); + } + }); + }); + }); + }); + }); }); }); diff --git a/test/protocol/MetaTransactionsHandlerTest.js b/test/protocol/MetaTransactionsHandlerTest.js index db5723d27..d79a88bd7 100644 --- a/test/protocol/MetaTransactionsHandlerTest.js +++ b/test/protocol/MetaTransactionsHandlerTest.js @@ -3462,7 +3462,7 @@ describe("IBosonMetaTransactionsHandler", function () { message.from = await assistant.getAddress(); message.contractAddress = await offerHandler.getAddress(); message.functionName = - "createOffer((uint256,uint256,uint256,uint256,uint256,uint256,address,string,string,bool,uint256),(uint256,uint256,uint256,uint256),(uint256,uint256,uint256),uint256,uint256)"; + "createOffer((uint256,uint256,uint256,uint256,uint256,uint256,address,string,string,bool,uint256,uint8),(uint256,uint256,uint256,uint256),(uint256,uint256,uint256),uint256,uint256)"; message.functionSignature = functionSignature; }); diff --git a/test/protocol/OfferHandlerTest.js b/test/protocol/OfferHandlerTest.js index 77df565b1..2e67cbe9c 100644 --- a/test/protocol/OfferHandlerTest.js +++ b/test/protocol/OfferHandlerTest.js @@ -1,5 +1,5 @@ -const hre = require("hardhat"); -const { getContractAt, ZeroAddress, getSigners, MaxUint256, provider, parseUnits } = hre.ethers; +const { ethers } = require("hardhat"); +const { getContractAt, ZeroAddress, getSigners, MaxUint256, provider, parseUnits } = ethers; const { assert, expect } = require("chai"); const Offer = require("../../scripts/domain/Offer"); @@ -222,7 +222,7 @@ describe("IBosonOfferHandler", function () { // Mock offer ({ offer, offerDates, offerDurations, offerFees } = await mockOffer()); - // Check if domais are valid + // Check if domains are valid expect(offer.isValid()).is.true; expect(offerDates.isValid()).is.true; expect(offerDurations.isValid()).is.true; diff --git a/test/protocol/OrchestrationHandlerTest.js b/test/protocol/OrchestrationHandlerTest.js index e8d776f70..0d2be6a07 100644 --- a/test/protocol/OrchestrationHandlerTest.js +++ b/test/protocol/OrchestrationHandlerTest.js @@ -391,13 +391,17 @@ describe("IBosonOrchestrationHandler", function () { .withArgs(exchangeId, buyerId, seller.id, await buyer.getAddress()); }); - it("should emit a DisputeEscalated event", async function () { - // Raise and Escalate a dispute, testing for the event - await expect( - orchestrationHandler - .connect(buyer) - .raiseAndEscalateDispute(exchangeId, { value: buyerEscalationDepositNative }) - ) + it("should emit FundsEncumbered and DisputeEscalated event", async function () { + // Raise and Escalate a dispute, testing for the events + const tx = await orchestrationHandler + .connect(buyer) + .raiseAndEscalateDispute(exchangeId, { value: buyerEscalationDepositNative }); + + await expect(tx) + .to.emit(disputeHandler, "FundsEncumbered") + .withArgs(buyerId, ZeroAddress, buyerEscalationDepositNative, await buyer.getAddress()); + + await expect(tx) .to.emit(disputeHandler, "DisputeEscalated") .withArgs(exchangeId, disputeResolverId, await buyer.getAddress()); }); @@ -456,8 +460,14 @@ describe("IBosonOrchestrationHandler", function () { // Protocol balance before const escrowBalanceBefore = await mockToken.balanceOf(protocolDiamondAddress); - // Escalate the dispute, testing for the event - await expect(orchestrationHandler.connect(buyer).raiseAndEscalateDispute(exchangeId)) + // Escalate the dispute, testing for the events + const tx = await orchestrationHandler.connect(buyer).raiseAndEscalateDispute(exchangeId); + + await expect(tx) + .to.emit(disputeHandler, "FundsEncumbered") + .withArgs(buyerId, await mockToken.getAddress(), buyerEscalationDepositToken, await buyer.getAddress()); + + await expect(tx) .to.emit(disputeHandler, "DisputeEscalated") .withArgs(exchangeId, disputeResolverId, await buyer.getAddress()); diff --git a/test/protocol/PauseHandlerTest.js b/test/protocol/PauseHandlerTest.js index f6cab7712..565cfedc7 100644 --- a/test/protocol/PauseHandlerTest.js +++ b/test/protocol/PauseHandlerTest.js @@ -1,6 +1,6 @@ -const hre = require("hardhat"); +const { ethers } = require("hardhat"); const { expect } = require("chai"); -const { id } = hre.ethers; +const { id } = ethers; const { getStorageAt } = require("@nomicfoundation/hardhat-network-helpers"); const PausableRegion = require("../../scripts/domain/PausableRegion.js"); const { getInterfaceIds } = require("../../scripts/config/supported-interfaces.js"); diff --git a/test/protocol/PriceDiscoveryHandlerFacet.js b/test/protocol/PriceDiscoveryHandlerFacet.js new file mode 100644 index 000000000..d2a44c867 --- /dev/null +++ b/test/protocol/PriceDiscoveryHandlerFacet.js @@ -0,0 +1,1268 @@ +const { ethers } = require("hardhat"); +const { ZeroAddress, getContractFactory, parseUnits, provider, getContractAt } = ethers; +const { expect } = require("chai"); + +const Exchange = require("../../scripts/domain/Exchange"); +const PriceDiscovery = require("../../scripts/domain/PriceDiscovery"); +const Side = require("../../scripts/domain/Side"); +const PriceType = require("../../scripts/domain/PriceType.js"); +const { DisputeResolverFee } = require("../../scripts/domain/DisputeResolverFee"); +const PausableRegion = require("../../scripts/domain/PausableRegion.js"); +const { FundsList } = require("../../scripts/domain/Funds"); +const { getInterfaceIds } = require("../../scripts/config/supported-interfaces.js"); +const { RevertReasons } = require("../../scripts/config/revert-reasons.js"); +const { deployMockTokens } = require("../../scripts/util/deploy-mock-tokens"); +const { + mockOffer, + mockDisputeResolver, + mockAuthToken, + mockVoucherInitValues, + mockSeller, + mockVoucher, + mockExchange, + mockBuyer, + accountId, +} = require("../util/mock"); +const { + setNextBlockTimestamp, + calculateVoucherExpiry, + calculateBosonProxyAddress, + calculateCloneAddress, + applyPercentage, + setupTestEnvironment, + getSnapshot, + revertToSnapshot, + deriveTokenId, + getCurrentBlockAndSetTimeForward, +} = require("../util/utils.js"); +const { oneWeek, oneMonth } = require("../util/constants"); + +/** + * Test the Boson Price Discovery Handler interface + */ +describe("IPriceDiscoveryHandlerFacet", function () { + // Common vars + let InterfaceIds; + let pauser, assistant, admin, treasury, rando, buyer, assistantDR, adminDR, treasuryDR; + let erc165, + accountHandler, + exchangeHandler, + offerHandler, + fundsHandler, + pauseHandler, + configHandler, + priceDiscoveryHandler; + let bosonVoucherClone; + let offerId, seller, disputeResolverId; + let block, tx; + let support; + let price, sellerPool; + let voucherRedeemableFrom; + let voucherValid; + let protocolFeePercentage; + let voucher; + let exchange; + let disputeResolver, disputeResolverFees; + let expectedCloneAddress; + let voucherInitValues; + let emptyAuthToken; + let agentId; + let exchangeId; + let offer, offerFees; + let offerDates, offerDurations; + let weth; + let protocolDiamondAddress; + let snapshotId; + let priceDiscoveryContract; + let tokenId; + let bosonVoucher; + + before(async function () { + accountId.next(true); + + // get interface Ids + InterfaceIds = await getInterfaceIds(); + + // Add WETH + const wethFactory = await getContractFactory("WETH9"); + weth = await wethFactory.deploy(); + await weth.waitForDeployment(); + + // Specify contracts needed for this test + const contracts = { + erc165: "ERC165Facet", + accountHandler: "IBosonAccountHandler", + offerHandler: "IBosonOfferHandler", + exchangeHandler: "IBosonExchangeHandler", + fundsHandler: "IBosonFundsHandler", + configHandler: "IBosonConfigHandler", + pauseHandler: "IBosonPauseHandler", + priceDiscoveryHandler: "IBosonPriceDiscoveryHandler", + }; + + ({ + signers: [pauser, admin, treasury, buyer, rando, adminDR, treasuryDR], + contractInstances: { + erc165, + accountHandler, + offerHandler, + exchangeHandler, + fundsHandler, + configHandler, + pauseHandler, + priceDiscoveryHandler, + }, + protocolConfig: [, , { percentage: protocolFeePercentage }], + diamondAddress: protocolDiamondAddress, + } = await setupTestEnvironment(contracts, { wethAddress: await weth.getAddress() })); + + // make all account the same + assistant = admin; + assistantDR = adminDR; + + // Deploy PriceDiscovery contract + const PriceDiscoveryFactory = await getContractFactory("PriceDiscovery"); + priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); + await priceDiscoveryContract.waitForDeployment(); + + // Get snapshot id + snapshotId = await getSnapshot(); + }); + + afterEach(async function () { + await revertToSnapshot(snapshotId); + snapshotId = await getSnapshot(); + }); + + // Interface support (ERC-156 provided by ProtocolDiamond, others by waitForDeployment facets) + context("📋 Interfaces", async function () { + context("👉 supportsInterface()", async function () { + it("should indicate support for IPriceDiscoveryHandlerFacet interface", async function () { + // Current interfaceId for IBosonPriceDiscoveryHandler + support = await erc165.supportsInterface(InterfaceIds.IBosonPriceDiscoveryHandler); + + // Test + expect(support, "PriceDiscoveryHandlerFacet interface not supported").is.true; + }); + }); + }); + + // All supported Price discovery methods + context("📋 Price discovery Methods", async function () { + beforeEach(async function () { + // Initial ids for all the things + exchangeId = offerId = "1"; + agentId = "0"; // agent id is optional while creating an offer + + // Create a valid seller + seller = mockSeller(assistant.address, admin.address, ZeroAddress, treasury.address); + expect(seller.isValid()).is.true; + + // AuthToken + emptyAuthToken = mockAuthToken(); + expect(emptyAuthToken.isValid()).is.true; + + // VoucherInitValues + voucherInitValues = mockVoucherInitValues(); + expect(voucherInitValues.isValid()).is.true; + + await accountHandler.connect(admin).createSeller(seller, emptyAuthToken, voucherInitValues); + + const beaconProxyAddress = await calculateBosonProxyAddress(protocolDiamondAddress); + expectedCloneAddress = calculateCloneAddress(protocolDiamondAddress, beaconProxyAddress, admin.address); + + // Create a valid dispute resolver + disputeResolver = mockDisputeResolver( + assistantDR.address, + adminDR.address, + ZeroAddress, + treasuryDR.address, + true + ); + expect(disputeResolver.isValid()).is.true; + + //Create DisputeResolverFee array so offer creation will succeed + disputeResolverFees = [new DisputeResolverFee(ZeroAddress, "Native", "0")]; + + // Make empty seller list, so every seller is allowed + const sellerAllowList = []; + + // Register the dispute resolver + await accountHandler + .connect(adminDR) + .createDisputeResolver(disputeResolver, disputeResolverFees, sellerAllowList); + + // Create the offer + const mo = await mockOffer(); + ({ offerDates, offerDurations } = mo); + offer = mo.offer; + offer.priceType = PriceType.Discovery; + offer.price = "0"; + offer.buyerCancelPenalty = "0"; + offerFees = mo.offerFees; + offerFees.protocolFee = applyPercentage(offer.price, protocolFeePercentage); + + offer.quantityAvailable = "10"; + disputeResolverId = mo.disputeResolverId; + + offerDurations.voucherValid = (oneMonth * 12n).toString(); + + // Check if domains are valid + expect(offer.isValid()).is.true; + expect(offerDates.isValid()).is.true; + expect(offerDurations.isValid()).is.true; + + // Create the offer, reserve range and premint vouchers + await offerHandler.connect(assistant).createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId); + await offerHandler.connect(assistant).reserveRange(offer.id, offer.quantityAvailable, assistant.address); + bosonVoucher = await getContractAt("BosonVoucher", expectedCloneAddress); + await bosonVoucher.connect(assistant).preMint(offer.id, offer.quantityAvailable); + await bosonVoucher.connect(assistant).setApprovalForAll(await priceDiscoveryContract.getAddress(), true); + + // Set used variables + voucherRedeemableFrom = offerDates.voucherRedeemableFrom; + voucherValid = offerDurations.voucherValid; + sellerPool = parseUnits("15", "ether").toString(); + + // Required voucher constructor params + voucher = mockVoucher(); + voucher.redeemedDate = "0"; + + // Mock exchange + exchange = mockExchange(); + exchange.finalizedDate = "0"; + + // Deposit seller funds so the commit will succeed + await fundsHandler.connect(assistant).depositFunds(seller.id, ZeroAddress, sellerPool, { value: sellerPool }); + }); + + afterEach(async function () { + // Reset the accountId iterator + accountId.next(true); + }); + + context("👉 commitToPriceDiscoveryOffer()", async function () { + let priceDiscovery; + let newBuyer; + + context("Ask order", async function () { + let order; + beforeEach(async function () { + // Price on secondary market + price = 100n; + tokenId = deriveTokenId(offer.id, exchangeId); + + // Prepare calldata for PriceDiscovery contract + order = { + seller: assistant.address, + buyer: buyer.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: offer.exchangeToken, + price: price, + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [order]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + + priceDiscovery = new PriceDiscovery( + price, + Side.Ask, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Approve transfers + // Buyer does not approve, since its in ETH. + // Seller approves price discovery to transfer the voucher + bosonVoucherClone = await getContractAt("IBosonVoucher", expectedCloneAddress); + await bosonVoucherClone.connect(buyer).setApprovalForAll(await priceDiscoveryContract.getAddress(), true); + + newBuyer = mockBuyer(buyer.address); + exchange.buyerId = newBuyer.id; + }); + + it("should emit FundsEncumbered and BuyerCommitted events", async function () { + // Commit to offer + tx = await priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }); + + // Get the block timestamp of the confirmed tx + block = await provider.getBlock(tx.blockNumber); + + // Update the committed date in the expected exchange struct with the block timestamp of the tx + voucher.committedDate = block.timestamp.toString(); + voucher.validUntilDate = calculateVoucherExpiry(block, voucherRedeemableFrom, voucherValid); + + // Test for events + // Seller deposit + await expect(tx) + .to.emit(priceDiscoveryHandler, "FundsEncumbered") + .withArgs(seller.id, ZeroAddress, offer.sellerDeposit, expectedCloneAddress); + + // Buyers funds - in ask order, they are taken from the seller deposit + await expect(tx) + .to.emit(priceDiscoveryHandler, "FundsEncumbered") + .withArgs(seller.id, ZeroAddress, price, buyer.address); + + await expect(tx) + .to.emit(priceDiscoveryHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), expectedCloneAddress); + }); + + it("should update state", async function () { + // Escrow amount before + const escrowBefore = await provider.getBalance(await priceDiscoveryHandler.getAddress()); + const buyerBefore = await provider.getBalance(buyer.address); + const { funds: sellerAvailableFundsBefore } = FundsList.fromStruct( + await fundsHandler.getAvailableFunds(seller.id, [ZeroAddress]) + ); + + // Commit to offer + await priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price, gasPrice: 0 }); + + // Get the exchange as a struct + const [, exchangeStruct] = await exchangeHandler.connect(rando).getExchange(exchangeId); + + // Parse into entity + let returnedExchange = Exchange.fromStruct(exchangeStruct); + expect(returnedExchange.buyerId).to.equal(newBuyer.id); + + // Contract's balance should stay the same (funds are only moved from the pool to the escrow) + const escrowAfter = await provider.getBalance(await priceDiscoveryHandler.getAddress()); + expect(escrowAfter).to.equal(escrowBefore); + + // Buyer's balance should decrease + const buyerAfter = await provider.getBalance(buyer.address); + expect(buyerAfter).to.equal(buyerBefore - price); + + // Seller's available funds should decrease for the amount of the seller deposit and the price + const { funds: sellerAvailableFundsAfter } = FundsList.fromStruct( + await fundsHandler.getAvailableFunds(seller.id, [ZeroAddress]) + ); + expect(BigInt(sellerAvailableFundsAfter[0].availableAmount)).to.equal( + BigInt(sellerAvailableFundsBefore[0].availableAmount) - BigInt(offer.sellerDeposit) - price + ); + }); + + it("should transfer the voucher", async function () { + // seller is owner of voucher + expect(await bosonVoucherClone.ownerOf(tokenId)).to.equal(assistant.address); + + // Commit to offer + await priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }); + + // buyer is owner of voucher + expect(await bosonVoucherClone.ownerOf(tokenId)).to.equal(buyer.address); + }); + + it("should not increment the next exchange id counter", async function () { + const nextExchangeIdBefore = await exchangeHandler.getNextExchangeId(); + + // Commit to offer, creating a new exchange + await priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }); + + // Get the next exchange id and ensure it was no incremented + const nextExchangeIdAfter = await exchangeHandler.getNextExchangeId(); + expect(nextExchangeIdAfter).to.equal(nextExchangeIdBefore); + }); + + it("Should not decrement quantityAvailable", async function () { + // Get quantityAvailable before + const [, { quantityAvailable: quantityAvailableBefore }] = await offerHandler + .connect(rando) + .getOffer(offerId); + + // Commit to offer, creating a new exchange + await priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }); + + // Get quantityAvailable after + const [, { quantityAvailable: quantityAvailableAfter }] = await offerHandler.connect(rando).getOffer(offerId); + + expect(quantityAvailableAfter).to.equal(quantityAvailableBefore, "Quantity available should be the same"); + }); + + it("It is possible to commit on someone else's behalf", async function () { + const buyerBefore = await provider.getBalance(buyer.address); + const callerBefore = await provider.getBalance(rando.address); + + // Commit to offer + tx = await priceDiscoveryHandler + .connect(rando) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price, gasPrice: 0 }); + + // Get the block timestamp of the confirmed tx + block = await provider.getBlock(tx.blockNumber); + + // Update the committed date in the expected exchange struct with the block timestamp of the tx + voucher.committedDate = block.timestamp.toString(); + voucher.validUntilDate = calculateVoucherExpiry(block, voucherRedeemableFrom, voucherValid); + + await expect(tx) + .to.emit(priceDiscoveryHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), expectedCloneAddress); + + // Buyer is owner of voucher, not rando + expect(await bosonVoucherClone.ownerOf(tokenId)).to.equal(buyer.address); + + // Buyer's balance should not change + const buyerAfter = await provider.getBalance(buyer.address); + expect(buyerAfter).to.equal(buyerBefore); + + // Caller's balance should decrease + const callerAfter = await provider.getBalance(rando.address); + expect(callerAfter).to.equal(callerBefore - price); + }); + + it("Works if the buyer provides offerId instead of tokenId", async function () { + // Commit to offer + tx = await priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, offer.id, priceDiscovery, { value: price }); + + // Get the block timestamp of the confirmed tx + block = await provider.getBlock(tx.blockNumber); + + // Update the committed date in the expected exchange struct with the block timestamp of the tx + voucher.committedDate = block.timestamp.toString(); + voucher.validUntilDate = calculateVoucherExpiry(block, voucherRedeemableFrom, voucherValid); + + // Test for events + // Seller deposit + await expect(tx) + .to.emit(priceDiscoveryHandler, "FundsEncumbered") + .withArgs(seller.id, ZeroAddress, offer.sellerDeposit, expectedCloneAddress); + + // Buyers funds - in ask order, they are taken from the seller deposit + await expect(tx) + .to.emit(priceDiscoveryHandler, "FundsEncumbered") + .withArgs(seller.id, ZeroAddress, price, buyer.address); + + await expect(tx) + .to.emit(priceDiscoveryHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), expectedCloneAddress); + }); + + context("💔 Revert Reasons", async function () { + it("The exchanges region of protocol is paused", async function () { + // Pause the exchanges region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.REGION_PAUSED); + }); + + it("The buyers region of protocol is paused", async function () { + // Pause the buyers region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Buyers]); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.REGION_PAUSED); + }); + + it("buyer address is the zero address", async function () { + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(ZeroAddress, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.INVALID_ADDRESS); + }); + + it("token id is invalid", async function () { + // An invalid token id + exchangeId = "666"; + tokenId = deriveTokenId(offer.id, exchangeId); + order.tokenId = tokenId; + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [order]); + priceDiscovery.priceDiscoveryData = priceDiscoveryData; + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.ERC721_INVALID_TOKEN_ID); + }); + + it("offer is voided", async function () { + // Void the offer first + await offerHandler.connect(assistant).voidOffer(offerId); + + // Attempt to commit to the voided offer, expecting revert + await expect( + priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.OFFER_HAS_BEEN_VOIDED); + }); + + it("offer is not yet available for commits", async function () { + // Create an offer with staring date in the future + // get current block timestamp + const block = await provider.getBlock("latest"); + + // set validFrom date in the past + offerDates.validFrom = (BigInt(block.timestamp) + oneMonth * 6n).toString(); // 6 months in the future + offerDates.validUntil = BigInt(offerDates.validFrom + 10).toString(); // just after the valid from so it succeeds. + + offer.id = "2"; + exchangeId = await exchangeHandler.getNextExchangeId(); + let tokenId = deriveTokenId(offer.id, exchangeId); + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId); + await offerHandler.connect(assistant).reserveRange(offer.id, offer.quantityAvailable, assistant.address); + await bosonVoucher.connect(assistant).preMint(offer.id, offer.quantityAvailable); + + // Attempt to commit to the not available offer, expecting revert + order.tokenId = tokenId; + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [order]); + priceDiscovery.priceDiscoveryData = priceDiscoveryData; + await expect( + priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.OFFER_NOT_AVAILABLE); + }); + + it("offer has expired", async function () { + // Go past offer expiration date + await setNextBlockTimestamp(Number(offerDates.validUntil) + 1); + + // Attempt to commit to the expired offer, expecting revert + await expect( + priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.OFFER_HAS_EXPIRED); + }); + + it.skip("offer sold", async function () { + // maybe for offers without explicit token id + }); + + it("protocol fees to high", async function () { + // Set protocol fees to 95% + await configHandler.setProtocolFeePercentage(9500); + // Set royalty fees to 6% + await bosonVoucherClone.connect(assistant).setRoyaltyPercentage(600); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.FEE_AMOUNT_TOO_HIGH); + }); + + it("insufficient values sent", async function () { + price = price - 1n; + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.INSUFFICIENT_VALUE_RECEIVED); + }); + + it("price discovery does not send the voucher anywhere", async function () { + // Deploy bad price discovery contract + const PriceDiscoveryFactory = await getContractFactory("PriceDiscoveryNoTransfer"); + const priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); + await priceDiscoveryContract.waitForDeployment(); + + // Prepare calldata for PriceDiscovery contract + tokenId = deriveTokenId(offer.id, exchangeId); + order.tokenId = tokenId; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [order]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + priceDiscovery = new PriceDiscovery( + price, + Side.Ask, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.TOKEN_ID_MISMATCH); + }); + + it("price discovery does not send the voucher to the protocol", async function () { + // Deploy bad price discovery contract + const PriceDiscoveryFactory = await getContractFactory("PriceDiscoveryTransferElsewhere"); + const priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); + await priceDiscoveryContract.waitForDeployment(); + await bosonVoucherClone + .connect(assistant) + .setApprovalForAll(await priceDiscoveryContract.getAddress(), true); + + // Prepare calldata for PriceDiscovery contract + tokenId = deriveTokenId(offer.id, exchangeId); + order.tokenId = tokenId; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrderElsewhere", [ + order, + ]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + priceDiscovery = new PriceDiscovery( + price, + Side.Ask, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.VOUCHER_NOT_RECEIVED); + }); + }); + }); + + context("Bid order", async function () { + let order; + beforeEach(async function () { + // Price market + price = 100n; + + // Prepare calldata for PriceDiscovery contract + tokenId = deriveTokenId(offer.id, exchangeId); + order = { + seller: await priceDiscoveryHandler.getAddress(), // since protocol owns the voucher, it acts as seller from price discovery mechanism + buyer: buyer.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: await weth.getAddress(), // buyer pays in ETH, but they cannot approve ETH, so we use WETH + price: price, + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilSellOrder", [order]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + + priceDiscovery = new PriceDiscovery( + price, + Side.Bid, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Approve transfers + // Buyer needs to approve price discovery to transfer the ETH + await weth.connect(buyer).deposit({ value: price }); + await weth.connect(buyer).approve(await priceDiscoveryContract.getAddress(), price); + + // Seller approves protocol to transfer the voucher + bosonVoucherClone = await getContractAt("IBosonVoucher", expectedCloneAddress); + await bosonVoucherClone.connect(assistant).setApprovalForAll(await priceDiscoveryHandler.getAddress(), true); + + newBuyer = mockBuyer(buyer.address); + exchange.buyerId = newBuyer.id; + }); + + it("should emit FundsEncumbered and BuyerCommitted events", async function () { + // Commit to offer, retrieving the event + const tx = await priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery); + + // Get the block timestamp of the confirmed tx + block = await provider.getBlock(tx.blockNumber); + + // Update the committed date in the expected exchange struct with the block timestamp of the tx + voucher.committedDate = block.timestamp.toString(); + voucher.validUntilDate = calculateVoucherExpiry(block, voucherRedeemableFrom, voucherValid); + + // Test for events + // Seller deposit + await expect(tx) + .to.emit(priceDiscoveryHandler, "FundsEncumbered") + .withArgs(seller.id, ZeroAddress, offer.sellerDeposit, expectedCloneAddress); + + // Buyers funds - in bid order, they are taken directly from the buyer + await expect(tx) + .to.emit(priceDiscoveryHandler, "FundsEncumbered") + .withArgs(newBuyer.id, ZeroAddress, price, assistant.address); + + await expect(tx) + .to.emit(priceDiscoveryHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), expectedCloneAddress); + }); + + it("should update state", async function () { + // Escrow amount before + const escrowBefore = await provider.getBalance(await exchangeHandler.getAddress()); + const buyerBefore = await weth.balanceOf(buyer.address); + const { funds: sellerAvailableFundsBefore } = FundsList.fromStruct( + await fundsHandler.getAvailableFunds(seller.id, [ZeroAddress]) + ); + + // Commit to offer + await priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery); + + // Get the exchange as a struct + const [, exchangeStruct] = await exchangeHandler.connect(rando).getExchange(exchangeId); + + // Parse into entity + let returnedExchange = Exchange.fromStruct(exchangeStruct); + expect(returnedExchange.buyerId).to.equal(newBuyer.id); + + // Contract's balance should increase for the amount of the price + const escrowAfter = await provider.getBalance(await exchangeHandler.getAddress()); + expect(escrowAfter).to.equal(escrowBefore + price); + + // Buyer's balance should decrease + const buyerAfter = await weth.balanceOf(buyer.address); + expect(buyerAfter).to.equal(buyerBefore - price); + + // Seller's available funds should decrease for the amount of the seller deposit + const { funds: sellerAvailableFundsAfter } = FundsList.fromStruct( + await fundsHandler.getAvailableFunds(seller.id, [ZeroAddress]) + ); + expect(BigInt(sellerAvailableFundsAfter[0].availableAmount)).to.equal( + BigInt(sellerAvailableFundsBefore[0].availableAmount) - BigInt(offer.sellerDeposit) + ); + }); + + it("should transfer the voucher", async function () { + // reseller is owner of voucher + expect(await bosonVoucherClone.ownerOf(tokenId)).to.equal(assistant.address); + + // Commit to offer + await priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery); + + // buyer2 is owner of voucher + expect(await bosonVoucherClone.ownerOf(tokenId)).to.equal(buyer.address); + }); + + it("should not increment the next exchange id counter", async function () { + const nextExchangeIdBefore = await exchangeHandler.connect(rando).getNextExchangeId(); + + // Commit to offer, creating a new exchange + await priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery); + + // Get the next exchange id and ensure it was incremented + const nextExchangeIdAfter = await exchangeHandler.connect(rando).getNextExchangeId(); + expect(nextExchangeIdAfter).to.equal(nextExchangeIdBefore); + }); + + it("Should not decrement quantityAvailable", async function () { + // Get quantityAvailable before + const [, { quantityAvailable: quantityAvailableBefore }] = await offerHandler + .connect(rando) + .getOffer(offerId); + + // Commit to offer, creating a new exchange + await priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery); + + // Get quantityAvailable after + const [, { quantityAvailable: quantityAvailableAfter }] = await offerHandler.connect(rando).getOffer(offerId); + + expect(quantityAvailableAfter).to.equal(quantityAvailableBefore, "Quantity available should be the same"); + }); + + context("💔 Revert Reasons", async function () { + it("The exchanges region of protocol is paused", async function () { + // Pause the exchanges region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.REGION_PAUSED); + }); + + it("The buyers region of protocol is paused", async function () { + // Pause the buyers region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Buyers]); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.REGION_PAUSED); + }); + + it("buyer address is the zero address", async function () { + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler.connect(assistant).commitToPriceDiscoveryOffer(ZeroAddress, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.INVALID_ADDRESS); + }); + + it("offer id is invalid", async function () { + // An invalid token id + offerId = "666"; + tokenId = deriveTokenId(offerId, exchangeId); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.NO_SUCH_OFFER); + }); + + it("token id is invalid", async function () { + // An invalid token id + exchangeId = "666"; + tokenId = deriveTokenId(offer.id, exchangeId); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.ERC721_INVALID_TOKEN_ID); + }); + + it("offer is voided", async function () { + // Void the offer first + await offerHandler.connect(assistant).voidOffer(offerId); + + // Attempt to commit to the voided offer, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.OFFER_HAS_BEEN_VOIDED); + }); + + it("offer is not yet available for commits", async function () { + // Create an offer with staring date in the future + // get current block timestamp + const block = await provider.getBlock("latest"); + + // set validFrom date in the past + offerDates.validFrom = (BigInt(block.timestamp) + oneMonth * 6n).toString(); // 6 months in the future + offerDates.validUntil = BigInt(offerDates.validFrom + 10).toString(); // just after the valid from so it succeeds. + + offer.id = "2"; + exchangeId = await exchangeHandler.getNextExchangeId(); + let tokenId = deriveTokenId(offer.id, exchangeId); + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId); + await offerHandler.connect(assistant).reserveRange(offer.id, offer.quantityAvailable, assistant.address); + await bosonVoucher.connect(assistant).preMint(offer.id, offer.quantityAvailable); + + // Attempt to commit to the not available offer, expecting revert + order.tokenId = tokenId; + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilSellOrder", [order]); + priceDiscovery.priceDiscoveryData = priceDiscoveryData; + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.OFFER_NOT_AVAILABLE); + }); + + it("offer has expired", async function () { + // Go past offer expiration date + await setNextBlockTimestamp(Number(offerDates.validUntil) + 1); + + // Attempt to commit to the expired offer, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.OFFER_HAS_EXPIRED); + }); + + it.skip("offer sold", async function () { + // maybe for offers without explicit token id + }); + + it("protocol fees to high", async function () { + // Set protocol fees to 95% + await configHandler.setProtocolFeePercentage(9500); + // Set royalty fees to 6% + await bosonVoucherClone.connect(assistant).setRoyaltyPercentage(600); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.FEE_AMOUNT_TOO_HIGH); + }); + + it("voucher transfer not approved", async function () { + // revoke approval + await bosonVoucherClone.connect(assistant).setApprovalForAll(await exchangeHandler.getAddress(), false); + + // Attempt to commit to, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.ERC721_CALLER_NOT_OWNER_OR_APPROVED); + }); + + it("price discovery sends less than expected", async function () { + // Set higher price in price discovery + priceDiscovery.price = BigInt(priceDiscovery.price) + 1n; + + // Attempt to commit to, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.INSUFFICIENT_VALUE_RECEIVED); + }); + + it("Only seller can call, if side is bid", async function () { + // Commit to offer, retrieving the event + await expect( + priceDiscoveryHandler.connect(rando).commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.NOT_VOUCHER_HOLDER); + }); + }); + }); + + context("Wrapped voucher", async function () { + const MASK = (1n << 128n) - 1n; + context("Mock auction", async function () { + let tokenId, mockAuction, amount, auctionId; + + beforeEach(async function () { + // 1. Deploy Mock Auction + const MockAuctionFactory = await getContractFactory("MockAuction"); + mockAuction = await MockAuctionFactory.deploy(await weth.getAddress()); + + tokenId = deriveTokenId(offer.id, 2); + }); + + it("Transfer can't happens outside protocol", async function () { + // 2. Set approval for all + await bosonVoucher.connect(assistant).setApprovalForAll(await mockAuction.getAddress(), true); + + // 3. Create an auction + const tokenContract = await bosonVoucher.getAddress(); + const auctionCurrency = offer.exchangeToken; + const curator = ZeroAddress; + + await mockAuction.connect(assistant).createAuction(tokenId, tokenContract, auctionCurrency, curator); + + // 4. Bid + auctionId = 0; + amount = 10; + await mockAuction.connect(buyer).createBid(auctionId, amount, { value: amount }); + + // Set time forward + await getCurrentBlockAndSetTimeForward(oneWeek); + + // Zora should be the owner of the token + expect(await bosonVoucher.ownerOf(tokenId)).to.equal(await mockAuction.getAddress()); + + // safe transfer from will fail on onPremintedTransferredHook and transaction should fail + await expect(mockAuction.connect(rando).endAuction(auctionId)).to.be.revertedWith( + RevertReasons.VOUCHER_TRANSFER_NOT_ALLOWED + ); + + // Exchange doesn't exist + const exchangeId = tokenId & MASK; + const [exist, ,] = await exchangeHandler.getExchange(exchangeId); + + expect(exist).to.equal(false); + }); + + context("Works with Zora auction wrapper", async function () { + let wrappedBosonVoucher; + + beforeEach(async function () { + // 2. Create wrapped voucher + const wrappedBosonVoucherFactory = await ethers.getContractFactory("ZoraWrapper"); + wrappedBosonVoucher = await wrappedBosonVoucherFactory + .connect(assistant) + .deploy( + await bosonVoucher.getAddress(), + await mockAuction.getAddress(), + await exchangeHandler.getAddress(), + await weth.getAddress() + ); + + // 3. Wrap voucher + await bosonVoucher.connect(assistant).setApprovalForAll(await wrappedBosonVoucher.getAddress(), true); + await wrappedBosonVoucher.connect(assistant).wrap(tokenId); + + // 4. Create an auction + const tokenContract = await wrappedBosonVoucher.getAddress(); + const curator = assistant.address; + const auctionCurrency = offer.exchangeToken; + + await mockAuction.connect(assistant).createAuction(tokenId, tokenContract, auctionCurrency, curator); + + auctionId = 0; + }); + + it("Auction ends normally", async function () { + // 5. Bid + const amount = 10; + + await mockAuction.connect(buyer).createBid(auctionId, amount, { value: amount }); + + // 6. End auction + await getCurrentBlockAndSetTimeForward(oneWeek); + await mockAuction.connect(assistant).endAuction(auctionId); + + expect(await wrappedBosonVoucher.ownerOf(tokenId)).to.equal(buyer.address); + expect(await weth.balanceOf(await wrappedBosonVoucher.getAddress())).to.equal(amount); + + // 7. Commit to offer + const calldata = wrappedBosonVoucher.interface.encodeFunctionData("unwrap", [tokenId]); + const priceDiscovery = new PriceDiscovery( + amount, + Side.Wrapper, + await wrappedBosonVoucher.getAddress(), + await wrappedBosonVoucher.getAddress(), + calldata + ); + + const protocolBalanceBefore = await provider.getBalance(await exchangeHandler.getAddress()); + + const tx = await priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery); + const { timestamp } = await provider.getBlock(tx.blockNumber); + + expect(await bosonVoucher.ownerOf(tokenId)).to.equal(buyer.address); + expect(await provider.getBalance(await exchangeHandler.getAddress())).to.equal( + protocolBalanceBefore + BigInt(amount) + ); + + const exchangeId = tokenId & MASK; + const [, , voucher] = await exchangeHandler.getExchange(exchangeId); + + expect(voucher.committedDate).to.equal(timestamp); + }); + + it("Cancel auction", async function () { + // 6. Cancel auction + await mockAuction.connect(assistant).cancelAuction(auctionId); + + // 7. Unwrap token + const protocolBalanceBefore = await provider.getBalance(await exchangeHandler.getAddress()); + await wrappedBosonVoucher.connect(assistant).unwrap(tokenId); + + expect(await bosonVoucher.ownerOf(tokenId)).to.equal(assistant.address); + expect(await provider.getBalance(await exchangeHandler.getAddress())).to.equal(protocolBalanceBefore); + + const exchangeId = tokenId & MASK; + const [exists, , voucher] = await exchangeHandler.getExchange(exchangeId); + + expect(exists).to.equal(false); + expect(voucher.committedDate).to.equal(0); + }); + + it("Cancel auction and unwrap via commitToPriceDiscoveryOffer", async function () { + // How sensible is this scenario? Should it be prevented? + + // 6. Cancel auction + await mockAuction.connect(assistant).cancelAuction(auctionId); + + // 7. Unwrap token via commitToOffer + const protocolBalanceBefore = await provider.getBalance(await exchangeHandler.getAddress()); + + const calldata = wrappedBosonVoucher.interface.encodeFunctionData("unwrap", [tokenId]); + const priceDiscovery = new PriceDiscovery( + 0, + Side.Wrapper, + await wrappedBosonVoucher.getAddress(), + await wrappedBosonVoucher.getAddress(), + calldata + ); + const tx = await priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(assistant.address, tokenId, priceDiscovery); + const { timestamp } = await provider.getBlock(tx.blockNumber); + + expect(await bosonVoucher.ownerOf(tokenId)).to.equal(assistant.address); + expect(await provider.getBalance(await exchangeHandler.getAddress())).to.equal(protocolBalanceBefore); + + const exchangeId = tokenId & MASK; + const [exists, , voucher] = await exchangeHandler.getExchange(exchangeId); + + expect(exists).to.equal(true); + expect(voucher.committedDate).to.equal(timestamp); + }); + }); + }); + }); + }); + + context("👉 onERC721Received()", async function () { + let priceDiscoveryContract, priceDiscovery; + + beforeEach(async function () { + // Price + price = 100n; + + // Approve transfers + // Buyer does not approve, since its in ETH. + // Seller approves price discovery to transfer the voucher + bosonVoucherClone = await getContractAt("IBosonVoucher", expectedCloneAddress); + }); + + context("💔 Revert Reasons", async function () { + it("Correct caller, wrong id", async function () { + // Deploy Bad PriceDiscovery contract + const PriceDiscoveryFactory = await getContractFactory("PriceDiscoveryModifyTokenId"); + priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); + await priceDiscoveryContract.waitForDeployment(); + + // Prepare calldata for PriceDiscovery contract + tokenId = deriveTokenId(offer.id, exchangeId); + let order = { + seller: assistant.address, + buyer: buyer.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: offer.exchangeToken, + price: price, + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [order]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + + // Seller approves price discovery to transfer the voucher + await bosonVoucherClone.connect(assistant).setApprovalForAll(await priceDiscoveryContract.getAddress(), true); + + priceDiscovery = new PriceDiscovery( + price, + Side.Ask, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.TOKEN_ID_MISMATCH); + }); + + it("Correct token id, wrong caller", async function () { + // Deploy mock erc721 contract + const [foreign721] = await deployMockTokens(["Foreign721"]); + + // Deploy Bad PriceDiscovery contract + const PriceDiscoveryFactory = await getContractFactory("PriceDiscoveryModifyVoucherContract"); + priceDiscoveryContract = await PriceDiscoveryFactory.deploy(await foreign721.getAddress()); + await priceDiscoveryContract.waitForDeployment(); + + // Prepare calldata for PriceDiscovery contract + tokenId = deriveTokenId(offer.id, exchangeId); + let order = { + seller: assistant.address, + buyer: buyer.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: offer.exchangeToken, + price: price, + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [order]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + + // Seller approves price discovery to transfer the voucher + await bosonVoucherClone.connect(assistant).setApprovalForAll(await priceDiscoveryContract.getAddress(), true); + + priceDiscovery = new PriceDiscovery( + price, + Side.Ask, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(buyer) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.UNEXPECTED_ERC721_RECEIVED); + }); + }); + }); + + context("👉 onPremintedVoucherTransferred()", async function () { + context("💔 Revert Reasons", async function () { + it("Only the initial owner can transfer the preminted voucher without starting the commit", async function () { + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + + // Transfer a preminted voucher to the price discovery contract + // Make sure it does not trigger the commit + const tokenId = deriveTokenId(offer.id, exchangeId); + await expect( + bosonVoucher.connect(assistant).transferFrom(assistant.address, priceDiscoveryContractAddress, tokenId) + ).to.not.emit(priceDiscoveryHandler, "BuyerCommitted"); + + // Call fulfilBuyOrder, which transfers the voucher to the buyer, expect revert + const order = { + seller: priceDiscoveryContractAddress, + buyer: buyer.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: offer.exchangeToken, + price: "0", + }; + + await expect(priceDiscoveryContract.fulfilBuyOrder(order)).to.be.revertedWith( + RevertReasons.VOUCHER_TRANSFER_NOT_ALLOWED + ); + }); + + it("The preminted voucher cannot be transferred to EOA without starting the commit", async function () { + // Transfer a preminted voucher to rando EOA and expect revert + // Make sure it does not trigger the commit + const tokenId = deriveTokenId(offer.id, exchangeId); + await expect( + bosonVoucher.connect(assistant).transferFrom(assistant.address, rando.address, tokenId) + ).to.be.revertedWith(RevertReasons.VOUCHER_TRANSFER_NOT_ALLOWED); + }); + }); + }); + }); +}); diff --git a/test/protocol/ProtocolDiamondTest.js b/test/protocol/ProtocolDiamondTest.js index 8be65e4f6..f19d05806 100644 --- a/test/protocol/ProtocolDiamondTest.js +++ b/test/protocol/ProtocolDiamondTest.js @@ -1,6 +1,6 @@ const { assert, expect } = require("chai"); -const hre = require("hardhat"); -const { getSigners, getContractAt, getContractFactory, Interface, ZeroAddress } = hre.ethers; +const { ethers } = require("hardhat"); +const { getSigners, getContractAt, getContractFactory, Interface, ZeroAddress } = ethers; const Role = require("../../scripts/domain/Role"); const Facet = require("../../scripts/domain/Facet"); diff --git a/test/protocol/ProtocolInitializationHandlerTest.js b/test/protocol/ProtocolInitializationHandlerTest.js index 43609c528..4a2c78562 100644 --- a/test/protocol/ProtocolInitializationHandlerTest.js +++ b/test/protocol/ProtocolInitializationHandlerTest.js @@ -683,6 +683,7 @@ describe("ProtocolInitializationHandler", async function () { voucherBeacon: await beacon.getAddress(), }; + let doPreprocess = true; // Due to "hardhat-preprocessor" way of caching, we need a workaround to toggle preprocessing on and off // Make initial deployment (simulate v2.2.1) // The new config initialization deploys the same voucher proxy as initV2_3_0, which makes the initV2_3_0 test fail // One way to approach would be to checkout the contracts from the previous tag. @@ -690,17 +691,19 @@ describe("ProtocolInitializationHandler", async function () { hre.config.preprocess = { eachLine: () => ({ transform: (line) => { - if ( - line.includes("address beaconProxy = address(new BeaconClientProxy{ salt: VOUCHER_PROXY_SALT }());") - ) { - // comment out the proxy deployment - line = "//" + line; - } else if (line.includes("setBeaconProxyAddress(beaconProxy)")) { - // set beacon proxy from config, not the deployed one - line = line.replace( - "setBeaconProxyAddress(beaconProxy)", - "setBeaconProxyAddress(_addresses.beaconProxy)" - ); + if (doPreprocess) { + if ( + line.includes("address beaconProxy = address(new BeaconClientProxy{ salt: VOUCHER_PROXY_SALT }());") + ) { + // comment out the proxy deployment + line = "//" + line; + } else if (line.includes("setBeaconProxyAddress(beaconProxy)")) { + // set beacon proxy from config, not the deployed one + line = line.replace( + "setBeaconProxyAddress(beaconProxy)", + "setBeaconProxyAddress(_addresses.beaconProxy)" + ); + } } return line; }, @@ -724,10 +727,9 @@ describe("ProtocolInitializationHandler", async function () { await accountHandler.connect(rando).createSeller(seller, emptyAuthToken, voucherInitValues); // Deploy v2.3.0 facets - // Remove preprocess - hre.config.preprocess = {}; - // Compile old version - await hre.run("compile"); + // Skip preprocessing and compile new version + doPreprocess = false; + await hre.run("compile", { force: true }); [{ contract: deployedProtocolInitializationHandlerFacet }, { contract: configHandler }] = await deployProtocolFacets( diff --git a/test/protocol/SequentialCommitHandlerTest.js b/test/protocol/SequentialCommitHandlerTest.js new file mode 100644 index 000000000..e643c7280 --- /dev/null +++ b/test/protocol/SequentialCommitHandlerTest.js @@ -0,0 +1,1588 @@ +const { ethers } = require("hardhat"); +const { ZeroAddress, getContractFactory, getSigners, parseUnits, provider, getContractAt } = ethers; +const { expect } = require("chai"); + +const Exchange = require("../../scripts/domain/Exchange"); +const Voucher = require("../../scripts/domain/Voucher"); +const PriceDiscovery = require("../../scripts/domain/PriceDiscovery"); +const Side = require("../../scripts/domain/Side"); +const { DisputeResolverFee } = require("../../scripts/domain/DisputeResolverFee"); +const PausableRegion = require("../../scripts/domain/PausableRegion.js"); +const { getInterfaceIds } = require("../../scripts/config/supported-interfaces.js"); +const { RevertReasons } = require("../../scripts/config/revert-reasons.js"); +const { deployMockTokens } = require("../../scripts/util/deploy-mock-tokens"); +const { + mockOffer, + mockDisputeResolver, + mockAuthToken, + mockVoucherInitValues, + mockSeller, + mockVoucher, + mockExchange, + mockBuyer, + accountId, +} = require("../util/mock"); +const { + setNextBlockTimestamp, + calculateVoucherExpiry, + calculateBosonProxyAddress, + calculateCloneAddress, + applyPercentage, + setupTestEnvironment, + getSnapshot, + revertToSnapshot, + deriveTokenId, +} = require("../util/utils.js"); +const { oneMonth } = require("../util/constants"); + +/** + * Test the Boson Sequential Commit Handler interface + */ +describe("IBosonSequentialCommitHandler", function () { + // Common vars + let InterfaceIds; + let deployer, pauser, assistant, admin, treasury, rando, buyer, buyer2, assistantDR, adminDR, treasuryDR; + let erc165, + accountHandler, + exchangeHandler, + offerHandler, + fundsHandler, + pauseHandler, + configHandler, + sequentialCommitHandler; + let bosonVoucherClone; + let buyerId, offerId, seller, disputeResolverId; + let block, blockNumber, tx; + let support; + let price, sellerPool; + let voucherRedeemableFrom; + let voucherValid; + let protocolFeePercentage; + let voucher; + let exchange; + let disputeResolver, disputeResolverFees; + let expectedCloneAddress; + let voucherInitValues; + let emptyAuthToken; + let agentId; + let exchangeId; + let offer, offerFees; + let offerDates, offerDurations; + let weth; + let protocolDiamondAddress; + let snapshotId; + let priceDiscoveryContract; + let tokenId; + + before(async function () { + accountId.next(true); + + // get interface Ids + InterfaceIds = await getInterfaceIds(); + + // Add WETH + const wethFactory = await getContractFactory("WETH9"); + weth = await wethFactory.deploy(); + await weth.waitForDeployment(); + + // Specify contracts needed for this test + const contracts = { + erc165: "ERC165Facet", + accountHandler: "IBosonAccountHandler", + offerHandler: "IBosonOfferHandler", + exchangeHandler: "IBosonExchangeHandler", + fundsHandler: "IBosonFundsHandler", + configHandler: "IBosonConfigHandler", + pauseHandler: "IBosonPauseHandler", + sequentialCommitHandler: "IBosonSequentialCommitHandler", + }; + + ({ + signers: [pauser, admin, treasury, buyer, buyer2, rando, adminDR, treasuryDR], + contractInstances: { + erc165, + accountHandler, + offerHandler, + exchangeHandler, + fundsHandler, + configHandler, + pauseHandler, + sequentialCommitHandler, + }, + protocolConfig: [, , { percentage: protocolFeePercentage }], + diamondAddress: protocolDiamondAddress, + } = await setupTestEnvironment(contracts, { wethAddress: await weth.getAddress() })); + + // make all account the same + assistant = admin; + assistantDR = adminDR; + + [deployer] = await getSigners(); + + // Deploy PriceDiscovery contract + const PriceDiscoveryFactory = await getContractFactory("PriceDiscovery"); + priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); + await priceDiscoveryContract.waitForDeployment(); + + // Get snapshot id + snapshotId = await getSnapshot(); + }); + + afterEach(async function () { + await revertToSnapshot(snapshotId); + snapshotId = await getSnapshot(); + }); + + // Interface support (ERC-156 provided by ProtocolDiamond, others by waitForDeployment facets) + context("📋 Interfaces", async function () { + context("👉 supportsInterface()", async function () { + it("should indicate support for IBosonSequentialCommitHandler interface", async function () { + // Current interfaceId for IBosonSequentialCommitHandler + support = await erc165.supportsInterface(InterfaceIds.IBosonSequentialCommitHandler); + + // Test + expect(support, "IBosonSequentialCommitHandler interface not supported").is.true; + }); + }); + }); + + // All supported Sequential commit methods + context("📋 Sequential Commit Methods", async function () { + beforeEach(async function () { + // Initial ids for all the things + exchangeId = offerId = "1"; + agentId = "0"; // agent id is optional while creating an offer + + // Create a valid seller + seller = mockSeller(assistant.address, admin.address, ZeroAddress, treasury.address); + expect(seller.isValid()).is.true; + + // AuthToken + emptyAuthToken = mockAuthToken(); + expect(emptyAuthToken.isValid()).is.true; + + // VoucherInitValues + voucherInitValues = mockVoucherInitValues(); + expect(voucherInitValues.isValid()).is.true; + + await accountHandler.connect(admin).createSeller(seller, emptyAuthToken, voucherInitValues); + + const beaconProxyAddress = await calculateBosonProxyAddress(protocolDiamondAddress); + expectedCloneAddress = calculateCloneAddress(protocolDiamondAddress, beaconProxyAddress, admin.address); + + // Create a valid dispute resolver + disputeResolver = mockDisputeResolver( + assistantDR.address, + adminDR.address, + ZeroAddress, + treasuryDR.address, + true + ); + expect(disputeResolver.isValid()).is.true; + + //Create DisputeResolverFee array so offer creation will succeed + disputeResolverFees = [new DisputeResolverFee(ZeroAddress, "Native", "0")]; + + // Make empty seller list, so every seller is allowed + const sellerAllowList = []; + + // Register the dispute resolver + await accountHandler + .connect(adminDR) + .createDisputeResolver(disputeResolver, disputeResolverFees, sellerAllowList); + + // Create the offer + const mo = await mockOffer(); + ({ offerDates, offerDurations } = mo); + offer = mo.offer; + offerFees = mo.offerFees; + offerFees.protocolFee = applyPercentage(offer.price, protocolFeePercentage); + + offer.quantityAvailable = "10"; + disputeResolverId = mo.disputeResolverId; + + offerDurations.voucherValid = (oneMonth * 12n).toString(); + + // Check if domains are valid + expect(offer.isValid()).is.true; + expect(offerDates.isValid()).is.true; + expect(offerDurations.isValid()).is.true; + + // Create the offer + await offerHandler.connect(assistant).createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId); + + // Set used variables + price = BigInt(offer.price); + voucherRedeemableFrom = offerDates.voucherRedeemableFrom; + voucherValid = offerDurations.voucherValid; + sellerPool = parseUnits("15", "ether").toString(); + + // Required voucher constructor params + voucher = mockVoucher(); + voucher.redeemedDate = "0"; + + // Mock exchange + exchange = mockExchange(); + + buyerId = accountId.next().value; + exchange.buyerId = buyerId; + exchange.finalizedDate = "0"; + + // Deposit seller funds so the commit will succeed + await fundsHandler.connect(assistant).depositFunds(seller.id, ZeroAddress, sellerPool, { value: sellerPool }); + }); + + afterEach(async function () { + // Reset the accountId iterator + accountId.next(true); + }); + + context("👉 sequentialCommitToOffer()", async function () { + let priceDiscovery, price2; + let newBuyer; + let reseller, resellerId; // for clarity in tests + + beforeEach(async function () { + // Commit to offer with first buyer + tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); + + // Get the block timestamp of the confirmed tx + blockNumber = tx.blockNumber; + block = await provider.getBlock(blockNumber); + + // Update the committed date in the expected exchange struct with the block timestamp of the tx + voucher.committedDate = block.timestamp.toString(); + + // Update the validUntilDate date in the expected exchange struct + voucher.validUntilDate = calculateVoucherExpiry(block, voucherRedeemableFrom, voucherValid); + + reseller = buyer; + resellerId = buyerId; + }); + + context("Ask order", async function () { + context("General actions", async function () { + beforeEach(async function () { + // Price on secondary market + price2 = (BigInt(price) * 11n) / 10n; // 10% above the original price + tokenId = deriveTokenId(offer.id, exchangeId); + + // Prepare calldata for PriceDiscovery contract + let order = { + seller: buyer.address, + buyer: buyer2.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: offer.exchangeToken, + price: price2, + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [order]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + + priceDiscovery = new PriceDiscovery( + price2, + Side.Ask, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Seller needs to deposit weth in order to fill the escrow at the last step + // Price2 is theoretically the highest amount needed, in practice it will be less (around price2-price) + await weth.connect(buyer).deposit({ value: price2 }); + await weth.connect(buyer).approve(protocolDiamondAddress, price2); + + // Approve transfers + // Buyer does not approve, since its in ETH. + // Seller approves price discovery to transfer the voucher + bosonVoucherClone = await getContractAt("IBosonVoucher", expectedCloneAddress); + await bosonVoucherClone.connect(buyer).setApprovalForAll(await priceDiscoveryContract.getAddress(), true); + + mockBuyer(buyer.address); // call only to increment account id counter + newBuyer = mockBuyer(buyer2.address); + exchange.buyerId = newBuyer.id; + }); + + it("should emit FundsEncumbered, FundsReleased, FundsWithdrawn and BuyerCommitted events", async function () { + // Sequential commit to offer, retrieving the event + const tx = sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }); + + await expect(tx) + .to.emit(sequentialCommitHandler, "FundsEncumbered") + .withArgs(newBuyer.id, ZeroAddress, price2, buyer2.address); + + const immediatePayout = BigInt(price); + await expect(tx) + .to.emit(sequentialCommitHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, ZeroAddress, immediatePayout, buyer2.address); + + await expect(tx) + .to.emit(sequentialCommitHandler, "FundsWithdrawn") + .withArgs(resellerId, reseller.address, ZeroAddress, immediatePayout, buyer2.address); + + await expect(tx) + .to.emit(sequentialCommitHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), buyer2.address); + }); + + it("should update state", async function () { + // Escrow amount before + const escrowBefore = await provider.getBalance(await exchangeHandler.getAddress()); + + // Sequential commit to offer + await sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }); + + // buyer2 is exchange.buyerId + // Get the exchange as a struct + const [, exchangeStruct] = await exchangeHandler.connect(rando).getExchange(exchangeId); + + // Parse into entity + let returnedExchange = Exchange.fromStruct(exchangeStruct); + expect(returnedExchange.buyerId).to.equal(newBuyer.id); + + // Contract's balance should increase for minimal escrow amount + const escrowAfter = await provider.getBalance(await exchangeHandler.getAddress()); + expect(escrowAfter).to.equal(escrowBefore + price2 - price); + }); + + it("should transfer the voucher", async function () { + // buyer is owner of voucher + const tokenId = deriveTokenId(offer.id, exchangeId); + expect(await bosonVoucherClone.connect(buyer).ownerOf(tokenId)).to.equal(buyer.address); + + // Sequential commit to offer + await sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }); + + // buyer2 is owner of voucher + expect(await bosonVoucherClone.connect(buyer2).ownerOf(tokenId)).to.equal(buyer2.address); + }); + + it("voucher should remain unchanged", async function () { + // Voucher before + let [, , voucherStruct] = await exchangeHandler.connect(rando).getExchange(exchangeId); + let returnedVoucher = Voucher.fromStruct(voucherStruct); + expect(returnedVoucher).to.deep.equal(voucher); + + // Sequential commit to offer, creating a new exchange + await sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }); + + // Voucher after + [, , voucherStruct] = await exchangeHandler.connect(rando).getExchange(exchangeId); + returnedVoucher = Voucher.fromStruct(voucherStruct); + expect(returnedVoucher).to.deep.equal(voucher); + }); + + it("only new buyer can redeem voucher", async function () { + // Sequential commit to offer, creating a new exchange + await sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }); + + // Old buyer cannot redeem + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchangeId)).to.be.revertedWith( + RevertReasons.NOT_VOUCHER_HOLDER + ); + + // Redeem voucher, test for event + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + expect(await exchangeHandler.connect(buyer2).redeemVoucher(exchangeId)) + .to.emit(exchangeHandler, "VoucherRedeemed") + .withArgs(offerId, exchangeId, buyer2.address); + }); + + it("only new buyer can cancel voucher", async function () { + // Sequential commit to offer, creating a new exchange + await sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }); + + // Old buyer cannot redeem + await expect(exchangeHandler.connect(buyer).cancelVoucher(exchangeId)).to.be.revertedWith( + RevertReasons.NOT_VOUCHER_HOLDER + ); + + // Redeem voucher, test for event + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + expect(await exchangeHandler.connect(buyer2).cancelVoucher(exchangeId)) + .to.emit(exchangeHandler, "VoucherCanceled") + .withArgs(offerId, exchangeId, buyer2.address); + }); + + it("should not increment the next exchange id counter", async function () { + const nextExchangeIdBefore = await exchangeHandler.connect(rando).getNextExchangeId(); + + // Sequential commit to offer, creating a new exchange + await sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }); + + // Get the next exchange id and ensure it was incremented + const nextExchangeIdAfter = await exchangeHandler.connect(rando).getNextExchangeId(); + expect(nextExchangeIdAfter).to.equal(nextExchangeIdBefore); + }); + + it("Should not decrement quantityAvailable", async function () { + // Get quantityAvailable before + const [, { quantityAvailable: quantityAvailableBefore }] = await offerHandler + .connect(rando) + .getOffer(offerId); + + // Sequential commit to offer, creating a new exchange + await sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }); + + // Get quantityAvailable after + const [, { quantityAvailable: quantityAvailableAfter }] = await offerHandler + .connect(rando) + .getOffer(offerId); + + expect(quantityAvailableAfter).to.equal(quantityAvailableBefore, "Quantity available should be the same"); + }); + + it("It is possible to commit on someone else's behalf", async function () { + // Sequential commit to offer, retrieving the event + await expect( + sequentialCommitHandler + .connect(rando) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ) + .to.emit(sequentialCommitHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), rando.address); + + // buyer2 is owner of voucher, not rando + expect(await bosonVoucherClone.connect(buyer2).ownerOf(tokenId)).to.equal(buyer2.address); + }); + + it("It is possible to commit even if offer is voided", async function () { + // Void the offer + await offerHandler.connect(assistant).voidOffer(offerId); + + // Committing directly is not possible + await expect( + exchangeHandler.connect(buyer2).commitToOffer(buyer2.address, offerId, { value: price }) + ).to.revertedWith(RevertReasons.OFFER_HAS_BEEN_VOIDED); + + // Sequential commit to offer, retrieving the event + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ) + .to.emit(sequentialCommitHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), buyer2.address); + }); + + it("It is possible to commit even if redemption period has not started yet", async function () { + // Redemption not yet possible + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchangeId)).to.revertedWith( + RevertReasons.VOUCHER_NOT_REDEEMABLE + ); + + // Sequential commit to offer, retrieving the event + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ) + .to.emit(sequentialCommitHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), buyer2.address); + }); + + it("It is possible to commit even if offer has expired", async function () { + // Advance time to after offer expiry + await setNextBlockTimestamp(Number(offerDates.validUntil) + 1); + + // Committing directly is not possible + await expect( + exchangeHandler.connect(buyer2).commitToOffer(buyer2.address, offerId, { value: price }) + ).to.revertedWith(RevertReasons.OFFER_HAS_EXPIRED); + + // Sequential commit to offer, retrieving the event + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ) + .to.emit(sequentialCommitHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), buyer2.address); + }); + + it("It is possible to commit even if is sold out", async function () { + // Commit to all remaining quantity + for (let i = 1; i < offer.quantityAvailable; i++) { + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); + } + + // Committing directly is not possible + await expect( + exchangeHandler.connect(buyer2).commitToOffer(buyer2.address, offerId, { value: price }) + ).to.revertedWith(RevertReasons.OFFER_SOLD_OUT); + + // Sequential commit to offer, retrieving the event + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ) + .to.emit(sequentialCommitHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), buyer2.address); + }); + + context("💔 Revert Reasons", async function () { + it("The exchanges region of protocol is paused", async function () { + // Pause the exchanges region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); + + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ).to.revertedWith(RevertReasons.REGION_PAUSED); + }); + + it("The buyers region of protocol is paused", async function () { + // Pause the buyers region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Buyers]); + + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ).to.revertedWith(RevertReasons.REGION_PAUSED); + }); + + it("buyer address is the zero address", async function () { + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(ZeroAddress, exchangeId, priceDiscovery, { value: price2 }) + ).to.revertedWith(RevertReasons.INVALID_ADDRESS); + }); + + it("exchange id is invalid", async function () { + // An invalid exchange id + exchangeId = "666"; + tokenId = deriveTokenId(offer.id, exchangeId); + + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ).to.revertedWith(RevertReasons.NO_SUCH_EXCHANGE); + }); + + it("voucher not valid anymore", async function () { + // Go past offer expiration date + await setNextBlockTimestamp(Number(voucher.validUntilDate) + 1); + + // Attempt to sequentially commit to the expired voucher, expecting revert + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ).to.revertedWith(RevertReasons.VOUCHER_HAS_EXPIRED); + }); + + it("protocol fees to high", async function () { + // Set protocol fees to 95% + await configHandler.setProtocolFeePercentage(9500); + // Set royalty fees to 6% + await bosonVoucherClone.connect(assistant).setRoyaltyPercentage(600); + + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ).to.revertedWith(RevertReasons.FEE_AMOUNT_TOO_HIGH); + }); + + it("insufficient values sent", async function () { + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price }) + ).to.revertedWith(RevertReasons.INSUFFICIENT_VALUE_RECEIVED); + }); + + it("price discovery does not send the voucher anywhere", async function () { + // Deploy bad price discovery contract + const PriceDiscoveryFactory = await getContractFactory("PriceDiscoveryNoTransfer"); + const priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); + await priceDiscoveryContract.waitForDeployment(); + + // Prepare calldata for PriceDiscovery contract + tokenId = deriveTokenId(offer.id, exchangeId); + let order = { + seller: buyer.address, + buyer: buyer2.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: offer.exchangeToken, + price: price2, + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [order]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + priceDiscovery = new PriceDiscovery( + price2, + Side.Ask, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ).to.revertedWith(RevertReasons.TOKEN_ID_MISMATCH); + }); + + it("price discovery does not send the voucher to the protocol", async function () { + // Deploy bad price discovery contract + const PriceDiscoveryFactory = await getContractFactory("PriceDiscoveryTransferElsewhere"); + const priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); + await priceDiscoveryContract.waitForDeployment(); + await bosonVoucherClone.connect(buyer).setApprovalForAll(await priceDiscoveryContract.getAddress(), true); + + // Prepare calldata for PriceDiscovery contract + tokenId = deriveTokenId(offer.id, exchangeId); + let order = { + seller: buyer.address, + buyer: buyer2.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: offer.exchangeToken, + price: price2, + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData( + "fulfilBuyOrderElsewhere", + [order] + ); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + priceDiscovery = new PriceDiscovery( + price2, + Side.Ask, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ).to.revertedWith(RevertReasons.VOUCHER_NOT_RECEIVED); + }); + }); + }); + + context("Escrow amount", async function () { + let scenarios = [ + { case: "Increasing price", multiplier: 11 }, + { case: "Constant price", multiplier: 10 }, + { case: "Decreasing price", multiplier: 9 }, + ]; + + async function getBalances() { + const [protocol, seller, sellerWeth, newBuyer, originalSeller] = await Promise.all([ + provider.getBalance(await exchangeHandler.getAddress()), + provider.getBalance(buyer.address), + weth.balanceOf(buyer.address), + provider.getBalance(buyer2.address), + provider.getBalance(treasury.address), + ]); + + return { protocol, seller: seller + sellerWeth, newBuyer, originalSeller }; + } + + scenarios.forEach((scenario) => { + context(scenario.case, async function () { + beforeEach(async function () { + // Price on secondary market + price2 = (BigInt(price) * BigInt(scenario.multiplier)) / 10n; + + // Prepare calldata for PriceDiscovery contract + tokenId = deriveTokenId(offer.id, exchangeId); + let order = { + seller: buyer.address, + buyer: buyer2.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: offer.exchangeToken, + price: price2.toString(), + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [ + order, + ]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + priceDiscovery = new PriceDiscovery( + price2, + Side.Ask, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Seller needs to deposit weth in order to fill the escrow at the last step + // Price2 is theoretically the highest amount needed, in practice it will be less (around price2-price) + await weth.connect(buyer).deposit({ value: price2 }); // you don't need to approve whole amount, just what goes in escrow + await weth.connect(buyer).approve(protocolDiamondAddress, price2); + + // Approve transfers + // Buyer does not approve, since its in ETH. + // Seller approves price discovery to transfer the voucher + bosonVoucherClone = await getContractAt("IBosonVoucher", expectedCloneAddress); + await bosonVoucherClone + .connect(buyer) + .setApprovalForAll(await priceDiscoveryContract.getAddress(), true); + + mockBuyer(buyer.address); // call only to increment account id counter + newBuyer = mockBuyer(buyer2.address); + exchange.buyerId = newBuyer.id; + }); + + const fees = [ + { + protocol: 0, + royalties: 0, + }, + { + protocol: 500, + royalties: 0, + }, + { + protocol: 0, + royalties: 600, + }, + { + protocol: 300, + royalties: 400, // less than profit + }, + { + protocol: 500, + royalties: 700, // more than profit + }, + ]; + + fees.forEach((fee) => { + it(`protocol fee: ${fee.protocol / 100}%; royalties: ${fee.royalties / 100}%`, async function () { + await configHandler.setProtocolFeePercentage(fee.protocol); + await bosonVoucherClone.connect(assistant).setRoyaltyPercentage(fee.royalties); + + const balancesBefore = await getBalances(); + + // Sequential commit to offer + await sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { + value: price2, + gasPrice: 0, + }); + + const balancesAfter = await getBalances(); + + // Expected changes + const expectedBuyerChange = price2; + const reducedSecondaryPrice = + (BigInt(price2) * BigInt(10000 - fee.protocol - fee.royalties)) / 10000n; + const expectedSellerChange = reducedSecondaryPrice <= price ? reducedSecondaryPrice : price; + const expectedProtocolChange = price2 - expectedSellerChange; + const expectedOriginalSellerChange = 0n; + + // Contract's balance should increase for minimal escrow amount + expect(balancesAfter.protocol).to.equal(balancesBefore.protocol + expectedProtocolChange); + expect(balancesAfter.seller).to.equal(balancesBefore.seller + expectedSellerChange); + expect(balancesAfter.newBuyer).to.equal(balancesBefore.newBuyer - expectedBuyerChange); + expect(balancesAfter.originalSeller).to.equal( + balancesBefore.originalSeller + expectedOriginalSellerChange + ); + }); + + it(`protocol fee: ${fee.protocol / 100}%; royalties: ${ + fee.royalties / 100 + }% - overpaid`, async function () { + await configHandler.setProtocolFeePercentage(fee.protocol); + await bosonVoucherClone.connect(assistant).setRoyaltyPercentage(fee.royalties); + + const balancesBefore = await getBalances(); + + // Sequential commit to offer. Buyer pays more than needed + priceDiscovery.price = price2 * 3n; + + await sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { + value: priceDiscovery.price, + gasPrice: 0, + }); + + const balancesAfter = await getBalances(); + + // Expected changes + const expectedBuyerChange = price2; + const reducedSecondaryPrice = (price2 * BigInt(10000 - fee.protocol - fee.royalties)) / 10000n; + const expectedSellerChange = reducedSecondaryPrice <= price ? reducedSecondaryPrice : price; + const expectedProtocolChange = price2 - expectedSellerChange; + const expectedOriginalSellerChange = 0n; + + // Contract's balance should increase for minimal escrow amount + expect(balancesAfter.protocol).to.equal(balancesBefore.protocol + expectedProtocolChange); + expect(balancesAfter.seller).to.equal(balancesBefore.seller + expectedSellerChange); + expect(balancesAfter.newBuyer).to.equal(balancesBefore.newBuyer - expectedBuyerChange); + expect(balancesAfter.originalSeller).to.equal( + balancesBefore.originalSeller + expectedOriginalSellerChange + ); + }); + }); + }); + }); + }); + }); + + context("Bid order", async function () { + context("General actions", async function () { + beforeEach(async function () { + // Price on secondary market + price2 = (price * 11n) / 10n; // 10% above the original price + + // Prepare calldata for PriceDiscovery contract + tokenId = deriveTokenId(offer.id, exchangeId); + let order = { + seller: await exchangeHandler.getAddress(), // since protocol owns the voucher, it acts as seller from price discovery mechanism + buyer: buyer2.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: await weth.getAddress(), // buyer pays in ETH, but they cannot approve ETH, so we use WETH + price: price2.toString(), + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilSellOrder", [order]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + + priceDiscovery = new PriceDiscovery( + price2, + Side.Bid, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Approve transfers + // Buyer2 needs to approve price discovery to transfer the ETH + await weth.connect(buyer2).deposit({ value: price2 }); + await weth.connect(buyer2).approve(await priceDiscoveryContract.getAddress(), price2); + + // Seller approves protocol to transfer the voucher + bosonVoucherClone = await getContractAt("IBosonVoucher", expectedCloneAddress); + await bosonVoucherClone.connect(reseller).setApprovalForAll(await exchangeHandler.getAddress(), true); + + mockBuyer(reseller.address); // call only to increment account id counter + newBuyer = mockBuyer(buyer2.address); + exchange.buyerId = newBuyer.id; + }); + + it("should emit FundsEncumbered, FundsReleased, FundsWithdrawn and BuyerCommitted events", async function () { + // Sequential commit to offer, retrieving the event + const tx = sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery); + + await expect(tx) + .to.emit(sequentialCommitHandler, "FundsEncumbered") + .withArgs(newBuyer.id, ZeroAddress, price2, reseller.address); + + const immediatePayout = BigInt(price); + await expect(tx) + .to.emit(sequentialCommitHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, ZeroAddress, immediatePayout, reseller.address); + + await expect(tx) + .to.emit(sequentialCommitHandler, "FundsWithdrawn") + .withArgs(resellerId, reseller.address, ZeroAddress, immediatePayout, reseller.address); + + await expect(tx) + .to.emit(sequentialCommitHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), reseller.address); + }); + + it("should update state", async function () { + // Escrow amount before + const escrowBefore = await provider.getBalance(await exchangeHandler.getAddress()); + + // Sequential commit to offer + await sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery); + + // buyer2 is exchange.buyerId + // Get the exchange as a struct + const [, exchangeStruct] = await exchangeHandler.connect(rando).getExchange(exchangeId); + + // Parse into entity + let returnedExchange = Exchange.fromStruct(exchangeStruct); + expect(returnedExchange.buyerId).to.equal(newBuyer.id); + + // Contract's balance should increase for minimal escrow amount + const escrowAfter = await provider.getBalance(await exchangeHandler.getAddress()); + expect(escrowAfter).to.equal(escrowBefore + price2 - price); + }); + + it("should transfer the voucher", async function () { + // reseller is owner of voucher + const tokenId = deriveTokenId(offer.id, exchangeId); + expect(await bosonVoucherClone.connect(reseller).ownerOf(tokenId)).to.equal(reseller.address); + + // Sequential commit to offer + await sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery); + + // buyer2 is owner of voucher + expect(await bosonVoucherClone.connect(buyer2).ownerOf(tokenId)).to.equal(buyer2.address); + }); + + it("voucher should remain unchanged", async function () { + // Voucher before + let [, , voucherStruct] = await exchangeHandler.connect(rando).getExchange(exchangeId); + let returnedVoucher = Voucher.fromStruct(voucherStruct); + expect(returnedVoucher).to.deep.equal(voucher); + + // Sequential commit to offer, creating a new exchange + await sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery); + + // Voucher after + [, , voucherStruct] = await exchangeHandler.connect(rando).getExchange(exchangeId); + returnedVoucher = Voucher.fromStruct(voucherStruct); + expect(returnedVoucher).to.deep.equal(voucher); + }); + + it("only new buyer can redeem voucher", async function () { + // Sequential commit to offer, creating a new exchange + await sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery); + + // Old buyer cannot redeem + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchangeId)).to.be.revertedWith( + RevertReasons.NOT_VOUCHER_HOLDER + ); + + // Redeem voucher, test for event + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + expect(await exchangeHandler.connect(buyer2).redeemVoucher(exchangeId)) + .to.emit(exchangeHandler, "VoucherRedeemed") + .withArgs(offerId, exchangeId, buyer2.address); + }); + + it("only new buyer can cancel voucher", async function () { + // Sequential commit to offer, creating a new exchange + await sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery); + + // Old buyer cannot redeem + await expect(exchangeHandler.connect(buyer).cancelVoucher(exchangeId)).to.be.revertedWith( + RevertReasons.NOT_VOUCHER_HOLDER + ); + + // Redeem voucher, test for event + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + expect(await exchangeHandler.connect(buyer2).cancelVoucher(exchangeId)) + .to.emit(exchangeHandler, "VoucherCanceled") + .withArgs(offerId, exchangeId, buyer2.address); + }); + + it("should not increment the next exchange id counter", async function () { + const nextExchangeIdBefore = await exchangeHandler.connect(rando).getNextExchangeId(); + + // Sequential commit to offer, creating a new exchange + await sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery); + + // Get the next exchange id and ensure it was incremented + const nextExchangeIdAfter = await exchangeHandler.connect(rando).getNextExchangeId(); + expect(nextExchangeIdAfter).to.equal(nextExchangeIdBefore); + }); + + it("Should not decrement quantityAvailable", async function () { + // Get quantityAvailable before + const [, { quantityAvailable: quantityAvailableBefore }] = await offerHandler + .connect(rando) + .getOffer(offerId); + + // Sequential commit to offer, creating a new exchange + await sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery); + + // Get quantityAvailable after + const [, { quantityAvailable: quantityAvailableAfter }] = await offerHandler + .connect(rando) + .getOffer(offerId); + + expect(quantityAvailableAfter).to.equal(quantityAvailableBefore, "Quantity available should be the same"); + }); + + it("It is possible to commit even if offer is voided", async function () { + // Void the offer + await offerHandler.connect(assistant).voidOffer(offerId); + + // Committing directly is not possible + await expect(exchangeHandler.connect(buyer2).commitToOffer(buyer2.address, offerId)).to.revertedWith( + RevertReasons.OFFER_HAS_BEEN_VOIDED + ); + + // Sequential commit to offer, retrieving the event + await expect( + sequentialCommitHandler.connect(reseller).sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery) + ) + .to.emit(exchangeHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), reseller.address); + }); + + it("It is possible to commit even if redemption period has not started yet", async function () { + // Redemption not yet possible + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchangeId)).to.revertedWith( + RevertReasons.VOUCHER_NOT_REDEEMABLE + ); + + // Sequential commit to offer, retrieving the event + await expect( + sequentialCommitHandler.connect(reseller).sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery) + ) + .to.emit(sequentialCommitHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), reseller.address); + }); + + it("It is possible to commit even if offer has expired", async function () { + // Advance time to after offer expiry + await setNextBlockTimestamp(Number(offerDates.validUntil) + 1); + + // Committing directly is not possible + await expect(exchangeHandler.connect(buyer2).commitToOffer(buyer2.address, offerId)).to.revertedWith( + RevertReasons.OFFER_HAS_EXPIRED + ); + + // Sequential commit to offer, retrieving the event + await expect( + sequentialCommitHandler.connect(reseller).sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery) + ) + .to.emit(sequentialCommitHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), reseller.address); + }); + + it("It is possible to commit even if is sold out", async function () { + // Commit to all remaining quantity + for (let i = 1; i < offer.quantityAvailable; i++) { + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); + } + + // Committing directly is not possible + await expect( + exchangeHandler.connect(buyer2).commitToOffer(buyer2.address, offerId, { value: price }) + ).to.revertedWith(RevertReasons.OFFER_SOLD_OUT); + + // Sequential commit to offer, retrieving the event + await expect( + sequentialCommitHandler.connect(reseller).sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery) + ) + .to.emit(sequentialCommitHandler, "BuyerCommitted") + .withArgs(offerId, newBuyer.id, exchangeId, exchange.toStruct(), voucher.toStruct(), reseller.address); + }); + + context("💔 Revert Reasons", async function () { + it("The exchanges region of protocol is paused", async function () { + // Pause the exchanges region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); + + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.REGION_PAUSED); + }); + + it("The buyers region of protocol is paused", async function () { + // Pause the buyers region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Buyers]); + + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.REGION_PAUSED); + }); + + it("buyer address is the zero address", async function () { + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(ZeroAddress, exchangeId, priceDiscovery) + ).to.revertedWith(RevertReasons.INVALID_ADDRESS); + }); + + it("exchange id is invalid", async function () { + // An invalid exchange id + exchangeId = "666"; + tokenId = deriveTokenId(offer.id, exchangeId); + + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.NO_SUCH_EXCHANGE); + }); + + it("voucher not valid anymore", async function () { + // Go past offer expiration date + await setNextBlockTimestamp(Number(voucher.validUntilDate) + 1); + + // Attempt to sequentially commit to the expired voucher, expecting revert + await expect( + sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.VOUCHER_HAS_EXPIRED); + }); + + it("protocol fees to high", async function () { + // Set protocol fees to 95% + await configHandler.setProtocolFeePercentage(9500); + // Set royalty fees to 6% + await bosonVoucherClone.connect(assistant).setRoyaltyPercentage(600); + + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.FEE_AMOUNT_TOO_HIGH); + }); + + it("voucher transfer not approved", async function () { + // revoke approval + await bosonVoucherClone.connect(reseller).setApprovalForAll(await exchangeHandler.getAddress(), false); + + // Attempt to sequentially commit to, expecting revert + await expect( + sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.ERC721_CALLER_NOT_OWNER_OR_APPROVED); + }); + + it("price discovery sends less than expected", async function () { + // Set higher price in price discovery + priceDiscovery.price = BigInt(priceDiscovery.price) + 1n; + + // Attempt to sequentially commit to, expecting revert + await expect( + sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.INSUFFICIENT_VALUE_RECEIVED); + }); + + it("Only seller can call, if side is bid", async function () { + // Sequential commit to offer, retrieving the event + await expect( + sequentialCommitHandler.connect(rando).sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery) + ).to.revertedWith(RevertReasons.NOT_VOUCHER_HOLDER); + }); + }); + }); + + context("Escrow amount", async function () { + let scenarios = [ + { case: "Increasing price", multiplier: 11 }, + { case: "Constant price", multiplier: 10 }, + { case: "Decreasing price", multiplier: 9 }, + ]; + + async function getBalances() { + const [protocol, seller, sellerWeth, newBuyer, newBuyerWeth, originalSeller] = await Promise.all([ + provider.getBalance(await exchangeHandler.getAddress()), + provider.getBalance(reseller.address), + weth.balanceOf(reseller.address), + provider.getBalance(buyer2.address), + weth.balanceOf(buyer2.address), + provider.getBalance(treasury.address), + ]); + + return { protocol, seller: seller + sellerWeth, newBuyer: newBuyer + newBuyerWeth, originalSeller }; + } + + scenarios.forEach((scenario) => { + context(scenario.case, async function () { + beforeEach(async function () { + // Price on secondary market + price2 = (price * BigInt(scenario.multiplier)) / 10n; + + // Prepare calldata for PriceDiscovery contract + tokenId = deriveTokenId(offer.id, exchangeId); + let order = { + seller: await exchangeHandler.getAddress(), // since protocol owns the voucher, it acts as seller from price discovery mechanism + buyer: buyer2.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: await weth.getAddress(), // buyer pays in ETH, but they cannot approve ETH, so we use WETH + price: price2.toString(), + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilSellOrder", [ + order, + ]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + + priceDiscovery = new PriceDiscovery( + price2, + Side.Bid, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Approve transfers + // Buyer2 needs to approve price discovery to transfer the ETH + await weth.connect(buyer2).deposit({ value: price2 }); + await weth.connect(buyer2).approve(await priceDiscoveryContract.getAddress(), price2); + + // Seller approves protocol to transfer the voucher + bosonVoucherClone = await getContractAt("IBosonVoucher", expectedCloneAddress); + await bosonVoucherClone.connect(reseller).setApprovalForAll(await exchangeHandler.getAddress(), true); + + mockBuyer(buyer.address); // call only to increment account id counter + newBuyer = mockBuyer(buyer2.address); + exchange.buyerId = newBuyer.id; + }); + + const fees = [ + { + protocol: 0, + royalties: 0, + }, + { + protocol: 500, + royalties: 0, + }, + { + protocol: 0, + royalties: 600, + }, + { + protocol: 300, + royalties: 400, // less than profit + }, + { + protocol: 500, + royalties: 700, // more than profit + }, + ]; + + fees.forEach((fee) => { + it(`protocol fee: ${fee.protocol / 100}%; royalties: ${fee.royalties / 100}%`, async function () { + await configHandler.setProtocolFeePercentage(fee.protocol); + await bosonVoucherClone.connect(assistant).setRoyaltyPercentage(fee.royalties); + + const balancesBefore = await getBalances(); + + // Sequential commit to offer + await sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { + gasPrice: 0, + }); + + const balancesAfter = await getBalances(); + + // Expected changes + const expectedBuyerChange = price2; + const reducedSecondaryPrice = (price2 * BigInt(10000 - fee.protocol - fee.royalties)) / 10000n; + const expectedSellerChange = reducedSecondaryPrice <= price ? reducedSecondaryPrice : price; + const expectedProtocolChange = price2 - expectedSellerChange; + const expectedOriginalSellerChange = 0n; + + // Contract's balance should increase for minimal escrow amount + expect(balancesAfter.protocol).to.equal(balancesBefore.protocol + expectedProtocolChange); + expect(balancesAfter.seller).to.equal(balancesBefore.seller + expectedSellerChange); + expect(balancesAfter.newBuyer).to.equal(balancesBefore.newBuyer - expectedBuyerChange); + expect(balancesAfter.originalSeller).to.equal( + balancesBefore.originalSeller + expectedOriginalSellerChange + ); + }); + + it(`protocol fee: ${fee.protocol / 100}%; royalties: ${ + fee.royalties / 100 + }% - underpriced`, async function () { + await configHandler.setProtocolFeePercentage(fee.protocol); + await bosonVoucherClone.connect(assistant).setRoyaltyPercentage(fee.royalties); + + const balancesBefore = await getBalances(); + + // Sequential commit to offer. Buyer pays more than needed + priceDiscovery.price = price2 / 2n; + + await sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { + gasPrice: 0, + }); + + const balancesAfter = await getBalances(); + + // Expected changes + const expectedBuyerChange = price2; + const reducedSecondaryPrice = (price2 * BigInt(10000 - fee.protocol - fee.royalties)) / 10000n; + const expectedSellerChange = reducedSecondaryPrice <= price ? reducedSecondaryPrice : price; + const expectedProtocolChange = price2 - expectedSellerChange; + const expectedOriginalSellerChange = 0n; + + // Contract's balance should increase for minimal escrow amount + expect(balancesAfter.protocol).to.equal(balancesBefore.protocol + expectedProtocolChange); + expect(balancesAfter.seller).to.equal(balancesBefore.seller + expectedSellerChange); + expect(balancesAfter.newBuyer).to.equal(balancesBefore.newBuyer - expectedBuyerChange); + expect(balancesAfter.originalSeller).to.equal( + balancesBefore.originalSeller + expectedOriginalSellerChange + ); + }); + + it(`protocol fee: ${fee.protocol / 100}%; royalties: ${ + fee.royalties / 100 + }% - non zero msg.value`, async function () { + await configHandler.setProtocolFeePercentage(fee.protocol); + await bosonVoucherClone.connect(assistant).setRoyaltyPercentage(fee.royalties); + + const balancesBefore = await getBalances(); + + const sellerMsgValue = parseUnits("0.001", "ether"); + + // Sequential commit to offer + await sequentialCommitHandler + .connect(reseller) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { + gasPrice: 0, + value: sellerMsgValue, + }); + + const balancesAfter = await getBalances(); + + // Expected changes + const expectedBuyerChange = price2; + const reducedSecondaryPrice = (price2 * BigInt(10000 - fee.protocol - fee.royalties)) / 10000n; + const expectedSellerChange = reducedSecondaryPrice <= price ? reducedSecondaryPrice : price; + const expectedProtocolChange = price2 - expectedSellerChange; + const expectedOriginalSellerChange = 0n; + + // Contract's balance should increase for minimal escrow amount + expect(balancesAfter.protocol).to.equal(balancesBefore.protocol + expectedProtocolChange); + expect(balancesAfter.seller).to.equal( + balancesBefore.seller + expectedSellerChange - sellerMsgValue / 2n + ); // PriceDiscovery returns back half of the sent native value + expect(balancesAfter.newBuyer).to.equal(balancesBefore.newBuyer - expectedBuyerChange); + expect(balancesAfter.originalSeller).to.equal( + balancesBefore.originalSeller + expectedOriginalSellerChange + ); + }); + }); + }); + }); + }); + }); + }); + + context("👉 onERC721Received()", async function () { + let priceDiscoveryContract, priceDiscovery, price2; + let reseller; // for clarity in tests + + beforeEach(async function () { + // Commit to offer with first buyer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); + + reseller = buyer; + + // Price on secondary market + price2 = (price * 11n) / 10n; // 10% above the original price + + // Seller needs to deposit weth in order to fill the escrow at the last step + // Price2 is theoretically the highest amount needed, in practice it will be less (around price2-price) + await weth.connect(buyer).deposit({ value: price2 }); + await weth.connect(buyer).approve(protocolDiamondAddress, price2); + + // Approve transfers + // Buyer does not approve, since its in ETH. + // Seller approves price discovery to transfer the voucher + bosonVoucherClone = await getContractAt("IBosonVoucher", expectedCloneAddress); + }); + + it("should transfer the voucher during sequential commit", async function () { + // Deploy PriceDiscovery contract + const PriceDiscoveryFactory = await getContractFactory("PriceDiscovery"); + priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); + await priceDiscoveryContract.waitForDeployment(); + + // Prepare calldata for PriceDiscovery contract + tokenId = deriveTokenId(offer.id, exchangeId); + let order = { + seller: reseller.address, + buyer: buyer2.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: offer.exchangeToken, + price: price2, + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [order]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + + // Seller approves price discovery to transfer the voucher + await bosonVoucherClone.connect(reseller).setApprovalForAll(await priceDiscoveryContract.getAddress(), true); + + priceDiscovery = new PriceDiscovery( + price2, + Side.Ask, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // buyer is owner of voucher + expect(await bosonVoucherClone.connect(buyer).ownerOf(tokenId)).to.equal(buyer.address); + + // Sequential commit to offer + await sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }); + + // buyer2 is owner of voucher + expect(await bosonVoucherClone.connect(buyer2).ownerOf(tokenId)).to.equal(buyer2.address); + }); + + context("💔 Revert Reasons", async function () { + it("Correct caller, wrong id", async function () { + // Commit to offer with first buyer once more (so they have two vouchers) + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); + + // Deploy Bad PriceDiscovery contract + const PriceDiscoveryFactory = await getContractFactory("PriceDiscoveryModifyTokenId"); + priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); + await priceDiscoveryContract.waitForDeployment(); + + // Prepare calldata for PriceDiscovery contract + tokenId = deriveTokenId(offer.id, exchangeId); + let order = { + seller: reseller.address, + buyer: buyer2.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: offer.exchangeToken, + price: price2, + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [order]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + + // Seller approves price discovery to transfer the voucher + await bosonVoucherClone.connect(reseller).setApprovalForAll(await priceDiscoveryContract.getAddress(), true); + + priceDiscovery = new PriceDiscovery( + price2, + Side.Ask, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ).to.revertedWith(RevertReasons.TOKEN_ID_MISMATCH); + }); + + it("Correct token id, wrong caller", async function () { + // Deploy mock erc721 contract + const [foreign721] = await deployMockTokens(["Foreign721"]); + + // Deploy Bad PriceDiscovery contract + const PriceDiscoveryFactory = await getContractFactory("PriceDiscoveryModifyVoucherContract"); + priceDiscoveryContract = await PriceDiscoveryFactory.deploy(await foreign721.getAddress()); + await priceDiscoveryContract.waitForDeployment(); + + // Prepare calldata for PriceDiscovery contract + tokenId = deriveTokenId(offer.id, exchangeId); + let order = { + seller: reseller.address, + buyer: buyer2.address, + voucherContract: expectedCloneAddress, + tokenId: tokenId, + exchangeToken: offer.exchangeToken, + price: price2, + }; + + const priceDiscoveryData = priceDiscoveryContract.interface.encodeFunctionData("fulfilBuyOrder", [order]); + const priceDiscoveryContractAddress = await priceDiscoveryContract.getAddress(); + + // Seller approves price discovery to transfer the voucher + await bosonVoucherClone.connect(reseller).setApprovalForAll(await priceDiscoveryContract.getAddress(), true); + + priceDiscovery = new PriceDiscovery( + price2, + Side.Ask, + priceDiscoveryContractAddress, + priceDiscoveryContractAddress, + priceDiscoveryData + ); + + // Attempt to sequentially commit, expecting revert + await expect( + sequentialCommitHandler + .connect(buyer2) + .sequentialCommitToOffer(buyer2.address, tokenId, priceDiscovery, { value: price2 }) + ).to.revertedWith(RevertReasons.UNEXPECTED_ERC721_RECEIVED); + }); + + it("Random erc721 transfer", async function () { + // Deploy mock erc721 contract + const [foreign721] = await deployMockTokens(["Foreign721"]); + + const tokenId = 123; + await foreign721.mint(tokenId, 1); + + // Attempt to sequentially commit, expecting revert + await expect( + foreign721["safeTransferFrom(address,address,uint256)"](deployer.address, protocolDiamondAddress, tokenId) + ).to.revertedWith(RevertReasons.UNEXPECTED_ERC721_RECEIVED); + }); + }); + }); + }); +}); diff --git a/test/protocol/clients/BosonVoucherTest.js b/test/protocol/clients/BosonVoucherTest.js index e382ce321..adc0a5994 100644 --- a/test/protocol/clients/BosonVoucherTest.js +++ b/test/protocol/clients/BosonVoucherTest.js @@ -1429,7 +1429,7 @@ describe("IBosonVoucher", function () { assert.equal(tokenOwner, await rando.getAddress(), "Rando is not the owner"); }); - it("Should call commitToPreMintedOffer", async function () { + it("Should call onPremintedVoucherTransferred", async function () { const tx = await bosonVoucher .connect(assistant) [selector](await assistant.getAddress(), await rando.getAddress(), tokenId, ...additionalArgs); @@ -1448,7 +1448,7 @@ describe("IBosonVoucher", function () { // Update the validUntilDate date in the expected exchange struct voucher.validUntilDate = calculateVoucherExpiry(block, voucherRedeemableFrom, voucherValid); - // First transfer should call commitToPreMintedOffer + // First transfer should call onPremintedVoucherTransferred await expect(tx) .to.emit(exchangeHandler, "BuyerCommitted") .withArgs( @@ -1462,21 +1462,21 @@ describe("IBosonVoucher", function () { }); it("Second transfer should behave as normal voucher transfer", async function () { - // First transfer should call commitToPreMintedOffer, and not onVoucherTransferred + // First transfer should call onPremintedVoucherTransferred, and not onVoucherTransferred let tx = await bosonVoucher .connect(assistant) [selector](await assistant.getAddress(), await rando.getAddress(), tokenId, ...additionalArgs); await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); await expect(tx).to.not.emit(exchangeHandler, "VoucherTransferred"); - // Second transfer should call onVoucherTransferred, and not commitToPreMintedOffer + // Second transfer should call onVoucherTransferred, and not onPremintedVoucherTransferred tx = await bosonVoucher .connect(rando) [selector](await rando.getAddress(), await assistant.getAddress(), tokenId, ...additionalArgs); await expect(tx).to.emit(exchangeHandler, "VoucherTransferred"); await expect(tx).to.not.emit(exchangeHandler, "BuyerCommitted"); - // Next transfer should call onVoucherTransferred, and not commitToPreMintedOffer, even if seller is the owner + // Next transfer should call onVoucherTransferred, and not onPremintedVoucherTransferred, even if seller is the owner tx = await bosonVoucher .connect(assistant) [selector](await assistant.getAddress(), await rando.getAddress(), tokenId, ...additionalArgs); @@ -1583,7 +1583,7 @@ describe("IBosonVoucher", function () { bosonVoucher .connect(rando) [selector](await rando.getAddress(), await rando.getAddress(), tokenId, ...additionalArgs) - ).to.be.revertedWith(RevertReasons.NO_SILENT_MINT_ALLOWED); + ).to.be.revertedWith(RevertReasons.ERC721_CALLER_NOT_OWNER_OR_APPROVED); }); }); }); diff --git a/test/util/constants.js b/test/util/constants.js index 738eb72a1..b5f72c1f3 100644 --- a/test/util/constants.js +++ b/test/util/constants.js @@ -5,7 +5,9 @@ const oneWeek = oneDay * 7n; // 7 days in seconds const oneMonth = oneDay * 31n; // 31 days in seconds const VOUCHER_NAME = "Boson Voucher (rNFT)"; const VOUCHER_SYMBOL = "BOSON_VOUCHER_RNFT"; -const SEAPORT_ADDRESS = "0x00000000000001ad428e4906aE43D8F9852d0dD6"; // 1.4 +const SEAPORT_ADDRESS_4 = "0x00000000000001ad428e4906aE43D8F9852d0dD6"; // 1.4 +const SEAPORT_ADDRESS_5 = "0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC"; // 1.5 + const tipMultiplier = 1n; // use 1 in tests const tipSuggestion = 1500000000n; // ethers.js always returns this constant, it does not vary per block const maxPriorityFeePerGas = tipSuggestion * tipMultiplier; @@ -17,4 +19,5 @@ exports.oneMonth = oneMonth; exports.VOUCHER_NAME = VOUCHER_NAME; exports.VOUCHER_SYMBOL = VOUCHER_SYMBOL; exports.maxPriorityFeePerGas = maxPriorityFeePerGas; -exports.SEAPORT_ADDRESS = SEAPORT_ADDRESS; +exports.SEAPORT_ADDRESS_4 = SEAPORT_ADDRESS_4; +exports.SEAPORT_ADDRESS_5 = SEAPORT_ADDRESS_5; diff --git a/test/util/mock.js b/test/util/mock.js index 8e1dfe3ac..7be5072a1 100644 --- a/test/util/mock.js +++ b/test/util/mock.js @@ -23,21 +23,13 @@ const Agent = require("../../scripts/domain/Agent"); const Receipt = require("../../scripts/domain/Receipt"); const Voucher = require("../../scripts/domain/Voucher"); const Dispute = require("../../scripts/domain/Dispute"); -const { applyPercentage } = require("../../test/util/utils.js"); +const { applyPercentage, incrementer } = require("../../test/util/utils.js"); const { oneWeek, oneMonth } = require("./constants.js"); +const PriceType = require("../../scripts/domain/PriceType"); let DisputeResolver = require("../../scripts/domain/DisputeResolver.js"); let Seller = require("../../scripts/domain/Seller"); const { ZeroHash } = require("ethers"); -function* incrementer() { - let i = 0; - while (true) { - const reset = yield (i++).toString(); - if (reset) { - i = 0; - } - } -} const accountId = incrementer(); function mockOfferDurations() { @@ -83,6 +75,7 @@ async function mockOffer({ refreshModule } = {}) { const metadataUri = `https://ipfs.io/ipfs/${metadataHash}`; const voided = false; const collectionIndex = "0"; + const priceType = PriceType.Static; // Create a valid offer, then set fields in tests directly let offer = new Offer( @@ -96,7 +89,8 @@ async function mockOffer({ refreshModule } = {}) { metadataUri, metadataHash, voided, - collectionIndex + collectionIndex, + priceType ); const offerDates = await mockOfferDates(); diff --git a/test/util/test-chunks.txt b/test/util/test-chunks.txt index f94802e2b..831cf237d 100644 --- a/test/util/test-chunks.txt +++ b/test/util/test-chunks.txt @@ -1,59 +1,63 @@ [ [ - "test/protocol/clients/BosonVoucherTest.js", "test/protocol/ExchangeHandlerTest.js", + "test/example/SnapshotGateTest.js", + "test/protocol/ProtocolInitializationHandlerTest.js", + "test/protocol/SequentialCommitHandlerTest.js", "test/protocol/OrchestrationHandlerTest.js", - "test/domain/AuthTokenTest.js" + "test/protocol/DisputeHandlerTest.js" ], [ - "test/protocol/FundsHandlerTest.js", - "test/protocol/DisputeHandlerTest.js", - "test/protocol/OfferHandlerTest.js", + "test/protocol/clients/BosonVoucherTest.js", + "test/protocol/SellerHandlerTest.js", "test/protocol/GroupHandlerTest.js", - "test/protocol/ProtocolInitializationHandlerTest.js" - ], - [ + "test/protocol/OfferHandlerTest.js", "test/protocol/DisputeResolverHandlerTest.js", - "test/protocol/ProtocolDiamondTest.js", - "test/protocol/BundleHandlerTest.js", + "test/access/AccessControllerTest.js", "test/protocol/MetaTransactionsHandlerTest.js", - "test/protocol/SellerHandlerTest.js", + "test/protocol/BundleHandlerTest.js", + "test/protocol/PriceDiscoveryHandlerFacet.js", + "test/protocol/ProtocolDiamondTest.js", "test/protocol/TwinHandlerTest.js", "test/protocol/BuyerHandlerTest.js", - "test/access/AccessControllerTest.js", "test/protocol/AgentHandlerTest.js", - "test/domain/ReceiptTest.js" + "test/domain/AgentTest.js" ], [ "test/protocol/ConfigHandlerTest.js", "test/protocol/PauseHandlerTest.js", "test/protocol/AccountHandlerTest.js", "test/protocol/clients/ClientExternalAddressesTest.js", - "test/example/SnapshotGateTest.js", - "test/protocol/clients/BeaconClientProxy.js", + "test/domain/TwinTest.js", + "test/domain/PriceDiscoveryTest.js", + "test/domain/BuyerTest.js", + "test/domain/DisputeResolutionTermsTest.js", + "test/domain/VoucherInitValuesTest.js", "test/domain/VoucherTest.js", - "test/domain/DisputeTest.js", + "test/domain/AuthTokenTest.js", + "test/domain/ReceiptTest.js", + "test/domain/DisputeDatesTest.js", + "test/domain/SellerTest.js", + "test/domain/FacetCutTest.js", "test/domain/ConditionTest.js", + "test/domain/GroupTest.js", "test/domain/TwinReceiptTest.js", - "test/domain/DisputeResolutionTermsTest.js", - "test/domain/SellerTest.js", + "test/domain/OfferDatesTest.js", "test/domain/RangeTest.js", "test/domain/DisputeResolverFeeTest.js", - "test/domain/AgentTest.js", + "test/domain/OfferDurationsTest.js", + "test/domain/CollectionTest.js", + "test/domain/OfferTest.js", "test/domain/FundsTest.js", - "test/domain/DisputeResolverTest.js", "test/domain/BundleTest.js", - "test/domain/OfferDatesTest.js", - "test/domain/VoucherInitValuesTest.js", - "test/domain/DisputeDatesTest.js", - "test/domain/TwinTest.js", - "test/domain/ExchangeTest.js", + "test/domain/DisputeResolverTest.js", "test/domain/FacetTest.js", - "test/domain/BuyerTest.js", - "test/domain/OfferDurationsTest.js", - "test/domain/OfferTest.js", - "test/domain/GroupTest.js", + "test/domain/ExchangeTest.js", + "test/domain/DisputeTest.js", "test/domain/OfferFeesTest.js", - "test/domain/FacetCutTest.js" + "test/protocol/clients/BeaconClientProxy.js" + ], + [ + "test/protocol/FundsHandlerTest.js" ] ] \ No newline at end of file diff --git a/test/util/utils.js b/test/util/utils.js index 3c343c7fd..4a3f644ef 100644 --- a/test/util/utils.js +++ b/test/util/utils.js @@ -143,6 +143,13 @@ async function setNextBlockTimestamp(timestamp, mine = false) { if (mine) await provider.send("evm_mine", []); } +async function getCurrentBlockAndSetTimeForward(seconds) { + const blockNumber = await provider.getBlockNumber(); + const block = await provider.getBlock(blockNumber); + const newTime = block.timestamp + Number(seconds); + await setNextBlockTimestamp(newTime); +} + function getSignatureParameters(signature) { if (!isHexString(signature)) { throw new Error('Given value "'.concat(signature, '" is not a valid hex string.')); @@ -331,7 +338,7 @@ async function getFacetsWithArgs(facetNames, config) { const facets = await getFacets(config); const keys = Object.keys(facets).filter((key) => facetNames.includes(key)); return keys.reduce((obj, key) => { - obj[key] = facets[key]; + obj[key] = { init: facets[key].init, constructorArgs: facets[key].constructorArgs }; return obj; }, {}); } @@ -358,7 +365,7 @@ function objectToArray(input) { return result; } -async function setupTestEnvironment(contracts, { bosonTokenAddress, forwarderAddress } = {}) { +async function setupTestEnvironment(contracts, { bosonTokenAddress, forwarderAddress, wethAddress } = {}) { // Load modules only here to avoid the caching issues in upgrade tests const { deployProtocolDiamond } = require("../../scripts/util/deploy-protocol-diamond.js"); const { deployProtocolClients } = require("../../scripts/util/deploy-protocol-clients"); @@ -383,6 +390,8 @@ async function setupTestEnvironment(contracts, { bosonTokenAddress, forwarderAdd "ProtocolInitializationHandlerFacet", "ConfigHandlerFacet", "MetaTransactionsHandlerFacet", + "SequentialCommitHandlerFacet", + "PriceDiscoveryHandlerFacet", ]; const signers = await getSigners(); @@ -452,6 +461,8 @@ async function setupTestEnvironment(contracts, { bosonTokenAddress, forwarderAdd ]; const facetsToDeploy = await getFacetsWithArgs(facetNames, protocolConfig); + facetsToDeploy["SequentialCommitHandlerFacet"].constructorArgs[0] = wethAddress || ZeroAddress; // update only weth address + facetsToDeploy["PriceDiscoveryHandlerFacet"].constructorArgs[0] = wethAddress || ZeroAddress; // update only weth address // Cut the protocol handler facets into the Diamond await deployAndCutFacets(await protocolDiamond.getAddress(), facetsToDeploy, maxPriorityFeePerGas); @@ -484,6 +495,17 @@ function deriveTokenId(offerId, exchangeId) { return (BigInt(offerId) << 128n) + BigInt(exchangeId); } +function* incrementer() { + let i = 0; + while (true) { + const reset = yield (i++).toString(); + if (reset) { + // reset to 0 instead of 1 to not count the reset call + i = 0; + } + } +} + exports.setNextBlockTimestamp = setNextBlockTimestamp; exports.getEvent = getEvent; exports.eventEmittedWithArgs = eventEmittedWithArgs; @@ -498,8 +520,10 @@ exports.paddingType = paddingType; exports.getFacetsWithArgs = getFacetsWithArgs; exports.compareOfferStructs = compareOfferStructs; exports.objectToArray = objectToArray; +exports.deriveTokenId = deriveTokenId; +exports.incrementer = incrementer; +exports.getCurrentBlockAndSetTimeForward = getCurrentBlockAndSetTimeForward; exports.setupTestEnvironment = setupTestEnvironment; exports.getSnapshot = getSnapshot; exports.revertToSnapshot = revertToSnapshot; -exports.deriveTokenId = deriveTokenId; exports.getSellerSalt = getSellerSalt;