Platform: Code4rena
Start Date: 12/09/2022
End Date: 19/09/2022
Period: 7 days
Status: Completed
Pot Size: $75,000 USDC
Participants: 110
Reporter: itsmetechjay
Judge: HardlyDifficult
Id: 160
League: ETH
Lambda | 1/110 | $17,083.74 | 9 | 4 | 1 | 3 | 0 | - | - | 0 |
Trust | 2/110 | $13,443.25 | 7 | 3 | 1 | 3 | 0 | - | 0 | 0 |
0x52 | 3/110 | $13,012.21 | 3 | 2 | 2 | 0 | 0 | - | 0 | 0 |
csanuragjain | 4/110 | $4,818.07 | 4 | 0 | 0 | 3 | 2 | - | 0 | 0 |
cccz | 5/110 | $3,406.84 | 4 | 0 | 0 | 3 | 1 | - | 0 | 0 |
8olidity | 6/110 | $2,899.28 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
bin2chen | 7/110 | $2,015.33 | 2 | 0 | 0 | 1 | 1 | - | 0 | 0 |
hyh | 8/110 | $1,932.85 | 1 | 0 | 0 | 1 | 1 | 0 | 0 | 0 |
ladboy233 | 9/110 | $1,858.14 | 3 | 1 | 0 | 0 | 0 | - | - | 0 |
CertoraInc | 10/110 | $1,676.17 | 4 | 0 | 0 | 2 | 0 | - | - | 0 |
Auditor per page
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 https://github.com/crytic/slither/pull/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
Additional out-of-scope contracts that are either consumed by or referenced by the in-scope business logic:
AuctionCrowdfund
. Inherited from V1 of the protocol.At a high level, the typical business flow of the key contracts in this protocol goes like:
AuctionCrowdfund
, BuyCrowdfund
, or CollectionBuyCrowdfund
) to acquire an NFT (could be a specific one or any from a collection) and form a party around it.
Party
) is deployed.
ProposalExecutionEngine
logic contract which decodes and executes the proposal.
TokenDistributor
contract and creates a distribution within it.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.
delegatecall
's on the ProposalExecutionEngine
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.
TokenDistributor
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.
PartyBuy
and PartyCollectionBuy
crowdfunds, the contract allows arbitrary calls via the buy()
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 to buy()
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 call buy()
.maximumPrice
, but we'd prefer to do this on the frontend.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 before finalize()
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 created Party
instance, effectively burned. The crowdfund could transfer over contributions to the created Party
to distribute()
back (refunding it) but this introduces the possibility for someone to make a last minute contribution before finalize()
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._settleZoraAuction()
in ListOnZoraProposal
, a try/catch statement is used to get the state of an auction. If the endAuction()
call fails but returns an "Auction doesn't exit"
we take this as meaning someone else had called endAuction()
before we did, ending the auction and emitting a ZoraAuctionSold
event. There is a possibility that a safeTransferFrom()
call in Zora's endAuction()
reverts at onERC721Received()
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.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.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.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.