From 7f84db8962c92f1cae16fd0d29176459856fd825 Mon Sep 17 00:00:00 2001 From: zajck Date: Tue, 9 Jan 2024 09:40:37 +0100 Subject: [PATCH] Test handleWrapper revert reasons --- contracts/mock/MockWrapper.sol | 239 ++++++++++++++++++++ scripts/config/revert-reasons.js | 2 + test/protocol/PriceDiscoveryHandlerFacet.js | 121 ++++++++++ 3 files changed, 362 insertions(+) create mode 100644 contracts/mock/MockWrapper.sol diff --git a/contracts/mock/MockWrapper.sol b/contracts/mock/MockWrapper.sol new file mode 100644 index 000000000..fb5dc7f61 --- /dev/null +++ b/contracts/mock/MockWrapper.sol @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.9; + +import { IBosonOfferHandler } from "../interfaces/handlers/IBosonOfferHandler.sol"; +import { IBosonExchangeHandler } from "../interfaces/handlers/IBosonExchangeHandler.sol"; +import { BosonTypes } from "../domain/BosonTypes.sol"; +import { ERC721 } from "../example/support/ERC721.sol"; +import { IERC721Metadata } from "../example/support/IERC721Metadata.sol"; +import { IERC721 } from "../interfaces/IERC721.sol"; +import { IERC165 } from "../interfaces/IERC165.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC721Receiver } from "../interfaces/IERC721Receiver.sol"; + +/** + * @title MockWrapper + * @notice Wrapper contract used in tests + * + */ +contract MockWrapper is BosonTypes, ERC721, IERC721Receiver { + // Add safeTransferFrom to IERC20 + using SafeERC20 for IERC20; + + // Contract addresses + address private immutable voucherAddress; + address private immutable mockAuctionAddress; + address private immutable protocolAddress; + address private immutable wethAddress; + + // Token ID for which the price is not yet known + uint256 private pendingTokenId; + + // Mapping from token ID to price. If pendingTokenId == tokenId, this is not the final price. + mapping(uint256 => uint256) private price; + + // Mapping to cache exchange token address, so costly call to the protocol is not needed every time. + mapping(uint256 => address) private cachedExchangeToken; + + mapping(uint256 => address) private wrapper; + + /** + * @notice Constructor + * + * @param _voucherAddress The address of the voucher that are wrapped by this contract. + * @param _mockAuctionAddress The address of Mock Auction. + */ + constructor( + address _voucherAddress, + address _mockAuctionAddress, + address _protocolAddress, + address _wethAddress + ) ERC721(getVoucherName(_voucherAddress), getVoucherSymbol(_voucherAddress)) { + voucherAddress = _voucherAddress; + mockAuctionAddress = _mockAuctionAddress; + protocolAddress = _protocolAddress; + wethAddress = _wethAddress; + + // Approve Mock Auction to transfer wrapped vouchers + _setApprovalForAll(address(this), _mockAuctionAddress, true); + } + + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[EIP section] + * to learn more about how these ids are created. + */ + function supportsInterface(bytes4 _interfaceId) public view virtual override(ERC721) returns (bool) { + return (_interfaceId == type(IERC721).interfaceId || _interfaceId == type(IERC165).interfaceId); + } + + /** + * @notice Wraps the voucher, transfer true voucher to itself and approves the contract owner to operate on it. + * + * Reverts if: + * - caller is not the contract owner + * + * @param _tokenId The token id. + */ + function wrap(uint256 _tokenId) external { + // Transfer voucher to this contract + // Instead of msg.sender it could be voucherAddress, if vouchers were preminted to contract itself + // Not using safeTransferFrom since this contract is the recipient and we are sure it can handle the vouchers + IERC721(voucherAddress).transferFrom(msg.sender, address(this), _tokenId); + + // Mint to itself, so it can be used with Mock Auction + _mint(address(this), _tokenId); // why not sender instead of address(this)? + + // Approves original token owner to operate on wrapped token + _approve(msg.sender, _tokenId); + + wrapper[_tokenId] = msg.sender; + } + + /** + * @notice Unwraps the voucher, transfer true voucher to owner and funds to the protocol. + * + * Reverts if: + * - caller is neither protocol nor voucher owner + * + * @param _tokenId The token id. + */ + function unwrap(uint256 _tokenId) external { + address wrappedVoucherOwner = ownerOf(_tokenId); + if (wrappedVoucherOwner == address(this)) wrappedVoucherOwner = wrapper[_tokenId]; + + // Either contract owner or protocol can unwrap + // If contract owner is unwrapping, this is equivalent to canceled auction + require( + msg.sender == protocolAddress || wrappedVoucherOwner == msg.sender, + "MockWrapper: Only owner or protocol can unwrap" + ); + + // If some token price is not know yet, update it now + if (pendingTokenId != 0) updatePendingTokenPrice(); + + uint256 priceToPay = price[_tokenId]; + + // Delete price and pendingTokenId to prevent reentrancy + delete price[_tokenId]; + delete pendingTokenId; + + // transfer voucher to voucher owner + IERC721(voucherAddress).safeTransferFrom(address(this), wrappedVoucherOwner, _tokenId); + + // Transfer token to protocol + if (priceToPay > 0) { + IERC20(cachedExchangeToken[_tokenId]).safeTransfer(protocolAddress, priceToPay); + } + + delete cachedExchangeToken[_tokenId]; // gas refund + delete wrapper[_tokenId]; + + // Burn wrapped voucher + _burn(_tokenId); + + // Send funds to protocol (testing purposes) + payable(msg.sender).transfer(address(this).balance); + } + + /** + * @notice Handle transfers out of Mock Auction. + * + * @param _from The address of the sender. + * @param _to The address of the recipient. + * @param _tokenId The token id. + */ + function _beforeTokenTransfer(address _from, address _to, uint256 _tokenId) internal virtual override(ERC721) { + if (_from == mockAuctionAddress && _to != address(this)) { + // Auction is over, and wrapped voucher is being transferred to voucher owner + // If recipient is address(this), it means the auction was canceled and price updating can be skipped + + // If some token price is not know yet, update it now + if (pendingTokenId != 0) updatePendingTokenPrice(); + + // Store current balance and set the pending token id + price[_tokenId] = getCurrentBalance(_tokenId); + pendingTokenId = _tokenId; + } + + super._beforeTokenTransfer(_from, _to, _tokenId); + } + + function updatePendingTokenPrice() internal { + uint256 tokenId = pendingTokenId; + price[tokenId] = getCurrentBalance(tokenId) - price[tokenId]; + } + + /** + * @notice Gets own token balance for the exchange token, associated with the token ID. + * + * @dev If the exchange token is not known, it is fetched from the protocol and cached for future use. + * + * @param _tokenId The token id. + */ + function getCurrentBalance(uint256 _tokenId) internal returns (uint256) { + address exchangeToken = cachedExchangeToken[_tokenId]; + + // If exchange token is not known, get it from the protocol. + if (exchangeToken == address(0)) { + uint256 offerId = _tokenId >> 128; // OfferId is the first 128 bits of the token ID. + + if (offerId == 0) { + // pre v2.2.0. Token does not have offerId, so we need to get it from the protocol. + // Get Boson exchange. Don't explicitly check if the exchange exists, since existance of the token implies it does. + uint256 exchangeId = _tokenId & type(uint128).max; // ExchangeId is the last 128 bits of the token ID. + (, BosonTypes.Exchange memory exchange, ) = IBosonExchangeHandler(protocolAddress).getExchange( + exchangeId + ); + offerId = exchange.offerId; + } + + // Get Boson offer. Don't explicitly check if the offer exists, since existance of the token implies it does. + (, BosonTypes.Offer memory offer, , , , ) = IBosonOfferHandler(protocolAddress).getOffer(offerId); + exchangeToken = offer.exchangeToken; + + // If exchange token is 0, it means native token is used. In that case, use WETH. + if (exchangeToken == address(0)) exchangeToken = wethAddress; + cachedExchangeToken[_tokenId] = exchangeToken; + } + + return IERC20(exchangeToken).balanceOf(address(this)); + } + + /** + * @notice Gets the Boson Voucher token name and adds "Wrapped" prefix. + * + * @dev Used only in the constructor. + * + * @param _voucherAddress Boson Voucher address + */ + function getVoucherName(address _voucherAddress) internal view returns (string memory) { + string memory name = IERC721Metadata(_voucherAddress).name(); + return string.concat("Wrapped ", name); + } + + /** + * @notice Gets the the Boson Voucher symbol and adds "W" prefix. + * + * @dev Used only in the constructor. + * + * @param _voucherAddress Boson Voucher address + */ + function getVoucherSymbol(address _voucherAddress) internal view returns (string memory) { + string memory symbol = IERC721Metadata(_voucherAddress).symbol(); + return string.concat("W", symbol); + } + + /** + * @dev See {IERC721Receiver-onERC721Received}. + * + * Always returns `IERC721Receiver.onERC721Received.selector`. + */ + function onERC721Received(address, address, uint256, bytes calldata) public virtual override returns (bytes4) { + return this.onERC721Received.selector; + } + + function topUp() external payable {} +} diff --git a/scripts/config/revert-reasons.js b/scripts/config/revert-reasons.js index f8bb341a7..e57468afe 100644 --- a/scripts/config/revert-reasons.js +++ b/scripts/config/revert-reasons.js @@ -224,4 +224,6 @@ exports.RevertReasons = { FEE_AMOUNT_TOO_HIGH: "FeeAmountTooHigh", INVALID_PRICE_DISCOVERY: "InvalidPriceDiscovery", INVALID_CONDUIT_ADDRESS: "InvalidConduitAddress", + TOKEN_ID_MANDATORY: "TokenIdMandatory", + NEGATIVE_PRICE_NOT_ALLOWED: "NegativePriceNotAllowed", }; diff --git a/test/protocol/PriceDiscoveryHandlerFacet.js b/test/protocol/PriceDiscoveryHandlerFacet.js index fe62834b6..3f8c6b348 100644 --- a/test/protocol/PriceDiscoveryHandlerFacet.js +++ b/test/protocol/PriceDiscoveryHandlerFacet.js @@ -1211,6 +1211,127 @@ describe("IPriceDiscoveryHandlerFacet", function () { expect(voucher.committedDate).to.equal(timestamp); }); }); + + context("💔 Revert Reasons", async function () { + let price; + let wrappedBosonVoucher; + beforeEach(async function () { + // Deploy wrapped voucher contract + const wrappedBosonVoucherFactory = await ethers.getContractFactory("MockWrapper"); + wrappedBosonVoucher = await wrappedBosonVoucherFactory + .connect(assistant) + .deploy( + await bosonVoucher.getAddress(), + await mockAuction.getAddress(), + await exchangeHandler.getAddress(), + await weth.getAddress() + ); + + // Price discovery data + price = 10n; + const calldata = wrappedBosonVoucher.interface.encodeFunctionData("unwrap", [tokenId]); + priceDiscovery = new PriceDiscovery( + price, + Side.Wrapper, + await wrappedBosonVoucher.getAddress(), + await wrappedBosonVoucher.getAddress(), + calldata + ); + }); + + it("Committing with offer id", async function () { + const tokenIdOrOfferId = offer.id; + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenIdOrOfferId, priceDiscovery) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.TOKEN_ID_MANDATORY); + }); + + it("Price discovery is not the owner", async function () { + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NOT_VOUCHER_HOLDER); + }); + + context("Malfunctioning wrapper", async function () { + beforeEach(async function () { + // 3. Wrap voucher + await bosonVoucher.connect(assistant).setApprovalForAll(await wrappedBosonVoucher.getAddress(), true); + await wrappedBosonVoucher.connect(assistant).wrap(tokenId); + + // 4. Create an auction + const tokenContract = await wrappedBosonVoucher.getAddress(); + const curator = assistant.address; + const auctionCurrency = offer.exchangeToken; + + await mockAuction.connect(assistant).createAuction(tokenId, tokenContract, auctionCurrency, curator); + + auctionId = 0; + + await mockAuction.connect(buyer).createBid(auctionId, price, { value: price }); + + // 6. End auction + await getCurrentBlockAndSetTimeForward(oneWeek); + await mockAuction.connect(assistant).endAuction(auctionId); + + expect(await wrappedBosonVoucher.ownerOf(tokenId)).to.equal(buyer.address); + expect(await weth.balanceOf(await wrappedBosonVoucher.getAddress())).to.equal(price); + }); + + it("Wrapper sends some ether", async function () { + // send some ether to wrapper + await wrappedBosonVoucher.topUp({ value: parseUnits("1", "ether") }); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NATIVE_NOT_ALLOWED); + }); + + it("Price mismatch", async function () { + priceDiscovery.price += 10n; + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.PRICE_TOO_LOW); + }); + + 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")); + + const calldata = weth.interface.encodeFunctionData("transfer", [ + rando.address, + parseUnits("1", "ether"), + ]); + priceDiscovery = new PriceDiscovery(price, Side.Wrapper, wethAddress, wethAddress, calldata); + + // Transfer the voucher to weth to pass the "is owner" check + await bosonVoucher.connect(assistant).transferFrom(assistant.address, wethAddress, tokenId); + + // Attempt to commit, expecting revert + await expect( + priceDiscoveryHandler + .connect(assistant) + .commitToPriceDiscoveryOffer(buyer.address, tokenId, priceDiscovery) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NEGATIVE_PRICE_NOT_ALLOWED); + }); + }); + }); }); }); });