- $71,250 USDC main award pot
- $3,750 USDC gas optimization award pot
- Join C4 Discord to register
- Submit findings using the C4 form
- Read our guidelines for more details
- Starts September 12, 2022 20:00 UTC
- Ends September 19, 2022 20:00 UTC
🚨🚨🚨🚨 The code and docs for this contest are located in a separate [PartyDAO Protocol Repo]. 🚨🚨🚨🚨
- Number of non-library contracts in scope: 46
- Number of library dependencies: 1
- Number of structs in scope: 34
- Number of interfaces in scope: 15
- Number of external calls: 4+
- Seaport 1.1, Zora V1, Fractional V1, PartyBid V1 MarketWrappers, user-supplied arbitrary calls, arbitrary ERC20s and ERC721s.
- Codebase uses mostly inheritance.
- Total in-scope SLOC: 3995
- No oracles used.
- No unique curve logic or math models.
- It is not an AMM.
- It is not a fork of a popular project.
- It does not use rollups.
- It does not implement an ERC20 token.
- Some contracts are NFTs (ERC721).
- It relies on time-based logic.
- It is not multi-chain.
- It does not use a side-chain.
- Preferred communication timezone: EDT or PDT
Here's an example one-liner to immediately get started with the codebase. It will clone the project, build it, run every test, and display gas reports (except that the TS tests do not have a gas report since they only use waffle).
export FORK_URL='<your_alchemy_mainnet_url_goes_here>' && rm -Rf party-contracts-c4 || true && git clone https://github.com/PartyDAO/party-contracts-c4 && cd party-contracts-c4 && nvm install 16.0 && foundryup && forge install && yarn -D && yarn build:sol && yarn test:ts && yarn test:gas && forge test -m testFork --fork-url $FORK_URL --gas-report
Refer to the code repo README for targeted, individual test commands you can run.
Note that slither does not seem to be working with the repo as-is 🤷, resulting in an enum type not found error:
slither.solc_parsing.exceptions.ParsingError: Type not found enum Crowdfund.CrowdfundLifecycle
Seems to be related to crytic/slither#1300, which will be included in the 0.8.4 release.
There's mixed success with running slither against individual files.
docs/ # Start here
├── overview.md
├── crowdfund.md
└── governance.md
contracts/
│ # Used during the crowdfund phase
├── crowdfund/
│ ├── AuctionCrowdfund.sol
│ ├── BuyCrowdfund.sol
│ ├── CollectionBuyCrowdfund.sol
│ ├── CrowdfundFactory.sol
│ ├── Crowdfund.sol
│ └── CrowdfundNFT.sol
├── gatekeepers/
│ ├── AllowListGateKeeper.sol
│ └── TokenGateKeeper.sol
├── globals/
│ └── Globals.sol
│ # Used during the governance phase
├── party/
│ ├── Party.sol
│ ├── PartyFactory.sol
│ ├── PartyGovernance.sol
│ └── PartyGovernanceNFT.sol
├── proposals/
│ ├── ProposalExecutionEngine.sol
│ ├── ArbitraryCallsProposal.sol
│ ├── FractionalizeProposal.sol
│ ├── ListOnOpenseaProposal.sol
│ └── ListOnZoraProposal.sol
├── distribution/
│ └── TokenDistributor.sol
| # Used to render crowdfund and governance NFTs
└── renderers/
├── CrowdfundNFTRenderer.sol
└── PartyGovernanceNFTRenderer.sol
sol-tests/ # Foundry tests
tests/ # TS tests
- openzeppelin/contracts/interfaces/IERC2981.sol
- openzeppelin/contracts/utils/cryptography/MerkleProof.sol
Additional out-of-scope contracts that are either consumed by or referenced by the in-scope business logic:
- Gatekeepers: Contracts that crowdfunds can optionally use to enforce who can participate in a crowdfund.
- MarketWrappers: Wrappers to various NFT auction markets to provide a unified interface for the
AuctionCrowdfund
. Inherited from V1 of the protocol. - PartyBidV1: Prior iteration of the protocol. Only featured the crowdfunds phase. Historical and motivational context.
At a high level, the typical business flow of the key contracts in this protocol goes like:
- Someone starts a crowdfund (
AuctionCrowdfund
,BuyCrowdfund
, orCollectionBuyCrowdfund
) to acquire an NFT (could be a specific one or any from a collection) and form a party around it.- At creation time they also choose the (fixed) parameters for the governance phase, should they succeed.
- At creation time they also choose initial "party hosts" for the crowdfund, which have special powers that carry over to the governance phase.
- While the crowdfund is active (not expired or finalized), people can contribute ETH to the crowdfund.
- This mints each contributor a soulbound "participation NFT" as well.
- Contributors may also preemptively choose whom to delegate their voting power to if the party goes on to the governance phase.
- At any time while the crowdfund is active, someone can take an action on the crowdfund (depends on the crowdfund type) to attempt to purchase an NFT.
- This could be placing a bid on an auction market or executing arbitrary call data to outright buy a fixed price listing (e.g., on opensea).
- If the crowdfund fails to acquire an NFT before it expires, Anyone can burn a contributor's participation NFT to return their contributed ETH. That is the end of the lifecycle for that party. Stop here.
- If the crowdfund can successfully acquire the NFT before it expires, it transitions into governance phase.
- A "governance party" (
Party
) is deployed.- The fixed governance parameters chosen when the crowdfund was created are used to initialize the party.
- The bought NFT is transferred into this party.
- Anyone can burn a contributor's participation NFT to:
- Refund any of the contributor's ETH that was not used to purchase the NFT.
- Mint voting power for the contributor in the governance party equal to the amount of their ETH that was used.
- This voting power is tokenized as a transferrable NFT ("governance NFT").
- If an initial delegate was chosen (other then themselves), the voting power will also be delegated at this time.
- The governance party now has the purchased NFT (called "precious") and users with effective voting power can make, vote, and execute proposals through governance.
- Party hosts (inherited from the crowdfund phase) can unilaterally veto any proposal made.
- Snapshots of voting power are taken whenever a user's voting power or delegation is updated to make sure only voting power at the time a proposal is made is used in voting.
- When a proposal passes and is executed, the governance party will delegatecall into the
ProposalExecutionEngine
logic contract which decodes and executes the proposal.- Some proposal types are non-atomic, multi-step, requiring repeated executions. No other proposal may be executed while another proposal is incomplete.
- If any ETH or ERC20s accrue in the party, anyone with effective voting power can trigger a "distribution".
- This moves all of the chosen asset (ETH or ERC20) into the canonical
TokenDistributor
contract and creates a distribution within it. - Anyone with a governance NFT in that party can redeem their share of the asset distribution.
- Distribution share for a governance NFT is generally equal to its individual voting power over the total voting power of the party.
- This moves all of the chosen asset (ETH or ERC20) into the canonical
Here's an abbreviated inheritance graph of the major contracts in the protocol (leaf nodes are deployable):
┌─────►AuctionCrowdfund
│
CrowdfundNFT─────►Crowdfund───┤ ┌─────►BuyCrowdfund
│ │
└─────►BuyCrowdfundBase─────┤
│
└─────►CollectionBuyCrowdfund
PartyGovernance──────►PartyGovernanceNFT──────►Party
ListOnOpenseaProposal────┐
│
ListOnZoraProposal───────┤
├─────►ProposalExecutionEngine
FractionalizeProposal────┤
│
ArbitraryCallsProposal───┘
For deeper details, visit the Protocol Documentation.
Throughout both phases of the product (crowdfund and governance), we rely heavily on external protocols. It's critical that our integrations with them are correct. The governance proposal contracts (ListOnOpenseaProposal
, ListOnZoraProposal
, ListOnFractionalProposal
) are areas of highest concern because a bad integration there can either cause the loss of an NFT held by the party or can brick the party until the proposal can be canceled.
The ProposalExecutionEngine
implements all the logic with parsing and executing proposals passed by PartyGovernance
. In addition to this it is stateful, reading and writing to storage slots shared with PartyGovernance
and also utilizing its own "private" storage slots. Low-level storage operations are used to ensure these regions of storage are predictable and do not overlap.
To save gas, we do not use traditional reentrancy guard modifiers anywhere in our protocol. Instead, we try to make use of some existing state variables already being touched to prevent reentrancy. That, or we simply allow reentrancy in places that we've decided are low-risk. These assumptions should be tested.
The PartyGovernance
contract creates a record of voting power for members of the party (and delegates) every time a governance NFT is moved or delegation is changed. We search these records during voting to ensure members cannot game proposals by voting with voting power earned after voting begins. It's important to have high confidence in this accounting logic to ensure malicious proposals cannot be passed without consensus.
A major goal that runs through the protocol design is to protect governance parties from unfairly losing the NFT they formed the party around. We call these NFTs "preciouses," and we have logic that restricts the way parties can interact with these NFTs to prevent them from being moved, or having the potential to be moved, without passing a rigorous (sometimes unanimous) governance process. For example, to list a precious NFT for sale on Opensea, it must first go through a Zora auction to mitigate a malicious whale with voting power from listing it for far below floor price and buying it themselves. We want to be sure these safeguards are effective for the majority of NFTs.
The TokenDistributor
is a single, canonical contract that's used across all governance parties. It custodies any ETH or ERC20 that a party transfers into it when creating a distribution. As such, it is a major honeypot for potential exploits. Also, anyone (not just official governance parties) can create a distribution on the TokenDistributor
, and the creator of a distribution can dictate how funds within that distribution are split. Because of these properties, funds from each distribution must be isolated from the other (no distribution can transfer more than their initial deposit).
There are some proposals that can take multiple executions to complete (eg. ListOnZoraProposal
), as opposed to proposals that are completed in one transaction like it commonly works in other governance implementations. While a multi-step proposal is in progress, no other proposals can be executed until it is finished. There is support for canceling proposals should they get "stuck" for any reason.
The off-chain storage pattern is used across almost all contracts to drastically reduce the gas costs involved in reading/writing storage. We verify the data coming off-chain is correct by checking it against an on-chain hash of the data we expect.
The explicit storage buckets pattern is used in both the PartyGovernance
and ProposalExecutionEngine
implementation. Instead of relying on the compiler to assign/access these storage buckets, we manually define pointers that explicitly map to the storage slot of our choosing that does not overlap with any automatically assigned slot.
In places where we want to make sure delegateCall
ing does not alter state (is read-only), we use a custom _readOnlyDelegateCall()
function. It works by delegateCalling
ing on a contract but always reverting with the return data than using a try/catch statement to bubble up the result to return.
We do not use any explicit state to indicate that a proxified contract has been initialized before (to prevent re-initialization attacks). Because all our initialization logic runs inside constructors, we simply check that our address has no code in it.
There are two different upgrade models at play in the protocol. One is through the Global
registry contract, which other contracts will use to look up the latest instance (address) of a contract. Often this is done just-in-time, such as looking up the PartyFactory
when a crowdfund has won and is transitioning to the governance phase. This registry is also used to look up the latest implementation contract for Proxy
instances when deploying new crowdfunds or governance parties. The PartyDAO multisig (which controls the Globals
contract) has the ability to unilaterally change the addresses pointed to in these cases. The other upgrade mechanism is voluntary, occuring when a governance party passes a proposal to upgrade its ProposalExecutionEngine
implementation to the latest version (also looked up in Globals
). In either case, publishing a contract that is not backwards compatible with existing crowdfund and governance instances can brick them.
- It is possible that someone could manipulate parties by contributing ETH and then buying their NFT that they own. This is known and not considered a bug or a valid finding by the team.
- For
PartyBuy
andPartyCollectionBuy
crowdfunds, the contract allows arbitrary calls via thebuy()
method to buy the NFT and check if the NFT is owned by the contract at the end. A potential attack here happens if the crowdfund raises more than the buy price (or if the seller reduces the price). An attacker can then write a contract that when called with the crowdfunds entire ETH balance, buys the NFT for less and sends it to the crowdfund contract, then sends the remaining ETH to the attacker. This behavior exists in V1 as well. In V1, we used an allow list to help mitigate. The issue with this is that a motivated actor could still buy the original listing and create a new one on an allowed target then trigger the party tobuy()
that one instead at a higher price, pocketing the difference. We have made peace with this issue and accepted the risk, but the team is open to new solutions.PartyCollectionBuy
will be much less likely to be affected by this since only a host may callbuy()
.- On the frontend, we have thought about notifying contributors whose contribution would push the crowdfund above the buy price to reduce their contribution. Another solution is to cap contributions to
maximumPrice
, but we'd prefer to do this on the frontend.
- For
PartyBid
crowdfunds, in the case that a party did not bid at all on the NFT in the auction yet still (somehow) manages to acquire the NFT beforefinalize()
is called, all contributions to the crowdfund are considered used so that everyone who contributed wins but no contributions will be refunded back. The ETH contribution would stay in the crowdfund and not be transferred over to the createdParty
instance, effectively burned. The crowdfund could transfer over contributions to the createdParty
todistribute()
back (refunding it) but this introduces the possibility for someone to make a last minute contribution beforefinalize()
is called to boost their voting power at no cost because they could get back all their contributions. This is a rare and atypical enough case that we feel comfortable leaving this behavior alone. - In
_settleZoraAuction()
inListOnZoraProposal
, a try/catch statement is used to get the state of an auction. If theendAuction()
call fails but returns an"Auction doesn't exit"
we take this as meaning someone else had calledendAuction()
before we did, ending the auction and emitting aZoraAuctionSold
event. There is a possibility that asafeTransferFrom()
call in Zora'sendAuction()
reverts atonERC721Received()
with a"Auction doesn't exit"
to trick the party into completing the proposal even though the auction wasn't settled. However, the party still has the NFT and can just list it elsewhere. This is a known grief and, unless there can be more serious implications, we do not consider it a valid finding. - If a party were to list a malicious NFT (eg. reverts on transfer after listing), somewhere along the way it may break the proposal flow and put the party in a stuck state until
cancelDelay
is reached. While it is annoying, we do not consider it serious because (1) the proposal can always be canceled and (2) it is unlikely a malicious NFT will have a market. - By controlling the
Globals
registry contract, it’s understood that the PartyDAO multisig has a lot of power over the protocol and could cause harm in various ways by updating the Globals contract maliciously or incorrectly. On top of this, there are emergency admin recovery functions on governance parties and the token distributor callable by the multisig that can execute arbitrary bytecode, though party hosts can disable them on governance parties. - Party hosts have unilateral veto power on any proposal. Hosts can essentially block a governance party in this way. Therefore crowdfunds must be extremely careful with their host selection. We accept these risks, given that the PartyDAO multisig is comprised of several different members, all of whom have signed legal agreements to abide by the DAO's governance system.
- Because voting power is represented by governance NFTs proportional to their (used) crowdfund contribution, it limits the ways voting power can be distributed across party members. The lack of quorum or "no" votes can amplify this situation. For example, if someone contributed 51% of the ETH to a crowdfund and the passing threshold for that governance party is also 51%, whoever holds that governance NFT will always be able to pass proposals unless a host vetoes them.
- Canceling an
InProgress
proposal (mid-step) can leave the governance party in a vulnerable or undesirable state because there is no cleanup logic run during a cancel. For example, if a party cancels a Zora proposal while the Zora auction is active, the party will no longer possess the NFT (because Zora is custodial), and must either wait for the auction to conclude or execute an arbitrary call to cancel the action (directly on Zora) in order to retrieve it.
This code is provided under the Beta Software License Agreement.