Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(crowdfund): support claiming unreceived NFT and refunds #126

Merged
merged 2 commits into from
Sep 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 53 additions & 16 deletions contracts/crowdfund/Crowdfund.sol
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ abstract contract Crowdfund is Implementation, ERC721Receiver, CrowdfundNFT {
uint96 amount;
}

// A record of the refund and governance NFT owed to a contributor if it
// could not be received by them from `burn()`.
struct Claim {
0xble marked this conversation as resolved.
Show resolved Hide resolved
uint256 refund;
uint256 governanceTokenId;
}

error PartyAlreadyExistsError(Party party);
error WrongLifecycleError(CrowdfundLifecycle lc);
error InvalidGovernanceOptionsError(bytes32 actualHash, bytes32 expectedHash);
Expand All @@ -79,6 +86,7 @@ abstract contract Crowdfund is Implementation, ERC721Receiver, CrowdfundNFT {
error NotAllowedByGateKeeperError(address contributor, IGateKeeper gateKeeper, bytes12 gateKeeperId, bytes gateData);
error SplitRecipientAlreadyBurnedError();
error InvalidBpsError(uint16 bps);
error NothingToClaimError();

event Burned(address contributor, uint256 ethUsed, uint256 ethOwed, uint256 votingPower);
event Contributed(address contributor, uint256 amount, address delegate, uint256 previousTotalContributions);
Expand Down Expand Up @@ -113,7 +121,11 @@ abstract contract Crowdfund is Implementation, ERC721Receiver, CrowdfundNFT {
mapping(address => address) public delegationsByContributor;
// Array of contributions by a contributor.
// One is created for every nonzero contribution made.
mapping (address => Contribution[]) private _contributionsByContributor;
mapping(address => Contribution[]) private _contributionsByContributor;
/// @notice Stores the amount of ETH owed back to a contributor and governance NFT
/// that should be minted to them if it could not be transferred to
/// them with `burn()`.
mapping(address => Claim) public claims;

// Set the `Globals` contract.
constructor(IGlobals globals) CrowdfundNFT(globals) {
Expand Down Expand Up @@ -165,24 +177,43 @@ abstract contract Crowdfund is Implementation, ERC721Receiver, CrowdfundNFT {
/// If the party has lost, this will only refund unused ETH (all of it) for
/// the given `contributor`.
/// @param contributor The contributor whose NFT to burn for.
function burn(address payable contributor)
public
{
function burn(address payable contributor) external {
return _burn(contributor, getCrowdfundLifecycle(), party);
}

/// @notice `burn()` in batch form.
/// @param contributors The contributors whose NFT to burn for.
function batchBurn(address payable[] calldata contributors)
external
{
function batchBurn(address payable[] calldata contributors) external {
Party party_ = party;
CrowdfundLifecycle lc = getCrowdfundLifecycle();
for (uint256 i = 0; i < contributors.length; ++i) {
_burn(contributors[i], lc, party_);
}
}

/// @notice Claim a governance NFT or refund that is owed back but could not be
/// given due to error in `_burn()` (eg. a contract that does not
/// implement `onERC721Received()` or cannot receive ETH). Only call
/// this if refund and governance NFT minting could not be returned
/// with `burn()`.
/// @param receiver The address to receive the NFT or refund.
function claim(address payable receiver) external {
Claim memory claimInfo = claims[msg.sender];
delete claims[msg.sender];

if (claimInfo.refund == 0 && claimInfo.governanceTokenId == 0) {
revert NothingToClaimError();
}

0xble marked this conversation as resolved.
Show resolved Hide resolved
if (claimInfo.refund != 0) {
receiver.transferEth(claimInfo.refund);
}

if (claimInfo.governanceTokenId != 0) {
party.safeTransferFrom(address(this), receiver, claimInfo.governanceTokenId);
}
}

/// @notice Contribute to this crowdfund and/or update your delegation for the
/// governance phase should the crowdfund succeed.
/// For restricted crowdfunds, `gateData` can be provided to prove
Expand Down Expand Up @@ -443,9 +474,7 @@ abstract contract Crowdfund is Implementation, ERC721Receiver, CrowdfundNFT {
}
}

function _burn(address payable contributor, CrowdfundLifecycle lc, Party party_)
private
{
function _burn(address payable contributor, CrowdfundLifecycle lc, Party party_) private {
// If the CF has won, a party must have been created prior.
if (lc == CrowdfundLifecycle.Won) {
if (party_ == Party(payable(0))) {
Expand Down Expand Up @@ -479,14 +508,22 @@ abstract contract Crowdfund is Implementation, ERC721Receiver, CrowdfundNFT {
delegate = contributor;
}
// Mint governance NFT for the contributor.
party_.mint(
contributor,
votingPower,
delegate
);
try party_.mint(contributor, votingPower, delegate) returns (uint256) {
0xble marked this conversation as resolved.
Show resolved Hide resolved
// OK
} catch {
// Mint to the crowdfund itself to escrow for contributor to
// come claim later on.
uint256 tokenId = party_.mint(address(this), votingPower, delegate);
claims[contributor].governanceTokenId = tokenId;
}
}
// Refund any ETH owed back to the contributor.
contributor.transferEth(ethOwed);
(bool s, ) = contributor.call{value: ethOwed}("");
if (!s) {
// If the transfer fails, the contributor can still come claim it
// from the crowdfund.
claims[contributor].refund = ethOwed;
}
emit Burned(contributor, ethUsed, ethOwed, votingPower);
}

Expand Down
5 changes: 3 additions & 2 deletions contracts/party/PartyGovernanceNFT.sol
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ contract PartyGovernanceNFT is
external
onlyMinter
onlyDelegateCall
returns (uint256 tokenId)
{
uint256 tokenId = ++tokenCount;
tokenId = ++tokenCount;
votingPowerByTokenId[tokenId] = votingPower;

// Use delegate from party over the one set during crowdfund.
Expand All @@ -137,7 +138,7 @@ contract PartyGovernanceNFT is
}

_adjustVotingPower(owner, votingPower.safeCastUint256ToInt192(), delegate);
_mint(owner, tokenId);
_safeMint(owner, tokenId);
}

/// @inheritdoc ERC721
Expand Down
2 changes: 1 addition & 1 deletion lib/forge-std
2 changes: 1 addition & 1 deletion lib/openzeppelin-contracts
Submodule openzeppelin-contracts updated 36 files
+28 −0 .github/workflows/changelog.yml
+13 −13 .github/workflows/checks.yml
+3 −0 .gitmodules
+5 −1 CHANGELOG.md
+1 −1 contracts/governance/IGovernor.sol
+20 −0 contracts/interfaces/IERC2309.sol
+48 −12 contracts/mocks/CheckpointsMock.sol
+1 −1 contracts/mocks/ERC165/ERC165ReturnBomb.sol
+158 −0 contracts/mocks/ERC721ConsecutiveMock.sol
+12 −0 contracts/mocks/MathMock.sol
+1 −1 contracts/mocks/MulticallTest.sol
+1 −1 contracts/token/ERC20/extensions/ERC20Votes.sol
+45 −6 contracts/token/ERC721/ERC721.sol
+3 −0 contracts/token/ERC721/README.adoc
+123 −0 contracts/token/ERC721/extensions/ERC721Consecutive.sol
+24 −0 contracts/token/ERC721/extensions/ERC721Enumerable.sol
+11 −0 contracts/token/ERC721/extensions/ERC721Pausable.sol
+15 −0 contracts/token/ERC721/extensions/draft-ERC721Votes.sol
+9 −0 contracts/token/ERC721/presets/ERC721PresetMinterPauserAutoId.sol
+130 −43 contracts/utils/Checkpoints.sol
+4 −60 contracts/utils/Strings.sol
+156 −37 contracts/utils/math/Math.sol
+5 −3 contracts/utils/structs/EnumerableMap.sol
+5 −3 contracts/utils/structs/EnumerableSet.sol
+3 −3 docs/modules/ROOT/pages/crosschain.adoc
+7 −0 hardhat/skip-foundry-tests.js
+1 −0 lib/forge-std
+52 −31 scripts/generate/templates/Checkpoints.js
+16 −8 scripts/generate/templates/CheckpointsMock.js
+5 −3 scripts/generate/templates/EnumerableMap.js
+5 −3 scripts/generate/templates/EnumerableSet.js
+191 −0 test/token/ERC721/extensions/ERC721Consecutive.test.js
+26 −7 test/utils/Checkpoints.test.js
+1 −1 test/utils/Multicall.test.js
+118 −0 test/utils/math/Math.t.sol
+115 −22 test/utils/math/Math.test.js
169 changes: 154 additions & 15 deletions sol-tests/crowdfund/Crowdfund.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ import "./MockPartyFactory.sol";
import "./MockParty.sol";
import "./TestableCrowdfund.sol";

contract BadETHReceiver is ERC721Receiver {
// Does not implement `receive()`.
// But can still receive NFT.
}

contract BadERC721Receiver {
// Does not implement `onERC721Received()`.
// But can still receive ETH.
receive() external payable {}
}

contract CrowdfundTest is Test, TestUtils {
event MockPartyFactoryCreateParty(
address caller,
Expand Down Expand Up @@ -795,23 +806,151 @@ contract CrowdfundTest is Test, TestUtils {
cf.contribute{ value: contributor2.balance }(delegate2, abi.encode(new bytes32[](0)));
}

function testBurn_failMintingGovNFT() public {
TestableCrowdfund cf = _createCrowdfund(0);
address delegate1 = _randomAddress();
address payable badERC721Receiver = payable(new BadERC721Receiver());
// badERC721Receiver contributes 1 ETH
vm.deal(badERC721Receiver, 1e18);
vm.prank(badERC721Receiver);
cf.contribute{ value: badERC721Receiver.balance }(delegate1, "");
assertEq(cf.totalContributions(), 1e18);
// set up a win using badERC721Receiver's total contribution
(IERC721[] memory erc721Tokens, uint256[] memory erc721TokenIds) =
_createTokens(address(cf), 2);
vm.expectEmit(false, false, false, true);
emit MockPartyFactoryCreateParty(
address(cf),
address(cf),
_createExpectedPartyOptions(cf, 1e18),
erc721Tokens,
erc721TokenIds
);
Party party_ = cf.testSetWon(
1e18,
defaultGovernanceOpts,
erc721Tokens,
erc721TokenIds
);
assertEq(address(party_), address(party));
// badERC721Receiver burns tokens
vm.expectEmit(false, false, false, true);
emit MockMint(
address(cf),
address(cf), // Gov NFT was minted to crowdfund to escrow
1e18,
delegate1
);
cf.burn(badERC721Receiver);
assertEq(party.balanceOf(badERC721Receiver), 0);
assertEq(party.balanceOf(address(cf)), 1);

// Expect revert if claiming to bad receiver
vm.prank(badERC721Receiver);
vm.expectRevert();
cf.claim(badERC721Receiver);

address payable receiver = payable(_randomAddress());
vm.prank(badERC721Receiver);
cf.claim(receiver);
assertEq(party.balanceOf(receiver), 1);
assertEq(party.balanceOf(address(cf)), 0);

// Check that claim is now cleared
(uint256 refund, uint256 governanceTokenId) = cf.claims(badERC721Receiver);
assertEq(refund, 0);
assertEq(governanceTokenId, 0);
}

function testBurn_failRefundingETH() public {
TestableCrowdfund cf = _createCrowdfund(0);
address delegate1 = _randomAddress();
address payable badETHReceiver = payable(address(new BadETHReceiver()));
// badETHReceiver contributes 2 ETH
vm.deal(badETHReceiver, 2e18);
vm.prank(badETHReceiver);
cf.contribute{ value: badETHReceiver.balance }(delegate1, "");
assertEq(cf.totalContributions(), 2e18);
// set up a win using badETHReceiver's total contribution
(IERC721[] memory erc721Tokens, uint256[] memory erc721TokenIds) =
_createTokens(address(cf), 2);
vm.expectEmit(false, false, false, true);
emit MockPartyFactoryCreateParty(
address(cf),
address(cf),
_createExpectedPartyOptions(cf, 1e18),
erc721Tokens,
erc721TokenIds
);
Party party_ = cf.testSetWon(
1e18,
defaultGovernanceOpts,
erc721Tokens,
erc721TokenIds
);
assertEq(address(party_), address(party));
// badETHReceiver burns tokens
vm.expectEmit(false, false, false, true);
emit MockMint(
address(cf),
badETHReceiver,
1e18,
delegate1
);
cf.burn(badETHReceiver);
assertEq(badETHReceiver.balance, 0);

// Expect revert if claiming to bad receiver
vm.prank(badETHReceiver);
vm.expectRevert(abi.encodeWithSelector(
LibAddress.EthTransferFailed.selector,
badETHReceiver,
""
));
cf.claim(badETHReceiver);

address payable receiver = payable(_randomAddress());
vm.prank(badETHReceiver);
cf.claim(receiver);
assertEq(receiver.balance, 1e18);
assertEq(badETHReceiver.balance, 0);
0xble marked this conversation as resolved.
Show resolved Hide resolved

// Check that claim is now cleared
(uint256 refund, uint256 governanceTokenId) = cf.claims(badETHReceiver);
assertEq(refund, 0);
assertEq(governanceTokenId, 0);
}

function testClaim_nothingToClaim() public {
TestableCrowdfund cf = _createCrowdfund(0);
vm.expectRevert(abi.encodeWithSelector(
Crowdfund.NothingToClaimError.selector
));
cf.claim(_randomAddress());
}

function test_revertIfNullContributor() public {
// Attempt creating a crowdfund and setting a null address as the initial contributor
Implementation impl = Implementation(new TestableCrowdfund(globals));
// Attempt creating a crowdfund and setting a null address as the
// initial contributor. Should revert when it attempts to mint a
// contributor NFT to `address(0)`.
vm.expectRevert(CrowdfundNFT.InvalidAddressError.selector);
new TestableCrowdfund{value: 1 ether }(
globals,
Crowdfund.CrowdfundOptions({
name: defaultName,
symbol: defaultSymbol,
splitRecipient: defaultSplitRecipient,
splitBps: defaultSplitBps,
initialContributor: address(0),
initialDelegate: address(this),
gateKeeper: defaultGateKeeper,
gateKeeperId: defaultGateKeeperId,
governanceOpts: defaultGovernanceOpts
})
);
TestableCrowdfund(payable(new Proxy{ value: 1 ether }(
impl,
abi.encodeCall(TestableCrowdfund.initialize, (
Crowdfund.CrowdfundOptions({
name: defaultName,
symbol: defaultSymbol,
splitRecipient: defaultSplitRecipient,
splitBps: defaultSplitBps,
initialContributor: address(0),
initialDelegate: address(this),
gateKeeper: defaultGateKeeper,
gateKeeperId: defaultGateKeeperId,
governanceOpts: defaultGovernanceOpts
})
))
)));
}

// test nft renderer
Expand Down
13 changes: 11 additions & 2 deletions sol-tests/crowdfund/MockParty.sol
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8;

contract MockParty {
import "contracts/tokens/IERC721Receiver.sol";
import "contracts/vendor/solmate/ERC721.sol";

contract MockParty is ERC721("MockParty", "MOCK") {
event MockMint(
address caller,
address owner,
uint256 amount,
address delegate
);

uint256 public tokenCount;

function tokenURI(uint256 id) public view override returns (string memory) {}

function mint(
address owner,
uint256 amount,
address delegate
) external {
) external returns (uint256 tokenId) {
tokenId = ++tokenCount;
_safeMint(owner, tokenId);
emit MockMint(msg.sender, owner, amount, delegate);
}
}