├── index.ts ├── .mocharc.json ├── auction-house.png ├── addresses ├── 1.json ├── 137.json ├── 3.json ├── 4.json └── 80001.json ├── .gitignore ├── contracts ├── test │ ├── BadERC721.sol │ ├── TestERC721.sol │ ├── BadBidder.sol │ └── WETH.sol ├── interfaces │ └── IAuctionHouse.sol └── AuctionHouse.sol ├── tsconfig.json ├── utils └── Decimal.ts ├── hardhat.config.ts ├── package.json ├── scripts └── deploy.ts ├── test ├── utils.ts ├── integration.test.ts └── AuctionHouse.test.ts └── README.md /index.ts: -------------------------------------------------------------------------------- 1 | export * from "./typechain"; 2 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": "ts-node/register/files", 3 | "timeout": 20000 4 | } -------------------------------------------------------------------------------- /auction-house.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourzora/auction-house/HEAD/auction-house.png -------------------------------------------------------------------------------- /addresses/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "weth": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 3 | "auctionHouse": "0xE468cE99444174Bd3bBBEd09209577d25D1ad673" 4 | } -------------------------------------------------------------------------------- /addresses/137.json: -------------------------------------------------------------------------------- 1 | { 2 | "weth": "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", 3 | "auctionHouse": "0x48F1C97259Dc7f3900965391651693BaeBfd59A2" 4 | } -------------------------------------------------------------------------------- /addresses/3.json: -------------------------------------------------------------------------------- 1 | { 2 | "weth": "0xc778417e063141139fce010982780140aa0cd5ab", 3 | "auctionHouse": "0x6953190AAfD8f8995e8f47e8F014d0dB83E92300" 4 | } -------------------------------------------------------------------------------- /addresses/4.json: -------------------------------------------------------------------------------- 1 | { 2 | "weth": "0xc778417E063141139Fce010982780140Aa0cD5Ab", 3 | "auctionHouse": "0xE7dd1252f50B3d845590Da0c5eADd985049a03ce" 4 | } -------------------------------------------------------------------------------- /addresses/80001.json: -------------------------------------------------------------------------------- 1 | { 2 | "weth": "0x9c3C9283D3e44854697Cd22D3Faa240Cfb032889", 3 | "auctionHouse": "0x6953190AAfD8f8995e8f47e8F014d0dB83E92300" 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | #Hardhat files 4 | cache 5 | artifacts 6 | typechain 7 | 8 | coverage 9 | coverage.json 10 | 11 | .idea 12 | 13 | .env.dev 14 | .env.prod 15 | .env.* 16 | 17 | dist -------------------------------------------------------------------------------- /contracts/test/BadERC721.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0 2 | 3 | // FOR TEST PURPOSES ONLY. NOT PRODUCTION SAFE 4 | pragma solidity 0.6.8; 5 | 6 | contract BadERC721 { 7 | function supportsInterface(bytes4 _interface) public returns (bool){ 8 | return false; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /contracts/test/TestERC721.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0 2 | 3 | // FOR TEST PURPOSES ONLY. NOT PRODUCTION SAFE 4 | pragma solidity 0.6.8; 5 | 6 | import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 7 | 8 | contract TestERC721 is ERC721 { 9 | constructor() ERC721("TestERC721", "TEST") public {} 10 | 11 | function mint(address to, uint256 tokenId) public { 12 | _safeMint(to, tokenId); 13 | } 14 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "strict": false, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "outDir": "dist", 13 | "resolveJsonModule": true 14 | }, 15 | "include": [ 16 | "./typechain", 17 | "./addresses", 18 | "index.ts" 19 | ], 20 | "files": ["./hardhat.config.ts"] 21 | } -------------------------------------------------------------------------------- /utils/Decimal.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from "ethers"; 2 | 3 | export default class Decimal { 4 | static new(value: number) { 5 | const decimalPlaces = countDecimals(value); 6 | const difference = 18 - decimalPlaces; 7 | const zeros = BigNumber.from(10).pow(difference); 8 | const abs = BigNumber.from(`${value.toString().replace(".", "")}`); 9 | return { value: abs.mul(zeros) }; 10 | } 11 | 12 | static raw(value: number) { 13 | return { value: BigNumber.from(value) }; 14 | } 15 | } 16 | 17 | function countDecimals(value) { 18 | if (Math.floor(value) !== value) 19 | return value.toString().split(".")[1].length || 0; 20 | return 0; 21 | } 22 | -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import { task } from "hardhat/config"; 2 | import "@nomiclabs/hardhat-waffle"; 3 | import "@nomiclabs/hardhat-ethers"; 4 | import "hardhat-typechain"; 5 | import "solidity-coverage"; 6 | import "@nomiclabs/hardhat-etherscan"; 7 | 8 | // This is a sample Hardhat task. To learn how to create your own go to 9 | // https://hardhat.org/guides/create-task.html 10 | task("accounts", "Prints the list of accounts", async (args, hre) => { 11 | const accounts = await hre.ethers.getSigners(); 12 | 13 | for (const account of accounts) { 14 | console.log(account.address); 15 | } 16 | }); 17 | 18 | // You need to export an object to set up your config 19 | // Go to https://hardhat.org/config/ to learn more 20 | 21 | /** 22 | * @type import('hardhat/config').HardhatUserConfig 23 | */ 24 | export default { 25 | solidity: "0.6.8", 26 | }; 27 | -------------------------------------------------------------------------------- /contracts/test/BadBidder.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0 2 | 3 | // FOR TEST PURPOSES ONLY. NOT PRODUCTION SAFE 4 | pragma solidity 0.6.8; 5 | import {IAuctionHouse} from "../interfaces/IAuctionHouse.sol"; 6 | 7 | // This contract is meant to mimic a bidding contract that does not implement on IERC721 Received, 8 | // and thus should cause a revert when an auction is finalized with this as the winning bidder. 9 | contract BadBidder { 10 | address auction; 11 | address zora; 12 | 13 | constructor(address _auction, address _zora) public { 14 | auction = _auction; 15 | zora = _zora; 16 | } 17 | 18 | function placeBid(uint256 auctionId, uint256 amount) external payable { 19 | IAuctionHouse(auction).createBid{value: amount}(auctionId, amount); 20 | } 21 | 22 | receive() external payable {} 23 | fallback() external payable {} 24 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zoralabs/auction-house", 3 | "version": "1.1.3", 4 | "private": false, 5 | "homepage": "https://github.com/ourzora/coldies", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/ourzora/coldies.git" 9 | }, 10 | "main": "./dist/index.js", 11 | "types": "./dist/index.d.ts", 12 | "files": [ 13 | "./dist/**/*", 14 | "./dist/*" 15 | ], 16 | "scripts": { 17 | "prepublishOnly": "yarn build:contracts && yarn build:package", 18 | "build:contracts": "hardhat compile", 19 | "build:package": "rm -rf ./dist && tsc && cp typechain/*.d.ts dist/typechain && cp -R addresses dist && cp -R artifacts/contracts dist/artifacts && cp -R contracts dist", 20 | "test": "hardhat test", 21 | "deploy": "ts-node scripts/deploy.ts" 22 | }, 23 | "devDependencies": { 24 | "@nomiclabs/hardhat-ethers": "^2.0.2", 25 | "@nomiclabs/hardhat-etherscan": "^2.1.1", 26 | "@nomiclabs/hardhat-waffle": "^2.0.1", 27 | "@typechain/ethers-v5": "^6.0.4", 28 | "@types/chai": "^4.2.15", 29 | "@types/chai-as-promised": "^7.1.3", 30 | "@types/mocha": "^8.2.1", 31 | "@types/node": "^14.14.35", 32 | "chai": "^4.3.4", 33 | "chai-as-promised": "^7.1.1", 34 | "ethereum-waffle": "^3.3.0", 35 | "ethers": "^5.0.32", 36 | "hardhat": "^2.1.1", 37 | "hardhat-gas-reporter": "^1.0.4", 38 | "hardhat-typechain": "^0.3.5", 39 | "prettier": "^2.2.1", 40 | "ts-generator": "^0.1.1", 41 | "ts-node": "^9.1.1", 42 | "typechain": "^4.0.3", 43 | "typescript": "^4.2.3" 44 | }, 45 | "dependencies": { 46 | "@openzeppelin/contracts": "^3.2.0", 47 | "@zoralabs/core": "^1.0.7", 48 | "dotenv": "^8.2.0", 49 | "fs-extra": "^9.1.0", 50 | "minimist": "^1.2.5", 51 | "solidity-coverage": "^0.7.16" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /contracts/test/WETH.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0 2 | 3 | // FOR TEST PURPOSES ONLY. NOT PRODUCTION SAFE 4 | // Source: https://github.com/gnosis/canonical-weth/blob/0dd1ea3e295eef916d0c6223ec63141137d22d67/contracts/WETH9.sol 5 | pragma solidity 0.6.8; 6 | import "hardhat/console.sol"; 7 | 8 | 9 | contract WETH { 10 | string public name = "Wrapped Ether"; 11 | string public symbol = "WETH"; 12 | uint8 public decimals = 18; 13 | 14 | event Approval(address indexed src, address indexed guy, uint wad); 15 | event Transfer(address indexed src, address indexed dst, uint wad); 16 | event Deposit(address indexed dst, uint wad); 17 | event Withdrawal(address indexed src, uint wad); 18 | 19 | mapping (address => uint) public balanceOf; 20 | mapping (address => mapping (address => uint)) public allowance; 21 | 22 | fallback() external payable { 23 | deposit(); 24 | } 25 | receive() external payable { deposit(); } 26 | function deposit() public payable { 27 | balanceOf[msg.sender] += msg.value; 28 | emit Deposit(msg.sender, msg.value); 29 | } 30 | function withdraw(uint wad) public { 31 | require(balanceOf[msg.sender] >= wad); 32 | balanceOf[msg.sender] -= wad; 33 | msg.sender.transfer(wad); 34 | emit Withdrawal(msg.sender, wad); 35 | } 36 | 37 | function totalSupply() public view returns (uint) { 38 | return address(this).balance; 39 | } 40 | 41 | function approve(address guy, uint wad) public returns (bool) { 42 | allowance[msg.sender][guy] = wad; 43 | emit Approval(msg.sender, guy, wad); 44 | return true; 45 | } 46 | 47 | function transfer(address dst, uint wad) public returns (bool) { 48 | return transferFrom(msg.sender, dst, wad); 49 | } 50 | 51 | function transferFrom(address src, address dst, uint wad) 52 | public 53 | returns (bool) 54 | { 55 | require(balanceOf[src] >= wad); 56 | 57 | if (src != msg.sender && allowance[src][msg.sender] != uint(-1)) { 58 | require(allowance[src][msg.sender] >= wad); 59 | allowance[src][msg.sender] -= wad; 60 | } 61 | 62 | balanceOf[src] -= wad; 63 | balanceOf[dst] += wad; 64 | 65 | emit Transfer(src, dst, wad); 66 | 67 | return true; 68 | } 69 | } -------------------------------------------------------------------------------- /scripts/deploy.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { ethers } from "hardhat"; 3 | import fs from "fs-extra"; 4 | import { AuctionHouse } from "../typechain"; 5 | 6 | async function main() { 7 | const args = require("minimist")(process.argv.slice(2)); 8 | 9 | if (!args.chainId) { 10 | throw new Error("--chainId chain ID is required"); 11 | } 12 | const path = `${process.cwd()}/.env${ 13 | args.chainId === 1 ? ".prod" : args.chainId === 4 ? ".dev" : ".local" 14 | }`; 15 | await require("dotenv").config({ path }); 16 | const provider = new ethers.providers.JsonRpcProvider( 17 | process.env.RPC_ENDPOINT 18 | ); 19 | const wallet = new ethers.Wallet(`0x${process.env.PRIVATE_KEY}`, provider); 20 | const addressPath = `${process.cwd()}/addresses/${args.chainId}.json`; 21 | const protocolAddressPath = `${process.cwd()}/node_modules/@zoralabs/core/dist/addresses/${ 22 | args.chainId 23 | }.json`; 24 | 25 | // @ts-ignore 26 | const addressBook = JSON.parse(await fs.readFileSync(addressPath)); 27 | const protocolAddressBook = JSON.parse( 28 | // @ts-ignore 29 | await fs.readFileSync(protocolAddressPath) 30 | ); 31 | 32 | if (!addressBook.weth) { 33 | throw new Error("Missing WETH address in address book."); 34 | } 35 | if (!protocolAddressBook.media) { 36 | throw new Error("Missing Media address in protocol address book."); 37 | } 38 | if (addressBook.auctionHouse) { 39 | throw new Error( 40 | "auctionHouse already in address book, it must be moved before deploying." 41 | ); 42 | } 43 | 44 | // We get the contract to deploy 45 | const AuctionHouse = (await ethers.getContractFactory( 46 | "AuctionHouse", 47 | wallet 48 | )) as AuctionHouse; 49 | 50 | console.log( 51 | `Deploying Auction House from deployment address ${wallet.address}...` 52 | ); 53 | const impl = await AuctionHouse.deploy( 54 | protocolAddressBook.media, 55 | addressBook.weth 56 | ); 57 | console.log( 58 | `Auction House deploying to ${impl.address}. Awaiting confirmation...` 59 | ); 60 | await impl.deployed(); 61 | addressBook.auctionHouse = impl.address; 62 | await fs.writeFile(addressPath, JSON.stringify(addressBook, null, 2)); 63 | 64 | console.log("Auction House contracts deployed 📿"); 65 | } 66 | 67 | main() 68 | .then(() => process.exit(0)) 69 | .catch((error) => { 70 | console.error(error); 71 | process.exit(1); 72 | }); 73 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { ethers } from "hardhat"; 3 | import { 4 | MarketFactory, 5 | Media, 6 | MediaFactory, 7 | } from "@zoralabs/core/dist/typechain"; 8 | import { 9 | BadBidder, 10 | AuctionHouse, 11 | WETH, 12 | BadERC721, 13 | TestERC721, 14 | } from "../typechain"; 15 | import { sha256 } from "ethers/lib/utils"; 16 | import Decimal from "../utils/Decimal"; 17 | import { BigNumber } from "ethers"; 18 | 19 | export const THOUSANDTH_ETH = ethers.utils.parseUnits( 20 | "0.001", 21 | "ether" 22 | ) as BigNumber; 23 | export const TENTH_ETH = ethers.utils.parseUnits("0.1", "ether") as BigNumber; 24 | export const ONE_ETH = ethers.utils.parseUnits("1", "ether") as BigNumber; 25 | export const TWO_ETH = ethers.utils.parseUnits("2", "ether") as BigNumber; 26 | 27 | export const deployWETH = async () => { 28 | const [deployer] = await ethers.getSigners(); 29 | return (await (await ethers.getContractFactory("WETH")).deploy()) as WETH; 30 | }; 31 | 32 | export const deployOtherNFTs = async () => { 33 | const bad = (await ( 34 | await ethers.getContractFactory("BadERC721") 35 | ).deploy()) as BadERC721; 36 | const test = (await ( 37 | await ethers.getContractFactory("TestERC721") 38 | ).deploy()) as TestERC721; 39 | 40 | return { bad, test }; 41 | }; 42 | 43 | export const deployZoraProtocol = async () => { 44 | const [deployer] = await ethers.getSigners(); 45 | const market = await (await new MarketFactory(deployer).deploy()).deployed(); 46 | const media = await ( 47 | await new MediaFactory(deployer).deploy(market.address) 48 | ).deployed(); 49 | await market.configure(media.address); 50 | return { market, media }; 51 | }; 52 | 53 | export const deployBidder = async (auction: string, nftContract: string) => { 54 | return (await ( 55 | await (await ethers.getContractFactory("BadBidder")).deploy( 56 | auction, 57 | nftContract 58 | ) 59 | ).deployed()) as BadBidder; 60 | }; 61 | 62 | export const mint = async (media: Media) => { 63 | const metadataHex = ethers.utils.formatBytes32String("{}"); 64 | const metadataHash = await sha256(metadataHex); 65 | const hash = ethers.utils.arrayify(metadataHash); 66 | await media.mint( 67 | { 68 | tokenURI: "zora.co", 69 | metadataURI: "zora.co", 70 | contentHash: hash, 71 | metadataHash: hash, 72 | }, 73 | { 74 | prevOwner: Decimal.new(0), 75 | owner: Decimal.new(85), 76 | creator: Decimal.new(15), 77 | } 78 | ); 79 | }; 80 | 81 | export const approveAuction = async ( 82 | media: Media, 83 | auctionHouse: AuctionHouse 84 | ) => { 85 | await media.approve(auctionHouse.address, 0); 86 | }; 87 | 88 | export const revert = (messages: TemplateStringsArray) => 89 | `VM Exception while processing transaction: revert ${messages[0]}`; 90 | -------------------------------------------------------------------------------- /contracts/interfaces/IAuctionHouse.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0 2 | 3 | pragma solidity 0.6.8; 4 | pragma experimental ABIEncoderV2; 5 | 6 | /** 7 | * @title Interface for Auction Houses 8 | */ 9 | interface IAuctionHouse { 10 | struct Auction { 11 | // ID for the ERC721 token 12 | uint256 tokenId; 13 | // Address for the ERC721 contract 14 | address tokenContract; 15 | // Whether or not the auction curator has approved the auction to start 16 | bool approved; 17 | // The current highest bid amount 18 | uint256 amount; 19 | // The length of time to run the auction for, after the first bid was made 20 | uint256 duration; 21 | // The time of the first bid 22 | uint256 firstBidTime; 23 | // The minimum price of the first bid 24 | uint256 reservePrice; 25 | // The sale percentage to send to the curator 26 | uint8 curatorFeePercentage; 27 | // The address that should receive the funds once the NFT is sold. 28 | address tokenOwner; 29 | // The address of the current highest bid 30 | address payable bidder; 31 | // The address of the auction's curator. 32 | // The curator can reject or approve an auction 33 | address payable curator; 34 | // The address of the ERC-20 currency to run the auction with. 35 | // If set to 0x0, the auction will be run in ETH 36 | address auctionCurrency; 37 | } 38 | 39 | event AuctionCreated( 40 | uint256 indexed auctionId, 41 | uint256 indexed tokenId, 42 | address indexed tokenContract, 43 | uint256 duration, 44 | uint256 reservePrice, 45 | address tokenOwner, 46 | address curator, 47 | uint8 curatorFeePercentage, 48 | address auctionCurrency 49 | ); 50 | 51 | event AuctionApprovalUpdated( 52 | uint256 indexed auctionId, 53 | uint256 indexed tokenId, 54 | address indexed tokenContract, 55 | bool approved 56 | ); 57 | 58 | event AuctionReservePriceUpdated( 59 | uint256 indexed auctionId, 60 | uint256 indexed tokenId, 61 | address indexed tokenContract, 62 | uint256 reservePrice 63 | ); 64 | 65 | event AuctionBid( 66 | uint256 indexed auctionId, 67 | uint256 indexed tokenId, 68 | address indexed tokenContract, 69 | address sender, 70 | uint256 value, 71 | bool firstBid, 72 | bool extended 73 | ); 74 | 75 | event AuctionDurationExtended( 76 | uint256 indexed auctionId, 77 | uint256 indexed tokenId, 78 | address indexed tokenContract, 79 | uint256 duration 80 | ); 81 | 82 | event AuctionEnded( 83 | uint256 indexed auctionId, 84 | uint256 indexed tokenId, 85 | address indexed tokenContract, 86 | address tokenOwner, 87 | address curator, 88 | address winner, 89 | uint256 amount, 90 | uint256 curatorFee, 91 | address auctionCurrency 92 | ); 93 | 94 | event AuctionCanceled( 95 | uint256 indexed auctionId, 96 | uint256 indexed tokenId, 97 | address indexed tokenContract, 98 | address tokenOwner 99 | ); 100 | 101 | function createAuction( 102 | uint256 tokenId, 103 | address tokenContract, 104 | uint256 duration, 105 | uint256 reservePrice, 106 | address payable curator, 107 | uint8 curatorFeePercentages, 108 | address auctionCurrency 109 | ) external returns (uint256); 110 | 111 | function setAuctionApproval(uint256 auctionId, bool approved) external; 112 | 113 | function setAuctionReservePrice(uint256 auctionId, uint256 reservePrice) external; 114 | 115 | function createBid(uint256 auctionId, uint256 amount) external payable; 116 | 117 | function endAuction(uint256 auctionId) external; 118 | 119 | function cancelAuction(uint256 auctionId) external; 120 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Deprecation Notice 2 | 3 | This is a legacy NFT protocol which ZORA does not use anymore but is still alive onchain due to the nature of decentralized applications. 4 | 5 | The media onchain is supported by multiple NFT platforms (including ZORA) for historical purposes, trading, and display but we strongly recommend using other approaches to mint and sell NFTs today (including our newer https://github.com/ourzora/zora-protocol protocol monorepo). 6 | 7 | 8 | # Zora — Auction House 〜 𓀨 〜 9 | 10 | ![Auction House Header Image](./auction-house.png) 11 | 12 | The Zora Auction House is an open and permissionless system that allows any creator, community, platform or DAO to create and run their own curated auction houses. 13 | 14 | These auction houses run reserve timed auctions for NFTs, with special emphasis given to the role of curators. If an owner of an NFT chooses to list with a curator, that curator can charge a curator fee and has to approve any auction before it commences with that curators auction house. 15 | 16 | Anyone is able to run an NFT auction on the protocol for free by simply not specifying a curator. 17 | 18 | The Zora ethos is to create public goods that are either owned by the community or by no one. As such, we have deployed this without admin functionality, and is therefore entirely permissionless and unstoppable. 19 | 20 | *Mainnet address:* `0xE468cE99444174Bd3bBBEd09209577d25D1ad673` 21 | 22 | *Rinkeby address:* `0xE7dd1252f50B3d845590Da0c5eADd985049a03ce` 23 | 24 | ## Table of Contents 25 | - [Architecture](#architecture) 26 | - [Curators](#curators) 27 | - [Create Auction](#create-auction) 28 | - [Cancel Auction](#cancel-auction) 29 | - [Set Auction Approval](#set-auction-approval) 30 | - [Create Bid](#create-bid) 31 | - [End Auction](#end-auction) 32 | - [Local Development](#local-development) 33 | - [Install Dependencies](#install-dependencies) 34 | - [Compile Contracts](#compile-contracts) 35 | - [Run Tests](#run-tests) 36 | - [Bug Bounty](#bug-bounty) 37 | - [Acknowledgements](#acknowledgements) 38 | 39 | ## Architecture 40 | This protocol allows a holder of any NFT to create and perform 41 | a permissionless reserve auction. It also acknowledges the role of 42 | curators in auctions, and optionally allows the auction creator to 43 | dedicate a portion of the winnings from the auction to a curator of their choice. 44 | 45 | Note that if a curator is specified, the curator decides when to start the auction. 46 | Additionally, the curator is able to cancel an auction before it begins. 47 | 48 | ### Curators 49 | In a metaverse of millions of NFTs, the act of curation is critical. Curators create and facilitate context and community which augment the value of NFTs that they select. The act of curation creates value for the NFT by contextualizing it and signalling its importance to a particular community. The act of curation is extremely valuable, and is directly recognized by the Auction House system. A curator who successfully auctions off an NFT for an owner can earn a share in the sale. 50 | 51 | We have defined a *curator* role in the auction house. A curator can: 52 | - Approve and deny proposals for an NFT to be listed with them. 53 | - Earn a fee for their curation 54 | - Cancel an auction prior to bidding being commenced 55 | 56 | Creators and collectors can submit a proposal to list their NFTs with a curator onchain, which the curator must accept (or optionally reject). This creates an onchain record of a curators activity and value creation. 57 | 58 | Creators and collectors always have the option to run an auction themselves for free. 59 | 60 | ### Create Auction 61 | At any time, the holder of a token can create an auction. When an auction is created, 62 | the token is moved out of their wallet and held in escrow by the auction. The owner can 63 | retrieve the token at any time, so long as the auction has not begun. 64 | 65 | | **Name** | **Type** | **Description** | 66 | |------------------------|----------------|------------------------------------------------------------------------------------------------| 67 | | `tokenId` | `uint256` | The tokenID to use in the auction | 68 | | `tokenContract` | `address` | The address of the nft contract the token is from | 69 | | `duration` | `uint256` | The length of time, in seconds, that the auction should run for once the reserve price is hit. | 70 | | `reservePrice` | `uint256` | The minimum price for the first bid, starting the auction. | 71 | | `creator` | `address` | The address of the current token holder, the creator of the auction | 72 | | `curator` | `address` | The address of the curator for this auction | 73 | | `curatorFeePercentage` | `uint8` | The percentage of the winning bid to share with the curator | 74 | | `auctionCurrency` | `address` | The currency to perform this auction in, or 0x0 for ETH | 75 | 76 | ### Cancel Auction 77 | If an auction has not started yet, the curator or the creator of the auction may cancel the auction, and remove it from the registry. 78 | This action returns the token to the previous holder. 79 | 80 | | **Name** | **Type** | **Description** | 81 | |------------------------|----------------|------------------------------------------------------------------------------------------------| 82 | | `auctionId` | `uint256` | The ID of the auction | 83 | 84 | ### Set Auction Approval 85 | If a created auction specifies a curator to start the auction, the curator _must_ approve it in order for it to start. 86 | This is to allow curators to specifically choose which auctions they are willing to curate and perform. 87 | 88 | | **Name** | **Type** | **Description** | 89 | |------------------------|----------------|------------------------------------------------------------------------------------------------| 90 | | `auctionId` | `uint256` | The ID of the auction | 91 | | `approved` | `bool` | The approval state to set on the auction | 92 | 93 | ### Create Bid 94 | If an auction is approved, anyone is able to bid. The first bid _must_ be greater than the reserve price. 95 | Once the first bid is successfully placed, other bidders may continue to place bids up until the auction's duration has passed. 96 | 97 | If a bid is placed in the final 15 minutes of the auction, the auction is extended for another 15 minutes. 98 | 99 | | **Name** | **Type** | **Description** | 100 | |------------------------|----------------|------------------------------------------------------------------------------------------------| 101 | | `auctionId` | `uint256` | The ID of the auction | 102 | | `amount` | `uint256` | The amount of currency to bid. If the bid is in ETH, this must match the sent ETH value | 103 | 104 | ### End Auction 105 | Once the auction is no longer receiving bids, Anyone may finalize the auction. 106 | This action transfers the NFT to the winner, places the winning bid on the piece, and pays out the auction creator and curator. 107 | 108 | | **Name** | **Type** | **Description** | 109 | |------------------------|----------------|------------------------------------------------------------------------------------------------| 110 | | `auctionId` | `uint256` | The ID of the auction | 111 | 112 | ## Local Development 113 | The following assumes `node >= 12` 114 | 115 | ### Install Dependencies 116 | 117 | ```shell script 118 | yarn 119 | ``` 120 | 121 | ### Compile Contracts 122 | 123 | ```shell script 124 | npx hardhat compile 125 | ``` 126 | 127 | ### Run Tests 128 | 129 | ```shell script 130 | npx hardhat test 131 | ``` 132 | 133 | ## Acknowledgements 134 | 135 | This project is the result of an incredible community of builders, projects and contributors. 136 | 137 | We would like to acknowledge the [Mint Fund](https://mint.af) and the [$BOUNTY backers](https://mint.mirror.xyz/6tD-QHgfCWvfKTjZgMoDd-8Gwdx3oibYuaGvg715Xco) for crowdfunding and coordinating the development of an opensource version of reserve auctions, implemented by [Billy Rennekamp](https://twitter.com/billyrennekamp). 138 | 139 | We would also like to credit projects that have pioneered and improved on the reserve auction mechanism and experience, such as SuperRare. Lastly, we'd like to ackowledge [Coldie](https://twitter.com/Coldie), the original pioneer of the reserve timed auction mechanism. 140 | -------------------------------------------------------------------------------- /test/integration.test.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { ethers } from "hardhat"; 3 | import chai, { expect } from "chai"; 4 | import asPromised from "chai-as-promised"; 5 | import { 6 | deployOtherNFTs, 7 | deployWETH, 8 | deployZoraProtocol, 9 | mint, 10 | ONE_ETH, 11 | TENTH_ETH, 12 | THOUSANDTH_ETH, 13 | TWO_ETH, 14 | } from "./utils"; 15 | import { Market, Media } from "@zoralabs/core/dist/typechain"; 16 | import { BigNumber, Signer } from "ethers"; 17 | import { AuctionHouse, TestERC721, WETH } from "../typechain"; 18 | 19 | chai.use(asPromised); 20 | 21 | const ONE_DAY = 24 * 60 * 60; 22 | 23 | // helper function so we can parse numbers and do approximate number calculations, to avoid annoying gas calculations 24 | const smallify = (bn: BigNumber) => bn.div(THOUSANDTH_ETH).toNumber(); 25 | 26 | describe("integration", () => { 27 | let market: Market; 28 | let media: Media; 29 | let weth: WETH; 30 | let auction: AuctionHouse; 31 | let otherNft: TestERC721; 32 | let deployer, creator, owner, curator, bidderA, bidderB, otherUser: Signer; 33 | let deployerAddress, 34 | ownerAddress, 35 | creatorAddress, 36 | curatorAddress, 37 | bidderAAddress, 38 | bidderBAddress, 39 | otherUserAddress: string; 40 | 41 | async function deploy(): Promise { 42 | const AuctionHouse = await ethers.getContractFactory("AuctionHouse"); 43 | const auctionHouse = await AuctionHouse.deploy(media.address, weth.address); 44 | 45 | return auctionHouse as AuctionHouse; 46 | } 47 | 48 | beforeEach(async () => { 49 | await ethers.provider.send("hardhat_reset", []); 50 | [ 51 | deployer, 52 | creator, 53 | owner, 54 | curator, 55 | bidderA, 56 | bidderB, 57 | otherUser, 58 | ] = await ethers.getSigners(); 59 | [ 60 | deployerAddress, 61 | creatorAddress, 62 | ownerAddress, 63 | curatorAddress, 64 | bidderAAddress, 65 | bidderBAddress, 66 | otherUserAddress, 67 | ] = await Promise.all( 68 | [deployer, creator, owner, curator, bidderA, bidderB].map((s) => 69 | s.getAddress() 70 | ) 71 | ); 72 | const contracts = await deployZoraProtocol(); 73 | const nfts = await deployOtherNFTs(); 74 | market = contracts.market; 75 | media = contracts.media; 76 | weth = await deployWETH(); 77 | auction = await deploy(); 78 | otherNft = nfts.test; 79 | await mint(media.connect(creator)); 80 | await otherNft.mint(creator.address, 0); 81 | await media.connect(creator).transferFrom(creatorAddress, ownerAddress, 0); 82 | await otherNft 83 | .connect(creator) 84 | .transferFrom(creatorAddress, ownerAddress, 0); 85 | }); 86 | 87 | describe("ETH Auction with no curator", async () => { 88 | async function run() { 89 | await media.connect(owner).approve(auction.address, 0); 90 | await auction 91 | .connect(owner) 92 | .createAuction( 93 | 0, 94 | media.address, 95 | ONE_DAY, 96 | TENTH_ETH, 97 | ethers.constants.AddressZero, 98 | 0, 99 | ethers.constants.AddressZero 100 | ); 101 | await auction.connect(bidderA).createBid(0, ONE_ETH, { value: ONE_ETH }); 102 | await auction.connect(bidderB).createBid(0, TWO_ETH, { value: TWO_ETH }); 103 | await ethers.provider.send("evm_setNextBlockTimestamp", [ 104 | Date.now() + ONE_DAY, 105 | ]); 106 | await auction.connect(otherUser).endAuction(0); 107 | } 108 | 109 | it("should transfer the NFT to the winning bidder", async () => { 110 | await run(); 111 | expect(await media.ownerOf(0)).to.eq(bidderBAddress); 112 | }); 113 | 114 | it("should withdraw the winning bid amount from the winning bidder", async () => { 115 | const beforeBalance = await ethers.provider.getBalance(bidderBAddress); 116 | await run(); 117 | const afterBalance = await ethers.provider.getBalance(bidderBAddress); 118 | 119 | expect(smallify(beforeBalance.sub(afterBalance))).to.be.approximately( 120 | smallify(TWO_ETH), 121 | smallify(TENTH_ETH) 122 | ); 123 | }); 124 | 125 | it("should refund the losing bidder", async () => { 126 | const beforeBalance = await ethers.provider.getBalance(bidderAAddress); 127 | await run(); 128 | const afterBalance = await ethers.provider.getBalance(bidderAAddress); 129 | 130 | expect(smallify(beforeBalance)).to.be.approximately( 131 | smallify(afterBalance), 132 | smallify(TENTH_ETH) 133 | ); 134 | }); 135 | 136 | it("should pay the auction creator", async () => { 137 | const beforeBalance = await ethers.provider.getBalance(ownerAddress); 138 | await run(); 139 | const afterBalance = await ethers.provider.getBalance(ownerAddress); 140 | 141 | // 15% creator fee -> 2ETH * 85% = 1.7 ETH 142 | expect(smallify(afterBalance)).to.be.approximately( 143 | smallify(beforeBalance.add(TENTH_ETH.mul(17))), 144 | smallify(TENTH_ETH) 145 | ); 146 | }); 147 | 148 | it("should pay the token creator in WETH", async () => { 149 | const beforeBalance = await weth.balanceOf(creatorAddress); 150 | await run(); 151 | const afterBalance = await weth.balanceOf(creatorAddress); 152 | 153 | // 15% creator fee -> 2 ETH * 15% = 0.3 WETH 154 | expect(afterBalance).to.eq(beforeBalance.add(THOUSANDTH_ETH.mul(300))); 155 | }); 156 | }); 157 | 158 | describe("ETH auction with curator", () => { 159 | async function run() { 160 | await media.connect(owner).approve(auction.address, 0); 161 | await auction 162 | .connect(owner) 163 | .createAuction( 164 | 0, 165 | media.address, 166 | ONE_DAY, 167 | TENTH_ETH, 168 | curatorAddress, 169 | 20, 170 | ethers.constants.AddressZero 171 | ); 172 | await auction.connect(curator).setAuctionApproval(0, true); 173 | await auction.connect(bidderA).createBid(0, ONE_ETH, { value: ONE_ETH }); 174 | await auction.connect(bidderB).createBid(0, TWO_ETH, { value: TWO_ETH }); 175 | await ethers.provider.send("evm_setNextBlockTimestamp", [ 176 | Date.now() + ONE_DAY, 177 | ]); 178 | await auction.connect(otherUser).endAuction(0); 179 | } 180 | 181 | it("should transfer the NFT to the winning bidder", async () => { 182 | await run(); 183 | expect(await media.ownerOf(0)).to.eq(bidderBAddress); 184 | }); 185 | 186 | it("should withdraw the winning bid amount from the winning bidder", async () => { 187 | const beforeBalance = await ethers.provider.getBalance(bidderBAddress); 188 | await run(); 189 | const afterBalance = await ethers.provider.getBalance(bidderBAddress); 190 | 191 | expect(smallify(beforeBalance.sub(afterBalance))).to.be.approximately( 192 | smallify(TWO_ETH), 193 | smallify(TENTH_ETH) 194 | ); 195 | }); 196 | 197 | it("should refund the losing bidder", async () => { 198 | const beforeBalance = await ethers.provider.getBalance(bidderAAddress); 199 | await run(); 200 | const afterBalance = await ethers.provider.getBalance(bidderAAddress); 201 | 202 | expect(smallify(beforeBalance)).to.be.approximately( 203 | smallify(afterBalance), 204 | smallify(TENTH_ETH) 205 | ); 206 | }); 207 | 208 | it("should pay the auction creator", async () => { 209 | const beforeBalance = await ethers.provider.getBalance(ownerAddress); 210 | await run(); 211 | const afterBalance = await ethers.provider.getBalance(ownerAddress); 212 | 213 | expect(smallify(afterBalance)).to.be.approximately( 214 | // 15% creator share + 20% curator fee -> 1.7 ETH * 80% = 1.36 ETH 215 | smallify(beforeBalance.add(TENTH_ETH.mul(14))), 216 | smallify(TENTH_ETH) 217 | ); 218 | }); 219 | 220 | it("should pay the token creator in WETH", async () => { 221 | const beforeBalance = await weth.balanceOf(creatorAddress); 222 | await run(); 223 | const afterBalance = await weth.balanceOf(creatorAddress); 224 | 225 | // 15% creator fee -> 2 ETH * 15% = 0.3 WETH 226 | expect(afterBalance).to.eq(beforeBalance.add(THOUSANDTH_ETH.mul(300))); 227 | }); 228 | 229 | it("should pay the curator", async () => { 230 | const beforeBalance = await ethers.provider.getBalance(curatorAddress); 231 | await run(); 232 | const afterBalance = await ethers.provider.getBalance(curatorAddress); 233 | 234 | // 20% of 1.7 WETH -> 0.34 235 | expect(smallify(afterBalance)).to.be.approximately( 236 | smallify(beforeBalance.add(THOUSANDTH_ETH.mul(340))), 237 | smallify(TENTH_ETH) 238 | ); 239 | }); 240 | }); 241 | 242 | describe("WETH Auction with no curator", () => { 243 | async function run() { 244 | await media.connect(owner).approve(auction.address, 0); 245 | await auction 246 | .connect(owner) 247 | .createAuction( 248 | 0, 249 | media.address, 250 | ONE_DAY, 251 | TENTH_ETH, 252 | ethers.constants.AddressZero, 253 | 20, 254 | weth.address 255 | ); 256 | await weth.connect(bidderA).deposit({ value: ONE_ETH }); 257 | await weth.connect(bidderA).approve(auction.address, ONE_ETH); 258 | await weth.connect(bidderB).deposit({ value: TWO_ETH }); 259 | await weth.connect(bidderB).approve(auction.address, TWO_ETH); 260 | await auction.connect(bidderA).createBid(0, ONE_ETH, { value: ONE_ETH }); 261 | await auction.connect(bidderB).createBid(0, TWO_ETH, { value: TWO_ETH }); 262 | await ethers.provider.send("evm_setNextBlockTimestamp", [ 263 | Date.now() + ONE_DAY, 264 | ]); 265 | await auction.connect(otherUser).endAuction(0); 266 | } 267 | 268 | it("should transfer the NFT to the winning bidder", async () => { 269 | await run(); 270 | expect(await media.ownerOf(0)).to.eq(bidderBAddress); 271 | }); 272 | 273 | it("should withdraw the winning bid amount from the winning bidder", async () => { 274 | await run(); 275 | const afterBalance = await weth.balanceOf(bidderBAddress); 276 | 277 | expect(afterBalance).to.eq(ONE_ETH.mul(0)); 278 | }); 279 | 280 | it("should refund the losing bidder", async () => { 281 | await run(); 282 | const afterBalance = await weth.balanceOf(bidderAAddress); 283 | 284 | expect(afterBalance).to.eq(ONE_ETH); 285 | }); 286 | 287 | it("should pay the auction creator", async () => { 288 | await run(); 289 | const afterBalance = await weth.balanceOf(ownerAddress); 290 | 291 | // 15% creator fee -> 2 ETH * 85% = 1.7WETH 292 | expect(afterBalance).to.eq(TENTH_ETH.mul(17)); 293 | }); 294 | 295 | it("should pay the token creator", async () => { 296 | const beforeBalance = await weth.balanceOf(creatorAddress); 297 | await run(); 298 | const afterBalance = await weth.balanceOf(creatorAddress); 299 | 300 | // 15% creator fee -> 2 ETH * 15% = 0.3 WETH 301 | expect(afterBalance).to.eq(beforeBalance.add(THOUSANDTH_ETH.mul(300))); 302 | }); 303 | }); 304 | 305 | describe("WETH auction with curator", async () => { 306 | async function run() { 307 | await media.connect(owner).approve(auction.address, 0); 308 | await auction 309 | .connect(owner) 310 | .createAuction( 311 | 0, 312 | media.address, 313 | ONE_DAY, 314 | TENTH_ETH, 315 | curator.address, 316 | 20, 317 | weth.address 318 | ); 319 | await auction.connect(curator).setAuctionApproval(0, true); 320 | await weth.connect(bidderA).deposit({ value: ONE_ETH }); 321 | await weth.connect(bidderA).approve(auction.address, ONE_ETH); 322 | await weth.connect(bidderB).deposit({ value: TWO_ETH }); 323 | await weth.connect(bidderB).approve(auction.address, TWO_ETH); 324 | await auction.connect(bidderA).createBid(0, ONE_ETH, { value: ONE_ETH }); 325 | await auction.connect(bidderB).createBid(0, TWO_ETH, { value: TWO_ETH }); 326 | await ethers.provider.send("evm_setNextBlockTimestamp", [ 327 | Date.now() + ONE_DAY, 328 | ]); 329 | await auction.connect(otherUser).endAuction(0); 330 | } 331 | 332 | it("should transfer the NFT to the winning bidder", async () => { 333 | await run(); 334 | expect(await media.ownerOf(0)).to.eq(bidderBAddress); 335 | }); 336 | 337 | it("should withdraw the winning bid amount from the winning bidder", async () => { 338 | await run(); 339 | const afterBalance = await weth.balanceOf(bidderBAddress); 340 | 341 | expect(afterBalance).to.eq(ONE_ETH.mul(0)); 342 | }); 343 | 344 | it("should refund the losing bidder", async () => { 345 | await run(); 346 | const afterBalance = await weth.balanceOf(bidderAAddress); 347 | 348 | expect(afterBalance).to.eq(ONE_ETH); 349 | }); 350 | 351 | it("should pay the auction creator", async () => { 352 | await run(); 353 | const afterBalance = await weth.balanceOf(ownerAddress); 354 | 355 | // 15% creator fee + 20% curator fee -> 2 ETH * 85% * 80% = 1.36WETH 356 | expect(afterBalance).to.eq(THOUSANDTH_ETH.mul(1360)); 357 | }); 358 | 359 | it("should pay the token creator", async () => { 360 | const beforeBalance = await weth.balanceOf(creatorAddress); 361 | await run(); 362 | const afterBalance = await weth.balanceOf(creatorAddress); 363 | 364 | // 15% creator fee -> 2 ETH * 15% = 0.3 WETH 365 | expect(afterBalance).to.eq(beforeBalance.add(THOUSANDTH_ETH.mul(300))); 366 | }); 367 | 368 | it("should pay the auction curator", async () => { 369 | const beforeBalance = await weth.balanceOf(curatorAddress); 370 | await run(); 371 | const afterBalance = await weth.balanceOf(curatorAddress); 372 | 373 | // 15% creator fee + 20% curator fee = 2 ETH * 85% * 20% = 0.34 WETH 374 | expect(afterBalance).to.eq(beforeBalance.add(THOUSANDTH_ETH.mul(340))); 375 | }); 376 | }); 377 | 378 | describe("3rd party nft auction", async () => { 379 | async function run() { 380 | await otherNft.connect(owner).approve(auction.address, 0); 381 | await auction 382 | .connect(owner) 383 | .createAuction( 384 | 0, 385 | otherNft.address, 386 | ONE_DAY, 387 | TENTH_ETH, 388 | curatorAddress, 389 | 20, 390 | ethers.constants.AddressZero 391 | ); 392 | await auction.connect(curator).setAuctionApproval(0, true); 393 | await auction.connect(bidderA).createBid(0, ONE_ETH, { value: ONE_ETH }); 394 | await auction.connect(bidderB).createBid(0, TWO_ETH, { value: TWO_ETH }); 395 | await ethers.provider.send("evm_setNextBlockTimestamp", [ 396 | Date.now() + ONE_DAY, 397 | ]); 398 | await auction.connect(otherUser).endAuction(0); 399 | } 400 | it("should transfer the NFT to the winning bidder", async () => { 401 | await run(); 402 | expect(await otherNft.ownerOf(0)).to.eq(bidderBAddress); 403 | }); 404 | 405 | it("should withdraw the winning bid amount from the winning bidder", async () => { 406 | const beforeBalance = await ethers.provider.getBalance(bidderBAddress); 407 | await run(); 408 | const afterBalance = await ethers.provider.getBalance(bidderBAddress); 409 | 410 | expect(smallify(beforeBalance.sub(afterBalance))).to.be.approximately( 411 | smallify(TWO_ETH), 412 | smallify(TENTH_ETH) 413 | ); 414 | }); 415 | 416 | it("should refund the losing bidder", async () => { 417 | const beforeBalance = await ethers.provider.getBalance(bidderAAddress); 418 | await run(); 419 | const afterBalance = await ethers.provider.getBalance(bidderAAddress); 420 | 421 | expect(smallify(beforeBalance)).to.be.approximately( 422 | smallify(afterBalance), 423 | smallify(TENTH_ETH) 424 | ); 425 | }); 426 | 427 | it("should pay the auction creator", async () => { 428 | const beforeBalance = await ethers.provider.getBalance(ownerAddress); 429 | await run(); 430 | const afterBalance = await ethers.provider.getBalance(ownerAddress); 431 | 432 | expect(smallify(afterBalance)).to.be.approximately( 433 | // 20% curator fee -> 2 ETH * 80% = 1.6 ETH 434 | smallify(beforeBalance.add(TENTH_ETH.mul(16))), 435 | smallify(TENTH_ETH) 436 | ); 437 | }); 438 | 439 | it("should pay the curator", async () => { 440 | const beforeBalance = await ethers.provider.getBalance(curatorAddress); 441 | await run(); 442 | const afterBalance = await ethers.provider.getBalance(curatorAddress); 443 | 444 | // 20% of 2 WETH -> 0.4 445 | expect(smallify(afterBalance)).to.be.approximately( 446 | smallify(beforeBalance.add(TENTH_ETH.mul(4))), 447 | smallify(THOUSANDTH_ETH) 448 | ); 449 | }); 450 | }); 451 | }); 452 | -------------------------------------------------------------------------------- /contracts/AuctionHouse.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0 2 | 3 | pragma solidity 0.6.8; 4 | pragma experimental ABIEncoderV2; 5 | 6 | import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; 7 | import { IERC721, IERC165 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; 8 | import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; 9 | import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 10 | import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; 11 | import {Counters} from "@openzeppelin/contracts/utils/Counters.sol"; 12 | import { IMarket, Decimal } from "@zoralabs/core/dist/contracts/interfaces/IMarket.sol"; 13 | import { IMedia } from "@zoralabs/core/dist/contracts/interfaces/IMedia.sol"; 14 | import { IAuctionHouse } from "./interfaces/IAuctionHouse.sol"; 15 | 16 | interface IWETH { 17 | function deposit() external payable; 18 | function withdraw(uint wad) external; 19 | 20 | function transfer(address to, uint256 value) external returns (bool); 21 | } 22 | 23 | interface IMediaExtended is IMedia { 24 | function marketContract() external returns(address); 25 | } 26 | 27 | /** 28 | * @title An open auction house, enabling collectors and curators to run their own auctions 29 | */ 30 | contract AuctionHouse is IAuctionHouse, ReentrancyGuard { 31 | using SafeMath for uint256; 32 | using SafeERC20 for IERC20; 33 | using Counters for Counters.Counter; 34 | 35 | // The minimum amount of time left in an auction after a new bid is created 36 | uint256 public timeBuffer; 37 | 38 | // The minimum percentage difference between the last bid amount and the current bid. 39 | uint8 public minBidIncrementPercentage; 40 | 41 | // The address of the zora protocol to use via this contract 42 | address public zora; 43 | 44 | // / The address of the WETH contract, so that any ETH transferred can be handled as an ERC-20 45 | address public wethAddress; 46 | 47 | // A mapping of all of the auctions currently running. 48 | mapping(uint256 => IAuctionHouse.Auction) public auctions; 49 | 50 | bytes4 constant interfaceId = 0x80ac58cd; // 721 interface id 51 | 52 | Counters.Counter private _auctionIdTracker; 53 | 54 | /** 55 | * @notice Require that the specified auction exists 56 | */ 57 | modifier auctionExists(uint256 auctionId) { 58 | require(_exists(auctionId), "Auction doesn't exist"); 59 | _; 60 | } 61 | 62 | /* 63 | * Constructor 64 | */ 65 | constructor(address _zora, address _weth) public { 66 | require( 67 | IERC165(_zora).supportsInterface(interfaceId), 68 | "Doesn't support NFT interface" 69 | ); 70 | zora = _zora; 71 | wethAddress = _weth; 72 | timeBuffer = 15 * 60; // extend 15 minutes after every bid made in last 15 minutes 73 | minBidIncrementPercentage = 5; // 5% 74 | } 75 | 76 | /** 77 | * @notice Create an auction. 78 | * @dev Store the auction details in the auctions mapping and emit an AuctionCreated event. 79 | * If there is no curator, or if the curator is the auction creator, automatically approve the auction. 80 | */ 81 | function createAuction( 82 | uint256 tokenId, 83 | address tokenContract, 84 | uint256 duration, 85 | uint256 reservePrice, 86 | address payable curator, 87 | uint8 curatorFeePercentage, 88 | address auctionCurrency 89 | ) public override nonReentrant returns (uint256) { 90 | require( 91 | IERC165(tokenContract).supportsInterface(interfaceId), 92 | "tokenContract does not support ERC721 interface" 93 | ); 94 | require(curatorFeePercentage < 100, "curatorFeePercentage must be less than 100"); 95 | address tokenOwner = IERC721(tokenContract).ownerOf(tokenId); 96 | require(msg.sender == IERC721(tokenContract).getApproved(tokenId) || msg.sender == tokenOwner, "Caller must be approved or owner for token id"); 97 | uint256 auctionId = _auctionIdTracker.current(); 98 | 99 | auctions[auctionId] = Auction({ 100 | tokenId: tokenId, 101 | tokenContract: tokenContract, 102 | approved: false, 103 | amount: 0, 104 | duration: duration, 105 | firstBidTime: 0, 106 | reservePrice: reservePrice, 107 | curatorFeePercentage: curatorFeePercentage, 108 | tokenOwner: tokenOwner, 109 | bidder: address(0), 110 | curator: curator, 111 | auctionCurrency: auctionCurrency 112 | }); 113 | 114 | IERC721(tokenContract).transferFrom(tokenOwner, address(this), tokenId); 115 | 116 | _auctionIdTracker.increment(); 117 | 118 | emit AuctionCreated(auctionId, tokenId, tokenContract, duration, reservePrice, tokenOwner, curator, curatorFeePercentage, auctionCurrency); 119 | 120 | 121 | if(auctions[auctionId].curator == address(0) || curator == tokenOwner) { 122 | _approveAuction(auctionId, true); 123 | } 124 | 125 | return auctionId; 126 | } 127 | 128 | /** 129 | * @notice Approve an auction, opening up the auction for bids. 130 | * @dev Only callable by the curator. Cannot be called if the auction has already started. 131 | */ 132 | function setAuctionApproval(uint256 auctionId, bool approved) external override auctionExists(auctionId) { 133 | require(msg.sender == auctions[auctionId].curator, "Must be auction curator"); 134 | require(auctions[auctionId].firstBidTime == 0, "Auction has already started"); 135 | _approveAuction(auctionId, approved); 136 | } 137 | 138 | function setAuctionReservePrice(uint256 auctionId, uint256 reservePrice) external override auctionExists(auctionId) { 139 | require(msg.sender == auctions[auctionId].curator || msg.sender == auctions[auctionId].tokenOwner, "Must be auction curator or token owner"); 140 | require(auctions[auctionId].firstBidTime == 0, "Auction has already started"); 141 | 142 | auctions[auctionId].reservePrice = reservePrice; 143 | 144 | emit AuctionReservePriceUpdated(auctionId, auctions[auctionId].tokenId, auctions[auctionId].tokenContract, reservePrice); 145 | } 146 | 147 | /** 148 | * @notice Create a bid on a token, with a given amount. 149 | * @dev If provided a valid bid, transfers the provided amount to this contract. 150 | * If the auction is run in native ETH, the ETH is wrapped so it can be identically to other 151 | * auction currencies in this contract. 152 | */ 153 | function createBid(uint256 auctionId, uint256 amount) 154 | external 155 | override 156 | payable 157 | auctionExists(auctionId) 158 | nonReentrant 159 | { 160 | address payable lastBidder = auctions[auctionId].bidder; 161 | require(auctions[auctionId].approved, "Auction must be approved by curator"); 162 | require( 163 | auctions[auctionId].firstBidTime == 0 || 164 | block.timestamp < 165 | auctions[auctionId].firstBidTime.add(auctions[auctionId].duration), 166 | "Auction expired" 167 | ); 168 | require( 169 | amount >= auctions[auctionId].reservePrice, 170 | "Must send at least reservePrice" 171 | ); 172 | require( 173 | amount >= auctions[auctionId].amount.add( 174 | auctions[auctionId].amount.mul(minBidIncrementPercentage).div(100) 175 | ), 176 | "Must send more than last bid by minBidIncrementPercentage amount" 177 | ); 178 | 179 | // For Zora Protocol, ensure that the bid is valid for the current bidShare configuration 180 | if(auctions[auctionId].tokenContract == zora) { 181 | require( 182 | IMarket(IMediaExtended(zora).marketContract()).isValidBid( 183 | auctions[auctionId].tokenId, 184 | amount 185 | ), 186 | "Bid invalid for share splitting" 187 | ); 188 | } 189 | 190 | // If this is the first valid bid, we should set the starting time now. 191 | // If it's not, then we should refund the last bidder 192 | if(auctions[auctionId].firstBidTime == 0) { 193 | auctions[auctionId].firstBidTime = block.timestamp; 194 | } else if(lastBidder != address(0)) { 195 | _handleOutgoingBid(lastBidder, auctions[auctionId].amount, auctions[auctionId].auctionCurrency); 196 | } 197 | 198 | _handleIncomingBid(amount, auctions[auctionId].auctionCurrency); 199 | 200 | auctions[auctionId].amount = amount; 201 | auctions[auctionId].bidder = msg.sender; 202 | 203 | 204 | bool extended = false; 205 | // at this point we know that the timestamp is less than start + duration (since the auction would be over, otherwise) 206 | // we want to know by how much the timestamp is less than start + duration 207 | // if the difference is less than the timeBuffer, increase the duration by the timeBuffer 208 | if ( 209 | auctions[auctionId].firstBidTime.add(auctions[auctionId].duration).sub( 210 | block.timestamp 211 | ) < timeBuffer 212 | ) { 213 | // Playing code golf for gas optimization: 214 | // uint256 expectedEnd = auctions[auctionId].firstBidTime.add(auctions[auctionId].duration); 215 | // uint256 timeRemaining = expectedEnd.sub(block.timestamp); 216 | // uint256 timeToAdd = timeBuffer.sub(timeRemaining); 217 | // uint256 newDuration = auctions[auctionId].duration.add(timeToAdd); 218 | uint256 oldDuration = auctions[auctionId].duration; 219 | auctions[auctionId].duration = 220 | oldDuration.add(timeBuffer.sub(auctions[auctionId].firstBidTime.add(oldDuration).sub(block.timestamp))); 221 | extended = true; 222 | } 223 | 224 | emit AuctionBid( 225 | auctionId, 226 | auctions[auctionId].tokenId, 227 | auctions[auctionId].tokenContract, 228 | msg.sender, 229 | amount, 230 | lastBidder == address(0), // firstBid boolean 231 | extended 232 | ); 233 | 234 | if (extended) { 235 | emit AuctionDurationExtended( 236 | auctionId, 237 | auctions[auctionId].tokenId, 238 | auctions[auctionId].tokenContract, 239 | auctions[auctionId].duration 240 | ); 241 | } 242 | } 243 | 244 | /** 245 | * @notice End an auction, finalizing the bid on Zora if applicable and paying out the respective parties. 246 | * @dev If for some reason the auction cannot be finalized (invalid token recipient, for example), 247 | * The auction is reset and the NFT is transferred back to the auction creator. 248 | */ 249 | function endAuction(uint256 auctionId) external override auctionExists(auctionId) nonReentrant { 250 | require( 251 | uint256(auctions[auctionId].firstBidTime) != 0, 252 | "Auction hasn't begun" 253 | ); 254 | require( 255 | block.timestamp >= 256 | auctions[auctionId].firstBidTime.add(auctions[auctionId].duration), 257 | "Auction hasn't completed" 258 | ); 259 | 260 | address currency = auctions[auctionId].auctionCurrency == address(0) ? wethAddress : auctions[auctionId].auctionCurrency; 261 | uint256 curatorFee = 0; 262 | 263 | uint256 tokenOwnerProfit = auctions[auctionId].amount; 264 | 265 | if(auctions[auctionId].tokenContract == zora) { 266 | // If the auction is running on zora, settle it on the protocol 267 | (bool success, uint256 remainingProfit) = _handleZoraAuctionSettlement(auctionId); 268 | tokenOwnerProfit = remainingProfit; 269 | if(success != true) { 270 | _handleOutgoingBid(auctions[auctionId].bidder, auctions[auctionId].amount, auctions[auctionId].auctionCurrency); 271 | _cancelAuction(auctionId); 272 | return; 273 | } 274 | } else { 275 | // Otherwise, transfer the token to the winner and pay out the participants below 276 | try IERC721(auctions[auctionId].tokenContract).safeTransferFrom(address(this), auctions[auctionId].bidder, auctions[auctionId].tokenId) {} catch { 277 | _handleOutgoingBid(auctions[auctionId].bidder, auctions[auctionId].amount, auctions[auctionId].auctionCurrency); 278 | _cancelAuction(auctionId); 279 | return; 280 | } 281 | } 282 | 283 | 284 | if(auctions[auctionId].curator != address(0)) { 285 | curatorFee = tokenOwnerProfit.mul(auctions[auctionId].curatorFeePercentage).div(100); 286 | tokenOwnerProfit = tokenOwnerProfit.sub(curatorFee); 287 | _handleOutgoingBid(auctions[auctionId].curator, curatorFee, auctions[auctionId].auctionCurrency); 288 | } 289 | _handleOutgoingBid(auctions[auctionId].tokenOwner, tokenOwnerProfit, auctions[auctionId].auctionCurrency); 290 | 291 | emit AuctionEnded( 292 | auctionId, 293 | auctions[auctionId].tokenId, 294 | auctions[auctionId].tokenContract, 295 | auctions[auctionId].tokenOwner, 296 | auctions[auctionId].curator, 297 | auctions[auctionId].bidder, 298 | tokenOwnerProfit, 299 | curatorFee, 300 | currency 301 | ); 302 | delete auctions[auctionId]; 303 | } 304 | 305 | /** 306 | * @notice Cancel an auction. 307 | * @dev Transfers the NFT back to the auction creator and emits an AuctionCanceled event 308 | */ 309 | function cancelAuction(uint256 auctionId) external override nonReentrant auctionExists(auctionId) { 310 | require( 311 | auctions[auctionId].tokenOwner == msg.sender || auctions[auctionId].curator == msg.sender, 312 | "Can only be called by auction creator or curator" 313 | ); 314 | require( 315 | uint256(auctions[auctionId].firstBidTime) == 0, 316 | "Can't cancel an auction once it's begun" 317 | ); 318 | _cancelAuction(auctionId); 319 | } 320 | 321 | /** 322 | * @dev Given an amount and a currency, transfer the currency to this contract. 323 | * If the currency is ETH (0x0), attempt to wrap the amount as WETH 324 | */ 325 | function _handleIncomingBid(uint256 amount, address currency) internal { 326 | // If this is an ETH bid, ensure they sent enough and convert it to WETH under the hood 327 | if(currency == address(0)) { 328 | require(msg.value == amount, "Sent ETH Value does not match specified bid amount"); 329 | IWETH(wethAddress).deposit{value: amount}(); 330 | } else { 331 | // We must check the balance that was actually transferred to the auction, 332 | // as some tokens impose a transfer fee and would not actually transfer the 333 | // full amount to the market, resulting in potentally locked funds 334 | IERC20 token = IERC20(currency); 335 | uint256 beforeBalance = token.balanceOf(address(this)); 336 | token.safeTransferFrom(msg.sender, address(this), amount); 337 | uint256 afterBalance = token.balanceOf(address(this)); 338 | require(beforeBalance.add(amount) == afterBalance, "Token transfer call did not transfer expected amount"); 339 | } 340 | } 341 | 342 | function _handleOutgoingBid(address to, uint256 amount, address currency) internal { 343 | // If the auction is in ETH, unwrap it from its underlying WETH and try to send it to the recipient. 344 | if(currency == address(0)) { 345 | IWETH(wethAddress).withdraw(amount); 346 | 347 | // If the ETH transfer fails (sigh), rewrap the ETH and try send it as WETH. 348 | if(!_safeTransferETH(to, amount)) { 349 | IWETH(wethAddress).deposit{value: amount}(); 350 | IERC20(wethAddress).safeTransfer(to, amount); 351 | } 352 | } else { 353 | IERC20(currency).safeTransfer(to, amount); 354 | } 355 | } 356 | 357 | function _safeTransferETH(address to, uint256 value) internal returns (bool) { 358 | (bool success, ) = to.call{value: value}(new bytes(0)); 359 | return success; 360 | } 361 | 362 | function _cancelAuction(uint256 auctionId) internal { 363 | address tokenOwner = auctions[auctionId].tokenOwner; 364 | IERC721(auctions[auctionId].tokenContract).safeTransferFrom(address(this), tokenOwner, auctions[auctionId].tokenId); 365 | 366 | emit AuctionCanceled(auctionId, auctions[auctionId].tokenId, auctions[auctionId].tokenContract, tokenOwner); 367 | delete auctions[auctionId]; 368 | } 369 | 370 | function _approveAuction(uint256 auctionId, bool approved) internal { 371 | auctions[auctionId].approved = approved; 372 | emit AuctionApprovalUpdated(auctionId, auctions[auctionId].tokenId, auctions[auctionId].tokenContract, approved); 373 | } 374 | 375 | function _exists(uint256 auctionId) internal view returns(bool) { 376 | return auctions[auctionId].tokenOwner != address(0); 377 | } 378 | 379 | function _handleZoraAuctionSettlement(uint256 auctionId) internal returns (bool, uint256) { 380 | address currency = auctions[auctionId].auctionCurrency == address(0) ? wethAddress : auctions[auctionId].auctionCurrency; 381 | 382 | IMarket.Bid memory bid = IMarket.Bid({ 383 | amount: auctions[auctionId].amount, 384 | currency: currency, 385 | bidder: address(this), 386 | recipient: auctions[auctionId].bidder, 387 | sellOnShare: Decimal.D256(0) 388 | }); 389 | 390 | IERC20(currency).approve(IMediaExtended(zora).marketContract(), bid.amount); 391 | IMedia(zora).setBid(auctions[auctionId].tokenId, bid); 392 | uint256 beforeBalance = IERC20(currency).balanceOf(address(this)); 393 | try IMedia(zora).acceptBid(auctions[auctionId].tokenId, bid) {} catch { 394 | // If the underlying NFT transfer here fails, we should cancel the auction and refund the winner 395 | IMediaExtended(zora).removeBid(auctions[auctionId].tokenId); 396 | return (false, 0); 397 | } 398 | uint256 afterBalance = IERC20(currency).balanceOf(address(this)); 399 | 400 | // We have to calculate the amount to send to the token owner here in case there was a 401 | // sell-on share on the token 402 | return (true, afterBalance.sub(beforeBalance)); 403 | } 404 | 405 | // TODO: consider reverting if the message sender is not WETH 406 | receive() external payable {} 407 | fallback() external payable {} 408 | } -------------------------------------------------------------------------------- /test/AuctionHouse.test.ts: -------------------------------------------------------------------------------- 1 | import chai, { expect } from "chai"; 2 | import asPromised from "chai-as-promised"; 3 | // @ts-ignore 4 | import { ethers } from "hardhat"; 5 | import { Market, Media } from "@zoralabs/core/dist/typechain"; 6 | import { AuctionHouse, BadBidder, TestERC721, BadERC721 } from "../typechain"; 7 | import { formatUnits } from "ethers/lib/utils"; 8 | import { BigNumber, Contract, Signer } from "ethers"; 9 | import { 10 | approveAuction, 11 | deployBidder, 12 | deployOtherNFTs, 13 | deployWETH, 14 | deployZoraProtocol, 15 | mint, 16 | ONE_ETH, 17 | revert, 18 | TWO_ETH, 19 | } from "./utils"; 20 | 21 | chai.use(asPromised); 22 | 23 | describe("AuctionHouse", () => { 24 | let market: Market; 25 | let media: Media; 26 | let weth: Contract; 27 | let badERC721: BadERC721; 28 | let testERC721: TestERC721; 29 | 30 | beforeEach(async () => { 31 | await ethers.provider.send("hardhat_reset", []); 32 | const contracts = await deployZoraProtocol(); 33 | const nfts = await deployOtherNFTs(); 34 | market = contracts.market; 35 | media = contracts.media; 36 | weth = await deployWETH(); 37 | badERC721 = nfts.bad; 38 | testERC721 = nfts.test; 39 | }); 40 | 41 | async function deploy(): Promise { 42 | const AuctionHouse = await ethers.getContractFactory("AuctionHouse"); 43 | const auctionHouse = await AuctionHouse.deploy(media.address, weth.address); 44 | 45 | return auctionHouse as AuctionHouse; 46 | } 47 | 48 | async function createAuction( 49 | auctionHouse: AuctionHouse, 50 | curator: string, 51 | currency = "0x0000000000000000000000000000000000000000" 52 | ) { 53 | const tokenId = 0; 54 | const duration = 60 * 60 * 24; 55 | const reservePrice = BigNumber.from(10).pow(18).div(2); 56 | 57 | await auctionHouse.createAuction( 58 | tokenId, 59 | media.address, 60 | duration, 61 | reservePrice, 62 | curator, 63 | 5, 64 | currency 65 | ); 66 | } 67 | 68 | describe("#constructor", () => { 69 | it("should be able to deploy", async () => { 70 | const AuctionHouse = await ethers.getContractFactory("AuctionHouse"); 71 | const auctionHouse = await AuctionHouse.deploy( 72 | media.address, 73 | weth.address 74 | ); 75 | 76 | expect(await auctionHouse.zora()).to.eq( 77 | media.address, 78 | "incorrect zora address" 79 | ); 80 | expect(formatUnits(await auctionHouse.timeBuffer(), 0)).to.eq( 81 | "900.0", 82 | "time buffer should equal 900" 83 | ); 84 | expect(await auctionHouse.minBidIncrementPercentage()).to.eq( 85 | 5, 86 | "minBidIncrementPercentage should equal 5%" 87 | ); 88 | }); 89 | 90 | it("should not allow a configuration address that is not the Zora Media Protocol", async () => { 91 | const AuctionHouse = await ethers.getContractFactory("AuctionHouse"); 92 | await expect( 93 | AuctionHouse.deploy(market.address, weth.address) 94 | ).eventually.rejectedWith("Transaction reverted without a reason"); 95 | }); 96 | }); 97 | 98 | describe("#createAuction", () => { 99 | let auctionHouse: AuctionHouse; 100 | beforeEach(async () => { 101 | auctionHouse = await deploy(); 102 | await mint(media); 103 | await approveAuction(media, auctionHouse); 104 | }); 105 | 106 | it("should revert if the token contract does not support the ERC721 interface", async () => { 107 | const duration = 60 * 60 * 24; 108 | const reservePrice = BigNumber.from(10).pow(18).div(2); 109 | const [_, curator] = await ethers.getSigners(); 110 | 111 | await expect( 112 | auctionHouse.createAuction( 113 | 0, 114 | badERC721.address, 115 | duration, 116 | reservePrice, 117 | curator.address, 118 | 5, 119 | "0x0000000000000000000000000000000000000000" 120 | ) 121 | ).eventually.rejectedWith( 122 | revert`tokenContract does not support ERC721 interface` 123 | ); 124 | }); 125 | 126 | it("should revert if the caller is not approved", async () => { 127 | const duration = 60 * 60 * 24; 128 | const reservePrice = BigNumber.from(10).pow(18).div(2); 129 | const [_, curator, __, ___, unapproved] = await ethers.getSigners(); 130 | await expect( 131 | auctionHouse 132 | .connect(unapproved) 133 | .createAuction( 134 | 0, 135 | media.address, 136 | duration, 137 | reservePrice, 138 | curator.address, 139 | 5, 140 | "0x0000000000000000000000000000000000000000" 141 | ) 142 | ).eventually.rejectedWith( 143 | revert`Caller must be approved or owner for token id` 144 | ); 145 | }); 146 | 147 | it("should revert if the token ID does not exist", async () => { 148 | const tokenId = 999; 149 | const duration = 60 * 60 * 24; 150 | const reservePrice = BigNumber.from(10).pow(18).div(2); 151 | const owner = await media.ownerOf(0); 152 | const [admin, curator] = await ethers.getSigners(); 153 | 154 | await expect( 155 | auctionHouse 156 | .connect(admin) 157 | .createAuction( 158 | tokenId, 159 | media.address, 160 | duration, 161 | reservePrice, 162 | curator.address, 163 | 5, 164 | "0x0000000000000000000000000000000000000000" 165 | ) 166 | ).eventually.rejectedWith( 167 | revert`ERC721: owner query for nonexistent token` 168 | ); 169 | }); 170 | 171 | it("should revert if the curator fee percentage is >= 100", async () => { 172 | const duration = 60 * 60 * 24; 173 | const reservePrice = BigNumber.from(10).pow(18).div(2); 174 | const owner = await media.ownerOf(0); 175 | const [_, curator] = await ethers.getSigners(); 176 | 177 | await expect( 178 | auctionHouse.createAuction( 179 | 0, 180 | media.address, 181 | duration, 182 | reservePrice, 183 | curator.address, 184 | 100, 185 | "0x0000000000000000000000000000000000000000" 186 | ) 187 | ).eventually.rejectedWith( 188 | revert`curatorFeePercentage must be less than 100` 189 | ); 190 | }); 191 | 192 | it("should create an auction", async () => { 193 | const owner = await media.ownerOf(0); 194 | const [_, expectedCurator] = await ethers.getSigners(); 195 | await createAuction(auctionHouse, await expectedCurator.getAddress()); 196 | 197 | const createdAuction = await auctionHouse.auctions(0); 198 | 199 | expect(createdAuction.duration).to.eq(24 * 60 * 60); 200 | expect(createdAuction.reservePrice).to.eq( 201 | BigNumber.from(10).pow(18).div(2) 202 | ); 203 | expect(createdAuction.curatorFeePercentage).to.eq(5); 204 | expect(createdAuction.tokenOwner).to.eq(owner); 205 | expect(createdAuction.curator).to.eq(expectedCurator.address); 206 | expect(createdAuction.approved).to.eq(false); 207 | }); 208 | 209 | it("should be automatically approved if the creator is the curator", async () => { 210 | const owner = await media.ownerOf(0); 211 | await createAuction(auctionHouse, owner); 212 | 213 | const createdAuction = await auctionHouse.auctions(0); 214 | 215 | expect(createdAuction.approved).to.eq(true); 216 | }); 217 | 218 | it("should be automatically approved if the creator is the Zero Address", async () => { 219 | await createAuction(auctionHouse, ethers.constants.AddressZero); 220 | 221 | const createdAuction = await auctionHouse.auctions(0); 222 | 223 | expect(createdAuction.approved).to.eq(true); 224 | }); 225 | 226 | it("should emit an AuctionCreated event", async () => { 227 | const owner = await media.ownerOf(0); 228 | const [_, expectedCurator] = await ethers.getSigners(); 229 | 230 | const block = await ethers.provider.getBlockNumber(); 231 | await createAuction(auctionHouse, await expectedCurator.getAddress()); 232 | const currAuction = await auctionHouse.auctions(0); 233 | const events = await auctionHouse.queryFilter( 234 | auctionHouse.filters.AuctionCreated( 235 | null, 236 | null, 237 | null, 238 | null, 239 | null, 240 | null, 241 | null, 242 | null, 243 | null 244 | ), 245 | block 246 | ); 247 | expect(events.length).eq(1); 248 | const logDescription = auctionHouse.interface.parseLog(events[0]); 249 | expect(logDescription.name).to.eq("AuctionCreated"); 250 | expect(logDescription.args.duration).to.eq(currAuction.duration); 251 | expect(logDescription.args.reservePrice).to.eq(currAuction.reservePrice); 252 | expect(logDescription.args.tokenOwner).to.eq(currAuction.tokenOwner); 253 | expect(logDescription.args.curator).to.eq(currAuction.curator); 254 | expect(logDescription.args.curatorFeePercentage).to.eq( 255 | currAuction.curatorFeePercentage 256 | ); 257 | expect(logDescription.args.auctionCurrency).to.eq( 258 | ethers.constants.AddressZero 259 | ); 260 | }); 261 | }); 262 | 263 | describe("#setAuctionApproval", () => { 264 | let auctionHouse: AuctionHouse; 265 | let admin: Signer; 266 | let curator: Signer; 267 | let bidder: Signer; 268 | 269 | beforeEach(async () => { 270 | [admin, curator, bidder] = await ethers.getSigners(); 271 | auctionHouse = (await deploy()).connect(curator) as AuctionHouse; 272 | await mint(media); 273 | await approveAuction(media, auctionHouse); 274 | await createAuction( 275 | auctionHouse.connect(admin), 276 | await curator.getAddress() 277 | ); 278 | }); 279 | 280 | it("should revert if the auctionHouse does not exist", async () => { 281 | await expect( 282 | auctionHouse.setAuctionApproval(1, true) 283 | ).eventually.rejectedWith(revert`Auction doesn't exist`); 284 | }); 285 | 286 | it("should revert if not called by the curator", async () => { 287 | await expect( 288 | auctionHouse.connect(admin).setAuctionApproval(0, true) 289 | ).eventually.rejectedWith(revert`Must be auction curator`); 290 | }); 291 | 292 | it("should revert if the auction has already started", async () => { 293 | await auctionHouse.setAuctionApproval(0, true); 294 | await auctionHouse 295 | .connect(bidder) 296 | .createBid(0, ONE_ETH, { value: ONE_ETH }); 297 | await expect( 298 | auctionHouse.setAuctionApproval(0, false) 299 | ).eventually.rejectedWith(revert`Auction has already started`); 300 | }); 301 | 302 | it("should set the auction as approved", async () => { 303 | await auctionHouse.setAuctionApproval(0, true); 304 | 305 | expect((await auctionHouse.auctions(0)).approved).to.eq(true); 306 | }); 307 | 308 | it("should emit an AuctionApproved event", async () => { 309 | const block = await ethers.provider.getBlockNumber(); 310 | await auctionHouse.setAuctionApproval(0, true); 311 | const events = await auctionHouse.queryFilter( 312 | auctionHouse.filters.AuctionApprovalUpdated(null, null, null, null), 313 | block 314 | ); 315 | expect(events.length).eq(1); 316 | const logDescription = auctionHouse.interface.parseLog(events[0]); 317 | 318 | expect(logDescription.args.approved).to.eq(true); 319 | }); 320 | }); 321 | 322 | describe("#setAuctionReservePrice", () => { 323 | let auctionHouse: AuctionHouse; 324 | let admin: Signer; 325 | let creator: Signer; 326 | let curator: Signer; 327 | let bidder: Signer; 328 | 329 | beforeEach(async () => { 330 | [admin, creator, curator, bidder] = await ethers.getSigners(); 331 | auctionHouse = (await deploy()).connect(curator) as AuctionHouse; 332 | await mint(media.connect(creator)); 333 | await approveAuction( 334 | media.connect(creator), 335 | auctionHouse.connect(creator) 336 | ); 337 | await createAuction( 338 | auctionHouse.connect(creator), 339 | await curator.getAddress() 340 | ); 341 | }); 342 | 343 | it("should revert if the auctionHouse does not exist", async () => { 344 | await expect( 345 | auctionHouse.setAuctionReservePrice(1, TWO_ETH) 346 | ).eventually.rejectedWith(revert`Auction doesn't exist`); 347 | }); 348 | 349 | it("should revert if not called by the curator or owner", async () => { 350 | await expect( 351 | auctionHouse.connect(admin).setAuctionReservePrice(0, TWO_ETH) 352 | ).eventually.rejectedWith(revert`Must be auction curator`); 353 | }); 354 | 355 | it("should revert if the auction has already started", async () => { 356 | await auctionHouse.setAuctionReservePrice(0, TWO_ETH); 357 | await auctionHouse.setAuctionApproval(0, true); 358 | await auctionHouse 359 | .connect(bidder) 360 | .createBid(0, TWO_ETH, { value: TWO_ETH }); 361 | await expect( 362 | auctionHouse.setAuctionReservePrice(0, ONE_ETH) 363 | ).eventually.rejectedWith(revert`Auction has already started`); 364 | }); 365 | 366 | it("should set the auction reserve price when called by the curator", async () => { 367 | await auctionHouse.setAuctionReservePrice(0, TWO_ETH); 368 | 369 | expect((await auctionHouse.auctions(0)).reservePrice).to.eq(TWO_ETH); 370 | }); 371 | 372 | it("should set the auction reserve price when called by the token owner", async () => { 373 | await auctionHouse.connect(creator).setAuctionReservePrice(0, TWO_ETH); 374 | 375 | expect((await auctionHouse.auctions(0)).reservePrice).to.eq(TWO_ETH); 376 | }); 377 | 378 | it("should emit an AuctionReservePriceUpdated event", async () => { 379 | const block = await ethers.provider.getBlockNumber(); 380 | await auctionHouse.setAuctionReservePrice(0, TWO_ETH); 381 | const events = await auctionHouse.queryFilter( 382 | auctionHouse.filters.AuctionReservePriceUpdated(null, null, null, null), 383 | block 384 | ); 385 | expect(events.length).eq(1); 386 | const logDescription = auctionHouse.interface.parseLog(events[0]); 387 | 388 | expect(logDescription.args.reservePrice).to.eq(TWO_ETH); 389 | }); 390 | }); 391 | 392 | describe("#createBid", () => { 393 | let auctionHouse: AuctionHouse; 394 | let admin: Signer; 395 | let curator: Signer; 396 | let bidderA: Signer; 397 | let bidderB: Signer; 398 | 399 | beforeEach(async () => { 400 | [admin, curator, bidderA, bidderB] = await ethers.getSigners(); 401 | auctionHouse = (await (await deploy()).connect(bidderA)) as AuctionHouse; 402 | await mint(media); 403 | await approveAuction(media, auctionHouse); 404 | await createAuction( 405 | auctionHouse.connect(admin), 406 | await curator.getAddress() 407 | ); 408 | await auctionHouse.connect(curator).setAuctionApproval(0, true); 409 | }); 410 | 411 | it("should revert if the specified auction does not exist", async () => { 412 | await expect( 413 | auctionHouse.createBid(11111, ONE_ETH) 414 | ).eventually.rejectedWith(revert`Auction doesn't exist`); 415 | }); 416 | 417 | it("should revert if the specified auction is not approved", async () => { 418 | await auctionHouse.connect(curator).setAuctionApproval(0, false); 419 | await expect( 420 | auctionHouse.createBid(0, ONE_ETH, { value: ONE_ETH }) 421 | ).eventually.rejectedWith(revert`Auction must be approved by curator`); 422 | }); 423 | 424 | it("should revert if the bid is less than the reserve price", async () => { 425 | await expect( 426 | auctionHouse.createBid(0, 0, { value: 0 }) 427 | ).eventually.rejectedWith(revert`Must send at least reservePrice`); 428 | }); 429 | 430 | it("should revert if the bid is invalid for share splitting", async () => { 431 | await expect( 432 | auctionHouse.createBid(0, ONE_ETH.add(1), { 433 | value: ONE_ETH.add(1), 434 | }) 435 | ).eventually.rejectedWith(revert`Bid invalid for share splitting`); 436 | }); 437 | 438 | it("should revert if msg.value does not equal specified amount", async () => { 439 | await expect( 440 | auctionHouse.createBid(0, ONE_ETH, { 441 | value: ONE_ETH.mul(2), 442 | }) 443 | ).eventually.rejectedWith( 444 | revert`Sent ETH Value does not match specified bid amount` 445 | ); 446 | }); 447 | describe("first bid", () => { 448 | it("should set the first bid time", async () => { 449 | // TODO: Fix this test on Sun Oct 04 2274 450 | await ethers.provider.send("evm_setNextBlockTimestamp", [9617249934]); 451 | await auctionHouse.createBid(0, ONE_ETH, { 452 | value: ONE_ETH, 453 | }); 454 | expect((await auctionHouse.auctions(0)).firstBidTime).to.eq(9617249934); 455 | }); 456 | 457 | it("should store the transferred ETH as WETH", async () => { 458 | await auctionHouse.createBid(0, ONE_ETH, { 459 | value: ONE_ETH, 460 | }); 461 | expect(await weth.balanceOf(auctionHouse.address)).to.eq(ONE_ETH); 462 | }); 463 | 464 | it("should not update the auction's duration", async () => { 465 | const beforeDuration = (await auctionHouse.auctions(0)).duration; 466 | await auctionHouse.createBid(0, ONE_ETH, { 467 | value: ONE_ETH, 468 | }); 469 | const afterDuration = (await auctionHouse.auctions(0)).duration; 470 | 471 | expect(beforeDuration).to.eq(afterDuration); 472 | }); 473 | 474 | it("should store the bidder's information", async () => { 475 | await auctionHouse.createBid(0, ONE_ETH, { 476 | value: ONE_ETH, 477 | }); 478 | const currAuction = await auctionHouse.auctions(0); 479 | 480 | expect(currAuction.bidder).to.eq(await bidderA.getAddress()); 481 | expect(currAuction.amount).to.eq(ONE_ETH); 482 | }); 483 | 484 | it("should emit an AuctionBid event", async () => { 485 | const block = await ethers.provider.getBlockNumber(); 486 | await auctionHouse.createBid(0, ONE_ETH, { 487 | value: ONE_ETH, 488 | }); 489 | const events = await auctionHouse.queryFilter( 490 | auctionHouse.filters.AuctionBid( 491 | null, 492 | null, 493 | null, 494 | null, 495 | null, 496 | null, 497 | null 498 | ), 499 | block 500 | ); 501 | expect(events.length).eq(1); 502 | const logDescription = auctionHouse.interface.parseLog(events[0]); 503 | 504 | expect(logDescription.name).to.eq("AuctionBid"); 505 | expect(logDescription.args.auctionId).to.eq(0); 506 | expect(logDescription.args.sender).to.eq(await bidderA.getAddress()); 507 | expect(logDescription.args.value).to.eq(ONE_ETH); 508 | expect(logDescription.args.firstBid).to.eq(true); 509 | expect(logDescription.args.extended).to.eq(false); 510 | }); 511 | }); 512 | 513 | describe("second bid", () => { 514 | beforeEach(async () => { 515 | auctionHouse = auctionHouse.connect(bidderB) as AuctionHouse; 516 | await auctionHouse 517 | .connect(bidderA) 518 | .createBid(0, ONE_ETH, { value: ONE_ETH }); 519 | }); 520 | 521 | it("should revert if the bid is smaller than the last bid + minBid", async () => { 522 | await expect( 523 | auctionHouse.createBid(0, ONE_ETH.add(1), { 524 | value: ONE_ETH.add(1), 525 | }) 526 | ).eventually.rejectedWith( 527 | revert`Must send more than last bid by minBidIncrementPercentage amount` 528 | ); 529 | }); 530 | 531 | it("should refund the previous bid", async () => { 532 | const beforeBalance = await ethers.provider.getBalance( 533 | await bidderA.getAddress() 534 | ); 535 | const beforeBidAmount = (await auctionHouse.auctions(0)).amount; 536 | await auctionHouse.createBid(0, TWO_ETH, { 537 | value: TWO_ETH, 538 | }); 539 | const afterBalance = await ethers.provider.getBalance( 540 | await bidderA.getAddress() 541 | ); 542 | 543 | expect(afterBalance).to.eq(beforeBalance.add(beforeBidAmount)); 544 | }); 545 | 546 | it("should not update the firstBidTime", async () => { 547 | const firstBidTime = (await auctionHouse.auctions(0)).firstBidTime; 548 | await auctionHouse.createBid(0, TWO_ETH, { 549 | value: TWO_ETH, 550 | }); 551 | expect((await auctionHouse.auctions(0)).firstBidTime).to.eq( 552 | firstBidTime 553 | ); 554 | }); 555 | 556 | it("should transfer the bid to the contract and store it as WETH", async () => { 557 | await auctionHouse.createBid(0, TWO_ETH, { 558 | value: TWO_ETH, 559 | }); 560 | 561 | expect(await weth.balanceOf(auctionHouse.address)).to.eq(TWO_ETH); 562 | }); 563 | 564 | it("should update the stored bid information", async () => { 565 | await auctionHouse.createBid(0, TWO_ETH, { 566 | value: TWO_ETH, 567 | }); 568 | 569 | const currAuction = await auctionHouse.auctions(0); 570 | 571 | expect(currAuction.amount).to.eq(TWO_ETH); 572 | expect(currAuction.bidder).to.eq(await bidderB.getAddress()); 573 | }); 574 | 575 | it("should not extend the duration of the bid if outside of the time buffer", async () => { 576 | const beforeDuration = (await auctionHouse.auctions(0)).duration; 577 | await auctionHouse.createBid(0, TWO_ETH, { 578 | value: TWO_ETH, 579 | }); 580 | const afterDuration = (await auctionHouse.auctions(0)).duration; 581 | expect(beforeDuration).to.eq(afterDuration); 582 | }); 583 | 584 | it("should emit an AuctionBid event", async () => { 585 | const block = await ethers.provider.getBlockNumber(); 586 | await auctionHouse.createBid(0, TWO_ETH, { 587 | value: TWO_ETH, 588 | }); 589 | const events = await auctionHouse.queryFilter( 590 | auctionHouse.filters.AuctionBid( 591 | null, 592 | null, 593 | null, 594 | null, 595 | null, 596 | null, 597 | null 598 | ), 599 | block 600 | ); 601 | expect(events.length).eq(2); 602 | const logDescription = auctionHouse.interface.parseLog(events[1]); 603 | 604 | expect(logDescription.name).to.eq("AuctionBid"); 605 | expect(logDescription.args.sender).to.eq(await bidderB.getAddress()); 606 | expect(logDescription.args.value).to.eq(TWO_ETH); 607 | expect(logDescription.args.firstBid).to.eq(false); 608 | expect(logDescription.args.extended).to.eq(false); 609 | }); 610 | 611 | describe("last minute bid", () => { 612 | beforeEach(async () => { 613 | const currAuction = await auctionHouse.auctions(0); 614 | await ethers.provider.send("evm_setNextBlockTimestamp", [ 615 | currAuction.firstBidTime 616 | .add(currAuction.duration) 617 | .sub(1) 618 | .toNumber(), 619 | ]); 620 | }); 621 | it("should extend the duration of the bid if inside of the time buffer", async () => { 622 | const beforeDuration = (await auctionHouse.auctions(0)).duration; 623 | await auctionHouse.createBid(0, TWO_ETH, { 624 | value: TWO_ETH, 625 | }); 626 | 627 | const currAuction = await auctionHouse.auctions(0); 628 | expect(currAuction.duration).to.eq( 629 | beforeDuration.add(await auctionHouse.timeBuffer()).sub(1) 630 | ); 631 | }); 632 | it("should emit an AuctionBid event", async () => { 633 | const block = await ethers.provider.getBlockNumber(); 634 | await auctionHouse.createBid(0, TWO_ETH, { 635 | value: TWO_ETH, 636 | }); 637 | const events = await auctionHouse.queryFilter( 638 | auctionHouse.filters.AuctionBid( 639 | null, 640 | null, 641 | null, 642 | null, 643 | null, 644 | null, 645 | null 646 | ), 647 | block 648 | ); 649 | expect(events.length).eq(2); 650 | const logDescription = auctionHouse.interface.parseLog(events[1]); 651 | 652 | expect(logDescription.name).to.eq("AuctionBid"); 653 | expect(logDescription.args.sender).to.eq(await bidderB.getAddress()); 654 | expect(logDescription.args.value).to.eq(TWO_ETH); 655 | expect(logDescription.args.firstBid).to.eq(false); 656 | expect(logDescription.args.extended).to.eq(true); 657 | }); 658 | }); 659 | describe("late bid", () => { 660 | beforeEach(async () => { 661 | const currAuction = await auctionHouse.auctions(0); 662 | await ethers.provider.send("evm_setNextBlockTimestamp", [ 663 | currAuction.firstBidTime 664 | .add(currAuction.duration) 665 | .add(1) 666 | .toNumber(), 667 | ]); 668 | }); 669 | 670 | it("should revert if the bid is placed after expiry", async () => { 671 | await expect( 672 | auctionHouse.createBid(0, TWO_ETH, { 673 | value: TWO_ETH, 674 | }) 675 | ).eventually.rejectedWith(revert`Auction expired`); 676 | }); 677 | }); 678 | }); 679 | }); 680 | 681 | describe("#cancelAuction", () => { 682 | let auctionHouse: AuctionHouse; 683 | let admin: Signer; 684 | let creator: Signer; 685 | let curator: Signer; 686 | let bidder: Signer; 687 | 688 | beforeEach(async () => { 689 | [admin, creator, curator, bidder] = await ethers.getSigners(); 690 | auctionHouse = (await deploy()).connect(creator) as AuctionHouse; 691 | await mint(media.connect(creator)); 692 | await approveAuction(media.connect(creator), auctionHouse); 693 | await createAuction( 694 | auctionHouse.connect(creator), 695 | await curator.getAddress() 696 | ); 697 | await auctionHouse.connect(curator).setAuctionApproval(0, true); 698 | }); 699 | 700 | it("should revert if the auction does not exist", async () => { 701 | await expect(auctionHouse.cancelAuction(12213)).eventually.rejectedWith( 702 | revert`Auction doesn't exist` 703 | ); 704 | }); 705 | 706 | it("should revert if not called by a creator or curator", async () => { 707 | await expect( 708 | auctionHouse.connect(bidder).cancelAuction(0) 709 | ).eventually.rejectedWith( 710 | `Can only be called by auction creator or curator` 711 | ); 712 | }); 713 | 714 | it("should revert if the auction has already begun", async () => { 715 | await auctionHouse 716 | .connect(bidder) 717 | .createBid(0, ONE_ETH, { value: ONE_ETH }); 718 | await expect(auctionHouse.cancelAuction(0)).eventually.rejectedWith( 719 | revert`Can't cancel an auction once it's begun` 720 | ); 721 | }); 722 | 723 | it("should be callable by the creator", async () => { 724 | await auctionHouse.cancelAuction(0); 725 | 726 | const auctionResult = await auctionHouse.auctions(0); 727 | 728 | expect(auctionResult.amount.toNumber()).to.eq(0); 729 | expect(auctionResult.duration.toNumber()).to.eq(0); 730 | expect(auctionResult.firstBidTime.toNumber()).to.eq(0); 731 | expect(auctionResult.reservePrice.toNumber()).to.eq(0); 732 | expect(auctionResult.curatorFeePercentage).to.eq(0); 733 | expect(auctionResult.tokenOwner).to.eq(ethers.constants.AddressZero); 734 | expect(auctionResult.bidder).to.eq(ethers.constants.AddressZero); 735 | expect(auctionResult.curator).to.eq(ethers.constants.AddressZero); 736 | expect(auctionResult.auctionCurrency).to.eq(ethers.constants.AddressZero); 737 | 738 | expect(await media.ownerOf(0)).to.eq(await creator.getAddress()); 739 | }); 740 | 741 | it("should be callable by the curator", async () => { 742 | await auctionHouse.connect(curator).cancelAuction(0); 743 | 744 | const auctionResult = await auctionHouse.auctions(0); 745 | 746 | expect(auctionResult.amount.toNumber()).to.eq(0); 747 | expect(auctionResult.duration.toNumber()).to.eq(0); 748 | expect(auctionResult.firstBidTime.toNumber()).to.eq(0); 749 | expect(auctionResult.reservePrice.toNumber()).to.eq(0); 750 | expect(auctionResult.curatorFeePercentage).to.eq(0); 751 | expect(auctionResult.tokenOwner).to.eq(ethers.constants.AddressZero); 752 | expect(auctionResult.bidder).to.eq(ethers.constants.AddressZero); 753 | expect(auctionResult.curator).to.eq(ethers.constants.AddressZero); 754 | expect(auctionResult.auctionCurrency).to.eq(ethers.constants.AddressZero); 755 | expect(await media.ownerOf(0)).to.eq(await creator.getAddress()); 756 | }); 757 | 758 | it("should emit an AuctionCanceled event", async () => { 759 | const block = await ethers.provider.getBlockNumber(); 760 | await auctionHouse.cancelAuction(0); 761 | const events = await auctionHouse.queryFilter( 762 | auctionHouse.filters.AuctionCanceled(null, null, null, null), 763 | block 764 | ); 765 | expect(events.length).eq(1); 766 | const logDescription = auctionHouse.interface.parseLog(events[0]); 767 | 768 | expect(logDescription.args.tokenId.toNumber()).to.eq(0); 769 | expect(logDescription.args.tokenOwner).to.eq(await creator.getAddress()); 770 | expect(logDescription.args.tokenContract).to.eq(media.address); 771 | }); 772 | }); 773 | 774 | describe("#endAuction", () => { 775 | let auctionHouse: AuctionHouse; 776 | let admin: Signer; 777 | let creator: Signer; 778 | let curator: Signer; 779 | let bidder: Signer; 780 | let other: Signer; 781 | let badBidder: BadBidder; 782 | 783 | beforeEach(async () => { 784 | [admin, creator, curator, bidder, other] = await ethers.getSigners(); 785 | auctionHouse = (await deploy()) as AuctionHouse; 786 | await mint(media.connect(creator)); 787 | await approveAuction(media.connect(creator), auctionHouse); 788 | await createAuction( 789 | auctionHouse.connect(creator), 790 | await curator.getAddress() 791 | ); 792 | await auctionHouse.connect(curator).setAuctionApproval(0, true); 793 | badBidder = await deployBidder(auctionHouse.address, media.address); 794 | }); 795 | 796 | it("should revert if the auction does not exist", async () => { 797 | await expect(auctionHouse.endAuction(1110)).eventually.rejectedWith( 798 | revert`Auction doesn't exist` 799 | ); 800 | }); 801 | 802 | it("should revert if the auction has not begun", async () => { 803 | await expect(auctionHouse.endAuction(0)).eventually.rejectedWith( 804 | revert`Auction hasn't begun` 805 | ); 806 | }); 807 | 808 | it("should revert if the auction has not completed", async () => { 809 | await auctionHouse.createBid(0, ONE_ETH, { 810 | value: ONE_ETH, 811 | }); 812 | 813 | await expect(auctionHouse.endAuction(0)).eventually.rejectedWith( 814 | revert`Auction hasn't completed` 815 | ); 816 | }); 817 | 818 | it("should cancel the auction if the winning bidder is unable to receive NFTs", async () => { 819 | await badBidder.placeBid(0, TWO_ETH, { value: TWO_ETH }); 820 | const endTime = 821 | (await auctionHouse.auctions(0)).duration.toNumber() + 822 | (await auctionHouse.auctions(0)).firstBidTime.toNumber(); 823 | await ethers.provider.send("evm_setNextBlockTimestamp", [endTime + 1]); 824 | 825 | await auctionHouse.endAuction(0); 826 | 827 | expect(await media.ownerOf(0)).to.eq(await creator.getAddress()); 828 | expect(await ethers.provider.getBalance(badBidder.address)).to.eq( 829 | TWO_ETH 830 | ); 831 | }); 832 | 833 | describe("ETH auction", () => { 834 | beforeEach(async () => { 835 | await auctionHouse 836 | .connect(bidder) 837 | .createBid(0, ONE_ETH, { value: ONE_ETH }); 838 | const endTime = 839 | (await auctionHouse.auctions(0)).duration.toNumber() + 840 | (await auctionHouse.auctions(0)).firstBidTime.toNumber(); 841 | await ethers.provider.send("evm_setNextBlockTimestamp", [endTime + 1]); 842 | }); 843 | 844 | it("should transfer the NFT to the winning bidder", async () => { 845 | await auctionHouse.endAuction(0); 846 | 847 | expect(await media.ownerOf(0)).to.eq(await bidder.getAddress()); 848 | }); 849 | 850 | it("should pay the curator their curatorFee percentage", async () => { 851 | const beforeBalance = await ethers.provider.getBalance( 852 | await curator.getAddress() 853 | ); 854 | await auctionHouse.endAuction(0); 855 | const expectedCuratorFee = "42500000000000000"; 856 | const curatorBalance = await ethers.provider.getBalance( 857 | await curator.getAddress() 858 | ); 859 | await expect(curatorBalance.sub(beforeBalance).toString()).to.eq( 860 | expectedCuratorFee 861 | ); 862 | }); 863 | 864 | it("should pay the creator the remainder of the winning bid", async () => { 865 | const beforeBalance = await ethers.provider.getBalance( 866 | await creator.getAddress() 867 | ); 868 | await auctionHouse.endAuction(0); 869 | const expectedProfit = "957500000000000000"; 870 | const creatorBalance = await ethers.provider.getBalance( 871 | await creator.getAddress() 872 | ); 873 | const wethBalance = await weth.balanceOf(await creator.getAddress()); 874 | await expect( 875 | creatorBalance.sub(beforeBalance).add(wethBalance).toString() 876 | ).to.eq(expectedProfit); 877 | }); 878 | 879 | it("should emit an AuctionEnded event", async () => { 880 | const block = await ethers.provider.getBlockNumber(); 881 | const auctionData = await auctionHouse.auctions(0); 882 | await auctionHouse.endAuction(0); 883 | const events = await auctionHouse.queryFilter( 884 | auctionHouse.filters.AuctionEnded( 885 | null, 886 | null, 887 | null, 888 | null, 889 | null, 890 | null, 891 | null, 892 | null, 893 | null 894 | ), 895 | block 896 | ); 897 | expect(events.length).eq(1); 898 | const logDescription = auctionHouse.interface.parseLog(events[0]); 899 | 900 | expect(logDescription.args.tokenId).to.eq(0); 901 | expect(logDescription.args.tokenOwner).to.eq(auctionData.tokenOwner); 902 | expect(logDescription.args.curator).to.eq(auctionData.curator); 903 | expect(logDescription.args.winner).to.eq(auctionData.bidder); 904 | expect(logDescription.args.amount.toString()).to.eq( 905 | "807500000000000000" 906 | ); 907 | expect(logDescription.args.curatorFee.toString()).to.eq( 908 | "42500000000000000" 909 | ); 910 | expect(logDescription.args.auctionCurrency).to.eq(weth.address); 911 | }); 912 | 913 | it("should delete the auction", async () => { 914 | await auctionHouse.endAuction(0); 915 | 916 | const auctionResult = await auctionHouse.auctions(0); 917 | 918 | expect(auctionResult.amount.toNumber()).to.eq(0); 919 | expect(auctionResult.duration.toNumber()).to.eq(0); 920 | expect(auctionResult.firstBidTime.toNumber()).to.eq(0); 921 | expect(auctionResult.reservePrice.toNumber()).to.eq(0); 922 | expect(auctionResult.curatorFeePercentage).to.eq(0); 923 | expect(auctionResult.tokenOwner).to.eq(ethers.constants.AddressZero); 924 | expect(auctionResult.bidder).to.eq(ethers.constants.AddressZero); 925 | expect(auctionResult.curator).to.eq(ethers.constants.AddressZero); 926 | expect(auctionResult.auctionCurrency).to.eq( 927 | ethers.constants.AddressZero 928 | ); 929 | }); 930 | }); 931 | }); 932 | }); 933 | --------------------------------------------------------------------------------