Last active
October 3, 2021 19:08
-
-
Save graemecode/0811326589450829ccd0641e8a5408d7 to your computer and use it in GitHub Desktop.
Crowdfunding NFTs with Zora
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//SPDX-License-Identifier: GPL-3.0-or-later | |
pragma solidity 0.6.8; | |
pragma experimental ABIEncoderV2; | |
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; | |
import { | |
ReentrancyGuard | |
} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; | |
import {SafeMath} from "@openzeppelin/contracts/math/SafeMath.sol"; | |
import {Decimal} from "../Decimal.sol"; | |
import {IMarket} from "../interfaces/IMarket.sol"; | |
import {IMedia} from "../interfaces/IMedia.sol"; | |
import {IWETH} from "./interfaces/IWETH.sol"; | |
/** | |
* @title Crowdfund | |
* @author MirrorXYZ | |
* | |
* Crowdfund the creation of NFTs by issuing ERC20 tokens that | |
* can be redeemed for the underlying value of the NFT once sold. | |
*/ | |
contract Crowdfund is ERC20, ReentrancyGuard { | |
using SafeMath for uint256; | |
// ============ Enums ============ | |
enum Status {FUNDING, TRADING} | |
// ============ Constants ============ | |
uint256 public constant fundingCap = 10000000000000000000; | |
uint256 private constant SCALING_FACTOR = 1e27; | |
// ============ Immutable Storage ============ | |
address payable public operator; | |
address public mediaAddress; | |
address public WETH; | |
uint256 public operatorEquityPercent; | |
// ============ Mutable Storage ============ | |
// Represents the current state of the campaign. | |
Status public status; | |
// ============ Events ============ | |
event FundingOpened( | |
address media, | |
address creator, | |
uint256 creatorEquityPercent | |
); | |
event Contribution(address contributor, uint256 amount); | |
event FundingClosed(uint256 amountRaised, uint256 creatorAllocation); | |
event BidAccepted(uint256 amount); | |
event Withdrawal(address contributor, uint256 amount); | |
// ============ Modifiers ============ | |
/** | |
* @dev Modifier to check whether the `msg.sender` is the operator. | |
* If it is, it will run the function. Otherwise, it will revert. | |
*/ | |
modifier onlyOperator() { | |
require(msg.sender == operator); | |
_; | |
} | |
// ============ Constructor ============ | |
constructor( | |
address payable operator_, | |
address mediaAddress_, | |
address WETH_, | |
uint256 operatorEquityPercent_, | |
IMedia.MediaData memory data, | |
IMarket.BidShares memory bidShares | |
) public ERC20("Crowdfund", "CROWD") { | |
// Initialize immutable storage. | |
mediaAddress = mediaAddress_; | |
operator = operator_; | |
operatorEquityPercent = operatorEquityPercent_; | |
WETH = WETH_; | |
// Initialize mutable storage. | |
status = Status.FUNDING; | |
// Mint an NFT token. | |
IMedia(mediaAddress).mint(data, bidShares); | |
// Signal that funding has been opened. | |
emit FundingOpened(mediaAddress, operator, operatorEquityPercent); | |
} | |
// ============ Crowdfunding Methods ============ | |
/** | |
* @notice Mints tokens for the sender propotional to the | |
* amount of ETH sent in the transaction. | |
* @dev Emits the Contribution event. | |
*/ | |
function contribute() external payable nonReentrant { | |
require(status == Status.FUNDING, "Funding must be open"); | |
require( | |
msg.value.add(address(this).balance) <= fundingCap, | |
"Total contributions would exceed funding cap" | |
); | |
// Mint equity for the contributor. | |
_mint(msg.sender, msg.value); | |
emit Contribution(msg.sender, msg.value); | |
} | |
/** | |
* @notice Burns the sender's tokens and redeems underlying ETH. | |
* @dev Emits the Withdrawal event. | |
*/ | |
function withdraw(uint256 tokenAmount) external nonReentrant { | |
require((balanceOf(msg.sender) >= tokenAmount), "Insufficient balance"); | |
uint256 redeemable = redeemableFromTokens(tokenAmount); | |
_burn(msg.sender, tokenAmount); | |
msg.sender.transfer(redeemable); | |
emit Withdrawal(msg.sender, redeemable); | |
} | |
/** | |
* @notice Returns the amount of ETH that is redeemable for tokenAmount. | |
*/ | |
function redeemableFromTokens(uint256 tokenAmount) | |
public | |
view | |
returns (uint256 redeemable) | |
{ | |
uint256 stakeScaled = | |
tokenAmount.mul(SCALING_FACTOR).div(totalSupply()); | |
// Round up after scaling. | |
redeemable = stakeScaled | |
.mul(address(this).balance) | |
.sub(1) | |
.div(SCALING_FACTOR) | |
.add(1); | |
} | |
// ============ Operator Methods ============ | |
/** | |
* @notice Transfers all funds to operator, and mints tokens for the operator. | |
* Updates status to TRADING. | |
* @dev Emits the FundingClosed event. | |
*/ | |
function closeFunding() external onlyOperator nonReentrant { | |
require(status == Status.FUNDING, "Funding must be open"); | |
// Close funding status, move to tradable. | |
status = Status.TRADING; | |
// Mint the operator a percent of the total supply. | |
uint256 tokensForOperator = | |
totalSupply().mul(operatorEquityPercent).div(100); | |
_mint(operator, tokensForOperator); | |
emit FundingClosed(address(this).balance, tokensForOperator); | |
// Transfer all funds to the operator. | |
operator.transfer(address(this).balance); | |
} | |
/** | |
* @notice Accepts the given bid on the associated market and unwraps WETH. | |
* @dev Emits the BidAccepted event. | |
*/ | |
function acceptNFTBid(IMarket.Bid calldata bid) | |
external | |
onlyOperator | |
nonReentrant | |
{ | |
require(status == Status.TRADING, "Trading must be open"); | |
// This will work if the publication is the owner of the token | |
IMedia(mediaAddress).acceptBid(0, bid); | |
// Accepting the bid will transfer WETH into this contract. | |
IWETH(WETH).withdraw(bid.amount); | |
emit BidAccepted(bid.amount); | |
} | |
// Allows the operator to update metadata associated with the NFT. | |
function updateTokenURI(string calldata tokenURI) | |
external | |
onlyOperator | |
nonReentrant | |
{ | |
IMedia(mediaAddress).updateTokenURI(0, tokenURI); | |
} | |
// Allows the operator to update metadata associated with the NFT. | |
function updateTokenMetadataURI(string calldata metadataURI) | |
external | |
onlyOperator | |
nonReentrant | |
{ | |
IMedia(mediaAddress).updateTokenMetadataURI(0, metadataURI); | |
} | |
/** | |
* @notice Prevents ETH from being sent directly to the contract, except | |
* from the WETH contract, during acceptBid. | |
*/ | |
receive() external payable { | |
assert(msg.sender == WETH); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Crowdfund | |
when Zora Media has been deployed | |
when deployed with appropriate arguments | |
✓ deploys a crowdfund contract with the correct configuration (65ms) | |
✓ gives no balance to the creator at deployment | |
✓ emits a FundingOpened event | |
when a contributor attempts to contribute 2 ETH | |
✓ uses 69992 gas | |
✓ increases the contract's balance by 2 ETH | |
✓ decrease the contributor's ETH balance by 2 ETH plus gas for the tx | |
✓ mints tokens for the contributor equal to the amount of ETH given | |
✓ grants them 2 ETH redeemable | |
✓ emits a Transfer and Contribution event | |
when the contributor attempts to withdraw 1.2 ETH from their contributions | |
✓ burns their tokens, so that their token balance is 800000000000000000 | |
✓ totalSupply() is now 800000000000000000 | |
✓ decreases the contract's balance by 1.2 ETH | |
✓ increases the sender's balance by 1.2 ETH, minus gas | |
✓ uses 49726 gas | |
✓ emits a Transfer and Withdrawal event | |
when another contributor adds 3.3 ETH | |
✓ uses 54992 gas | |
✓ increases the contract's balance by 3.3 ETH | |
✓ decrease the contributor's ETH balance by 3.3 ETH plus gas for the tx | |
✓ mints tokens for the contributor equal to the amount of ETH given | |
✓ grants them 3.3 ETH redeemable | |
✓ emits a Transfer and Contribution event | |
✓ totalSupply() is now 4100000000000000000 | |
when the contributor attempts to withdraw 4 ETH from the contract | |
✓ reverts the transaction | |
when the contributor attempts to withdraw .2 ETH from their contributions | |
✓ burns their tokens, so that their token balance is 3100000000000000000 | |
✓ decreases the contract's balance by 0.2 ETH | |
✓ increases the sender's balance by 0.2 ETH, minus gas | |
✓ uses 49726 gas | |
✓ emits a Transfer and Withdrawal event | |
✓ totalSupply() is now 3900000000000000000 | |
and the operator attempts to accept a bid before closing funding | |
✓ reverts the transaction | |
when the operator closes funding | |
✓ mints 5 percent of tokens to the operator | |
✓ sets the status to TRADING | |
when a contribution is attempted after funding is closed | |
✓ reverts the transaction | |
and a bid exists on the market | |
and the operator attempts accepts the bid | |
✓ increases the contract's balance by the bid amount | |
when a contributor attempts to contribute more than the funding cap | |
✓ reverts the transaction | |
if a contributor sends ETH directly to the contract | |
✓ reverts the transaction | |
36 passing (29s) |
Nice! Couple things –
-
Is the crowdfunding contract the initial market, or the permanent market? Once the NFT has been sold, does the creator (the
operator
) choose how it gets resold? Or am I misunderstanding. -
A code nitpick:
operatorEquityPercent
looks like it's an integer between 0 and 100 based on the way it's used Line 168:
totalSupply().div(100).mul(operatorEquityPercent);
I would change this to:
totalSupply().mul(operatorEquityPercent).div(100);
to avoid potential errors if you decide to allow greater decimal precision.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Beautiful ☼☽