diff --git a/contracts/PartyBid.sol b/contracts/PartyBid.sol index 48112cd..4eb346a 100755 --- a/contracts/PartyBid.sol +++ b/contracts/PartyBid.sol @@ -37,8 +37,8 @@ contract PartyBid is Party { // ============ Internal Constants ============ - // PartyBid version 3 - uint16 public constant VERSION = 3; + // PartyBid version 4 + uint16 public constant VERSION = 4; // ============ Public Not-Mutated Storage ============ @@ -203,6 +203,11 @@ contract PartyBid is Party { // after the auction has been finalized, // if the NFT is owned by the PartyBid, then the PartyBid won the auction address _owner = _getOwner(); + + // withdraw the wETH balance that could have been possibly deposited to the Party by + // actions like Fractional. + weth.withdraw(weth.balanceOf(address(this))); + partyStatus = _owner == address(this) ? PartyStatus.WON : PartyStatus.LOST; @@ -238,10 +243,13 @@ contract PartyBid is Party { // In case there's some variation in how contracts define a "high bid" // we fall back to making sure none of the eth contributed is outstanding. // If we ever add any features that can send eth for any other purpose we - // will revisit/remove this. + // will revisit/remove this. For the outstanding eth contributed we compare to the total + // of ETH and wETH balance for the Party, since some markets like Fractional will send back + // wETH when another entity out bids the Party's bid. + uint256 ethAndWethBalance = weth.balanceOf(address(this)) + address(this).balance; if ( address(this) == marketWrapper.getCurrentHighestBidder(auctionId) || - address(this).balance < totalContributedToParty + ethAndWethBalance < totalContributedToParty ) { return ExpireCapability.CurrentlyWinning; } @@ -276,6 +284,9 @@ contract PartyBid is Party { expireCapability == ExpireCapability.CanExpire, errorStringForCapability(expireCapability) ); + // withdraw the wETH balance that could have been possibly deposited to the Party by + // actions like Fractional. + weth.withdraw(weth.balanceOf(address(this))); partyStatus = PartyStatus.LOST; emit Finalized(partyStatus, 0, 0, totalContributedToParty, true); } diff --git a/contracts/external/interfaces/IERC721TokenVault.sol b/contracts/external/interfaces/IERC721TokenVault.sol new file mode 100644 index 0000000..661979e --- /dev/null +++ b/contracts/external/interfaces/IERC721TokenVault.sol @@ -0,0 +1,47 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import {ISettings} from "./IFractionalSettings.sol"; + +enum TokenState { inactive, live, ended, redeemed } +interface IERC721TokenVault { + /// @notice the ERC721 token address of the vault's token + function token() external view returns(address); + + /// @notice the ERC721 token ID of the vault's token + function id() external view returns(uint256); + + /// @notice the ERC721 totalSupply + function totalSupply() external view returns(uint256); + + /// @notice the unix timestamp end time of the token auction + function auctionEnd() external view returns(uint256); + + /// @notice the current price of the token during an auction + function livePrice() external view returns(uint256); + + /// @notice the current user winning the token auction + function winning() external view returns(address payable); + + function auctionState() external view returns(TokenState); + + function settings() external view returns(ISettings); + + /// @notice a boolean to indicate if the vault has closed + /// @dev is not used in Fractional's ERC721TokenVault but it is declared. + function vaultClosed() external view returns(bool); + + /// @notice the number of ownership tokens voting on the reserve price at any given time + function votingTokens() external view returns(uint256); + + function reservePrice() external view returns(uint256); + + /// @notice kick off an auction. Must send reservePrice in ETH + function start() external payable; + + /// @notice an external function to bid on purchasing the vaults NFT. The msg.value is the bid amount + function bid() external payable; + + /// @notice an external function to end an auction after the timer has run out + function end() external; +} \ No newline at end of file diff --git a/contracts/external/interfaces/IERC721VaultFactory.sol b/contracts/external/interfaces/IERC721VaultFactory.sol index c9e2e38..f3791cf 100644 --- a/contracts/external/interfaces/IERC721VaultFactory.sol +++ b/contracts/external/interfaces/IERC721VaultFactory.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.9; interface IERC721VaultFactory { /// @notice the mapping of vault number to vault address - function vaults(uint256) external returns (address); + function vaults(uint256) external view returns (address); /// @notice the function to mint a new vault /// @param _name the desired name of the vault diff --git a/contracts/external/interfaces/IFractionalSettings.sol b/contracts/external/interfaces/IFractionalSettings.sol new file mode 100644 index 0000000..7880a45 --- /dev/null +++ b/contracts/external/interfaces/IFractionalSettings.sol @@ -0,0 +1,8 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +interface ISettings { + function minBidIncrease() external view returns(uint256); + + function minVotePercentage() external view returns(uint256); +} \ No newline at end of file diff --git a/contracts/external/interfaces/IWETH.sol b/contracts/external/interfaces/IWETH.sol index 0fc9925..b7fa283 100644 --- a/contracts/external/interfaces/IWETH.sol +++ b/contracts/external/interfaces/IWETH.sol @@ -5,4 +5,8 @@ interface IWETH { function deposit() external payable; function transfer(address to, uint256 value) external returns (bool); + + function withdraw(uint) external; + + function balanceOf(address) external view returns(uint); } diff --git a/contracts/market-wrapper/FractionalMarketWrapper.sol b/contracts/market-wrapper/FractionalMarketWrapper.sol new file mode 100644 index 0000000..5de9124 --- /dev/null +++ b/contracts/market-wrapper/FractionalMarketWrapper.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +// ============ External Imports ============ +import {IERC721TokenVault, TokenState} from "../external/interfaces/IERC721TokenVault.sol"; +import {ISettings} from "../external/interfaces/IFractionalSettings.sol"; +import {IERC721VaultFactory} from "../external/interfaces/IERC721VaultFactory.sol"; +import {IWETH} from "../external/fractional/Interfaces/IWETH.sol"; + +// ============ Internal Imports ============ +import {IMarketWrapper} from "./IMarketWrapper.sol"; + +/** + * @title FractionalMarketWrapper + * @author Saw-mon and Natalie (@sw0nt) + Anna Carroll + Fractional Team + * @notice MarketWrapper contract implementing IMarketWrapper interface + * according to the logic of Fractional Token Vault + */ +contract FractionalMarketWrapper is IMarketWrapper { + // ============ Public Immutables ============ + + IERC721VaultFactory public immutable fractionalVault; + IWETH public immutable weth; + + // ======== Constructor ========= + + constructor(address _fractionalVault, address _weth) { + fractionalVault = IERC721VaultFactory(_fractionalVault); + weth = IWETH(_weth); + } + + // ======== External Functions ========= + + /** + * @notice Determine whether there is an existing, inactive aution which can be started + * @return TRUE if the auction state is inactive but there are enough voting tokens to start it. + */ + function auctionIsInActiveButCanBeStarted(uint256 auctionId) public view returns(bool) { + IERC721TokenVault tokenVault = IERC721TokenVault(fractionalVault.vaults(auctionId)); + ISettings tokenSettings = ISettings(tokenVault.settings()); + return tokenVault.auctionState() == TokenState.inactive && tokenVault.votingTokens() * 1000 >= tokenSettings.minVotePercentage() * tokenVault.totalSupply(); + } + + /** + * @notice Determine whether there is an existing, active aution which is not ended + * @return TRUE if the auction state is live and auction end time is less than block timestamp + */ + function auctionIsLiveAndNotEnded(uint256 auctionId) public view returns(bool) { + IERC721TokenVault tokenVault = IERC721TokenVault(fractionalVault.vaults(auctionId)); + + return tokenVault.auctionState() == TokenState.live && tokenVault.auctionEnd() > block.timestamp; + } + + /** + * @notice Determine whether there is an existing, active auction + * for this token. + * @return TRUE if the auction exists + */ + function auctionExists(uint256 auctionId) public view returns (bool) { + return auctionIsInActiveButCanBeStarted(auctionId) || auctionIsLiveAndNotEnded(auctionId); + } + + /** + * @notice Determine whether the given auctionId and tokenId is active. + * @return TRUE if the auctionId and tokenId matches the active auction + */ + function auctionIdMatchesToken( + uint256 auctionId, + address nftContract, + uint256 tokenId + ) public view override returns (bool) { + IERC721TokenVault tokenVault = IERC721TokenVault(fractionalVault.vaults(auctionId)); + return tokenVault.id() == tokenId && tokenVault.token() == nftContract && auctionExists(auctionId); + } + + /** + * @notice Calculate the minimum next bid for the active auction. If + * auction is inactive and can be started we will return the reservePrice + * otherwise the minimum bid amount needs to be calculated according to + * logic. + * @return minimum bid amount + */ + function getMinimumBid(uint256 auctionId) + external + view + override + returns (uint256) + { + require( + auctionExists(auctionId), + "FractionalMarketWrapper::getMinimumBid: Auction not active" + ); + + IERC721TokenVault tokenVault = IERC721TokenVault(fractionalVault.vaults(auctionId)); + + if(auctionIsInActiveButCanBeStarted(auctionId)) { + return tokenVault.reservePrice(); + } + + uint256 increase = ISettings(tokenVault.settings()).minBidIncrease() + 1000; + + /** + * minbound = 1000 * k + r, where 0 <= r < 1000 so, + * minBid = k, but if r > 0, minBid * 1000 < minBound, + * in that case we need to increment minBid by 1. Since + * minBid * 1000 = (k+1) * 1000 > 1000 * k + r = minBound + */ + uint256 minbound = tokenVault.livePrice() * increase; + uint256 minBid = minbound / 1000; + + if(minBid * 1000 < minbound) { + minBid += 1; + } + + return minBid; + } + + /** + * @notice Query the current highest bidder for this auction + * @return highest bidder + */ + function getCurrentHighestBidder(uint256 auctionId) + external + view + override + returns (address) + { + require( + auctionExists(auctionId), + "FractionalMarketWrapper::getCurrentHighestBidder: Auction not active" + ); + + IERC721TokenVault tokenVault = IERC721TokenVault(fractionalVault.vaults(auctionId)); + + return tokenVault.winning(); + } + + /** + * @notice Submit bid to Market contract + */ + function bid(uint256 auctionId, uint256 bidAmount) external override { + // @dev since PartyBid recieves weth from Fractional when another entity outbids, we will + // transfer that wETH to the PartyBid's ETH balance. The next line will be executed in the context + // of PartyBid contract since it uses a DELEGATECALL to this bid function. + weth.withdraw(weth.balanceOf(address(this))); + IERC721TokenVault tokenVault = IERC721TokenVault(fractionalVault.vaults(auctionId)); + + string memory endpoint = auctionIsInActiveButCanBeStarted(auctionId) ? "start()" : "bid()"; + + // line 331 of Fractional ERC721TokenVault, bid() function + (bool success, bytes memory returnData) = address(tokenVault).call{ + value: bidAmount + }(abi.encodeWithSignature(endpoint)); + require(success, string(returnData)); + } + + /** + * @notice Determine whether the auction has been finalized + * @return TRUE if the auction has been finalized + */ + function isFinalized(uint256 auctionId) + external + view + override + returns (bool) + { + IERC721TokenVault tokenVault = IERC721TokenVault(fractionalVault.vaults(auctionId)); + + return tokenVault.auctionState() == TokenState.ended; + } + + /** + * @notice Finalize the results of the auction + */ + function finalize( + uint256 auctionId + ) external override { + IERC721TokenVault tokenVault = IERC721TokenVault(fractionalVault.vaults(auctionId)); + + tokenVault.end(); + } +} diff --git a/deploy/partybid/index.js b/deploy/partybid/index.js index c0a08a0..d63fdd3 100644 --- a/deploy/partybid/index.js +++ b/deploy/partybid/index.js @@ -159,6 +159,46 @@ async function deployKoansMarketWrapper() { console.log(`Koans Market Wrapper written to ${filename}`); } +async function deployFractionalMarketWrapper() { + // load .env + const {CHAIN_NAME, RPC_ENDPOINT, DEPLOYER_PRIVATE_KEY} = loadEnv(); + + // load config.json + const config = loadConfig(CHAIN_NAME); + const {fractionalArtERC721VaultFactory, weth} = config; + if (!fractionalArtERC721VaultFactory) { + throw new Error("Must populate config with Fractional.Art ERC721VaultFactory address"); + } + + // setup deployer wallet + const deployer = getDeployer(RPC_ENDPOINT, DEPLOYER_PRIVATE_KEY); + + // Deploy Zora Market Wrapper + console.log(`Deploy Fractional Market Wrapper to ${CHAIN_NAME}`); + const fractionalMarketWrapper = await deploy( + deployer,'FractionalMarketWrapper', + [fractionalArtERC721VaultFactory, weth] + ); + console.log(`Deployed Fractional Market Wrapper to ${CHAIN_NAME}: `, fractionalMarketWrapper.address); + + // get the existing deployed addresses + let {directory, filename, contractAddresses} = getDeployedAddresses('partybid', CHAIN_NAME); + + // update the zora market wrapper address + if (contractAddresses["marketWrappers"]) { + contractAddresses["marketWrappers"]["fractional"] = fractionalMarketWrapper.address; + } else { + contractAddresses["marketWrappers"] = { + fractional: fractionalMarketWrapper.address + }; + } + + // write the updated object + writeDeployedAddresses(directory, filename, contractAddresses); + + console.log(`Fractional Market Wrapper written to ${filename}`); +} + async function deployPartyBidFactory() { // load .env const {CHAIN_NAME, RPC_ENDPOINT, DEPLOYER_PRIVATE_KEY} = loadEnv(); @@ -203,5 +243,6 @@ async function deployChain() { await deployZoraMarketWrapper(); await deployFoundationMarketWrapper(); await deployNounsMarketWrapper(); + await deployFractionalMarketWrapper(); await deployPartyBidFactory(); } diff --git a/deploy/partybid/verify.js b/deploy/partybid/verify.js index 8aeaa33..48b2a9a 100644 --- a/deploy/partybid/verify.js +++ b/deploy/partybid/verify.js @@ -20,7 +20,7 @@ async function verify() { // load deployed contracts const {contractAddresses} = getDeployedAddresses('partybid', CHAIN_NAME); const {partyBidFactory, partyBidLogic, marketWrappers} = contractAddresses; - const {foundation, zora, nouns, koans} = marketWrappers; + const {foundation, zora, nouns, koans, fractional} = marketWrappers; console.log(`Verifying ${CHAIN_NAME}`); @@ -51,6 +51,10 @@ async function verify() { // Verify Koans Market Wrapper console.log(`Verify Koans Market Wrapper`); await verifyContract(koans, [koansAuctionHouse]); + + // Verify Fractional Market Wrapper + console.log(`Verify Fractional Market Wrapper`); + await verifyContract(fractional, [fractionalArtERC721VaultFactory, weth]); } module.exports = { diff --git a/test/PartyBid/Bid.test.js b/test/PartyBid/Bid.test.js index b08d750..257c170 100644 --- a/test/PartyBid/Bid.test.js +++ b/test/PartyBid/Bid.test.js @@ -76,10 +76,23 @@ describe('Bid', async () => { }); } else if (!placedByPartyBid && success) { it('Accepts external bid', async () => { - const eventName = - marketName == MARKET_NAMES.FOUNDATION - ? 'ReserveAuctionBidPlaced' - : 'AuctionBid'; + let emittingContract = market; + let eventName = 'AuctionBid'; + + if(marketName == MARKET_NAMES.FRACTIONAL) { + const fractionalTokenVaultAddress = await market.vaults(auctionId); + const logic = await ethers.getContractFactory('TokenVault'); + emittingContract = new ethers.Contract(fractionalTokenVaultAddress, logic.interface, signers[0]); + const tokenState = await emittingContract.auctionState(); + if(tokenState == 0) { + eventName = 'Start'; + } else if (tokenState == 1) { + eventName = 'Bid'; + } + } else if(marketName == MARKET_NAMES.FOUNDATION) { + eventName = 'ReserveAuctionBidPlaced'; + } + await expect( placeBid( signers[0], @@ -88,7 +101,7 @@ describe('Bid', async () => { eth(amount), marketName, ), - ).to.emit(market, eventName); + ).to.emit(emittingContract, eventName); }); } else if (!placedByPartyBid && !success) { it('Does not accept external bid', async () => { diff --git a/test/PartyBid/Claim.test.js b/test/PartyBid/Claim.test.js index 58062f0..a2f7bf4 100755 --- a/test/PartyBid/Claim.test.js +++ b/test/PartyBid/Claim.test.js @@ -17,6 +17,8 @@ const { deployTestContractSetup, getTokenVault } = require('../helpers/deploy'); const { MARKETS, FOURTY_EIGHT_HOURS_IN_SECONDS, + EIGHT_DAYS_IN_SECONDS, + MARKET_NAMES, } = require('../helpers/constants'); const { testCases } = require('./partyBidTestCases.json'); @@ -89,8 +91,9 @@ describe('Claim', async () => { it('Allows Finalize', async () => { // increase time on-chain so that auction can be finalized + const timeIncreaseAmount = marketName == MARKET_NAMES.FRACTIONAL ? EIGHT_DAYS_IN_SECONDS : FOURTY_EIGHT_HOURS_IN_SECONDS; await provider.send('evm_increaseTime', [ - FOURTY_EIGHT_HOURS_IN_SECONDS, + timeIncreaseAmount, ]); await provider.send('evm_mine'); diff --git a/test/PartyBid/Deploy.test.js b/test/PartyBid/Deploy.test.js index 720441f..02918d8 100644 --- a/test/PartyBid/Deploy.test.js +++ b/test/PartyBid/Deploy.test.js @@ -91,9 +91,9 @@ describe('Deploy', async () => { expect(partyStatus).to.equal(PARTY_STATUS.ACTIVE); }); - it('Version is 3', async () => { + it('Version is 4', async () => { const version = await partyBid.VERSION(); - expect(version).to.equal(3); + expect(version).to.equal(4); }); it('Total contributed to party is zero', async () => { diff --git a/test/PartyBid/Finalize.test.js b/test/PartyBid/Finalize.test.js index 527c008..902dcba 100755 --- a/test/PartyBid/Finalize.test.js +++ b/test/PartyBid/Finalize.test.js @@ -16,8 +16,10 @@ const { placeBid } = require('../helpers/externalTransactions'); const { deployTestContractSetup, getTokenVault } = require('../helpers/deploy'); const { MARKETS, + MARKET_NAMES, PARTY_STATUS, FOURTY_EIGHT_HOURS_IN_SECONDS, + EIGHT_DAYS_IN_SECONDS, ETH_FEE_BASIS_POINTS, TOKEN_FEE_BASIS_POINTS, TOKEN_SCALE, @@ -44,6 +46,7 @@ describe('Finalize', async () => { market, nftContract, partyDAOMultisig, + weth, auctionId, multisigBalanceBefore, token; @@ -83,6 +86,7 @@ describe('Finalize', async () => { market = contracts.market; partyDAOMultisig = contracts.partyDAOMultisig; nftContract = contracts.nftContract; + weth = contracts.weth; auctionId = await partyBid.auctionId(); @@ -142,8 +146,9 @@ describe('Finalize', async () => { it('Does allow Finalize after the auction is over', async () => { // increase time on-chain so that auction can be finalized + const timeIncreaseAmount = marketName == MARKET_NAMES.FRACTIONAL ? EIGHT_DAYS_IN_SECONDS : FOURTY_EIGHT_HOURS_IN_SECONDS; await provider.send('evm_increaseTime', [ - FOURTY_EIGHT_HOURS_IN_SECONDS, + timeIncreaseAmount, ]); await provider.send('evm_mine'); @@ -252,7 +257,16 @@ describe('Finalize', async () => { const expectedEthBalance = totalContributed.minus(expectedTotalSpent); const ethBalance = await provider.getBalance(partyBid.address); - expect(weiToEth(ethBalance)).to.equal( + + let ethAndWethBalance = ethBalance; + + // @dev If another bidder outbids Party's bid, Fractional sends back WETH. + if(marketName == MARKET_NAMES.FRACTIONAL) { + const wethBalance = await weth.balanceOf(partyBid.address); + ethAndWethBalance = ethAndWethBalance.add(wethBalance); + } + + expect(weiToEth(ethAndWethBalance)).to.equal( expectedEthBalance.toNumber(), ); }); diff --git a/test/PartyBid/partyBidTestCases.json b/test/PartyBid/partyBidTestCases.json index aa48b97..b27beaa 100644 --- a/test/PartyBid/partyBidTestCases.json +++ b/test/PartyBid/partyBidTestCases.json @@ -58,7 +58,8 @@ "ZORA": 1.575, "FOUNDATION": 1.65, "NOUNS": 1.575, - "KOANS": 1.575 + "KOANS": 1.575, + "FRACTIONAL": 1.575 }, "claims": { "ZORA": [ @@ -116,6 +117,20 @@ "excessEth": 2.385625, "totalContributed": 2.5 } + ], + "FRACTIONAL": [ + { + "signerIndex": 0, + "tokens": 1500, + "excessEth": 1, + "totalContributed": 2.5 + }, + { + "signerIndex": 1, + "tokens": 114.375, + "excessEth": 2.385625, + "totalContributed": 2.5 + } ] } }, @@ -160,7 +175,8 @@ "ZORA": 10.5, "FOUNDATION": 11, "NOUNS": 10.5, - "KOANS": 10.5 + "KOANS": 10.5, + "FRACTIONAL": 10.5 }, "claims": { "ZORA": [ @@ -218,6 +234,20 @@ "excessEth": 89.2375, "totalContributed": 94.5 } + ], + "FRACTIONAL": [ + { + "signerIndex": 0, + "tokens": 5500, + "excessEth": 0, + "totalContributed": 5.5 + }, + { + "signerIndex": 1, + "tokens": 5262.5, + "excessEth": 89.2375, + "totalContributed": 94.5 + } ] } }, @@ -250,7 +280,8 @@ "ZORA": 0, "FOUNDATION": 0, "NOUNS": 0, - "KOANS": 0 + "KOANS": 0, + "FRACTIONAL": 0 }, "claims": { "ZORA": [ @@ -308,6 +339,20 @@ "excessEth": 5, "totalContributed": 5 } + ], + "FRACTIONAL": [ + { + "signerIndex": 1, + "tokens": 0, + "excessEth": 5, + "totalContributed": 5 + }, + { + "signerIndex": 0, + "tokens": 0, + "excessEth": 5, + "totalContributed": 5 + } ] } }, @@ -344,7 +389,8 @@ "ZORA": 10.5, "FOUNDATION": 11, "NOUNS": 10.5, - "KOANS": 10.5 + "KOANS": 10.5, + "FRACTIONAL": 10.5 }, "claims": { "ZORA": [ @@ -402,6 +448,20 @@ "excessEth": 10, "totalContributed": 10 } + ], + "FRACTIONAL": [ + { + "signerIndex": 0, + "tokens": 10762.5, + "excessEth": 0.5125, + "totalContributed": 11.275 + }, + { + "signerIndex": 1, + "tokens": 0, + "excessEth": 10, + "totalContributed": 10 + } ] } }, @@ -430,7 +490,8 @@ "ZORA": 1.05, "FOUNDATION": 1.1, "NOUNS": 1.05, - "KOANS": 1.05 + "KOANS": 1.05, + "FRACTIONAL": 1.05 }, "claims": { "ZORA": [ @@ -464,6 +525,14 @@ "excessEth": 0.92375, "totalContributed": 2 } + ], + "FRACTIONAL": [ + { + "signerIndex": 1, + "tokens": 1076.25, + "excessEth": 0.92375, + "totalContributed": 2 + } ] } }, @@ -508,7 +577,8 @@ "ZORA": 10.5, "FOUNDATION": 11, "NOUNS": 10.5, - "KOANS": 10.5 + "KOANS": 10.5, + "FRACTIONAL": 10.5 }, "claims": { "ZORA": [ @@ -566,6 +636,20 @@ "excessEth": 89.2375, "totalContributed": 94.5 } + ], + "FRACTIONAL": [ + { + "signerIndex": 0, + "tokens": 5500, + "excessEth": 0, + "totalContributed": 5.5 + }, + { + "signerIndex": 1, + "tokens": 5262.5, + "excessEth": 89.2375, + "totalContributed": 94.5 + } ] } }, @@ -610,7 +694,8 @@ "ZORA": 10.5, "FOUNDATION": 11, "NOUNS": 10.5, - "KOANS": 10.5 + "KOANS": 10.5, + "FRACTIONAL": 10.5 }, "claims": { "ZORA": [ @@ -668,6 +753,20 @@ "excessEth": 89.2375, "totalContributed": 94.5 } + ], + "FRACTIONAL": [ + { + "signerIndex": 0, + "tokens": 5500, + "excessEth": 0, + "totalContributed": 5.5 + }, + { + "signerIndex": 1, + "tokens": 5262.5, + "excessEth": 89.2375, + "totalContributed": 94.5 + } ] } }, @@ -700,7 +799,8 @@ "ZORA": 0, "FOUNDATION": 0, "NOUNS": 0, - "KOANS": 0 + "KOANS": 0, + "FRACTIONAL": 0 }, "claims": { "ZORA": [ @@ -758,6 +858,20 @@ "excessEth": 5, "totalContributed": 5 } + ], + "FRACTIONAL": [ + { + "signerIndex": 1, + "tokens": 0, + "excessEth": 5, + "totalContributed": 5 + }, + { + "signerIndex": 0, + "tokens": 0, + "excessEth": 5, + "totalContributed": 5 + } ] } } diff --git a/test/PartyBuy/helpers/constants.js b/test/PartyBuy/helpers/constants.js index fb5004f..e9b4e3e 100644 --- a/test/PartyBuy/helpers/constants.js +++ b/test/PartyBuy/helpers/constants.js @@ -13,8 +13,11 @@ const PARTY_STATUS = { const ONE_ETH = '1000000000000000000'; +const WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + module.exports = { ONE_ETH, + WETH_ADDRESS, PARTY_STATUS, FOURTY_EIGHT_HOURS_IN_SECONDS, TOKEN_FEE_BASIS_POINTS, diff --git a/test/custom/AuctionCanceled.test.js b/test/custom/AuctionCanceled.test.js index 6377809..9711b15 100644 --- a/test/custom/AuctionCanceled.test.js +++ b/test/custom/AuctionCanceled.test.js @@ -14,7 +14,7 @@ const { testCases } = require('../partybid/partyBidTestCases.json'); describe('Auction Canceled', async () => { // Noun auctions cannot be cancelled MARKETS.filter( - (m) => m !== MARKET_NAMES.NOUNS && m !== MARKET_NAMES.KOANS, + (m) => m !== MARKET_NAMES.NOUNS && m !== MARKET_NAMES.KOANS && m !== MARKET_NAMES.FRACTIONAL, ).map((marketName) => { describe(marketName, async () => { testCases.map((testCase, i) => { diff --git a/test/custom/EmergencyForceLost.test.js b/test/custom/EmergencyForceLost.test.js index 9c628b8..1405322 100644 --- a/test/custom/EmergencyForceLost.test.js +++ b/test/custom/EmergencyForceLost.test.js @@ -9,12 +9,14 @@ const { contribute, getBalances, emergencyForceLost, + emergencyCall, getTotalContributed, bidThroughParty, + encodeData, } = require('../helpers/utils'); const { placeBid } = require('../helpers/externalTransactions'); const { deployTestContractSetup } = require('../helpers/deploy'); -const { MARKETS, PARTY_STATUS } = require('../helpers/constants'); +const { MARKETS, MARKET_NAMES, PARTY_STATUS } = require('../helpers/constants'); const { testCases } = require('../partybid/partyBidTestCases.json'); describe('Emergency Force Lost', async () => { @@ -35,6 +37,7 @@ describe('Emergency Force Lost', async () => { let partyBid, market, nftContract, + weth, partyDAOMultisig, auctionId, multisigBalanceBefore, @@ -66,6 +69,7 @@ describe('Emergency Force Lost', async () => { market = contracts.market; partyDAOMultisig = contracts.partyDAOMultisig; nftContract = contracts.nftContract; + weth = contracts.weth; auctionId = await partyBid.auctionId(); @@ -115,6 +119,24 @@ describe('Emergency Force Lost', async () => { .reverted; }); + if(marketName == MARKET_NAMES.FRACTIONAL) { + // @dev If the Party is not won, there will be some wETH balance + // left for Party to claim, since Fractional only sends wETH + // when another entity out bids the party. Calling `finalize` or `expire` + // will move the wETH balance to the Party's ETH balance, so intervention by + // multisig is not required for PartyBid version 4. + it('Does allow multisig to force wETH withdraw', async () => { + const wethBalance = await weth.balanceOf(partyBid.address); + const withdrawData = encodeData(weth, "withdraw", [wethBalance]); + await expect(emergencyCall( + partyBid, + signers[0], + weth.address, + withdrawData + )).to.not.be.reverted; + }); + } + it('Is LOST after force lost', async () => { const partyStatus = await partyBid.partyStatus(); expect(partyStatus).to.equal(PARTY_STATUS.LOST); @@ -129,9 +151,17 @@ describe('Emergency Force Lost', async () => { const partyBidBalanceAfter = await provider.getBalance( partyBid.address, ); + + let ethAndWethBalance = partyBidBalanceAfter; + + // @dev If another bidder outbids Party's bid, Fractional sends back wETH. + if(marketName == MARKET_NAMES.FRACTIONAL) { + const wethBalance = await weth.balanceOf(partyBid.address); + ethAndWethBalance = ethAndWethBalance.add(wethBalance); + } const totalContributedToParty = await partyBid.totalContributedToParty(); - expect(partyBidBalanceAfter).to.equal(totalContributedToParty); + expect(ethAndWethBalance).to.equal(totalContributedToParty); }); it(`Did not transfer fee to multisig`, async () => { diff --git a/test/custom/ExternalFinalize.test.js b/test/custom/ExternalFinalize.test.js index 8fbd089..0155018 100755 --- a/test/custom/ExternalFinalize.test.js +++ b/test/custom/ExternalFinalize.test.js @@ -19,8 +19,10 @@ const { const { deployTestContractSetup, getTokenVault } = require('../helpers/deploy'); const { MARKETS, + MARKET_NAMES, PARTY_STATUS, FOURTY_EIGHT_HOURS_IN_SECONDS, + EIGHT_DAYS_IN_SECONDS, TOKEN_FEE_BASIS_POINTS, ETH_FEE_BASIS_POINTS, TOKEN_SCALE, @@ -48,7 +50,8 @@ describe('External Finalize', async () => { partyDAOMultisig, auctionId, multisigBalanceBefore, - token; + token, + weth; const lastBid = bids[bids.length - 1]; const partyBidWins = lastBid.placedByPartyBid && lastBid.success; const signers = provider.getWallets(); @@ -87,6 +90,7 @@ describe('External Finalize', async () => { market = contracts.market; partyDAOMultisig = contracts.partyDAOMultisig; nftContract = contracts.nftContract; + weth = contracts.weth; auctionId = await partyBid.auctionId(); @@ -130,8 +134,9 @@ describe('External Finalize', async () => { it('Accepts external Finalize', async () => { // increase time on-chain so that auction can be finalized + const timeIncreaseAmount = marketName == MARKET_NAMES.FRACTIONAL ? EIGHT_DAYS_IN_SECONDS : FOURTY_EIGHT_HOURS_IN_SECONDS; await provider.send('evm_increaseTime', [ - FOURTY_EIGHT_HOURS_IN_SECONDS, + timeIncreaseAmount, ]); await provider.send('evm_mine'); @@ -237,7 +242,14 @@ describe('External Finalize', async () => { const expectedEthBalance = totalContributed.minus(expectedTotalSpent); const ethBalance = await provider.getBalance(partyBid.address); - expect(weiToEth(ethBalance)).to.equal( + let ethAndWethBalance = ethBalance; + + // @dev If another bidder outbids Party's bid, Fractional sends back wETH. + if(marketName == MARKET_NAMES.FRACTIONAL) { + const wethBalance = await weth.balanceOf(partyBid.address); + ethAndWethBalance = ethAndWethBalance.add(wethBalance); + } + expect(weiToEth(ethAndWethBalance)).to.equal( expectedEthBalance.toNumber(), ); }); diff --git a/test/custom/FinalizeWhenPaused.test.js b/test/custom/FinalizeWhenPaused.test.js index b5296cb..08d0696 100644 --- a/test/custom/FinalizeWhenPaused.test.js +++ b/test/custom/FinalizeWhenPaused.test.js @@ -17,6 +17,7 @@ const { deployTestContractSetup, getTokenVault } = require('../helpers/deploy'); const { PARTY_STATUS, FOURTY_EIGHT_HOURS_IN_SECONDS, + EIGHT_DAYS_IN_SECONDS, } = require('../helpers/constants'); const { testCases } = require('../partybid/partyBidTestCases.json'); const { @@ -133,8 +134,9 @@ describe('Finalize When Paused', async () => { it('Does allow Finalize after the auction is over', async () => { // increase time on-chain so that auction can be finalized + const timeIncreaseAmount = marketName == MARKET_NAMES.FRACTIONAL ? EIGHT_DAYS_IN_SECONDS : FOURTY_EIGHT_HOURS_IN_SECONDS; await provider.send('evm_increaseTime', [ - FOURTY_EIGHT_HOURS_IN_SECONDS, + timeIncreaseAmount, ]); await provider.send('evm_mine'); diff --git a/test/custom/MaxOutbid.test.js b/test/custom/MaxOutbid.test.js index 648e257..759c6ac 100644 --- a/test/custom/MaxOutbid.test.js +++ b/test/custom/MaxOutbid.test.js @@ -27,6 +27,7 @@ const testCases = [ [MARKET_NAMES.FOUNDATION]: 281.875, [MARKET_NAMES.NOUNS]: 269.0625, [MARKET_NAMES.KOANS]: 269.0625, + [MARKET_NAMES.FRACTIONAL]: 269.0625, }, }, { @@ -36,6 +37,7 @@ const testCases = [ [MARKET_NAMES.FOUNDATION]: 1.1275, [MARKET_NAMES.NOUNS]: 1.07625, [MARKET_NAMES.KOANS]: 1.07625, + [MARKET_NAMES.FRACTIONAL]: 1.07625, }, }, ]; diff --git a/test/custom/NFTBurned.test.js b/test/custom/NFTBurned.test.js index c789bd0..d9ae2ab 100644 --- a/test/custom/NFTBurned.test.js +++ b/test/custom/NFTBurned.test.js @@ -13,7 +13,9 @@ const { deployTestContractSetup, getTokenVault } = require('../helpers/deploy'); const { PARTY_STATUS, FOURTY_EIGHT_HOURS_IN_SECONDS, + EIGHT_DAYS_IN_SECONDS, MARKETS, + MARKET_NAMES, } = require('../helpers/constants'); describe('NFT Burned', async () => { @@ -71,8 +73,9 @@ describe('NFT Burned', async () => { it('Accepts external Finalize', async () => { // increase time on-chain so that auction can be finalized + const timeIncreaseAmount = marketName == MARKET_NAMES.FRACTIONAL ? EIGHT_DAYS_IN_SECONDS : FOURTY_EIGHT_HOURS_IN_SECONDS; await provider.send('evm_increaseTime', [ - FOURTY_EIGHT_HOURS_IN_SECONDS, + timeIncreaseAmount, ]); await provider.send('evm_mine'); diff --git a/test/custom/NFTDestructed.test.js b/test/custom/NFTDestructed.test.js index 4d62e31..9664f5d 100644 --- a/test/custom/NFTDestructed.test.js +++ b/test/custom/NFTDestructed.test.js @@ -13,6 +13,7 @@ const { deployTestContractSetup, getTokenVault } = require('../helpers/deploy'); const { PARTY_STATUS, FOURTY_EIGHT_HOURS_IN_SECONDS, + EIGHT_DAYS_IN_SECONDS, MARKETS, MARKET_NAMES, } = require('../helpers/constants'); @@ -75,8 +76,9 @@ describe('NFT Contract Self-Destructed', async () => { it('Accepts external Finalize', async () => { // increase time on-chain so that auction can be finalized + const timeIncreaseAmount = marketName == MARKET_NAMES.FRACTIONAL ? EIGHT_DAYS_IN_SECONDS : FOURTY_EIGHT_HOURS_IN_SECONDS; await provider.send('evm_increaseTime', [ - FOURTY_EIGHT_HOURS_IN_SECONDS, + timeIncreaseAmount, ]); await provider.send('evm_mine'); diff --git a/test/custom/TokenGating.test.js b/test/custom/TokenGating.test.js index a708dc6..8092f62 100644 --- a/test/custom/TokenGating.test.js +++ b/test/custom/TokenGating.test.js @@ -22,6 +22,8 @@ const { const { MARKETS, FOURTY_EIGHT_HOURS_IN_SECONDS, + EIGHT_DAYS_IN_SECONDS, + MARKET_NAMES, } = require('../helpers/constants'); const { testCases } = require('../partybid/partyBidTestCases.json'); @@ -191,8 +193,9 @@ describe('TokenGating', async () => { } } // increase time on-chain so that auction can be finalized + const timeIncreaseAmount = marketName == MARKET_NAMES.FRACTIONAL ? EIGHT_DAYS_IN_SECONDS : FOURTY_EIGHT_HOURS_IN_SECONDS; await provider.send('evm_increaseTime', [ - FOURTY_EIGHT_HOURS_IN_SECONDS, + timeIncreaseAmount, ]); await provider.send('evm_mine'); diff --git a/test/custom/TransferWETH.test.js b/test/custom/TransferWETH.test.js index fe10d50..81830b6 100644 --- a/test/custom/TransferWETH.test.js +++ b/test/custom/TransferWETH.test.js @@ -10,6 +10,8 @@ const { deployTestContractSetup, deploy } = require('../helpers/deploy'); const { MARKETS, FOURTY_EIGHT_HOURS_IN_SECONDS, + EIGHT_DAYS_IN_SECONDS, + MARKET_NAMES, } = require('../helpers/constants'); describe('Transfer WETH', async () => { @@ -111,8 +113,9 @@ describe('Transfer WETH', async () => { it('Allows Finalize', async () => { // increase time on-chain so that auction can be finalized + const timeIncreaseAmount = marketName == MARKET_NAMES.FRACTIONAL ? EIGHT_DAYS_IN_SECONDS : FOURTY_EIGHT_HOURS_IN_SECONDS; await provider.send('evm_increaseTime', [ - FOURTY_EIGHT_HOURS_IN_SECONDS, + timeIncreaseAmount, ]); await provider.send('evm_mine'); diff --git a/test/helpers/constants.js b/test/helpers/constants.js index c6d08f3..91ac78f 100644 --- a/test/helpers/constants.js +++ b/test/helpers/constants.js @@ -1,7 +1,9 @@ const MARKET_NAMES = { ZORA: 'ZORA', FOUNDATION: 'FOUNDATION', - // NOUNS: 'NOUNS', + NOUNS: 'NOUNS', + KOANS: 'KOANS', + FRACTIONAL: 'FRACTIONAL' }; // MARKETS is an array of all values in MARKET_NAMES @@ -10,10 +12,13 @@ const MARKETS = Object.keys(MARKET_NAMES).map((key) => MARKET_NAMES[key]); const NFT_TYPE_ENUM = { ZORA: 0, FOUNDATION: 1, - // NOUNS: 2, + NOUNS: 2, + KOANS: 3, + FRACTIONAL: 4, }; const FOURTY_EIGHT_HOURS_IN_SECONDS = 48 * 60 * 60; +const EIGHT_DAYS_IN_SECONDS = 8 * 24 * 60 * 60; const TOKEN_FEE_BASIS_POINTS = 250; const ETH_FEE_BASIS_POINTS = 250; @@ -27,14 +32,17 @@ const PARTY_STATUS = { }; const ONE_ETH = '1000000000000000000'; +const WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; module.exports = { MARKETS, MARKET_NAMES, NFT_TYPE_ENUM, ONE_ETH, + WETH_ADDRESS, PARTY_STATUS, FOURTY_EIGHT_HOURS_IN_SECONDS, + EIGHT_DAYS_IN_SECONDS, TOKEN_FEE_BASIS_POINTS, ETH_FEE_BASIS_POINTS, TOKEN_SCALE, diff --git a/test/helpers/deploy.js b/test/helpers/deploy.js index 16968dc..c30577a 100644 --- a/test/helpers/deploy.js +++ b/test/helpers/deploy.js @@ -4,7 +4,7 @@ const { createReserveAuction, createZoraAuction, } = require('./utils'); -const { MARKET_NAMES, FOURTY_EIGHT_HOURS_IN_SECONDS } = require('./constants'); +const { MARKET_NAMES, FOURTY_EIGHT_HOURS_IN_SECONDS, WETH_ADDRESS } = require('./constants'); const { upgrades } = require('hardhat'); async function deploy(name, args = []) { @@ -13,6 +13,12 @@ async function deploy(name, args = []) { return contract.deployed(); } +async function deployWithSigner(name, signer, args = []) { + const Implementation = await ethers.getContractFactory(name, signer); + const contract = await Implementation.deploy(...args); + return contract.deployed(); +} + async function getTokenVault(party, signer) { const vaultAddress = await party.tokenVault(); const TokenVault = await ethers.getContractFactory('TokenVault'); @@ -168,6 +174,48 @@ async function deployNounsAndStartAuction( }; } +async function deployFractionalAndStartAuction( + artistSigner, + nftContract, + tokenId, + weth, + reservePrice, +) { + + const fractionalSettings = await deploy("Settings"); + const fractionalValutFactory = await deployWithSigner( + "ERC721VaultFactory", + artistSigner, + [ fractionalSettings.address ] + ); + + const marketWrapper = await deploy("FractionalMarketWrapper", [ + fractionalValutFactory.address, + weth.address + ]); + + // Approve NFT Transfer to fractionalValutFactory + await approve(artistSigner, nftContract, fractionalValutFactory.address, tokenId); + + await fractionalValutFactory.mint( + "FractionalTokenName", + "FractionalTokenSymbol", + nftContract.address, + tokenId, + 100, + eth(reservePrice), + 0 + ); + + const auctionId = 0; + + return { + market: fractionalValutFactory, + marketWrapper, + auctionId, + } +} + async function deployTestContractSetup( marketName, provider, @@ -181,8 +229,19 @@ async function deployTestContractSetup( gatedToken = '0x0000000000000000000000000000000000000000', gatedTokenAmount = 0, ) { - // Deploy WETH - const weth = await deploy('EtherToken'); + // Deploy wETH and hardcode wETH's address for Fractional + let weth; + weth = await deploy('EtherToken'); + if (marketName == MARKET_NAMES.FRACTIONAL) { + const code = await provider.send("eth_getCode", [ + weth.address + ]) + await provider.send("hardhat_setCode", [ + WETH_ADDRESS, + code, + ]); + weth = await ethers.getContractAt('contracts/external/fractional/Interfaces/IWETH.sol:IWETH', WETH_ADDRESS); + } // Nouns uses a custom ERC721 contract. Note that the Nouns // Auction House is responsible for token minting @@ -223,6 +282,14 @@ async function deployTestContractSetup( reservePrice, pauseAuctionHouse, ); + } else if (marketName == MARKET_NAMES.FRACTIONAL) { + marketContracts = await deployFractionalAndStartAuction( + artistSigner, + nftContract, + tokenId, + weth, + reservePrice, + ); } else { throw new Error('Unsupported market type'); } diff --git a/test/helpers/externalTransactions.js b/test/helpers/externalTransactions.js index 0f1c115..96e1dc3 100644 --- a/test/helpers/externalTransactions.js +++ b/test/helpers/externalTransactions.js @@ -11,6 +11,27 @@ async function placeBid(signer, marketContract, auctionId, value, marketName) { data = encodeData(marketContract, 'placeBid', [auctionId]); } else if (marketName == MARKET_NAMES.KOANS) { data = encodeData(marketContract, 'createBid', [auctionId]); + } else if (marketName == MARKET_NAMES.FRACTIONAL) { + const fractionalTokenVaultAddress = await marketContract.vaults(auctionId); + const logic = await ethers.getContractFactory('TokenVault'); + const fractionalTokenVault = new ethers.Contract(fractionalTokenVaultAddress, logic.interface, signer); + const tokenState = await fractionalTokenVault.auctionState(); + let funcName; + if (tokenState == 0) { + funcName = 'start'; + } else if (tokenState == 1) { + funcName = 'bid'; + } else { + throw new Error('Fractional Token is either ended or redeemed.') + } + data = encodeData(fractionalTokenVault, funcName, []); + + return signer.sendTransaction({ + to: fractionalTokenVault.address, + data, + value, + gasLimit: 900000 /* hardhat test cannot estimate gas */, + }); } else { throw new Error('Unsupported Market'); } @@ -32,6 +53,16 @@ async function externalFinalize(signer, marketContract, auctionId, marketName) { data = encodeData(marketContract, 'finalizeReserveAuction', [auctionId]); } else if (marketName == MARKET_NAMES.KOANS) { data = encodeData(marketContract, 'settleCurrentAndCreateNewAuction', []); + } else if (marketName == MARKET_NAMES.FRACTIONAL) { + const fractionalTokenVaultAddress = await marketContract.vaults(auctionId); + const logic = await ethers.getContractFactory('TokenVault'); + const fractionalTokenVault = new ethers.Contract(fractionalTokenVaultAddress, logic.interface, signer); + data = encodeData(fractionalTokenVault, 'end', []); + + return signer.sendTransaction({ + to: fractionalTokenVault.address, + data, + }); } else { throw new Error('Unsupported Market'); } diff --git a/test/helpers/utils.js b/test/helpers/utils.js index 88e41f1..381c931 100644 --- a/test/helpers/utils.js +++ b/test/helpers/utils.js @@ -98,6 +98,7 @@ async function expire(partyBidContract, signer) { return signer.sendTransaction({ to: partyBidContract.address, data, + gasLimit: 900000 /* hardhat test cannot estimate gas */, }); } @@ -115,6 +116,7 @@ async function bidThroughParty(partyBidContract, signer) { return signer.sendTransaction({ to: partyBidContract.address, data, + gasLimit: 900000 /* hardhat test cannot estimate gas */, }); }