From 4ef47b662630227d7e2a4df787f7f2c0873872cd Mon Sep 17 00:00:00 2001 From: Klemen <64400885+zajck@users.noreply.github.com> Date: Tue, 23 Jan 2024 10:02:25 +0100 Subject: [PATCH] [PDB-01M] Arbitrary External Contract Calls (#894) * Make price discovery client * pricediscovery unit tests and fixes * Unit tests pass * support erc721 unsafe transfer * Protocol can move the vouchers not the client * PriceDiscoveryAddress part of protocol addresses * ConfigHandler tests * Initialize price discovery address during upgrade * Price discovery client tests * Prevent wrong tokenId transfer + natspec update * Fix failing unit tests * Remove unused variables and clean up code * Fix failing unit tests * Applied review suggestions * Remove commented code --- contracts/domain/BosonErrors.sol | 6 +- .../example/Sudoswap/SudoswapWrapper.sol | 9 +- contracts/example/ZoraWrapper/ZoraWrapper.sol | 9 +- .../clients/IBosonPriceDiscovery.sol | 80 ++ .../interfaces/events/IBosonConfigEvents.sol | 1 + .../handlers/IBosonConfigHandler.sol | 22 +- contracts/mock/MockWrapper.sol | 9 +- contracts/mock/PriceDiscovery.sol | 61 +- .../protocol/bases/PriceDiscoveryBase.sol | 170 ++-- .../priceDiscovery/BosonPriceDiscovery.sol | 275 +++++++ .../protocol/facets/ConfigHandlerFacet.sol | 27 + .../protocol/facets/ExchangeHandlerFacet.sol | 25 +- .../facets/PriceDiscoveryHandlerFacet.sol | 2 +- .../ProtocolInitializationHandlerFacet.sol | 16 +- .../facets/SequentialCommitHandlerFacet.sol | 2 +- contracts/protocol/libs/ProtocolLib.sol | 2 + scripts/config/facet-deploy.js | 1 + scripts/config/protocol-parameters.js | 10 + scripts/config/revert-reasons.js | 3 +- scripts/config/supported-interfaces.js | 1 + test/example/SnapshotGateTest.js | 4 + test/integration/price-discovery/seaport.js | 41 +- test/integration/price-discovery/sudoswap.js | 41 +- test/protocol/ConfigHandlerTest.js | 55 +- test/protocol/FundsHandlerTest.js | 24 +- test/protocol/PriceDiscoveryHandlerFacet.js | 64 +- .../ProtocolInitializationHandlerTest.js | 52 +- test/protocol/SequentialCommitHandlerTest.js | 30 +- test/protocol/clients/PriceDiscoveryTest.js | 753 ++++++++++++++++++ test/util/utils.js | 16 + 30 files changed, 1593 insertions(+), 218 deletions(-) create mode 100644 contracts/interfaces/clients/IBosonPriceDiscovery.sol create mode 100644 contracts/protocol/clients/priceDiscovery/BosonPriceDiscovery.sol create mode 100644 test/protocol/clients/PriceDiscoveryTest.js diff --git a/contracts/domain/BosonErrors.sol b/contracts/domain/BosonErrors.sol index 9122d1a9e..166909719 100644 --- a/contracts/domain/BosonErrors.sol +++ b/contracts/domain/BosonErrors.sol @@ -340,8 +340,8 @@ interface BosonErrors { error InteractionNotAllowed(); // Price discovery related - // Price discovery returned a price that is too low - error PriceTooLow(); + // Price discovery returned a price that does not match the expected one + error PriceMismatch(); // Token id is mandatory for bid orders and wrappers error TokenIdMandatory(); // Incoming token id does not match the expected one @@ -362,6 +362,8 @@ interface BosonErrors { error NegativePriceNotAllowed(); // Price discovery did not send the voucher to the protocol error VoucherNotReceived(); + // Price discovery did not send the voucher from the protocol + error VoucherNotTransferred(); // Either token with wrong id received or wrong voucher contract made the transfer error UnexpectedERC721Received(); // Royalty fee exceeds the price diff --git a/contracts/example/Sudoswap/SudoswapWrapper.sol b/contracts/example/Sudoswap/SudoswapWrapper.sol index 6f41cd6b3..97a640dd0 100644 --- a/contracts/example/Sudoswap/SudoswapWrapper.sol +++ b/contracts/example/Sudoswap/SudoswapWrapper.sol @@ -60,6 +60,7 @@ contract SudoswapWrapper is BosonTypes, Ownable, ERC721 { address private poolAddress; address private immutable factoryAddress; address private immutable protocolAddress; + address private immutable unwrapperAddress; address private immutable wethAddress; // Mapping from token ID to price. If pendingTokenId == tokenId, this is not the final price. @@ -80,12 +81,14 @@ contract SudoswapWrapper is BosonTypes, Ownable, ERC721 { address _voucherAddress, address _factoryAddress, address _protocolAddress, - address _wethAddress + address _wethAddress, + address _unwrapperAddress ) ERC721(getVoucherName(_voucherAddress), getVoucherSymbol(_voucherAddress)) { voucherAddress = _voucherAddress; factoryAddress = _factoryAddress; protocolAddress = _protocolAddress; wethAddress = _wethAddress; + unwrapperAddress = _unwrapperAddress; // Approve pool to transfer wrapped vouchers _setApprovalForAll(address(this), _factoryAddress, true); @@ -137,7 +140,7 @@ contract SudoswapWrapper is BosonTypes, Ownable, ERC721 { // 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, + msg.sender == unwrapperAddress || wrappedVoucherOwner == msg.sender, "SudoswapWrapper: Only owner or protocol can unwrap" ); @@ -152,7 +155,7 @@ contract SudoswapWrapper is BosonTypes, Ownable, ERC721 { // Transfer token to protocol if (priceToPay > 0) { // This example only supports WETH - IERC20(cachedExchangeToken[_tokenId]).safeTransfer(protocolAddress, priceToPay); + IERC20(cachedExchangeToken[_tokenId]).safeTransfer(unwrapperAddress, priceToPay); } delete cachedExchangeToken[_tokenId]; // gas refund diff --git a/contracts/example/ZoraWrapper/ZoraWrapper.sol b/contracts/example/ZoraWrapper/ZoraWrapper.sol index 44071b319..46a56399d 100644 --- a/contracts/example/ZoraWrapper/ZoraWrapper.sol +++ b/contracts/example/ZoraWrapper/ZoraWrapper.sol @@ -50,6 +50,7 @@ contract ZoraWrapper is BosonTypes, ERC721, IERC721Receiver { address private immutable voucherAddress; address private immutable zoraAuctionHouseAddress; address private immutable protocolAddress; + address private immutable unwrapperAddress; address private immutable wethAddress; // Token ID for which the price is not yet known @@ -73,12 +74,14 @@ contract ZoraWrapper is BosonTypes, ERC721, IERC721Receiver { address _voucherAddress, address _zoraAuctionHouseAddress, address _protocolAddress, - address _wethAddress + address _wethAddress, + address _unwrapperAddress ) ERC721(getVoucherName(_voucherAddress), getVoucherSymbol(_voucherAddress)) { voucherAddress = _voucherAddress; zoraAuctionHouseAddress = _zoraAuctionHouseAddress; protocolAddress = _protocolAddress; wethAddress = _wethAddress; + unwrapperAddress = _unwrapperAddress; // Approve Zora Auction House to transfer wrapped vouchers _setApprovalForAll(address(this), _zoraAuctionHouseAddress, true); @@ -132,7 +135,7 @@ contract ZoraWrapper is BosonTypes, ERC721, IERC721Receiver { // 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, + msg.sender == unwrapperAddress || wrappedVoucherOwner == msg.sender, "ZoraWrapper: Only owner or protocol can unwrap" ); @@ -151,7 +154,7 @@ contract ZoraWrapper is BosonTypes, ERC721, IERC721Receiver { // 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); + IERC20(cachedExchangeToken[_tokenId]).safeTransfer(unwrapperAddress, priceToPay); } delete cachedExchangeToken[_tokenId]; // gas refund diff --git a/contracts/interfaces/clients/IBosonPriceDiscovery.sol b/contracts/interfaces/clients/IBosonPriceDiscovery.sol new file mode 100644 index 000000000..0d688be73 --- /dev/null +++ b/contracts/interfaces/clients/IBosonPriceDiscovery.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.22; + +import { IBosonVoucher } from "./IBosonVoucher.sol"; +import { IERC721Receiver } from "../IERC721Receiver.sol"; +import { BosonTypes } from "../../domain/BosonTypes.sol"; + +/** + * @title BosonPriceDiscovery + * + * @notice This is the interface for the Boson Price Discovery contract. + * + * The ERC-165 identifier for this interface is: 0x8bcce417 + */ +interface IBosonPriceDiscovery is IERC721Receiver { + /** + * @notice Fulfils an ask order on external contract. + * + * Reverts if: + * - Call to price discovery contract fails + * - The implied price is negative + * - Any external calls to erc20 contract fail + * + * @param _exchangeToken - the address of the exchange contract + * @param _priceDiscovery - the fully populated BosonTypes.PriceDiscovery struct + * @param _bosonVoucher - the boson voucher contract + * @param _msgSender - the address of the caller, as seen in boson protocol + * @return actualPrice - the actual price of the order + */ + function fulfilAskOrder( + address _exchangeToken, + BosonTypes.PriceDiscovery calldata _priceDiscovery, + IBosonVoucher _bosonVoucher, + address payable _msgSender + ) external returns (uint256 actualPrice); + + /** + * @notice Fulfils a bid order on external contract. + * + * Reverts if: + * - Call to price discovery contract fails + * - Protocol balance change after price discovery call is lower than the expected price + * - This contract is still owner of the voucher + * - Token id sent to buyer and token id set by the caller don't match + * - The implied price is negative + * - Any external calls to erc20 contract fail + * + * @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, + BosonTypes.PriceDiscovery calldata _priceDiscovery, + address _seller, + IBosonVoucher _bosonVoucher + ) external payable returns (uint256 actualPrice); + + /** + * @notice Call `unwrap` (or equivalent) function on the price discovery contract. + * + * Reverts if: + * - Protocol balance doesn't increase by the expected amount. + * - Token id sent to buyer and token id set by the caller don't match + * - The wrapper contract sends back the native currency + * - The implied price is negative + * + * @param _exchangeToken - the address of the exchange contract + * @param _priceDiscovery - the fully populated BosonTypes.PriceDiscovery struct + * @return actualPrice - the actual price of the order + */ + function handleWrapper( + address _exchangeToken, + BosonTypes.PriceDiscovery calldata _priceDiscovery + ) external payable returns (uint256 actualPrice); +} diff --git a/contracts/interfaces/events/IBosonConfigEvents.sol b/contracts/interfaces/events/IBosonConfigEvents.sol index bec964a6e..52ab5ecfe 100644 --- a/contracts/interfaces/events/IBosonConfigEvents.sol +++ b/contracts/interfaces/events/IBosonConfigEvents.sol @@ -13,6 +13,7 @@ interface IBosonConfigEvents { event TreasuryAddressChanged(address indexed treasuryAddress, address indexed executedBy); event VoucherBeaconAddressChanged(address indexed voucherBeaconAddress, address indexed executedBy); event BeaconProxyAddressChanged(address indexed beaconProxyAddress, address indexed executedBy); + event PriceDiscoveryAddressChanged(address indexed priceDiscoveryAddress, address indexed executedBy); event ProtocolFeePercentageChanged(uint256 feePercentage, address indexed executedBy); event ProtocolFeeFlatBosonChanged(uint256 feeFlatBoson, address indexed executedBy); event MaxEscalationResponsePeriodChanged(uint256 maxEscalationResponsePeriod, address indexed executedBy); diff --git a/contracts/interfaces/handlers/IBosonConfigHandler.sol b/contracts/interfaces/handlers/IBosonConfigHandler.sol index e6d30cda6..eeae63b68 100644 --- a/contracts/interfaces/handlers/IBosonConfigHandler.sol +++ b/contracts/interfaces/handlers/IBosonConfigHandler.sol @@ -10,7 +10,7 @@ import { IBosonConfigEvents } from "../events/IBosonConfigEvents.sol"; * * @notice Handles management of configuration within the protocol. * - * The ERC-165 identifier for this interface is: 0x7899c7b9 + * The ERC-165 identifier for this interface is: 0xe27f0773 */ interface IBosonConfigHandler is IBosonConfigEvents, BosonErrors { /** @@ -93,6 +93,26 @@ interface IBosonConfigHandler is IBosonConfigEvents, BosonErrors { */ function getBeaconProxyAddress() external view returns (address); + /** + * @notice Sets the Boson Price Discovery contract address. + * + * Emits a PriceDiscoveryAddressChanged event if successful. + * + * Reverts if _priceDiscovery is the zero address + * + * @dev Caller must have ADMIN role. + * + * @param _priceDiscovery - the Boson Price Discovery contract address + */ + function setPriceDiscoveryAddress(address _priceDiscovery) external; + + /** + * @notice Gets the Boson Price Discovery contract address. + * + * @return the Boson Price Discovery contract address + */ + function getPriceDiscoveryAddress() external view returns (address); + /** * @notice Sets the protocol fee percentage. * diff --git a/contracts/mock/MockWrapper.sol b/contracts/mock/MockWrapper.sol index fb5dc7f61..e79a21583 100644 --- a/contracts/mock/MockWrapper.sol +++ b/contracts/mock/MockWrapper.sol @@ -25,6 +25,7 @@ contract MockWrapper is BosonTypes, ERC721, IERC721Receiver { address private immutable voucherAddress; address private immutable mockAuctionAddress; address private immutable protocolAddress; + address private immutable unwrapperAddress; address private immutable wethAddress; // Token ID for which the price is not yet known @@ -48,12 +49,14 @@ contract MockWrapper is BosonTypes, ERC721, IERC721Receiver { address _voucherAddress, address _mockAuctionAddress, address _protocolAddress, - address _wethAddress + address _wethAddress, + address _unwrapperAddress ) ERC721(getVoucherName(_voucherAddress), getVoucherSymbol(_voucherAddress)) { voucherAddress = _voucherAddress; mockAuctionAddress = _mockAuctionAddress; protocolAddress = _protocolAddress; wethAddress = _wethAddress; + unwrapperAddress = _unwrapperAddress; // Approve Mock Auction to transfer wrapped vouchers _setApprovalForAll(address(this), _mockAuctionAddress, true); @@ -107,7 +110,7 @@ contract MockWrapper is BosonTypes, ERC721, IERC721Receiver { // 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, + msg.sender == unwrapperAddress || wrappedVoucherOwner == msg.sender, "MockWrapper: Only owner or protocol can unwrap" ); @@ -125,7 +128,7 @@ contract MockWrapper is BosonTypes, ERC721, IERC721Receiver { // Transfer token to protocol if (priceToPay > 0) { - IERC20(cachedExchangeToken[_tokenId]).safeTransfer(protocolAddress, priceToPay); + IERC20(cachedExchangeToken[_tokenId]).safeTransfer(unwrapperAddress, priceToPay); } delete cachedExchangeToken[_tokenId]; // gas refund diff --git a/contracts/mock/PriceDiscovery.sol b/contracts/mock/PriceDiscovery.sol index 03bdda8ee..18ed50085 100644 --- a/contracts/mock/PriceDiscovery.sol +++ b/contracts/mock/PriceDiscovery.sol @@ -13,7 +13,7 @@ import { IERC721Receiver } from "../interfaces/IERC721Receiver.sol"; * This contract simulates external price discovery mechanism. * When user commits to an offer, protocol talks to this contract to validate the exchange. */ -contract PriceDiscovery { +contract PriceDiscoveryMock { struct Order { address seller; address buyer; @@ -100,6 +100,57 @@ contract PriceDiscovery { // return half of the sent value back to the caller payable(msg.sender).transfer(msg.value / 2); } + + event MockFulfilCalled(); + + function mockFulfil(uint256 _percentReturn) public payable virtual { + if (orderType == OrderType.Ask) { + // received value must be equal to the price (or greater for ETH) + if (order.exchangeToken == address(0)) { + require(msg.value >= order.price, "ETH value mismatch"); + if (_percentReturn > 0) { + payable(msg.sender).transfer((msg.value * _percentReturn) / 100); + } + } else { + IERC20(order.exchangeToken).transferFrom(msg.sender, address(this), order.price); + if (_percentReturn > 0) { + IERC20(order.exchangeToken).transfer(msg.sender, (order.price * _percentReturn) / 100); + } + } + } else { + // handling bid and wrapper is the same for test purposes + if (order.exchangeToken == address(0)) { + if (_percentReturn > 0) { + payable(msg.sender).transfer((order.price * _percentReturn) / 100); + } + } else { + if (_percentReturn > 0) { + IERC20(order.exchangeToken).transfer(msg.sender, (order.price * _percentReturn) / 100); + } + } + + if (orderType == OrderType.Bid) { + IERC721(order.voucherContract).transferFrom(msg.sender, order.buyer, order.tokenId); + } + } + + emit MockFulfilCalled(); + } + + Order public order; + OrderType public orderType; + enum OrderType { + Ask, + Bid, + Weapper + } + + function setExpectedValues(Order calldata _order, OrderType _orderType) public payable virtual { + order = _order; + orderType = _orderType; + } + + receive() external payable {} } /** @@ -107,7 +158,7 @@ contract PriceDiscovery { * * This contract modifies the token id, simulates bad/malicious contract */ -contract PriceDiscoveryModifyTokenId is PriceDiscovery { +contract PriceDiscoveryModifyTokenId is PriceDiscoveryMock { /** * @dev simple fulfillOrder that does not perform any checks * Bump token id by 1 @@ -123,7 +174,7 @@ contract PriceDiscoveryModifyTokenId is PriceDiscovery { * * This contract modifies the erc721 token, simulates bad/malicious contract */ -contract PriceDiscoveryModifyVoucherContract is PriceDiscovery { +contract PriceDiscoveryModifyVoucherContract is PriceDiscoveryMock { Foreign721 private erc721; constructor(address _erc721) { @@ -149,7 +200,7 @@ contract PriceDiscoveryModifyVoucherContract is PriceDiscovery { * * This contract simply does not transfer the voucher to the caller */ -contract PriceDiscoveryNoTransfer is PriceDiscovery { +contract PriceDiscoveryNoTransfer is PriceDiscoveryMock { /** * @dev do nothing */ @@ -161,7 +212,7 @@ contract PriceDiscoveryNoTransfer is PriceDiscovery { * * This contract transfers the voucher to itself instead of the origina msg.sender */ -contract PriceDiscoveryTransferElsewhere is PriceDiscovery, IERC721Receiver { +contract PriceDiscoveryTransferElsewhere is PriceDiscoveryMock, IERC721Receiver { /** * @dev invoke fulfilBuyOrder on itself, making it the msg.sender */ diff --git a/contracts/protocol/bases/PriceDiscoveryBase.sol b/contracts/protocol/bases/PriceDiscoveryBase.sol index 07fb88e64..2581e4322 100644 --- a/contracts/protocol/bases/PriceDiscoveryBase.sol +++ b/contracts/protocol/bases/PriceDiscoveryBase.sol @@ -6,10 +6,9 @@ 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 { IBosonPriceDiscovery } from "../../interfaces/clients/IBosonPriceDiscovery.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 @@ -17,14 +16,11 @@ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.s * @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; /** * @notice - * For offers with native exchange token, it is expected the the price discovery contracts will + * For offers with native exchange token, it is expected that the price discovery contracts will * operate with wrapped native token. Set the address of the wrapped native token in the constructor. * * @param _wNative - the address of the wrapped native token @@ -74,7 +70,7 @@ contract PriceDiscoveryBase is ProtocolBase { protocolStatus().incomingVoucherCloneAddress = address(bosonVoucher); if (_priceDiscovery.side == Side.Ask) { - return fulfilAskOrder(_tokenId, _offer.exchangeToken, _priceDiscovery, _buyer, bosonVoucher); + return fulfilAskOrder(_tokenId, _offer.id, _offer.exchangeToken, _priceDiscovery, _buyer, bosonVoucher); } else if (_priceDiscovery.side == Side.Bid) { return fulfilBidOrder(_tokenId, _offer.exchangeToken, _priceDiscovery, _seller, bosonVoucher); } else { @@ -92,13 +88,12 @@ contract PriceDiscoveryBase is ProtocolBase { * - 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) + * - Token id sent to buyer and token id set by the caller don't match (if caller has provided the token id) + * - Token id sent to buyer and it does not belong to the offer, set by the caller (if caller has not provided the token id) * - * @param _tokenId - the id of the token + * @param _tokenId - the id of the token (can be 0 if unknown) + * @param _offerId - the id of the offer * @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) @@ -107,49 +102,36 @@ contract PriceDiscoveryBase is ProtocolBase { */ function fulfilAskOrder( uint256 _tokenId, + uint256 _offerId, 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); - } + // Cache price discovery contract address + address bosonPriceDiscovery = protocolAddresses().priceDiscovery; - uint256 protocolBalanceBefore = getBalance(_exchangeToken, address(this)); - - // Call the price discovery contract - _priceDiscovery.priceDiscoveryContract.functionCallWithValue(_priceDiscovery.priceDiscoveryData, msg.value); - - uint256 protocolBalanceAfter = getBalance(_exchangeToken, address(this)); - if (protocolBalanceBefore < protocolBalanceAfter) revert NegativePriceNotAllowed(); - actualPrice = protocolBalanceBefore - protocolBalanceAfter; + // Transfer buyers funds to protocol and forward them to price discovery contract + FundsLib.validateIncomingPayment(_exchangeToken, _priceDiscovery.price); + FundsLib.transferFundsFromProtocol(_exchangeToken, payable(bosonPriceDiscovery), _priceDiscovery.price); - // If token is ERC20, reset approval - if (_exchangeToken != address(0)) { - IERC20(_exchangeToken).forceApprove(address(_priceDiscovery.conduit), 0); - } + actualPrice = IBosonPriceDiscovery(bosonPriceDiscovery).fulfilAskOrder( + _exchangeToken, + _priceDiscovery, + _bosonVoucher, + payable(msgSender()) + ); _tokenId = getAndVerifyTokenId(_tokenId); - { - // Make sure that the price discovery contract has transferred the voucher to the protocol - if (_bosonVoucher.ownerOf(_tokenId) != address(this)) revert VoucherNotReceived(); + // Make sure that the exchange is part of the correct offer + if (_tokenId >> 128 != _offerId) revert TokenIdMismatch(); - // Transfer voucher to buyer - _bosonVoucher.safeTransferFrom(address(this), _buyer, _tokenId); - } - - uint256 overchargedAmount = _priceDiscovery.price - actualPrice; + // Make sure that the price discovery contract has transferred the voucher to the protocol + if (_bosonVoucher.ownerOf(_tokenId) != bosonPriceDiscovery) revert VoucherNotReceived(); - if (overchargedAmount > 0) { - // Return the surplus to caller - FundsLib.transferFundsFromProtocol(_exchangeToken, payable(msgSender()), overchargedAmount); - } + // Transfer voucher to buyer + _bosonVoucher.safeTransferFrom(bosonPriceDiscovery, _buyer, _tokenId); } /** @@ -157,16 +139,9 @@ contract PriceDiscoveryBase is ProtocolBase { * * 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 @@ -185,56 +160,31 @@ contract PriceDiscoveryBase is ProtocolBase { address sender = msgSender(); if (_seller != sender) revert NotVoucherHolder(); + // Cache price discovery contract address + address bosonPriceDiscovery = protocolAddresses().priceDiscovery; + // 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 - if (protocolBalanceAfter < protocolBalanceBefore) revert NegativePriceNotAllowed(); - actualPrice = protocolBalanceAfter - protocolBalanceBefore; - - // Make sure that balance change is at least the expected price - if (actualPrice < _priceDiscovery.price) revert InsufficientValueReceived(); + _bosonVoucher.transferFrom(_seller, bosonPriceDiscovery, _tokenId); + + actualPrice = IBosonPriceDiscovery(bosonPriceDiscovery).fulfilBidOrder{ value: msg.value }( + _tokenId, + _exchangeToken, + _priceDiscovery, + _seller, + _bosonVoucher + ); // 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 + * - The wrapper does not own the voucher * - Token id sent to buyer and token id set by the caller don't match * * @param _tokenId - the id of the token @@ -255,48 +205,18 @@ contract PriceDiscoveryBase is ProtocolBase { address owner = _bosonVoucher.ownerOf(_tokenId); if (owner != _priceDiscovery.priceDiscoveryContract) revert NotVoucherHolder(); - // 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; + // Cache price discovery contract address + address bosonPriceDiscovery = protocolAddresses().priceDiscovery; - // 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)); - if (protocolNativeBalanceAfter != protocolNativeBalanceBefore) revert NativeNotAllowed(); - - // Check balance after the price discovery call - uint256 protocolBalanceAfter = getBalance(_exchangeToken, address(this)); - - // Verify that actual price is within the expected range - if (protocolBalanceAfter < protocolBalanceBefore) revert NegativePriceNotAllowed(); - 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 - if (actualPrice != _priceDiscovery.price) revert PriceTooLow(); + actualPrice = IBosonPriceDiscovery(bosonPriceDiscovery).handleWrapper{ value: msg.value }( + _exchangeToken, + _priceDiscovery + ); // 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 * diff --git a/contracts/protocol/clients/priceDiscovery/BosonPriceDiscovery.sol b/contracts/protocol/clients/priceDiscovery/BosonPriceDiscovery.sol new file mode 100644 index 000000000..3332db42d --- /dev/null +++ b/contracts/protocol/clients/priceDiscovery/BosonPriceDiscovery.sol @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.22; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ERC165 } from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import { IWrappedNative } from "../../../interfaces/IWrappedNative.sol"; +import { IBosonVoucher } from "../../../interfaces/clients/IBosonVoucher.sol"; +import { IBosonPriceDiscovery } from "../../../interfaces/clients/IBosonPriceDiscovery.sol"; +import { IERC721Receiver } from "../../../interfaces/IERC721Receiver.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"; +import { BosonTypes } from "../../../domain/BosonTypes.sol"; +import { BosonErrors } from "../../../domain/BosonErrors.sol"; + +/** + * @title BosonPriceDiscovery + * + * @dev Boson Price Discovery is an external contract that is used to determine the price of an exchange. + */ +contract BosonPriceDiscovery is ERC165, IBosonPriceDiscovery, BosonErrors { + using Address for address; + using SafeERC20 for IERC20; + + IWrappedNative internal immutable wNative; + + address private incomingTokenAddress; + + address private immutable bosonProtocolAddress; + + /** + * @notice + * For offers with native exchange token, it is expected that the price discovery contracts will + * operate with wrapped native token. Set the address of the wrapped native token in the constructor. + * + * @param _wNative - the address of the wrapped native token + */ + //solhint-disable-next-line + constructor(address _wNative, address _bosonProtocolAddress) { + if (_wNative == address(0) || _bosonProtocolAddress == address(0)) revert InvalidAddress(); + wNative = IWrappedNative(_wNative); + bosonProtocolAddress = _bosonProtocolAddress; + } + + /** + * @notice Fulfils an ask order on external contract. + * + * Reverts if: + * - Call to price discovery contract fails + * - The implied price is negative + * - Any external calls to erc20 contract fail + * + * @param _exchangeToken - the address of the exchange contract + * @param _priceDiscovery - the fully populated BosonTypes.PriceDiscovery struct + * @param _bosonVoucher - the boson voucher contract + * @param _msgSender - the address of the caller, as seen in boson protocol + * @return actualPrice - the actual price of the order + */ + function fulfilAskOrder( + address _exchangeToken, + BosonTypes.PriceDiscovery calldata _priceDiscovery, + IBosonVoucher _bosonVoucher, + address payable _msgSender + ) external onlyProtocol returns (uint256 actualPrice) { + // Boson protocol (the caller) is trusted, so it can be assumed that all funds were forwarded to this contract + // If token is ERC20, approve price discovery contract to transfer the funds + if (_exchangeToken != address(0) && _priceDiscovery.price > 0) { + IERC20(_exchangeToken).forceApprove(_priceDiscovery.conduit, _priceDiscovery.price); + } + + uint256 thisBalanceBefore = getBalance(_exchangeToken); + + // Call the price discovery contract + incomingTokenAddress = address(_bosonVoucher); + _priceDiscovery.priceDiscoveryContract.functionCallWithValue( + _priceDiscovery.priceDiscoveryData, + _exchangeToken == address(0) ? _priceDiscovery.price : 0 + ); + + uint256 thisBalanceAfter = getBalance(_exchangeToken); + if (thisBalanceBefore < thisBalanceAfter) revert NegativePriceNotAllowed(); + unchecked { + actualPrice = thisBalanceBefore - thisBalanceAfter; + } + + // If token is ERC20, reset approval + if (_exchangeToken != address(0) && _priceDiscovery.price > 0) { + IERC20(_exchangeToken).forceApprove(address(_priceDiscovery.conduit), 0); + } + + uint256 overchargedAmount = _priceDiscovery.price - actualPrice; + + if (overchargedAmount > 0) { + // Return the surplus to caller + FundsLib.transferFundsFromProtocol(_exchangeToken, _msgSender, overchargedAmount); + } + + // sometimes tokenId is unknow, so we approve all. Since protocol is trusted, this is ok. + if (!_bosonVoucher.isApprovedForAll(address(this), bosonProtocolAddress)) { + _bosonVoucher.setApprovalForAll(bosonProtocolAddress, true); // approve protocol + } + + // In ask order, the this client does not receive the proceeds of the sale. + // Boson protocol handles the encumbering of the proceeds. + } + + /** + * @notice Fulfils a bid order on external contract. + * + * Reverts if: + * - Call to price discovery contract fails + * - Protocol balance change after price discovery call is lower than the expected price + * - This contract is still owner of the voucher + * - Token id sent to buyer and token id set by the caller don't match + * - The implied price is negative + * - Any external calls to erc20 contract fail + * + * @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, + BosonTypes.PriceDiscovery calldata _priceDiscovery, + address _seller, + IBosonVoucher _bosonVoucher + ) external payable onlyProtocol returns (uint256 actualPrice) { + // 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 thisNativeBalanceBefore = getBalance(address(0)) - msg.value; + + // Get protocol balance before calling price discovery contract + uint256 thisBalanceBefore = getBalance(_exchangeToken); + + // Call the price discovery contract + _priceDiscovery.priceDiscoveryContract.functionCallWithValue(_priceDiscovery.priceDiscoveryData, msg.value); + + // Get protocol balance after calling price discovery contract + uint256 thisBalanceAfter = getBalance(_exchangeToken); + + // Check the native balance and return the surplus to seller + uint256 thisNativeBalanceAfter = getBalance(address(0)); + if (thisNativeBalanceAfter > thisNativeBalanceBefore) { + // Return the surplus to seller + FundsLib.transferFundsFromProtocol( + address(0), + payable(_seller), + thisNativeBalanceAfter - thisNativeBalanceBefore + ); + } + + // Calculate actual price + if (thisBalanceAfter < thisBalanceBefore) revert NegativePriceNotAllowed(); + unchecked { + actualPrice = thisBalanceAfter - thisBalanceBefore; + } + + // Make sure that balance change is at least the expected price + if (actualPrice < _priceDiscovery.price) revert InsufficientValueReceived(); + + // Make sure the voucher was transferred + if (_bosonVoucher.ownerOf(_tokenId) == address(this)) { + revert VoucherNotTransferred(); + } + + // Send the actual price back to the protocol + if (actualPrice > 0) { + FundsLib.transferFundsFromProtocol(_exchangeToken, payable(bosonProtocolAddress), actualPrice); + } + } + + /** + * @notice Call `unwrap` (or equivalent) function on the price discovery contract. + * + * Reverts if: + * - Protocol balance doesn't increase by the expected amount. + * - Token id sent to buyer and token id set by the caller don't match + * - The wrapper contract sends back the native currency + * - The implied price is negative + * + * @param _exchangeToken - the address of the exchange contract + * @param _priceDiscovery - the fully populated BosonTypes.PriceDiscovery struct + * @return actualPrice - the actual price of the order + */ + function handleWrapper( + address _exchangeToken, + BosonTypes.PriceDiscovery calldata _priceDiscovery + ) external payable onlyProtocol returns (uint256 actualPrice) { + // Check balance before calling wrapper + bool isNative = _exchangeToken == address(0); + if (isNative) _exchangeToken = address(wNative); + uint256 thisBalanceBefore = getBalance(_exchangeToken); + + // 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 thisNativeBalanceBefore = getBalance(address(0)) - 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 thisNativeBalanceAfter = getBalance(address(0)); + if (thisNativeBalanceAfter != thisNativeBalanceBefore) revert NativeNotAllowed(); + + // Check balance after the price discovery call + uint256 thisBalanceAfter = getBalance(_exchangeToken); + + // Verify that actual price is within the expected range + if (thisBalanceAfter < thisBalanceBefore) revert NegativePriceNotAllowed(); + unchecked { + actualPrice = thisBalanceAfter - thisBalanceBefore; + } + // 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 + if (actualPrice != _priceDiscovery.price) revert PriceMismatch(); + + // Verify that token id provided by caller matches the token id that the price discovery contract has sent to buyer + // getAndVerifyTokenId(_tokenId); + // Send the actual price back to the protocol + if (actualPrice > 0) { + FundsLib.transferFundsFromProtocol(_exchangeToken, payable(bosonProtocolAddress), actualPrice); + } + } + + /** + * @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) internal view returns (uint256) { + return _tokenAddress == address(0) ? address(this).balance : IERC20(_tokenAddress).balanceOf(address(this)); + } + + /** + * @dev See {IERC721Receiver-onERC721Received}. + * + * Always returns `IERC721Receiver.onERC721Received.selector`. + */ + function onERC721Received(address, address, uint256, bytes calldata) external virtual override returns (bytes4) { + if (incomingTokenAddress != msg.sender) revert UnexpectedERC721Received(); + + delete incomingTokenAddress; + + return this.onERC721Received.selector; + } + + /** + * @notice Implements the {IERC165} interface. + * + */ + function supportsInterface(bytes4 _interfaceId) public view override returns (bool) { + return (_interfaceId == type(IBosonPriceDiscovery).interfaceId || + _interfaceId == type(IERC721Receiver).interfaceId || + super.supportsInterface(_interfaceId)); + } + + modifier onlyProtocol() { + if (msg.sender != bosonProtocolAddress) revert AccessDenied(); + _; + } + + receive() external payable { + // This is needed to receive native currency + } +} diff --git a/contracts/protocol/facets/ConfigHandlerFacet.sol b/contracts/protocol/facets/ConfigHandlerFacet.sol index d81f736fe..97581eb63 100644 --- a/contracts/protocol/facets/ConfigHandlerFacet.sol +++ b/contracts/protocol/facets/ConfigHandlerFacet.sol @@ -37,6 +37,7 @@ contract ConfigHandlerFacet is IBosonConfigHandler, ProtocolBase { setTokenAddress(_addresses.token); setTreasuryAddress(_addresses.treasury); setVoucherBeaconAddress(_addresses.voucherBeacon); + setPriceDiscoveryAddress(_addresses.priceDiscovery); setProtocolFeePercentage(_fees.percentage); setProtocolFeeFlatBoson(_fees.flatBoson); setMaxEscalationResponsePeriod(_limits.maxEscalationResponsePeriod); @@ -173,6 +174,32 @@ contract ConfigHandlerFacet is IBosonConfigHandler, ProtocolBase { return protocolAddresses().beaconProxy; } + /** + * @notice Sets the Boson Price Discovery contract address. + * + * Emits a PriceDiscoveryAddressChanged event if successful. + * + * Reverts if _priceDiscovery is the zero address + * + * @dev Caller must have ADMIN role. + * + * @param _priceDiscovery - the Boson Price Discovery contract address + */ + function setPriceDiscoveryAddress(address _priceDiscovery) public override onlyRole(ADMIN) nonReentrant { + checkNonZeroAddress(_priceDiscovery); + protocolAddresses().priceDiscovery = _priceDiscovery; + emit PriceDiscoveryAddressChanged(_priceDiscovery, msgSender()); + } + + /** + * @notice Gets the Boson Price Discovery contract address. + * + * @return the Boson Price Discovery contract address + */ + function getPriceDiscoveryAddress() external view override returns (address) { + return protocolAddresses().priceDiscovery; + } + /** * @notice Sets the protocol fee percentage. * diff --git a/contracts/protocol/facets/ExchangeHandlerFacet.sol b/contracts/protocol/facets/ExchangeHandlerFacet.sol index a1afba797..2efbd4f1c 100644 --- a/contracts/protocol/facets/ExchangeHandlerFacet.sol +++ b/contracts/protocol/facets/ExchangeHandlerFacet.sol @@ -10,7 +10,6 @@ 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 { 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,7 +20,7 @@ import { Address } from "@openzeppelin/contracts/utils/Address.sol"; * * @notice Handles exchanges associated with offers within the protocol. */ -contract ExchangeHandlerFacet is DisputeBase, BuyerBase, IBosonExchangeHandler, IERC721Receiver { +contract ExchangeHandlerFacet is DisputeBase, BuyerBase, IBosonExchangeHandler { using Address for address; using Address for address payable; @@ -677,7 +676,7 @@ contract ExchangeHandlerFacet is DisputeBase, BuyerBase, IBosonExchangeHandler, // 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)) { + if (_to == protocolAddresses().priceDiscovery) { // Avoid reentrancy if (ps.incomingVoucherId != 0) revert IncomingVoucherAlreadySet(); @@ -1392,26 +1391,6 @@ contract ExchangeHandlerFacet is DisputeBase, BuyerBase, IBosonExchangeHandler, } } - /** - * @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(); - - if (ps.incomingVoucherId != _tokenId || ps.incomingVoucherCloneAddress != msg.sender) { - revert UnexpectedERC721Received(); - } - - 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/PriceDiscoveryHandlerFacet.sol b/contracts/protocol/facets/PriceDiscoveryHandlerFacet.sol index 9c4ef038d..79dd88378 100644 --- a/contracts/protocol/facets/PriceDiscoveryHandlerFacet.sol +++ b/contracts/protocol/facets/PriceDiscoveryHandlerFacet.sol @@ -26,7 +26,7 @@ import { Address } from "@openzeppelin/contracts/utils/Address.sol"; contract PriceDiscoveryHandlerFacet is IBosonPriceDiscoveryHandler, PriceDiscoveryBase, BuyerBase { /** * @notice - * For offers with native exchange token, it is expected the the price discovery contracts will + * For offers with native exchange token, it is expected that the price discovery contracts will * operate with wrapped native token. Set the address of the wrapped native token in the constructor. * * @param _wNative - the address of the wrapped native token diff --git a/contracts/protocol/facets/ProtocolInitializationHandlerFacet.sol b/contracts/protocol/facets/ProtocolInitializationHandlerFacet.sol index 5b45e9a63..50e47f14a 100644 --- a/contracts/protocol/facets/ProtocolInitializationHandlerFacet.sol +++ b/contracts/protocol/facets/ProtocolInitializationHandlerFacet.sol @@ -197,14 +197,18 @@ contract ProtocolInitializationHandlerFacet is IBosonProtocolInitializationHandl * - Length of _sellerIds, _royaltyPercentages and _offerIds arrays do not match * - Any of the offerIds does not exist * - * @param _initializationData - data representing uint256[] _sellerIds, uint256[] _royaltyPercentages, uint256[][] _offerIds + * @param _initializationData - data representing uint256[] _sellerIds, uint256[] _royaltyPercentages, uint256[][] _offerIds, address _priceDiscovery */ function initV2_4_0(bytes calldata _initializationData) internal { // Current version must be 2.3.0 if (protocolStatus().version != bytes32("2.3.0")) revert WrongCurrentVersion(); - (uint256[] memory _royaltyPercentages, uint256[][] memory _sellerIds, uint256[][] memory _offerIds) = abi - .decode(_initializationData, (uint256[], uint256[][], uint256[][])); + ( + uint256[] memory _royaltyPercentages, + uint256[][] memory _sellerIds, + uint256[][] memory _offerIds, + address _priceDiscovery + ) = abi.decode(_initializationData, (uint256[], uint256[][], uint256[][], address)); if (_royaltyPercentages.length != _sellerIds.length || _royaltyPercentages.length != _offerIds.length) revert ArrayLengthMismatch(); @@ -229,6 +233,12 @@ contract ProtocolInitializationHandlerFacet is IBosonProtocolInitializationHandl royaltyInfo.bps.push(_royaltyPercentages[i]); } } + + if (_priceDiscovery != address(0)) { + // We allow it to be 0, since it can be set later + protocolAddresses().priceDiscovery = _priceDiscovery; + emit PriceDiscoveryAddressChanged(_priceDiscovery, msgSender()); + } } /** diff --git a/contracts/protocol/facets/SequentialCommitHandlerFacet.sol b/contracts/protocol/facets/SequentialCommitHandlerFacet.sol index 2df9e7a18..532b7134d 100644 --- a/contracts/protocol/facets/SequentialCommitHandlerFacet.sol +++ b/contracts/protocol/facets/SequentialCommitHandlerFacet.sol @@ -21,7 +21,7 @@ contract SequentialCommitHandlerFacet is IBosonSequentialCommitHandler, PriceDis /** * @notice - * For offers with native exchange token, it is expected the the price discovery contracts will + * For offers with native exchange token, it is expected that the price discovery contracts will * operate with wrapped native token. Set the address of the wrapped native token in the constructor. * * @param _wNative - the address of the wrapped native token diff --git a/contracts/protocol/libs/ProtocolLib.sol b/contracts/protocol/libs/ProtocolLib.sol index 53d9a585f..c2e25c184 100644 --- a/contracts/protocol/libs/ProtocolLib.sol +++ b/contracts/protocol/libs/ProtocolLib.sol @@ -28,6 +28,8 @@ library ProtocolLib { address voucherBeacon; // Address of the Boson Beacon proxy implementation address beaconProxy; + // Address of the Boson Price Discovery + address priceDiscovery; } // Protocol limits storage diff --git a/scripts/config/facet-deploy.js b/scripts/config/facet-deploy.js index 5efa5b8b4..569fd6f73 100644 --- a/scripts/config/facet-deploy.js +++ b/scripts/config/facet-deploy.js @@ -14,6 +14,7 @@ function getConfigHandlerInitArgs() { treasury: protocolConfig.TREASURY[network], voucherBeacon: protocolConfig.BEACON[network], beaconProxy: protocolConfig.BEACON_PROXY[network], + priceDiscovery: protocolConfig.PRICE_DISCOVERY[network], }, protocolConfig.limits, protocolConfig.fees, diff --git a/scripts/config/protocol-parameters.js b/scripts/config/protocol-parameters.js index 657890853..5d1185eb4 100644 --- a/scripts/config/protocol-parameters.js +++ b/scripts/config/protocol-parameters.js @@ -93,4 +93,14 @@ module.exports = { polygon: "0x17CDD65bebDe68cd8A4045422Fcff825A0740Ef9", //dummy goerli: "0x17CDD65bebDe68cd8A4045422Fcff825A0740Ef9", //dummy }, + + PRICE_DISCOVERY: { + mainnet: "0x4102621Ac55e068e148Da09151ce92102c952aab", //dummy + hardhat: "0x4102621Ac55e068e148Da09151ce92102c952aab", //dummy + localhost: "0x4102621Ac55e068e148Da09151ce92102c952aab", //dummy + test: "0x4102621Ac55e068e148Da09151ce92102c952aab", //dummy + mumbai: "0x4102621Ac55e068e148Da09151ce92102c952aab", //dummy + polygon: "0x17CDD65bebDe68cd8A4045422Fcff825A0740Ef9", //dummy + goerli: "0x4102621Ac55e068e148Da09151ce92102c952aab", //dummy + }, }; diff --git a/scripts/config/revert-reasons.js b/scripts/config/revert-reasons.js index 42e41f271..41c495590 100644 --- a/scripts/config/revert-reasons.js +++ b/scripts/config/revert-reasons.js @@ -220,10 +220,11 @@ exports.RevertReasons = { INIT_ADDRESS_WITH_NO_CODE: "LibDiamondCut: _init address has no code", // Price discovery related - PRICE_TOO_LOW: "PriceTooLow", + PRICE_MISMATCH: "PriceMismatch", TOKEN_ID_MISMATCH: "TokenIdMismatch", VOUCHER_TRANSFER_NOT_ALLOWED: "VoucherTransferNotAllowed", VOUCHER_NOT_RECEIVED: "VoucherNotReceived", + VOUCHER_NOT_TRANSFERRED: "VoucherNotTransferred", UNEXPECTED_ERC721_RECEIVED: "UnexpectedERC721Received", FEE_AMOUNT_TOO_HIGH: "FeeAmountTooHigh", INVALID_PRICE_DISCOVERY: "InvalidPriceDiscovery", diff --git a/scripts/config/supported-interfaces.js b/scripts/config/supported-interfaces.js index 9ff8eb2aa..0d197dfb4 100644 --- a/scripts/config/supported-interfaces.js +++ b/scripts/config/supported-interfaces.js @@ -56,6 +56,7 @@ async function getInterfaceIds(useCache = true) { "contracts/interfaces/IERC2981.sol:IERC2981", "IAccessControl", "IBosonSequentialCommitHandler", + "IBosonPriceDiscovery", ].forEach((iFace) => { skipBaseCheck[iFace] = false; }); diff --git a/test/example/SnapshotGateTest.js b/test/example/SnapshotGateTest.js index 18492da28..bffde3da4 100644 --- a/test/example/SnapshotGateTest.js +++ b/test/example/SnapshotGateTest.js @@ -69,6 +69,8 @@ describe("SnapshotGate", function () { // Reset the accountId iterator accountId.next(true); + let priceDiscovery; + // Make accounts available [ deployer, @@ -86,6 +88,7 @@ describe("SnapshotGate", function () { holder3, holder4, holder5, + priceDiscovery, ] = await getSigners(); // make all account the same @@ -123,6 +126,7 @@ describe("SnapshotGate", function () { token: await bosonToken.getAddress(), voucherBeacon: await beacon.getAddress(), beaconProxy: ZeroAddress, + priceDiscovery: priceDiscovery.address, // dummy address }, // Protocol limits { diff --git a/test/integration/price-discovery/seaport.js b/test/integration/price-discovery/seaport.js index 542e0cb30..a804384a8 100644 --- a/test/integration/price-discovery/seaport.js +++ b/test/integration/price-discovery/seaport.js @@ -1,5 +1,5 @@ const { ethers } = require("hardhat"); -const { ZeroHash, ZeroAddress, getContractAt, getContractFactory } = ethers; +const { ZeroHash, ZeroAddress, getContractAt, getContractFactory, MaxUint256 } = ethers; const { calculateBosonProxyAddress, @@ -36,11 +36,12 @@ describe("[@skip-on-coverage] seaport integration", function () { let assistant, buyer, DR; let fixtures; let offer, offerDates; - let exchangeHandler, priceDiscoveryHandler; + let priceDiscoveryHandler, fundsHandler; let weth; let seller; let seaport; let snapshotId; + let bpd; before(async function () { accountId.next(); @@ -49,21 +50,30 @@ describe("[@skip-on-coverage] seaport integration", function () { accountHandler: "IBosonAccountHandler", offerHandler: "IBosonOfferHandler", fundsHandler: "IBosonFundsHandler", - exchangeHandler: "IBosonExchangeHandler", priceDiscoveryHandler: "IBosonPriceDiscoveryHandler", + configHandler: "IBosonConfigHandler", }; const wethFactory = await getContractFactory("WETH9"); weth = await wethFactory.deploy(); await weth.waitForDeployment(); - let accountHandler, offerHandler, fundsHandler; + let accountHandler, offerHandler, configHandler; ({ signers: [, assistant, buyer, DR], - contractInstances: { accountHandler, offerHandler, fundsHandler, exchangeHandler, priceDiscoveryHandler }, + contractInstances: { accountHandler, offerHandler, priceDiscoveryHandler, fundsHandler, configHandler }, extraReturnValues: { bosonVoucher }, - } = await setupTestEnvironment(contracts, { wethAddress: await weth.getAddress() })); + } = await setupTestEnvironment(contracts, { + wethAddress: await weth.getAddress(), + })); + + // Add BosonPriceDiscovery + const bpdFactory = await getContractFactory("BosonPriceDiscovery"); + bpd = await bpdFactory.deploy(await weth.getAddress(), await priceDiscoveryHandler.getAddress()); + await bpd.waitForDeployment(); + + await configHandler.setPriceDiscoveryAddress(await bpd.getAddress()); seller = mockSeller(assistant.address, assistant.address, ZeroAddress, assistant.address); @@ -82,10 +92,18 @@ describe("[@skip-on-coverage] seaport integration", function () { ({ offer, offerDates, offerDurations, disputeResolverId } = await mockOffer()); offer.quantityAvailable = 10; offer.priceType = PriceType.Discovery; + const offerFeeLimit = MaxUint256; // unlimited offer fee to not affect the tests await offerHandler .connect(assistant) - .createOffer(offer.toStruct(), offerDates.toStruct(), offerDurations.toStruct(), disputeResolverId, "0"); + .createOffer( + offer.toStruct(), + offerDates.toStruct(), + offerDurations.toStruct(), + disputeResolverId, + "0", + offerFeeLimit + ); const beaconProxyAddress = await calculateBosonProxyAddress(await accountHandler.getAddress()); const voucherAddress = calculateCloneAddress(await accountHandler.getAddress(), beaconProxyAddress, seller.admin); @@ -171,15 +189,14 @@ describe("[@skip-on-coverage] seaport integration", function () { ZeroAddress, ]); - const priceDiscovery = new PriceDiscovery(value, seaportAddress, priceDiscoveryData, Side.Ask); + const priceDiscovery = new PriceDiscovery(value, Side.Ask, seaportAddress, seaportAddress, priceDiscoveryData); - // 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); + // Seller needs to deposit in order to fill the escrow at the last step + await fundsHandler.connect(assistant).depositFunds(seller.id, ZeroAddress, value, { value: value }); const tx = await priceDiscoveryHandler .connect(buyer) - .commitToPriceDiscoveryOffer(buyer.address, offer.id, priceDiscovery, { + .commitToPriceDiscoveryOffer(buyer.address, identifier, priceDiscovery, { value, }); diff --git a/test/integration/price-discovery/sudoswap.js b/test/integration/price-discovery/sudoswap.js index 9f52eab53..4aa61e752 100644 --- a/test/integration/price-discovery/sudoswap.js +++ b/test/integration/price-discovery/sudoswap.js @@ -32,6 +32,7 @@ describe("[@skip-on-coverage] sudoswap integration", function () { let exchangeHandler, priceDiscoveryHandler; let weth, wethAddress; let seller; + let bpd; before(async function () { accountId.next(); @@ -43,6 +44,7 @@ describe("[@skip-on-coverage] sudoswap integration", function () { fundsHandler: "IBosonFundsHandler", exchangeHandler: "IBosonExchangeHandler", priceDiscoveryHandler: "IBosonPriceDiscoveryHandler", + configHandler: "IBosonConfigHandler", }; const wethFactory = await getContractFactory("WETH9"); @@ -50,14 +52,28 @@ describe("[@skip-on-coverage] sudoswap integration", function () { await weth.waitForDeployment(); wethAddress = await weth.getAddress(); - let accountHandler, offerHandler, fundsHandler; + let accountHandler, offerHandler, fundsHandler, configHandler; ({ signers: [deployer, assistant, buyer, DR], - contractInstances: { accountHandler, offerHandler, fundsHandler, exchangeHandler, priceDiscoveryHandler }, + contractInstances: { + accountHandler, + offerHandler, + fundsHandler, + exchangeHandler, + priceDiscoveryHandler, + configHandler, + }, extraReturnValues: { bosonVoucher }, } = await setupTestEnvironment(contracts, { wethAddress })); + // Add BosonPriceDiscovery + const bpdFactory = await getContractFactory("BosonPriceDiscovery"); + bpd = await bpdFactory.deploy(await weth.getAddress(), await priceDiscoveryHandler.getAddress()); + await bpd.waitForDeployment(); + + await configHandler.setPriceDiscoveryAddress(await bpd.getAddress()); + const LSSVMPairEnumerableETH = await getContractFactory("LSSVMPairEnumerableETH", deployer); const lssvmPairEnumerableETH = await LSSVMPairEnumerableETH.deploy(); await lssvmPairEnumerableETH.waitForDeployment(); @@ -110,10 +126,18 @@ describe("[@skip-on-coverage] sudoswap integration", function () { offer.exchangeToken = wethAddress; offer.quantityAvailable = 10; offer.priceType = PriceType.Discovery; + const offerFeeLimit = MaxUint256; // unlimited offer fee to not affect the tests await offerHandler .connect(assistant) - .createOffer(offer.toStruct(), offerDates.toStruct(), offerDurations.toStruct(), disputeResolverId, "0"); + .createOffer( + offer.toStruct(), + offerDates.toStruct(), + offerDurations.toStruct(), + disputeResolverId, + "0", + offerFeeLimit + ); const pool = BigInt(offer.sellerDeposit) * BigInt(offer.quantityAvailable); @@ -158,7 +182,8 @@ describe("[@skip-on-coverage] sudoswap integration", function () { await bosonVoucher.getAddress(), await lssvmPairFactory.getAddress(), await exchangeHandler.getAddress(), - wethAddress + wethAddress, + await bpd.getAddress() ); const wrappedBosonVoucherAddress = await wrappedBosonVoucher.getAddress(); @@ -205,7 +230,13 @@ describe("[@skip-on-coverage] sudoswap integration", function () { const calldata = wrappedBosonVoucher.interface.encodeFunctionData("unwrap", [tokenId]); - const priceDiscovery = new PriceDiscovery(inputAmount, wrappedBosonVoucherAddress, calldata, Side.Ask); + const priceDiscovery = new PriceDiscovery( + inputAmount, + Side.Wrapper, + wrappedBosonVoucherAddress, + wrappedBosonVoucherAddress, + calldata + ); const protocolBalanceBefore = await weth.balanceOf(await exchangeHandler.getAddress()); diff --git a/test/protocol/ConfigHandlerTest.js b/test/protocol/ConfigHandlerTest.js index e0d1e05d3..42472eb85 100644 --- a/test/protocol/ConfigHandlerTest.js +++ b/test/protocol/ConfigHandlerTest.js @@ -16,7 +16,7 @@ const { deployAndCutFacets } = require("../../scripts/util/deploy-protocol-handl describe("IBosonConfigHandler", function () { // Common vars let InterfaceIds, support; - let accounts, deployer, rando, token, treasury, beacon; + let accounts, deployer, rando, token, treasury, beacon, priceDiscovery; let maxOffersPerGroup, maxTwinsPerBundle, maxOffersPerBundle, @@ -45,7 +45,7 @@ describe("IBosonConfigHandler", function () { // Make accounts available accounts = await getSigners(); - [deployer, rando, token, treasury, beacon] = accounts; + [deployer, rando, token, treasury, beacon, priceDiscovery] = accounts; // Deploy the Protocol Diamond [protocolDiamond, , , , accessController] = await deployProtocolDiamond(maxPriorityFeePerGas); @@ -103,6 +103,7 @@ describe("IBosonConfigHandler", function () { treasury: await treasury.getAddress(), voucherBeacon: await beacon.getAddress(), beaconProxy: ZeroAddress, + priceDiscovery: priceDiscovery.address, }, // Protocol limits { @@ -159,6 +160,10 @@ describe("IBosonConfigHandler", function () { .to.emit(configHandler, "BeaconProxyAddressChanged") .withArgs(proxyAddress, await deployer.getAddress()); + await expect(cutTransaction) + .to.emit(configHandler, "PriceDiscoveryAddressChanged") + .withArgs(priceDiscovery.address, await deployer.getAddress()); + await expect(cutTransaction) .to.emit(configHandler, "ProtocolFeePercentageChanged") .withArgs(protocolFeePercentage, await deployer.getAddress()); @@ -200,6 +205,7 @@ describe("IBosonConfigHandler", function () { token: await token.getAddress(), voucherBeacon: await beacon.getAddress(), beaconProxy: ZeroAddress, + priceDiscovery: priceDiscovery.address, }, // Protocol limits { @@ -412,6 +418,47 @@ describe("IBosonConfigHandler", function () { }); }); + context("👉 setPriceDiscoveryAddress()", async function () { + let priceDiscovery; + beforeEach(async function () { + // set new value for price discovery address + priceDiscovery = accounts[9]; + }); + + it("should emit a PriceDiscoveryAddressChanged event", async function () { + // Set new price discovery address, testing for the event + await expect(configHandler.connect(deployer).setPriceDiscoveryAddress(await priceDiscovery.getAddress())) + .to.emit(configHandler, "PriceDiscoveryAddressChanged") + .withArgs(await priceDiscovery.getAddress(), await deployer.getAddress()); + }); + + it("should update state", async function () { + // Set new price discovery address + await configHandler.connect(deployer).setPriceDiscoveryAddress(await priceDiscovery.getAddress()); + + // Verify that new value is stored + expect(await configHandler.connect(rando).getPriceDiscoveryAddress()).to.equal( + await priceDiscovery.getAddress() + ); + }); + + context("💔 Revert Reasons", async function () { + it("caller is not the admin", async function () { + // Attempt to set new price discovery address, expecting revert + await expect( + configHandler.connect(rando).setPriceDiscoveryAddress(await priceDiscovery.getAddress()) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.ACCESS_DENIED); + }); + + it("price discovery address is the zero address", async function () { + // Attempt to set new price discovery address, expecting revert + await expect( + configHandler.connect(deployer).setPriceDiscoveryAddress(ZeroAddress) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_ADDRESS); + }); + }); + }); + context("👉 setProtocolFeePercentage()", async function () { let protocolFeePercentage; beforeEach(async function () { @@ -930,6 +977,10 @@ describe("IBosonConfigHandler", function () { proxyAddress, "Invalid voucher proxy address" ); + expect(await configHandler.connect(rando).getPriceDiscoveryAddress()).to.equal( + priceDiscovery.address, + "Invalid voucher proxy address" + ); expect(await configHandler.connect(rando).getProtocolFeePercentage()).to.equal( protocolFeePercentage, "Invalid protocol fee percentage" diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index a26c83677..bd0acec6f 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -111,6 +111,7 @@ describe("IBosonFundsHandler", function () { let beaconProxyAddress; let offerFeeLimit; let bosonErrors; + let bpd; before(async function () { accountId.next(true); @@ -118,6 +119,11 @@ describe("IBosonFundsHandler", function () { // get interface Ids InterfaceIds = await getInterfaceIds(); + // Add WETH + const wethFactory = await getContractFactory("WETH9"); + const weth = await wethFactory.deploy(); + await weth.waitForDeployment(); + // Specify contracts needed for this test const contracts = { erc165: "ERC165Facet", @@ -161,7 +167,9 @@ describe("IBosonFundsHandler", function () { protocolConfig: [, , { percentage: protocolFeePercentage, buyerEscalationDepositPercentage }], diamondAddress: protocolDiamondAddress, extraReturnValues: { accessController }, - } = await setupTestEnvironment(contracts)); + } = await setupTestEnvironment(contracts, { + wethAddress: await weth.getAddress(), + })); bosonErrors = await getContractAt("BosonErrors", protocolDiamondAddress); @@ -175,8 +183,15 @@ describe("IBosonFundsHandler", function () { // Deploy the mock token [mockToken] = await deployMockTokens(["Foreign20"]); + // Add BosonPriceDiscovery + const bpdFactory = await getContractFactory("BosonPriceDiscovery"); + bpd = await bpdFactory.deploy(await weth.getAddress(), protocolDiamondAddress); + await bpd.waitForDeployment(); + + await configHandler.setPriceDiscoveryAddress(await bpd.getAddress()); + // Deploy PriceDiscovery contract - const PriceDiscoveryFactory = await ethers.getContractFactory("PriceDiscovery"); + const PriceDiscoveryFactory = await ethers.getContractFactory("PriceDiscoveryMock"); priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); await priceDiscoveryContract.waitForDeployment(); @@ -1609,6 +1624,7 @@ describe("IBosonFundsHandler", function () { disputeResolverId = mo.disputeResolverId; agentId = "0"; // agent id is optional while creating an offer + offerFeeLimit = MaxUint256; // Create both offers await Promise.all([ offerHandler @@ -4626,7 +4642,7 @@ describe("IBosonFundsHandler", function () { protocolId = 0; // Create buyer with protocol address to not mess up ids in tests - await accountHandler.createBuyer(mockBuyer(await exchangeHandler.getAddress())); + await accountHandler.createBuyer(mockBuyer(await bpd.getAddress())); // commit to offer await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id); @@ -6420,7 +6436,7 @@ describe("IBosonFundsHandler", function () { buyerId = 5; // Create buyer with protocol address to not mess up ids in tests - await accountHandler.createBuyer(mockBuyer(await exchangeHandler.getAddress())); + await accountHandler.createBuyer(mockBuyer(await bpd.getAddress())); // commit to offer await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id); diff --git a/test/protocol/PriceDiscoveryHandlerFacet.js b/test/protocol/PriceDiscoveryHandlerFacet.js index 6837f23e3..c6ff9ecb7 100644 --- a/test/protocol/PriceDiscoveryHandlerFacet.js +++ b/test/protocol/PriceDiscoveryHandlerFacet.js @@ -79,6 +79,7 @@ describe("IPriceDiscoveryHandlerFacet", function () { let bosonVoucher; let offerFeeLimit; let bosonErrors; + let bpd; before(async function () { accountId.next(true); @@ -117,16 +118,25 @@ describe("IPriceDiscoveryHandlerFacet", function () { }, protocolConfig: [, , { percentage: protocolFeePercentage }], diamondAddress: protocolDiamondAddress, - } = await setupTestEnvironment(contracts, { wethAddress: await weth.getAddress() })); + } = await setupTestEnvironment(contracts, { + wethAddress: await weth.getAddress(), + })); bosonErrors = await getContractAt("BosonErrors", await configHandler.getAddress()); + // Add BosonPriceDiscovery + const bpdFactory = await getContractFactory("BosonPriceDiscovery"); + bpd = await bpdFactory.deploy(await weth.getAddress(), protocolDiamondAddress); + await bpd.waitForDeployment(); + + await configHandler.setPriceDiscoveryAddress(await bpd.getAddress()); + // make all account the same assistant = admin; assistantDR = adminDR; // Deploy PriceDiscovery contract - const PriceDiscoveryFactory = await getContractFactory("PriceDiscovery"); + const PriceDiscoveryFactory = await getContractFactory("PriceDiscoveryMock"); priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); await priceDiscoveryContract.waitForDeployment(); @@ -701,6 +711,41 @@ describe("IPriceDiscoveryHandlerFacet", function () { .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery, { value: price }) ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_CONDUIT_ADDRESS); }); + + it("Transferred voucher is part of a different offer", async function () { + // create 2nd offer + const newOffer = offer.clone(); + newOffer.id = "2"; + await offerHandler + .connect(assistant) + .createOffer(newOffer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + await offerHandler + .connect(assistant) + .reserveRange(newOffer.id, newOffer.quantityAvailable, assistant.address); + await bosonVoucher.connect(assistant).preMint(newOffer.id, newOffer.quantityAvailable); + + const newExchangeId = "12"; + const newTokenId = deriveTokenId(newOffer.id, newExchangeId); + + order.tokenId = newTokenId; + 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, offer.id, priceDiscovery, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.TOKEN_ID_MISMATCH); + }); }); }); @@ -987,7 +1032,9 @@ describe("IPriceDiscoveryHandlerFacet", function () { it("voucher transfer not approved", async function () { // revoke approval - await bosonVoucherClone.connect(assistant).setApprovalForAll(await exchangeHandler.getAddress(), false); + await bosonVoucherClone + .connect(assistant) + .setApprovalForAll(await priceDiscoveryHandler.getAddress(), false); // Attempt to commit to, expecting revert await expect( @@ -1114,7 +1161,8 @@ describe("IPriceDiscoveryHandlerFacet", function () { await bosonVoucher.getAddress(), await mockAuction.getAddress(), await exchangeHandler.getAddress(), - await weth.getAddress() + await weth.getAddress(), + await bpd.getAddress() ); // 3. Wrap voucher @@ -1235,7 +1283,8 @@ describe("IPriceDiscoveryHandlerFacet", function () { await bosonVoucher.getAddress(), await mockAuction.getAddress(), await exchangeHandler.getAddress(), - await weth.getAddress() + await weth.getAddress(), + await bpd.getAddress() ); // Price discovery data @@ -1315,15 +1364,14 @@ describe("IPriceDiscoveryHandlerFacet", function () { priceDiscoveryHandler .connect(assistant) .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.PRICE_TOO_LOW); + ).to.revertedWithCustomError(bosonErrors, RevertReasons.PRICE_MISMATCH); }); it("Negative price", async function () { // Deposit some weth to the protocol const wethAddress = await weth.getAddress(); await weth.connect(assistant).deposit({ value: parseUnits("1", "ether") }); - await weth.connect(assistant).approve(await fundsHandler.getAddress(), parseUnits("1", "ether")); - await fundsHandler.connect(assistant).depositFunds(seller.id, wethAddress, parseUnits("1", "ether")); + await weth.connect(assistant).transfer(await bpd.getAddress(), parseUnits("1", "ether")); const calldata = weth.interface.encodeFunctionData("transfer", [ rando.address, diff --git a/test/protocol/ProtocolInitializationHandlerTest.js b/test/protocol/ProtocolInitializationHandlerTest.js index 20dbabec5..cb9392eed 100644 --- a/test/protocol/ProtocolInitializationHandlerTest.js +++ b/test/protocol/ProtocolInitializationHandlerTest.js @@ -25,7 +25,12 @@ const { maxPriorityFeePerGas, oneWeek, oneMonth } = require("../util/constants") const { getFees } = require("../../scripts/util/utils"); const { getFacetAddCut, getFacetReplaceCut } = require("../../scripts/util/diamond-utils"); const { RevertReasons } = require("../../scripts/config/revert-reasons.js"); -const { getFacetsWithArgs, getMappingStoragePosition, paddingType } = require("../util/utils.js"); +const { + getFacetsWithArgs, + getMappingStoragePosition, + paddingType, + compareProtocolVersions, +} = require("../util/utils.js"); const { getV2_2_0DeployConfig } = require("../upgrade/00_config.js"); const { deployProtocolClients } = require("../../scripts/util/deploy-protocol-clients"); const TokenType = require("../../scripts/domain/TokenType"); @@ -60,7 +65,7 @@ describe("ProtocolInitializationHandler", async function () { // Temporarily grant UPGRADER role to deployer account await accessController.grantRole(Role.UPGRADER, await deployer.getAddress()); - // Temporarily grant UPGRADER role to deployer 1ccount + // Temporarily grant UPGRADER role to deployer account await accessController.grantRole(Role.UPGRADER, await deployer.getAddress()); // Cast Diamond to IERC165 @@ -96,7 +101,9 @@ describe("ProtocolInitializationHandler", async function () { maxPriorityFeePerGas ); - expect(cutTransaction).to.emit(protocolInitializationFacet, "ProtocolInitialized").withArgs(version); + await expect(cutTransaction) + .to.emit(protocolInitializationFacet, "ProtocolInitialized") + .withArgs(compareProtocolVersions.bind(version)); }); context("💔 Revert Reasons", async function () { @@ -608,7 +615,9 @@ describe("ProtocolInitializationHandler", async function () { calldataProtocolInitialization, await getFees(maxPriorityFeePerGas) ); - expect(tx).to.emit(deployedProtocolInitializationHandlerFacet, "ProtocolInitialized"); + await expect(tx) + .to.emit(protocolInitializationFacet, "ProtocolInitialized") + .withArgs(compareProtocolVersions.bind(version)); }); context("💔 Revert Reasons", async function () { @@ -908,6 +917,7 @@ describe("ProtocolInitializationHandler", async function () { let deployedProtocolInitializationHandlerFacet; let facetCut; let calldataProtocolInitialization; + let priceDiscoveryAddress; beforeEach(async function () { version = "2.3.0"; @@ -945,7 +955,11 @@ describe("ProtocolInitializationHandler", async function () { deployedProtocolInitializationHandlerFacet.interface.fragments.find((f) => f.name == "initialize").selector, ]); - initializationData = abiCoder.encode(["uint256[]", "uint256[][]", "uint256[][]"], [[], [], []]); + priceDiscoveryAddress = rando.address; + initializationData = abiCoder.encode( + ["uint256[]", "uint256[][]", "uint256[][]", "address"], + [[], [], [], priceDiscoveryAddress] + ); // Prepare calldata calldataProtocolInitialization = deployedProtocolInitializationHandlerFacet.interface.encodeFunctionData( @@ -954,7 +968,7 @@ describe("ProtocolInitializationHandler", async function () { ); }); - it("Should initialize version 2.4.0 and emit ProtocolInitialized", async function () { + it("Should initialize version 2.4.0 and emit ProtocolInitialized and PriceDiscoveryAddressChanged", async function () { // Make the cut, check the event const tx = await diamondCutFacet.diamondCut( [facetCut], @@ -962,7 +976,27 @@ describe("ProtocolInitializationHandler", async function () { calldataProtocolInitialization, await getFees(maxPriorityFeePerGas) ); - expect(tx).to.emit(deployedProtocolInitializationHandlerFacet, "ProtocolInitialized"); + + await expect(tx) + .to.emit(protocolInitializationFacet, "ProtocolInitialized") + .withArgs(compareProtocolVersions.bind(version)); + + await expect(tx) + .to.emit(protocolInitializationFacet, "PriceDiscoveryAddressChanged") + .withArgs(priceDiscoveryAddress, deployer.address); + }); + + it("Should set the correct Price Discovery address", async function () { + // Make the cut + await diamondCutFacet.diamondCut( + [facetCut], + await deployedProtocolInitializationHandlerFacet.getAddress(), + calldataProtocolInitialization, + await getFees(maxPriorityFeePerGas) + ); + + const configHandler = await getContractAt("IBosonConfigHandler", await protocolDiamond.getAddress()); + expect(await configHandler.connect(rando).getPriceDiscoveryAddress()).to.equal(priceDiscoveryAddress); }); context("Data backfilling", async function () { @@ -1037,8 +1071,8 @@ describe("ProtocolInitializationHandler", async function () { ]; initializationData = abiCoder.encode( - ["uint256[]", "uint256[][]", "uint256[][]"], - [royaltyPercentages, sellerIds, offerIds] + ["uint256[]", "uint256[][]", "uint256[][]", "address"], + [royaltyPercentages, sellerIds, offerIds, ZeroAddress] ); expectedRoyaltyRecipientLists = [ diff --git a/test/protocol/SequentialCommitHandlerTest.js b/test/protocol/SequentialCommitHandlerTest.js index edc879998..40bb8e33a 100644 --- a/test/protocol/SequentialCommitHandlerTest.js +++ b/test/protocol/SequentialCommitHandlerTest.js @@ -76,6 +76,7 @@ describe("IBosonSequentialCommitHandler", function () { let tokenId; let offerFeeLimit; let bosonErrors; + let bpd; before(async function () { accountId.next(true); @@ -114,10 +115,19 @@ describe("IBosonSequentialCommitHandler", function () { }, protocolConfig: [, , { percentage: protocolFeePercentage }], diamondAddress: protocolDiamondAddress, - } = await setupTestEnvironment(contracts, { wethAddress: await weth.getAddress() })); + } = await setupTestEnvironment(contracts, { + wethAddress: await weth.getAddress(), + })); bosonErrors = await getContractAt("BosonErrors", await configHandler.getAddress()); + // Add BosonPriceDiscovery + const bpdFactory = await getContractFactory("BosonPriceDiscovery"); + bpd = await bpdFactory.deploy(await weth.getAddress(), protocolDiamondAddress); + await bpd.waitForDeployment(); + + await configHandler.setPriceDiscoveryAddress(await bpd.getAddress()); + // make all account the same assistant = admin; assistantDR = adminDR; @@ -125,7 +135,7 @@ describe("IBosonSequentialCommitHandler", function () { [deployer] = await getSigners(); // Deploy PriceDiscovery contract - const PriceDiscoveryFactory = await getContractFactory("PriceDiscovery"); + const PriceDiscoveryFactory = await getContractFactory("PriceDiscoveryMock"); priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); await priceDiscoveryContract.waitForDeployment(); @@ -972,7 +982,9 @@ describe("IBosonSequentialCommitHandler", function () { // Seller approves protocol to transfer the voucher bosonVoucherClone = await getContractAt("IBosonVoucher", expectedCloneAddress); - await bosonVoucherClone.connect(reseller).setApprovalForAll(await exchangeHandler.getAddress(), true); + await bosonVoucherClone + .connect(reseller) + .setApprovalForAll(await sequentialCommitHandler.getAddress(), true); mockBuyer(reseller.address); // call only to increment account id counter newBuyer = mockBuyer(buyer2.address); @@ -1271,7 +1283,9 @@ describe("IBosonSequentialCommitHandler", function () { it("voucher transfer not approved", async function () { // revoke approval - await bosonVoucherClone.connect(reseller).setApprovalForAll(await exchangeHandler.getAddress(), false); + await bosonVoucherClone + .connect(reseller) + .setApprovalForAll(await sequentialCommitHandler.getAddress(), false); // Attempt to sequentially commit to, expecting revert await expect( @@ -1359,7 +1373,9 @@ describe("IBosonSequentialCommitHandler", function () { // Seller approves protocol to transfer the voucher bosonVoucherClone = await getContractAt("IBosonVoucher", expectedCloneAddress); - await bosonVoucherClone.connect(reseller).setApprovalForAll(await exchangeHandler.getAddress(), true); + await bosonVoucherClone + .connect(reseller) + .setApprovalForAll(await sequentialCommitHandler.getAddress(), true); mockBuyer(buyer.address); // call only to increment account id counter newBuyer = mockBuyer(buyer2.address); @@ -1532,7 +1548,7 @@ describe("IBosonSequentialCommitHandler", function () { it("should transfer the voucher during sequential commit", async function () { // Deploy PriceDiscovery contract - const PriceDiscoveryFactory = await getContractFactory("PriceDiscovery"); + const PriceDiscoveryFactory = await getContractFactory("PriceDiscoveryMock"); priceDiscoveryContract = await PriceDiscoveryFactory.deploy(); await priceDiscoveryContract.waitForDeployment(); @@ -1667,7 +1683,7 @@ describe("IBosonSequentialCommitHandler", function () { // Attempt to sequentially commit, expecting revert await expect( - foreign721["safeTransferFrom(address,address,uint256)"](deployer.address, protocolDiamondAddress, tokenId) + foreign721["safeTransferFrom(address,address,uint256)"](deployer.address, await bpd.getAddress(), tokenId) ).to.revertedWithCustomError(bosonErrors, RevertReasons.UNEXPECTED_ERC721_RECEIVED); }); }); diff --git a/test/protocol/clients/PriceDiscoveryTest.js b/test/protocol/clients/PriceDiscoveryTest.js new file mode 100644 index 000000000..86bc8d3eb --- /dev/null +++ b/test/protocol/clients/PriceDiscoveryTest.js @@ -0,0 +1,753 @@ +const { ethers } = require("hardhat"); +const { expect } = require("chai"); +const { ZeroAddress, getSigners, getContractAt, getContractFactory, provider, parseUnits } = ethers; + +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"); +const { getSnapshot, revertToSnapshot } = require("../../util/utils.js"); +const { deployMockTokens } = require("../../../scripts/util/deploy-mock-tokens"); + +describe("IPriceDiscovery", function () { + let interfaceIds; + let bosonVoucher; + let protocol, buyer, seller, rando, foreign20; + let snapshotId; + let bosonErrors; + let bosonPriceDiscovery; + let externalPriceDiscovery; + let weth; + + before(async function () { + // Get interface id + const { IBosonPriceDiscovery, IERC721Receiver } = await getInterfaceIds(); + interfaceIds = { IBosonPriceDiscovery, IERC721Receiver }; + + // Use EOA for tests + [protocol, seller, buyer, rando] = await getSigners(); + + // Add WETH + const wethFactory = await getContractFactory("WETH9"); + weth = await wethFactory.deploy(); + await weth.waitForDeployment(); + + const bosonPriceDiscoveryFactory = await getContractFactory("BosonPriceDiscovery"); + bosonPriceDiscovery = await bosonPriceDiscoveryFactory.deploy(await weth.getAddress(), protocol.address); + await bosonPriceDiscovery.waitForDeployment(); + + bosonErrors = await getContractAt("BosonErrors", await bosonPriceDiscovery.getAddress()); + + // Deploy external price discovery + const externalPriceDiscoveryFactory = await getContractFactory("PriceDiscoveryMock"); + externalPriceDiscovery = await externalPriceDiscoveryFactory.deploy(); + await externalPriceDiscovery.waitForDeployment(); + + // Deploy BosonVoucher + [bosonVoucher, foreign20] = await deployMockTokens(["Foreign721", "Foreign20"]); // For the purpose of testing, a regular erc721 is ok + await foreign20.mint(seller.address, parseUnits("1000", "ether")); + await foreign20.mint(buyer.address, parseUnits("1000", "ether")); + await foreign20.mint(protocol.address, parseUnits("1000", "ether")); + + // Get snapshot id + snapshotId = await getSnapshot(); + }); + + afterEach(async function () { + await revertToSnapshot(snapshotId); + snapshotId = await getSnapshot(); + }); + + // Interface support + context("📋 Interfaces", async function () { + context("👉 supportsInterface()", async function () { + it("should indicate support for IBosonPriceDiscovery and IERC721Receiver interface", async function () { + // IBosonPriceDiscovery interface + let support = await bosonPriceDiscovery.supportsInterface(interfaceIds["IBosonPriceDiscovery"]); + expect(support, "IBosonPriceDiscovery interface not supported").is.true; + + // IERC721Receiver interface + support = await bosonPriceDiscovery.supportsInterface(interfaceIds["IERC721Receiver"]); + expect(support, "IERC721Receiver interface not supported").is.true; + }); + }); + }); + + context("📋 Constructor", async function () { + it("Deployment fails if wrapped native address is 0", async function () { + const bosonPriceDiscoveryFactory = await getContractFactory("BosonPriceDiscovery"); + + await expect(bosonPriceDiscoveryFactory.deploy(ZeroAddress, protocol.address)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.INVALID_ADDRESS + ); + }); + + it("Deployment fails if protocol address is 0", async function () { + const bosonPriceDiscoveryFactory = await getContractFactory("BosonPriceDiscovery"); + + await expect(bosonPriceDiscoveryFactory.deploy(await weth.getAddress(), ZeroAddress)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.INVALID_ADDRESS + ); + }); + }); + + context("General", async function () { + context("👉 fulfilAskOrder()", async function () { + let orderType = 0; + + context("Native token", async function () { + let order, price, priceDiscovery, exchangeToken; + + beforeEach(async function () { + price = 100n; + + order = { + seller: seller.address, + buyer: buyer.address, + voucherContract: await bosonVoucher.getAddress(), + tokenId: 0, + exchangeToken: ZeroAddress, + price: price, + }; + + await externalPriceDiscovery.setExpectedValues(order, orderType); + await protocol.sendTransaction({ to: await bosonPriceDiscovery.getAddress(), value: price }); + + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [0]); + + priceDiscovery = new PriceDiscovery( + price, + Side.Ask, + await externalPriceDiscovery.getAddress(), + await externalPriceDiscovery.getAddress(), + calldata + ); + + exchangeToken = ZeroAddress; + }); + + it("forwards call to priceDiscovery", async function () { + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilAskOrder(exchangeToken, priceDiscovery, await bosonVoucher.getAddress(), buyer.address) + ).to.emit(externalPriceDiscovery, "MockFulfilCalled"); + }); + + it("if priceDiscovery returns some funds, it forwards then to the buyer", async function () { + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [10]); + priceDiscovery.priceDiscoveryData = calldata; + + const buyerBalanceBefore = await provider.getBalance(buyer.address); + + await bosonPriceDiscovery + .connect(protocol) + .fulfilAskOrder(exchangeToken, priceDiscovery, await bosonVoucher.getAddress(), buyer.address); + + const buyerBalanceAfter = await provider.getBalance(buyer.address); + expect(buyerBalanceAfter - buyerBalanceBefore).to.eq(price / 10n); + }); + + context("💔 Revert Reasons", async function () { + it("Caller is not the protocol", async function () { + await expect( + bosonPriceDiscovery + .connect(rando) + .fulfilAskOrder(order.exchangeToken, priceDiscovery, await bosonVoucher.getAddress(), buyer.address) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.ACCESS_DENIED); + }); + + it("Price discovery reverts", async function () { + order.price = 1000n; + await externalPriceDiscovery.setExpectedValues(order, orderType); + + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilAskOrder(order.exchangeToken, priceDiscovery, await bosonVoucher.getAddress(), buyer.address) + ).to.revertedWith("ETH value mismatch"); + }); + + it("Negative price not allowed", async function () { + await protocol.sendTransaction({ to: await externalPriceDiscovery.getAddress(), value: 1000 }); + + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [110]); + priceDiscovery.priceDiscoveryData = calldata; + + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilAskOrder(order.exchangeToken, priceDiscovery, await bosonVoucher.getAddress(), buyer.address) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NEGATIVE_PRICE_NOT_ALLOWED); + }); + }); + }); + + context("ERC20 token", async function () { + let order, price, exchangeToken, priceDiscovery; + + beforeEach(async function () { + price = 100n; + + order = { + seller: seller.address, + buyer: buyer.address, + voucherContract: await bosonVoucher.getAddress(), + tokenId: 0, + exchangeToken: await foreign20.getAddress(), + price: price, + }; + + await externalPriceDiscovery.setExpectedValues(order, orderType); + + await foreign20.connect(protocol).transfer(await bosonPriceDiscovery.getAddress(), price); + + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [25]); + + priceDiscovery = new PriceDiscovery( + price, + Side.Ask, + await externalPriceDiscovery.getAddress(), + await externalPriceDiscovery.getAddress(), + calldata + ); + + exchangeToken = await foreign20.getAddress(); + }); + + it("if priceDiscovery returns some funds, it forwards then to the buyer", async function () { + const buyerBalanceBefore = await foreign20.balanceOf(buyer.address); + + await bosonPriceDiscovery + .connect(protocol) + .fulfilAskOrder(exchangeToken, priceDiscovery, await bosonVoucher.getAddress(), buyer.address); + + const buyerBalanceAfter = await foreign20.balanceOf(buyer.address); + expect(buyerBalanceAfter - buyerBalanceBefore).to.eq(price / 4n); + }); + + it("price discovery is not approved after the transaction is finalized", async function () { + await bosonPriceDiscovery + .connect(protocol) + .fulfilAskOrder(exchangeToken, priceDiscovery, await bosonVoucher.getAddress(), buyer.address); + + const allowance = await foreign20.allowance( + await bosonPriceDiscovery.getAddress(), + await externalPriceDiscovery.getAddress() + ); + expect(allowance).to.eq(0n); + }); + + context("💔 Revert Reasons", async function () { + it("Price discovery reverts", async function () { + order.price = 1000n; + await externalPriceDiscovery.setExpectedValues(order, orderType); + + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilAskOrder(exchangeToken, priceDiscovery, await bosonVoucher.getAddress(), buyer.address) + ).to.revertedWith(RevertReasons.ERC20_INSUFFICIENT_ALLOWANCE); + }); + + it("Negative price not allowed", async function () { + await foreign20.connect(protocol).transfer(await externalPriceDiscovery.getAddress(), 1000); + + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [110]); + priceDiscovery.priceDiscoveryData = calldata; + + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilAskOrder(exchangeToken, priceDiscovery, await bosonVoucher.getAddress(), buyer.address) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NEGATIVE_PRICE_NOT_ALLOWED); + }); + }); + }); + }); + + context("👉 fulfilBidOrder()", async function () { + let orderType = 1; + let tokenId = 1; + + beforeEach(async function () { + // mint the voucher + // for the test purposes, we mint the voucher directly to the bosonPriceDiscovery contract + await bosonVoucher.connect(protocol).mint(tokenId, 1); + await bosonVoucher + .connect(protocol) + .transferFrom(protocol.address, await bosonPriceDiscovery.getAddress(), tokenId); + }); + + context("Native token", async function () { + let order, price, priceDiscovery, exchangeToken; + + beforeEach(async function () { + price = 100n; + + order = { + seller: seller.address, + buyer: buyer.address, + voucherContract: await bosonVoucher.getAddress(), + tokenId: tokenId, + exchangeToken: await weth.getAddress(), + price: price, + }; + + await externalPriceDiscovery.setExpectedValues(order, orderType); + await weth.connect(protocol).deposit({ value: 10n * price }); + await weth.connect(protocol).transfer(await externalPriceDiscovery.getAddress(), 10n * price); + + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [100]); + + priceDiscovery = new PriceDiscovery( + price, + Side.Bid, + await externalPriceDiscovery.getAddress(), + await externalPriceDiscovery.getAddress(), + calldata + ); + + exchangeToken = ZeroAddress; + }); + + it("forwards call to priceDiscovery", async function () { + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilBidOrder(tokenId, exchangeToken, priceDiscovery, seller.address, await bosonVoucher.getAddress()) + ).to.emit(externalPriceDiscovery, "MockFulfilCalled"); + }); + + it("actual price is returned to the protocol", async function () { + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [110]); + priceDiscovery.priceDiscoveryData = calldata; + + const protocolBalanceBefore = await weth.balanceOf(protocol.address); + + await bosonPriceDiscovery + .connect(protocol) + .fulfilBidOrder(tokenId, exchangeToken, priceDiscovery, seller.address, await bosonVoucher.getAddress()); + + const protocolBalanceAfter = await weth.balanceOf(protocol.address); + expect(protocolBalanceAfter - protocolBalanceBefore).to.eq((price * 11n) / 10n); + }); + + context("💔 Revert Reasons", async function () { + it("Caller is not the protocol", async function () { + await expect( + bosonPriceDiscovery + .connect(rando) + .fulfilBidOrder(tokenId, exchangeToken, priceDiscovery, seller.address, await bosonVoucher.getAddress()) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.ACCESS_DENIED); + }); + + it("Price discovery reverts", async function () { + priceDiscovery.conduit = ZeroAddress; + + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilBidOrder(tokenId, exchangeToken, priceDiscovery, seller.address, await bosonVoucher.getAddress()) + ).to.revertedWith(RevertReasons.ERC721_CALLER_NOT_OWNER_OR_APPROVED); + }); + + it("Negative price not allowed", async function () { + await weth.connect(protocol).deposit({ value: 1000n }); + await weth.connect(protocol).transfer(await bosonPriceDiscovery.getAddress(), 1000n); + + const calldata = weth.interface.encodeFunctionData("transfer", [rando.address, 110]); + + const priceDiscovery = new PriceDiscovery( + price, + Side.Bid, + await weth.getAddress(), + await weth.getAddress(), + calldata + ); + + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilBidOrder(tokenId, exchangeToken, priceDiscovery, seller.address, await bosonVoucher.getAddress()) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NEGATIVE_PRICE_NOT_ALLOWED); + }); + + it("Insufficient value received", async function () { + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [90]); + + priceDiscovery.priceDiscoveryData = calldata; + + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilBidOrder(tokenId, exchangeToken, priceDiscovery, seller.address, await bosonVoucher.getAddress()) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INSUFFICIENT_VALUE_RECEIVED); + }); + + it("Voucher not transferred", async function () { + const calldata = foreign20.interface.encodeFunctionData("transfer", [rando.address, 0]); + + const priceDiscovery = new PriceDiscovery( + 0, + Side.Bid, + await foreign20.getAddress(), + await foreign20.getAddress(), + calldata + ); + + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilBidOrder(tokenId, exchangeToken, priceDiscovery, seller.address, await bosonVoucher.getAddress()) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.VOUCHER_NOT_TRANSFERRED); + }); + }); + }); + + context("ERC20 token", async function () { + let order, price, exchangeToken, priceDiscovery; + + beforeEach(async function () { + price = 100n; + + order = { + seller: seller.address, + buyer: buyer.address, + voucherContract: await bosonVoucher.getAddress(), + tokenId: tokenId, + exchangeToken: await foreign20.getAddress(), + price: price, + }; + + await externalPriceDiscovery.setExpectedValues(order, orderType); + + await foreign20.connect(protocol).transfer(await externalPriceDiscovery.getAddress(), 2n * price); + + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [100]); + + priceDiscovery = new PriceDiscovery( + price, + Side.Bid, + await externalPriceDiscovery.getAddress(), + await externalPriceDiscovery.getAddress(), + calldata + ); + + exchangeToken = await foreign20.getAddress(); + }); + + it("forwards call to priceDiscovery", async function () { + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilBidOrder(tokenId, exchangeToken, priceDiscovery, seller.address, await bosonVoucher.getAddress()) + ).to.emit(externalPriceDiscovery, "MockFulfilCalled"); + }); + + it("actual price is returned to the protocol", async function () { + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [110]); + priceDiscovery.priceDiscoveryData = calldata; + + const protocolBalanceBefore = await foreign20.balanceOf(protocol.address); + + await bosonPriceDiscovery + .connect(protocol) + .fulfilBidOrder(tokenId, exchangeToken, priceDiscovery, seller.address, await bosonVoucher.getAddress()); + + const protocolBalanceAfter = await foreign20.balanceOf(protocol.address); + expect(protocolBalanceAfter - protocolBalanceBefore).to.eq((price * 11n) / 10n); + }); + + it("price discovery is not approved after the transaction is finalized", async function () { + await bosonPriceDiscovery + .connect(protocol) + .fulfilBidOrder(tokenId, exchangeToken, priceDiscovery, seller.address, await bosonVoucher.getAddress()); + + const approved = await bosonVoucher.getApproved(tokenId); + expect(approved).to.eq(ZeroAddress); + }); + + context("💔 Revert Reasons", async function () { + it("Price discovery reverts", async function () { + order.price = 1000n; + await externalPriceDiscovery.setExpectedValues(order, orderType); + + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilBidOrder(tokenId, exchangeToken, priceDiscovery, seller.address, await bosonVoucher.getAddress()) + ).to.revertedWith(RevertReasons.ERC20_EXCEEDS_BALANCE); + }); + + it("Negative price not allowed", async function () { + await foreign20.connect(protocol).transfer(await bosonPriceDiscovery.getAddress(), 1000); + + const calldata = foreign20.interface.encodeFunctionData("transfer", [rando.address, 110]); + + const priceDiscovery = new PriceDiscovery( + price, + Side.Bid, + await foreign20.getAddress(), + await foreign20.getAddress(), + calldata + ); + + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilBidOrder(tokenId, exchangeToken, priceDiscovery, seller.address, await bosonVoucher.getAddress()) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NEGATIVE_PRICE_NOT_ALLOWED); + }); + + it("Insufficient value received", async function () { + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [90]); + + priceDiscovery.priceDiscoveryData = calldata; + + await expect( + bosonPriceDiscovery + .connect(protocol) + .fulfilBidOrder(tokenId, exchangeToken, priceDiscovery, seller.address, await bosonVoucher.getAddress()) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INSUFFICIENT_VALUE_RECEIVED); + }); + }); + }); + }); + + context("👉 handleWrapper()", async function () { + let orderType = 2; + let tokenId = 1; + + // beforeEach(async function () { + // // mint the voucher + // // for the test purposes, we mint the voucher directly to the bosonPriceDiscovery contract + // await bosonVoucher.connect(protocol).mint(tokenId, 1); + // await bosonVoucher + // .connect(protocol) + // .transferFrom(protocol.address, await bosonPriceDiscovery.getAddress(), tokenId); + // }); + + context("Native token", async function () { + let order, price, priceDiscovery, exchangeToken; + + beforeEach(async function () { + price = 100n; + + order = { + seller: seller.address, + buyer: buyer.address, + voucherContract: await bosonVoucher.getAddress(), + tokenId: tokenId, + exchangeToken: await weth.getAddress(), + price: price, + }; + + await externalPriceDiscovery.setExpectedValues(order, orderType); + await weth.connect(protocol).deposit({ value: 10n * price }); + await weth.connect(protocol).transfer(await externalPriceDiscovery.getAddress(), 10n * price); + + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [100]); + + priceDiscovery = new PriceDiscovery( + price, + Side.Wrapper, + await externalPriceDiscovery.getAddress(), + await externalPriceDiscovery.getAddress(), + calldata + ); + + exchangeToken = ZeroAddress; + }); + + it("forwards call to priceDiscovery", async function () { + await expect(bosonPriceDiscovery.connect(protocol).handleWrapper(exchangeToken, priceDiscovery)).to.emit( + externalPriceDiscovery, + "MockFulfilCalled" + ); + }); + + it("actual price is returned to the protocol", async function () { + const protocolBalanceBefore = await weth.balanceOf(protocol.address); + + await bosonPriceDiscovery.connect(protocol).handleWrapper(exchangeToken, priceDiscovery); + + const protocolBalanceAfter = await weth.balanceOf(protocol.address); + expect(protocolBalanceAfter - protocolBalanceBefore).to.eq(price); + }); + + context("💔 Revert Reasons", async function () { + it("Caller is not the protocol", async function () { + await expect( + bosonPriceDiscovery.connect(rando).handleWrapper(exchangeToken, priceDiscovery) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.ACCESS_DENIED); + }); + + it("Price discovery reverts", async function () { + await externalPriceDiscovery.setExpectedValues(order, 1); + + await expect( + bosonPriceDiscovery.connect(protocol).handleWrapper(exchangeToken, priceDiscovery) + ).to.revertedWith(RevertReasons.ERC721_INVALID_TOKEN_ID); + }); + + it("Negative price not allowed", async function () { + await weth.connect(protocol).deposit({ value: 1000n }); + await weth.connect(protocol).transfer(await bosonPriceDiscovery.getAddress(), 1000n); + + const calldata = weth.interface.encodeFunctionData("transfer", [rando.address, 110]); + + const priceDiscovery = new PriceDiscovery( + price, + Side.Bid, + await weth.getAddress(), + await weth.getAddress(), + calldata + ); + + await expect( + bosonPriceDiscovery.connect(protocol).handleWrapper(exchangeToken, priceDiscovery) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NEGATIVE_PRICE_NOT_ALLOWED); + }); + + it("Insufficient value received", async function () { + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [90]); + + priceDiscovery.priceDiscoveryData = calldata; + + await expect( + bosonPriceDiscovery.connect(protocol).handleWrapper(exchangeToken, priceDiscovery) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.PRICE_MISMATCH); + }); + + it("Returned too much", async function () { + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [110]); + + priceDiscovery.priceDiscoveryData = calldata; + + await expect( + bosonPriceDiscovery.connect(protocol).handleWrapper(exchangeToken, priceDiscovery) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.PRICE_MISMATCH); + }); + }); + }); + + context("ERC20 token", async function () { + let order, price, exchangeToken, priceDiscovery; + + beforeEach(async function () { + price = 100n; + + order = { + seller: seller.address, + buyer: buyer.address, + voucherContract: await bosonVoucher.getAddress(), + tokenId: tokenId, + exchangeToken: await foreign20.getAddress(), + price: price, + }; + + await externalPriceDiscovery.setExpectedValues(order, orderType); + + await foreign20.connect(protocol).transfer(await externalPriceDiscovery.getAddress(), 2n * price); + + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [100]); + + priceDiscovery = new PriceDiscovery( + price, + Side.Wrapper, + await externalPriceDiscovery.getAddress(), + await externalPriceDiscovery.getAddress(), + calldata + ); + + exchangeToken = await foreign20.getAddress(); + }); + + it("forwards call to priceDiscovery", async function () { + await expect(bosonPriceDiscovery.connect(protocol).handleWrapper(exchangeToken, priceDiscovery)).to.emit( + externalPriceDiscovery, + "MockFulfilCalled" + ); + }); + + it("actual price is returned to the protocol", async function () { + const protocolBalanceBefore = await foreign20.balanceOf(protocol.address); + + await bosonPriceDiscovery.connect(protocol).handleWrapper(exchangeToken, priceDiscovery); + + const protocolBalanceAfter = await foreign20.balanceOf(protocol.address); + expect(protocolBalanceAfter - protocolBalanceBefore).to.eq(price); + }); + + context("💔 Revert Reasons", async function () { + it("Price discovery reverts", async function () { + order.price = 1000n; + await externalPriceDiscovery.setExpectedValues(order, orderType); + + await expect( + bosonPriceDiscovery.connect(protocol).handleWrapper(exchangeToken, priceDiscovery) + ).to.revertedWith(RevertReasons.ERC20_EXCEEDS_BALANCE); + }); + + it("Negative price not allowed", async function () { + await foreign20.connect(protocol).transfer(await bosonPriceDiscovery.getAddress(), 1000); + + const calldata = foreign20.interface.encodeFunctionData("transfer", [rando.address, 110]); + + const priceDiscovery = new PriceDiscovery( + price, + Side.Bid, + await foreign20.getAddress(), + await foreign20.getAddress(), + calldata + ); + + await expect( + bosonPriceDiscovery.connect(protocol).handleWrapper(exchangeToken, priceDiscovery) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NEGATIVE_PRICE_NOT_ALLOWED); + }); + + it("Insufficient value received", async function () { + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [90]); + + priceDiscovery.priceDiscoveryData = calldata; + + await expect( + bosonPriceDiscovery.connect(protocol).handleWrapper(exchangeToken, priceDiscovery) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.PRICE_MISMATCH); + }); + + it("Returned too much", async function () { + const calldata = externalPriceDiscovery.interface.encodeFunctionData("mockFulfil", [110]); + + priceDiscovery.priceDiscoveryData = calldata; + + await expect( + bosonPriceDiscovery.connect(protocol).handleWrapper(exchangeToken, priceDiscovery) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.PRICE_MISMATCH); + }); + }); + }); + }); + }); + + context("📋 onERC721Received", async function () { + it("Can receive voucher only during price discovery", async function () { + const tokenId = 1; + await bosonVoucher.connect(protocol).mint(tokenId, 1); + + await expect( + bosonVoucher + .connect(protocol) + ["safeTransferFrom(address,address,uint256)"]( + protocol.address, + await bosonPriceDiscovery.getAddress(), + tokenId + ) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.UNEXPECTED_ERC721_RECEIVED); + }); + }); +}); diff --git a/test/util/utils.js b/test/util/utils.js index 7214cf997..ac18a5d9e 100644 --- a/test/util/utils.js +++ b/test/util/utils.js @@ -167,6 +167,20 @@ function compareRoyaltyInfo(returnedRoyaltyInfo) { return true; } +/** Predicate to compare protocol version in emitted events + * Bind expected protocol version to this function and pass it to .withArgs() instead of the expected protocol version + * If trimmed returned and expected versions are equal, the test will pass, otherwise it raises an error + * + * @param {*} returnedRoyaltyInfo + * @returns equality of expected and returned protocol versions + */ +function compareProtocolVersions(returnedVersion) { + // trim returned version + const trimmedReturnedVersion = returnedVersion.replace(/\0/g, ""); + + return trimmedReturnedVersion == this; +} + async function setNextBlockTimestamp(timestamp, mine = false) { if (typeof timestamp == "string" && timestamp.startsWith("0x0") && timestamp.length > 3) timestamp = "0x" + timestamp.substring(3); @@ -465,6 +479,7 @@ async function setupTestEnvironment(contracts, { bosonTokenAddress, forwarderAdd token: bosonTokenAddress || (await bosonToken.getAddress()), voucherBeacon: await beacon.getAddress(), beaconProxy: ZeroAddress, + priceDiscovery: await beacon.getAddress(), // dummy address, changed later }, // Protocol limits { @@ -563,3 +578,4 @@ exports.getSnapshot = getSnapshot; exports.revertToSnapshot = revertToSnapshot; exports.getSellerSalt = getSellerSalt; exports.compareRoyaltyInfo = compareRoyaltyInfo; +exports.compareProtocolVersions = compareProtocolVersions;