├── .gitignore ├── foundry.toml ├── .gitmodules ├── .github └── workflows │ └── test.yml ├── README.md ├── src └── Contract.sol └── test └── Contract.t.sol /.gitignore: -------------------------------------------------------------------------------- 1 | cache/ 2 | out/ 3 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [default] 2 | src = 'src' 3 | out = 'out' 4 | libs = ['lib'] 5 | 6 | solc_version = "0.8.9" 7 | remappings = [ 8 | "@openzeppelin/contracts=lib/openzeppelin-contracts/contracts", 9 | "@openzeppelin/contracts-upgradeable=lib/openzeppelin-contracts-upgradeable/contracts" 10 | ] 11 | 12 | # See more config options https://github.com/foundry-rs/foundry/tree/master/config 13 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/partybid"] 5 | path = lib/partybid 6 | url = https://github.com/PartyDAO/partybid 7 | [submodule "lib/openzeppelin-contracts"] 8 | path = lib/openzeppelin-contracts 9 | url = https://github.com/OpenZeppelin/openzeppelin-contracts/ 10 | [submodule "lib/openzeppelin-contracts-upgradeable"] 11 | path = lib/openzeppelin-contracts-upgradeable 12 | url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: solidity 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | submodules: recursive 16 | 17 | - name: Install Foundry 18 | uses: foundry-rs/foundry-toolchain@v1 19 | with: 20 | version: nightly 21 | 22 | - name: Run Forge tests 23 | run: | 24 | # :) 25 | forge t --rpc-url https://eth-mainnet.alchemyapi.io/v2/MdZcimFJ2yz2z6pw21UYL-KNA0zmgX-F --fork-block-number 14725939 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SaveNoun11 2 | 3 | Fractional adapter for PartyBid using Foundry. 4 | 5 | Unfortunately hits a limitation when getting outbid: 6 | 7 | Fractional [reimburses contributors in WETH](https://github.com/fractional-company/contracts/blob/master/src/ERC721TokenVault.sol#L399) if there's no fallback function implemented. This means that if a PartyBid gets outbid, it receives WETH, not ETH. PartyBid does not have a way to "convert" that WETH to ETH to re-bid, so the WETH is ~stuck ¯\_(ツ)_/¯. 8 | 9 | This should be fixable by just adding a `receive() external payable {}` to the PartyBid contracts, or by even more explicitly 10 | adding a `convertWETHtoETH` method which ideally gets automatically called on each bid. 11 | 12 | ## Testing 13 | 14 | Add your RPC url below and run: 15 | 16 | ``` 17 | `forge t --fork-url $ETH_RPC_URL --fork-block-number 14725939 -vvvvv` 18 | ``` 19 | -------------------------------------------------------------------------------- /src/Contract.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.9; 3 | 4 | import "partybid/market-wrapper/IMarketWrapper.sol"; 5 | 6 | // The fractional factory 7 | interface FractionalFactory { 8 | function vaults(uint256) external view returns (FractionalVault); 9 | } 10 | 11 | // The state of an auction 12 | enum State { inactive, live, ended, redeemed } 13 | 14 | // A specific fractional auction 15 | interface FractionalVault { 16 | function start() payable external; 17 | function bid() payable external; 18 | function end() external; 19 | function redeem() external; 20 | 21 | 22 | 23 | function auctionEnd() external view returns (uint256); 24 | function auctionState() external view returns (State); 25 | 26 | function reservePrice() external view returns (uint256); 27 | function livePrice() external view returns (uint256); 28 | function id() external view returns (uint256); 29 | 30 | function winning() external view returns (address); 31 | function curator() external view returns (address); 32 | } 33 | 34 | interface FractionalSettings { 35 | function minBidIncrease() external view returns (uint256); 36 | function feeReceiver() external view returns (address); 37 | } 38 | 39 | contract FractionalMarketWrapper is IMarketWrapper { 40 | // https://github.com/fractional-company/contracts#mainnet 41 | FractionalFactory public constant factory = FractionalFactory(0x85Aa7f78BdB2DE8F3e0c0010d99AD5853fFcfC63); 42 | FractionalSettings public constant settings = FractionalSettings(0xE0FC79183a22106229B84ECDd55cA017A07eddCa); 43 | 44 | // if the contract has any WETH, unwrap it into ETH? 45 | function bid(uint256 auctionId, uint256 bidAmount) external { 46 | FractionalVault vault = factory.vaults(auctionId); 47 | if (vault.auctionState() == State.inactive) { 48 | vault.start{value: bidAmount}(); 49 | } else { 50 | vault.bid{value: bidAmount}(); 51 | } 52 | } 53 | 54 | function finalize(uint256 auctionId) external override { 55 | factory.vaults(auctionId).end(); 56 | } 57 | 58 | function getCurrentHighestBidder(uint256 auctionId) external view override returns (address) { 59 | return factory.vaults(auctionId).winning(); 60 | } 61 | 62 | function isFinalized(uint256 auctionId) external view override returns (bool) { 63 | return factory.vaults(auctionId).auctionState() == State.ended; 64 | } 65 | 66 | function getMinimumBid(uint256 auctionId) external view override returns (uint256) { 67 | FractionalVault vault = factory.vaults(auctionId); 68 | uint256 price = vault.livePrice(); 69 | 70 | // use the reserve price if this is the first bid 71 | if (price == 0) { 72 | return vault.reservePrice(); 73 | } else { 74 | // bump the price just enough 75 | uint256 pctIncrease = settings.minBidIncrease() + 1001; 76 | // divide by 1000 to re-normalize the pct 77 | return price * pctIncrease / 1000; 78 | } 79 | } 80 | 81 | function auctionIdMatchesToken( 82 | uint256 auctionId, 83 | // unused params 84 | address, uint256 85 | ) public view override returns (bool) { 86 | return address(factory.vaults(auctionId)) != address(0x0); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /test/Contract.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.9; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import "src/Contract.sol"; 7 | import "partybid/PartyBid.sol"; 8 | import "partybid/PartyBidFactory.sol"; 9 | 10 | import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 11 | 12 | interface Nouns { 13 | function ownerOf(uint256) external view returns (address); 14 | } 15 | 16 | contract ContractTest is Test { 17 | // Mainnet contracts 18 | PartyBidFactory constant partyFactory = PartyBidFactory(0x0accf637e4F05eeA8B1D215C8C9e9E576dC63D33); 19 | PartyBid constant party = PartyBid(0x18B9F4aD986A0E0777a2E1A652a4499C8EE3E077); 20 | Nouns constant nouns = Nouns(0x9C8fF314C9Bc7F6e59A9d9225Fb22946427eDC03); 21 | FractionalVault vault; 22 | 23 | // the fractional wrapper 24 | FractionalMarketWrapper fractional; 25 | PartyBid bid; 26 | 27 | uint256 balanceBefore; 28 | 29 | // the noun we're testing with 30 | uint256 constant noun11 = 11; 31 | // https://etherscan.io/tx/0x14292770d0867c9d78234a11a7d5afe558dab4acf7269eb600674a034c54c350#eventlog 32 | uint256 constant noun11Vault = 275; 33 | uint256 constant noun11PartyVault = 1278; 34 | // the market wrapper creator wants some rent 35 | address constant rentAddress = address(0x1234); 36 | uint256 constant rentBasisPoints = 200; 37 | 38 | address constant bidder = address(0xbbbb); 39 | address constant curator = address(0xcccc); 40 | address constant enemy = address(0xdddd); 41 | 42 | // enable forge-std storage overwrites 43 | using stdStorage for StdStorage; 44 | 45 | function setUp() public { 46 | // deploy the market wrapper 47 | fractional = new FractionalMarketWrapper(); 48 | vault = fractional.factory().vaults(noun11Vault); 49 | 50 | // override the `curator` address on the fractional vaults 51 | // to be non-zero. the currently deployed Fractional version 52 | // does not have the `if curator != 0x0` check, and that causes 53 | // openzeppelin to choke on transfers to 0x0. 54 | // https://github.com/fractional-company/contracts/commit/5003fc2189a5998dcfaddcb83ddcbbb53ec9c628 55 | stdstore.target(address(vault)) 56 | .sig(FractionalVault.curator.selector) 57 | .checked_write(address(0x01)); 58 | 59 | // start the party 60 | address _bid = partyFactory.startParty( 61 | address(fractional), // market wrapper 62 | address(nouns), // nftcontract 63 | // these can be the same for Fractional, given that we go via the factory 64 | noun11, // tokenId 65 | noun11Vault, // auctionId 66 | Structs.AddressAndAmount({ addr: rentAddress, amount: rentBasisPoints }), 67 | Structs.AddressAndAmount({ addr: address(0), amount: 0 }), 68 | "Noun11", 69 | "N11" 70 | ); 71 | bid = PartyBid(_bid); 72 | 73 | // some people contribute to the auction 74 | hoax(bidder, 200 ether); 75 | bid.contribute{value: 200 ether}(); 76 | 77 | // save the balance before getting the change back 78 | balanceBefore = address(bidder).balance; 79 | 80 | // bid! 81 | vm.prank(bidder); 82 | bid.bid(); 83 | 84 | // add some labels for traces to be nicer 85 | vm.label(_bid, "PartyBid"); 86 | vm.label(rentAddress, "Rent"); 87 | vm.label(bidder, "Bidder"); 88 | vm.label(curator, "Curator"); 89 | vm.label(address(vault), "Vault"); 90 | } 91 | 92 | // helper to fast forward to the end of the auction 93 | function endAuction() private { 94 | uint256 endTime = vault.auctionEnd(); 95 | vm.warp(endTime); 96 | bid.finalize(); 97 | } 98 | 99 | function outbid() private { 100 | vm.label(enemy, "enemy"); 101 | hoax(enemy); 102 | 103 | // they outbid us directly on the vault 104 | vault.bid{value: 125 ether}(); 105 | 106 | // the bid gets its bid back in WETH 107 | IERC20 weth = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); 108 | assertGt(weth.balanceOf(address(bid)), 105 ether); 109 | assertLe(weth.balanceOf(address(bid)), 106 ether); 110 | } 111 | 112 | function testCanSaveNoun11() public { 113 | endAuction(); 114 | 115 | // the partydao deployed vault owns the token 116 | IERC20 partyVault = IERC20(bid.tokenVaultFactory().vaults(noun11PartyVault)); 117 | assertEq(nouns.ownerOf(noun11), address(partyVault)); 118 | 119 | 120 | bid.claim(bidder); 121 | 122 | // check that we own most of the supply, minus the fees 123 | assertGt(partyVault.balanceOf(bidder), 954 * partyVault.totalSupply() / 1000); 124 | 125 | uint256 balanceAfter = address(bidder).balance; 126 | 127 | // 200 - ~109 ETH = 91 eth received back 128 | assertGt(balanceAfter - balanceBefore, 91 ether); 129 | } 130 | 131 | function testOutbidLose() public { 132 | outbid(); 133 | 134 | // end the auction 135 | // they now own the noun 136 | endAuction(); 137 | assertEq(nouns.ownerOf(noun11), enemy); 138 | 139 | // but at least we can get our ETH back 140 | bid.claim(bidder); 141 | // the bidder claims its remaining ETH back 142 | assertGt(bidder.balance, 94 ether); 143 | assertLe(bidder.balance, 95 ether); 144 | // how do you get the remaining WETH out of the party? 145 | // can you not?! 146 | } 147 | 148 | function testOutbidRespondAndWin() public { 149 | outbid(); 150 | 151 | // party bids again and wins, but requires contributing more, 152 | // whereas ideally we'd be able to convert the returned 153 | // WETH to ETH and re-contribute it. 154 | // i.e. the re-contribution step wouldn't be needed 155 | vm.startPrank(bidder); 156 | vm.deal(bidder, 100 ether); 157 | bid.contribute{value: 100 ether}(); 158 | bid.bid(); 159 | vm.stopPrank(); 160 | 161 | endAuction(); 162 | IERC20 partyVault = IERC20(bid.tokenVaultFactory().vaults(noun11PartyVault)); 163 | assertEq(nouns.ownerOf(noun11), address(partyVault)); 164 | } 165 | } 166 | --------------------------------------------------------------------------------